/** * The HTTP/SSE wiring — Phase 2 of `docs/architecture/multiplayer.md` (§8-9, §12 steps 8 and 12), * extended for Phase 4 (§12 steps 17-20) to a lobby and more than one game. * * Plain `node:http`, no framework: the project has zero runtime dependencies * (`package.json`), and `scripts/build-web.ts` already shells out to `tsc` directly rather than * reaching for a bundler — this matches that everywhere-else choice rather than introducing the * first framework dependency for one route table. * * All the game logic lives in `session.ts` and `lobby.ts`; this file is deliberately thin — routing, * the join-secret gate on the DOOR (create/join), token resolution once a player is through it, SSE * mechanics, and static file serving for the built client (`dist/`, D16: the server serves the * client, which is what makes same-origin work with no CORS). * * TOKENS REPLACE `?seat=&secret=` ON THE RUNNING-GAME ROUTES. `lobby-and-sessions.md` §1: the token * already proves "I am the player who was in this game," which is the only identity claim `/api/stream` * and `/api/intent` need — the join secret's job ends at the lobby door. */ import { createServer } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { extname, join, normalize } from 'node:path'; import type { Intent } from '../engine/intents.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import { appendTiming, deleteLobby, gameDir, upsertIndexEntry, writeGame, writeLobby, writeSessions, } from './persistence.ts'; import { createSession } from './session.ts'; import type { GameSession, Push } from './session.ts'; import { createLobby, freshGameCode, joinLobby, reassignHost, setBotSeat, startLobby, } from './lobby.ts'; import type { Lobby, PlayerSession } from './lobby.ts'; export type ServerOptions = { port: number; bindAddress: string; /** D14 — a server-wide secret, passed out of band. Gates lobby creation and joining — the door; * once a player is through it and holds a token, the token alone authenticates them. */ joinSecret: string; /** The built client (`npm run build:web`'s `dist/`), served at `/` (D16). */ distDir: string; /** Where every game's files live, one subdirectory per `gameId` (`persistence.ts`'s `gameDir`). */ dataDir: string; /** `package.json`'s version — stamped onto every write, checked on every load (§12 step 15). */ engineVersion: string; /** Reconstructed by `index.ts`'s load-on-start. Empty maps for a fresh server. */ initialGames: Map; initialLobbies: Map; initialSessions: Map; }; const MIME: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', }; const HEARTBEAT_MS = 20_000; async function readJson(req: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const text = Buffer.concat(chunks).toString('utf8'); return text.trim() === '' ? {} : JSON.parse(text); } function sendJson(res: ServerResponse, status: number, body: unknown): void { const text = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) }); res.end(text); } async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise { const rel = urlPath === '/' ? '/index.html' : urlPath; // `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request // for `/../../etc/passwd` must not escape the one directory this is allowed to read from. const full = join(distDir, normalize(rel)); if (!full.startsWith(distDir)) { sendJson(res, 400, { error: 'bad path' }); return; } try { const info = await stat(full); if (!info.isFile()) throw new Error('not a file'); res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size }); createReadStream(full).pipe(res); } catch { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); } } /** * What a lobby SSE push carries — the whole `Lobby`, since the seat list is small and a delta * mechanism buys nothing at this size (`session.ts`'s `Push` deltas the BOARD, which is not this). * * `started` rides on the FINAL push of a lobby's life, sent the instant before the connection is * closed at `Lobby.Start` — without it, the client's only signal that the game began is the stream * simply ending, indistinguishable from a network hiccup that `EventSource` would otherwise retry. */ type LobbyPush = { lobby: Lobby; you: PlayerIndex; started: boolean }; export function startServer(opts: ServerOptions): void { const games = opts.initialGames; const lobbies = opts.initialLobbies; const sessions = opts.initialSessions; const gameCodes = new Map(); // gameCode -> gameId, for /api/lobby/join for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId); // One open SSE response per (gameId, seat) for a running game, and per (gameId, token) for a // lobby still being seated — a second connection from the same seat/token replaces the first // rather than fanning out to both (no concept yet of "the same seat from two tabs"). const gameConnections = new Map>(); const gameEventIds = new Map>(); const lobbyConnections = new Map>(); function writeSse(res: ServerResponse, id: number, data: unknown): void { res.write(`id: ${id}\ndata: ${JSON.stringify(data)}\n\n`); } function nextEventId(gameId: string, seat: PlayerIndex): number { const ids = gameEventIds.get(gameId) ?? new Map(); const id = (ids.get(seat) ?? 0) + 1; ids.set(seat, id); gameEventIds.set(gameId, ids); return id; } function broadcastGame(gameId: string, pushes: Map): void { const conns = gameConnections.get(gameId); if (!conns) return; for (const [seat, push] of pushes) { const res = conns.get(seat); if (res) writeSse(res, nextEventId(gameId, seat), push); } } /** * Presence is transport-layer news about a CONNECTION, never a `GameEvent` — it does not go * through `session.ts` at all (`lobby-and-sessions.md` §5). Sent to every OTHER currently * connected seat of the same game as a presence-only push (an empty board delta, no menu, no new * lines) rather than inventing a second SSE event type — one message shape for the client to parse. */ function broadcastPresence(gameId: string, seat: PlayerIndex, connected: boolean): void { const conns = gameConnections.get(gameId); if (!conns) return; for (const [other, res] of conns) { if (other === seat) continue; const push: Push = { menu: null, lines: [], presence: { seat, connected } }; writeSse(res, nextEventId(gameId, other), push); } } function broadcastLobby(gameId: string): void { const lobby = lobbies.get(gameId); const conns = lobbyConnections.get(gameId); if (!lobby || !conns) return; for (const [token, res] of conns) { const ps = sessions.get(token); if (!ps) continue; writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush); } } async function persistLobby(lobby: Lobby): Promise { lobbies.set(lobby.gameId, lobby); gameCodes.set(lobby.gameCode, lobby.gameId); await writeLobby(opts.dataDir, lobby); await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'lobby' }); } async function persistSession(ps: PlayerSession): Promise { sessions.set(ps.token, ps); const all = [...sessions.values()].filter((s) => s.gameId === ps.gameId); await writeSessions(opts.dataDir, ps.gameId, all); } const server = createServer((req, res) => { void (async () => { const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); // -- Is anyone home? -------------------------------------------------------------------- /** * THE ONE ROUTE THAT EXISTS TO BE FAILED. * * The same `dist/` is served two ways: by this server, and as a plain static upload with no * server behind it at all (`scripts/deploy-web.ts`). The bundle is byte-identical either way * — one client, mode decided at runtime (D4) — so the page cannot know from its own build * which it is, and every other route here answers a 404 for a path it does not have, exactly * as a static host would. Nothing distinguished them until this did. * * Unauthenticated on purpose: it says only that a Station Master server is answering, which * is what the client is about to offer the player anyway. It reveals no game and no seat. */ if (url.pathname === '/api/health' && req.method === 'GET') { sendJson(res, 200, { ok: true, service: 'station-master', engineVersion: opts.engineVersion }); return; } // -- Lobby: creating and joining (the door — join-secret gated) -------------------------- if (url.pathname === '/api/lobby/create' && req.method === 'POST') { const body = (await readJson(req)) as { secret?: string; config?: GameConfig; displayName?: string }; if (body.secret !== opts.joinSecret) { sendJson(res, 403, { error: 'bad or missing secret' }); return; } if (!body.config || typeof body.displayName !== 'string' || body.displayName.trim() === '') { sendJson(res, 400, { error: 'expected { secret, config, displayName }' }); return; } const gameCode = freshGameCode((code) => gameCodes.has(code)); const { lobby, session } = createLobby(body.config, body.displayName.trim(), gameCode); await persistLobby(lobby); await persistSession(session); sendJson(res, 200, { gameId: lobby.gameId, gameCode: lobby.gameCode, token: session.token, player: session.player }); return; } if (url.pathname === '/api/lobby/join' && req.method === 'POST') { const body = (await readJson(req)) as { secret?: string; gameCode?: string; displayName?: string }; if (body.secret !== opts.joinSecret) { sendJson(res, 403, { error: 'bad or missing secret' }); return; } if (typeof body.gameCode !== 'string' || typeof body.displayName !== 'string' || body.displayName.trim() === '') { sendJson(res, 400, { error: 'expected { secret, gameCode, displayName }' }); return; } const gameId = gameCodes.get(body.gameCode.trim().toUpperCase()); const lobby = gameId ? lobbies.get(gameId) : undefined; if (!lobby) { // A game code that already started is no longer in `lobbies` at all — same NOT_FOUND a // typo gets, which tells a latecomer "that game is gone" without leaking which case it was. sendJson(res, 404, { error: 'no open lobby with that code' }); return; } const result = joinLobby(lobby, body.displayName.trim()); if (!result.ok) { sendJson(res, 409, { error: result.code }); return; } await persistLobby(result.lobby); await persistSession(result.session); broadcastLobby(lobby.gameId); sendJson(res, 200, { gameId: lobby.gameId, token: result.session.token, player: result.session.player }); return; } // -- Lobby: seating, once inside (token-authenticated) ------------------------------------ if (url.pathname === '/api/lobby/bot' && req.method === 'POST') { const body = (await readJson(req)) as { token?: string; seat?: number; filled?: boolean }; const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined; const lobby = ps ? lobbies.get(ps.gameId) : undefined; if (!ps || !lobby) { sendJson(res, 404, { error: 'no such lobby' }); return; } if (lobby.hostToken !== ps.token) { sendJson(res, 403, { error: 'NOT_HOST' }); return; } if (typeof body.seat !== 'number' || typeof body.filled !== 'boolean') { sendJson(res, 400, { error: 'expected { token, seat, filled }' }); return; } const updated = setBotSeat(lobby, body.seat as PlayerIndex, body.filled); await persistLobby(updated); broadcastLobby(lobby.gameId); sendJson(res, 200, { ok: true }); return; } if (url.pathname === '/api/lobby/start' && req.method === 'POST') { const body = (await readJson(req)) as { token?: string }; const ps = typeof body.token === 'string' ? sessions.get(body.token) : undefined; const lobby = ps ? lobbies.get(ps.gameId) : undefined; if (!ps || !lobby) { sendJson(res, 404, { error: 'no such lobby' }); return; } const result = startLobby(lobby, ps.token); if (!result.ok) { sendJson(res, 409, { error: result.code }); return; } const session = createSession(Math.floor(Math.random() * 1e9), lobby.config, result.playerNames, result.botSeats); games.set(lobby.gameId, session); lobbies.delete(lobby.gameId); // Every SSE watcher on the LOBBY stream is done — the game stream is what carries the game // forward from here. `started: true` on one last message, THEN close, is what lets a // still-open lobby tab tell "the game began" apart from a network hiccup `EventSource` // would otherwise silently retry through. for (const [watcherToken, watcherRes] of lobbyConnections.get(lobby.gameId) ?? []) { const watcherPs = sessions.get(watcherToken); if (watcherPs) writeSse(watcherRes, 0, { lobby, you: watcherPs.player, started: true } satisfies LobbyPush); watcherRes.end(); } lobbyConnections.delete(lobby.gameId); await writeGame(gameDir(opts.dataDir, lobby.gameId), session.exportSave(), opts.engineVersion); await upsertIndexEntry(opts.dataDir, { gameId: lobby.gameId, gameCode: lobby.gameCode, status: 'active' }); await deleteLobby(opts.dataDir, lobby.gameId); sendJson(res, 200, { ok: true }); return; } if (url.pathname === '/api/lobby/stream' && req.method === 'GET') { const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); const lobby = ps ? lobbies.get(ps.gameId) : undefined; if (!ps || !lobby) { sendJson(res, 404, { error: 'no such lobby' }); return; } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); const conns = lobbyConnections.get(lobby.gameId) ?? new Map(); conns.set(token, res); lobbyConnections.set(lobby.gameId, conns); writeSse(res, 0, { lobby, you: ps.player, started: false } satisfies LobbyPush); const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); req.on('close', () => { clearInterval(heartbeat); const live = lobbyConnections.get(lobby.gameId); if (live?.get(token) === res) live.delete(token); // `lobby-and-sessions.md` §2 — host rights pass to the earliest-joined remaining player // if the host's connection closes before start. `lobbies.get` again, not the captured // `lobby`, because it may have changed (another join, another bot toggle) since connect. const current = lobbies.get(lobby.gameId); if (current && current.hostToken === token) { void persistLobby(reassignHost(current, token)).then(() => broadcastLobby(lobby.gameId)); } }); return; } // -- The running game (token-authenticated) ------------------------------------------------ if (url.pathname === '/api/stream' && req.method === 'GET') { const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); const session = ps ? games.get(ps.gameId) : undefined; if (!ps || !session) { sendJson(res, 404, { error: 'no such game' }); return; } const { gameId, player: seat } = ps; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }); const conns = gameConnections.get(gameId) ?? new Map(); conns.set(seat, res); gameConnections.set(gameId, conns); writeSse(res, nextEventId(gameId, seat), session.connect(seat)); broadcastPresence(gameId, seat, true); // Idle for minutes at a time is the expected shape of this game (multiplayer.md §9) — a // silent SSE connection is exactly what a proxy in the path may reap. A comment line is not a // real event (EventSource ignores lines starting with `:`), so it costs the client nothing. const heartbeat = setInterval(() => res.write(': ping\n\n'), HEARTBEAT_MS); req.on('close', () => { clearInterval(heartbeat); const live = gameConnections.get(gameId); if (live?.get(seat) === res) live.delete(seat); broadcastPresence(gameId, seat, false); }); return; } if (url.pathname === '/api/intent' && req.method === 'POST') { // Token comes from the QUERY STRING, matching `/api/stream` and matching what // `web/session.ts`'s `RemoteSession.submit` actually sends (`fetch('/api/intent?token=…')`) // — the body carries only what changes per call, `{ seq, intent }`. const token = url.searchParams.get('token') ?? ''; const ps = sessions.get(token); const session = ps ? games.get(ps.gameId) : undefined; if (!ps || !session) { sendJson(res, 404, { error: 'no such game' }); return; } const body = (await readJson(req)) as { seq?: number; intent?: Intent }; if (typeof body.seq !== 'number' || !body.intent) { sendJson(res, 400, { error: 'expected { seq, intent }' }); return; } const result = session.intent(ps.player, body.seq, body.intent); if (result.accepted) { // Persisted BEFORE the response goes out — "accepted" should mean "durably on disk" at // this scale, not just "applied in memory" (§12 step 14). const dir = gameDir(opts.dataDir, ps.gameId); await writeGame(dir, session.exportSave(), opts.engineVersion); if (result.timing) await appendTiming(dir, result.timing); if (session.exportSave().status === 'finished') { // `upsertIndexEntry` replaces the WHOLE row for this `gameId`, so the code has to be // carried forward here rather than left blank — `gameCodes` is the only place still // holding it once a lobby's own record is gone. const gameCode = [...gameCodes.entries()].find(([, id]) => id === ps.gameId)?.[0] ?? ''; await upsertIndexEntry(opts.dataDir, { gameId: ps.gameId, gameCode, status: 'finished' }); } } sendJson(res, 200, result.accepted ? { ok: true } : { ok: false, code: result.code }); if (result.accepted) broadcastGame(ps.gameId, result.pushes); return; } await serveStatic(opts.distDir, url.pathname, res); })().catch((err: unknown) => { sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' }); }); }); server.listen(opts.port, opts.bindAddress); }