diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 3d111e4..7f6f0a3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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(); +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; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 39f2bc0..5cecd81 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -22,7 +22,7 @@ import { type PlayerId, CURRENT_RULES_REV, } from "@wizwar/engine"; -import { appendLine, archiveRoomFile, ensureDataDir, readAllRooms, roomFileExists, type RoomLine } from "./store"; +import { appendLine, archiveRoomFile, countRoomFiles, ensureDataDir, readAllRooms, readRoom, roomFileExists, type RoomLine } from "./store"; import { recordRoom } from "./stats"; export interface LoggedCommand { @@ -50,6 +50,8 @@ export interface Room { chat: { player: PlayerId; text: string; at: string }[]; /** Seats the server itself plays: temperament, tier, and secrecy. */ bots: Map; + /** Last time anything looked at this room (memory eviction clock). */ + touchedAt?: number; } const rooms = new Map(); @@ -86,7 +88,7 @@ function makeRoomCode(): string { } export function roomCount(): number { - return rooms.size; + return countRoomFiles(); } /** Every restored, still-running room — so boot can wake their bot pumps. */ @@ -110,6 +112,7 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { events: [], chat: [], bots: new Map(), + touchedAt: Date.now(), }; rooms.set(room.id, room); recordRoom(room); @@ -125,7 +128,45 @@ export function createRoom(hostId: PlayerId): { room: Room; token: string } { } export function getRoom(id: string): Room | undefined { - return rooms.get(id.toUpperCase()); + const code = id.toUpperCase(); + const live = rooms.get(code); + if (live) { + live.touchedAt = Date.now(); + return live; + } + // A room out of memory sleeps in its ledger; wake it on demand. + const lines = readRoom(code); + if (!lines) return undefined; + try { + const room = rebuildRoom(code, lines); + if (!room) return undefined; + room.touchedAt = Date.now(); + rooms.set(code, room); + return room; + } catch (e) { + console.error(`could not wake room ${code}:`, e); + return undefined; + } +} + +/** Put idle rooms back to sleep: their ledger holds everything, so memory + * only carries rooms someone is actually at. A room leaves when no socket + * is attached and it has sat untouched past its allowance — a finished + * game soon, anything else after a day. */ +export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number { + const now = Date.now(); + const FINISHED_MS = Number(process.env.WIZWAR_EVICT_FINISHED_MS ?? 30 * 60_000); + const IDLE_MS = Number(process.env.WIZWAR_EVICT_IDLE_MS ?? 24 * 60 * 60_000); + let evicted = 0; + for (const [id, room] of rooms) { + if (hasSockets(id)) continue; + const idle = now - (room.touchedAt ?? now); + const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS; + if (idle < allowance) continue; + rooms.delete(id); + evicted++; + } + return evicted; } export function joinRoom( @@ -611,31 +652,30 @@ export function claimTransferCode(code: string): { roomId: string; name: PlayerI } /** Rebuild every persisted room by replaying its file. */ -export function loadPersistedRooms(): void { - ensureDataDir(); - let restored = 0; - for (const [id, lines] of readAllRooms()) { - try { - const meta = lines[0] as Extract; - const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null); - if (!hostHash) throw new Error("meta line has no host token"); - const room: Room = { - id, - hostId: meta.hostId, - players: [meta.hostId], - tokens: new Map([[meta.hostId, hostHash]]), - seed: meta.seed, - expansion: false, - colorChoices: new Map(), - createdAt: meta.createdAt, - state: null, - log: [], - events: [], - chat: [], - bots: new Map(), - }; - if (lines.some((l) => l.kind === "abandon")) continue; - for (const line of lines.slice(1)) { +/** Rebuild one room from its ledger lines — the seed plus the log IS the + * game. Returns null for an abandoned room; throws on a corrupt ledger. */ +function rebuildRoom(id: string, lines: RoomLine[]): Room | null { + const meta = lines[0] as Extract; + const hostHash = meta.hostTokenHash ?? (meta.hostToken ? hashToken(meta.hostToken) : null); + if (!hostHash) throw new Error("meta line has no host token"); + const room: Room = { + id, + hostId: meta.hostId, + players: [meta.hostId], + tokens: new Map([[meta.hostId, hostHash]]), + seed: meta.seed, + expansion: false, + colorChoices: new Map(), + createdAt: meta.createdAt, + state: null, + log: [], + events: [], + chat: [], + bots: new Map(), + touchedAt: Date.now(), + }; + if (lines.some((l) => l.kind === "abandon")) return null; + for (const line of lines.slice(1)) { if (line.kind === "kick") { room.players = room.players.filter((p) => p !== line.name); room.tokens.delete(line.name); @@ -672,7 +712,17 @@ export function loadPersistedRooms(): void { room.log.push({ seq: line.seq, playerId: line.playerId, command: line.command as Command, at: line.at }); room.events.push(...result.events); } - } + } + return room; +} + +export function loadPersistedRooms(): void { + ensureDataDir(); + let restored = 0; + for (const [id, lines] of readAllRooms()) { + try { + const room = rebuildRoom(id, lines); + if (!room) continue; rooms.set(id, room); recordRoom(room); restored++; diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 3a2ddb7..aec9938 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -97,6 +97,29 @@ export function appendLine(roomId: string, line: RoomLine): void { appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8"); } +/** Read one persisted room's lines, or null if it has no ledger. */ +export function readRoom(roomId: string): RoomLine[] | null { + const file = fileFor(roomId); + if (!existsSync(file)) return null; + try { + const lines = readFileSync(file, "utf8") + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as RoomLine); + return lines.length > 0 && lines[0]!.kind === "meta" ? lines : null; + } catch (e) { + console.error(`unreadable room file ${roomId}:`, e); + return null; + } +} + +/** How many rooms have ledgers on disk — the server-wide cap counts these, + * not just the rooms currently awake in memory. */ +export function countRoomFiles(): number { + ensureDataDir(); + return readdirSync(DATA_DIR).filter((f) => f.endsWith(".jsonl")).length; +} + /** Read every persisted room's lines, keyed by room id. */ export function readAllRooms(): Map { ensureDataDir();