Ready for a crowd: idle rooms sleep, share cards render once
Memory now carries only live tables — a sweep puts finished rooms to bed after 30 minutes and anything untouched after a day, and getRoom wakes a sleeping room from its ledger the moment anyone asks (the seed plus the log IS the game). The room cap counts ledgers on disk, not just rooms awake. Share-card PNGs render once per share id and cache immutable. Rate-limit kicks use terminate() so an abuser cannot hold the socket open by ignoring the closing handshake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
9cc0430ee2
commit
002698085f
@@ -42,6 +42,7 @@ import {
|
||||
getRoom,
|
||||
joinRoom,
|
||||
loadPersistedRooms,
|
||||
evictIdleRooms,
|
||||
runningRooms,
|
||||
addAutomaton,
|
||||
addChat,
|
||||
@@ -92,6 +93,13 @@ setTimeout(() => {
|
||||
for (const room of runningRooms()) runBots(room);
|
||||
}, 2000); // a beat for clients to reconnect before the clockwork stirs
|
||||
|
||||
// Idle rooms return to their ledgers, so memory carries only live tables;
|
||||
// getRoom wakes a sleeping room the moment anyone asks for it.
|
||||
setInterval(() => {
|
||||
const evicted = evictIdleRooms((roomId) => [...sessions].some((s) => s.roomId === roomId));
|
||||
if (evicted > 0) console.log(`put ${evicted} idle room(s) back to sleep`);
|
||||
}, Number(process.env.WIZWAR_EVICT_SWEEP_MS ?? 10 * 60_000));
|
||||
|
||||
// One process serves both the built client and the websocket, so production
|
||||
// needs only a TLS proxy in front (or nothing, on a LAN).
|
||||
const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist");
|
||||
@@ -217,6 +225,10 @@ function inviteHtml(room: Room, rawHost: string, rawProto: string): string {
|
||||
];
|
||||
return ogPage(metas);
|
||||
}
|
||||
/** Rendered share cards, by share id (immutable once minted). */
|
||||
const ogPngCache = new Map<string, Buffer>();
|
||||
const OG_PNG_CACHE_MAX = 200;
|
||||
|
||||
const httpServer = createServer((req, res) => {
|
||||
try {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
@@ -257,8 +269,17 @@ const httpServer = createServer((req, res) => {
|
||||
const data = shareData(watch[1]!);
|
||||
if (!data) { res.writeHead(404).end("no such replay"); return; }
|
||||
if (watch[2]) {
|
||||
const png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
||||
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=300" });
|
||||
// A share never changes, so its card renders once — crawlers
|
||||
// re-fetching the unfurl image must not cost CPU every time.
|
||||
let png = ogPngCache.get(watch[1]!);
|
||||
if (!png) {
|
||||
png = renderSharePng(data.steps[data.steps.length - 1]!.view);
|
||||
ogPngCache.set(watch[1]!, png);
|
||||
if (ogPngCache.size > OG_PNG_CACHE_MAX) {
|
||||
ogPngCache.delete(ogPngCache.keys().next().value!);
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400, immutable" });
|
||||
res.end(png);
|
||||
return;
|
||||
}
|
||||
@@ -511,7 +532,9 @@ wss.on("connection", (socket) => {
|
||||
|
||||
socket.on("message", (data) => {
|
||||
if (!underRateLimit(session)) {
|
||||
if (++session.overLimitStrikes > 100) socket.close();
|
||||
// terminate, not close: an abuser ignoring the closing handshake
|
||||
// would otherwise hold the socket (and its session slot) open.
|
||||
if (++session.overLimitStrikes > 100) return socket.terminate();
|
||||
return send(socket, { type: "error", message: "slow down" });
|
||||
}
|
||||
let msg: Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user