@joystyk/rooms 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +147 -2
  3. package/dist/canonical.d.ts +10 -0
  4. package/dist/canonical.js +30 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/core.d.ts +3 -0
  7. package/dist/core.js +1879 -0
  8. package/dist/core.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.js +7 -0
  11. package/dist/names.d.ts +19 -0
  12. package/dist/names.js +28 -0
  13. package/dist/names.js.map +1 -0
  14. package/dist/node/host.d.ts +67 -0
  15. package/dist/node/host.js +323 -0
  16. package/dist/node/host.js.map +1 -0
  17. package/dist/node/index.d.ts +6 -0
  18. package/dist/node/index.js +7 -0
  19. package/dist/node/jobs.d.ts +48 -0
  20. package/dist/node/jobs.js +176 -0
  21. package/dist/node/jobs.js.map +1 -0
  22. package/dist/node/registry.d.ts +32 -0
  23. package/dist/node/registry.js +43 -0
  24. package/dist/node/registry.js.map +1 -0
  25. package/dist/node/server.d.ts +59 -0
  26. package/dist/node/server.js +577 -0
  27. package/dist/node/server.js.map +1 -0
  28. package/dist/node/store.d.ts +53 -0
  29. package/dist/node/store.js +245 -0
  30. package/dist/node/store.js.map +1 -0
  31. package/dist/node/support.d.ts +67 -0
  32. package/dist/node/support.js +126 -0
  33. package/dist/node/support.js.map +1 -0
  34. package/dist/policy.d.ts +15 -0
  35. package/dist/policy.js +41 -0
  36. package/dist/policy.js.map +1 -0
  37. package/dist/protocol/index.d.ts +43 -0
  38. package/dist/protocol/index.js +48 -0
  39. package/dist/protocol/index.js.map +1 -0
  40. package/dist/protocol/schemas.d.ts +792 -0
  41. package/dist/protocol/schemas.js +332 -0
  42. package/dist/protocol/schemas.js.map +1 -0
  43. package/dist/protocol/types.d.ts +279 -0
  44. package/dist/protocol/types.js +1 -0
  45. package/dist/record.d.ts +173 -0
  46. package/dist/record.js +181 -0
  47. package/dist/record.js.map +1 -0
  48. package/dist/types.d.ts +374 -0
  49. package/dist/types.js +15 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +56 -11
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","names":["RoomCodeSchema"],"sources":["../../src/node/server.ts"],"sourcesContent":["// The room server's HTTP and WebSocket front (room-server.md sections 4, 5 and 8): limits,\n// decoding, room creation, health checks and draining. Everything that changes a room goes\n// through RoomHost.dispatch.\nimport { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';\nimport type { Duplex } from 'node:stream';\nimport type { Json, StandardSchemaV1 } from '@joystyk/contracts';\nimport { rateLimitKey } from '@joystyk/ops';\nimport { checkDisplayName } from '@joystyk/identity/names';\nimport { WebSocket, WebSocketServer, type RawData } from 'ws';\nimport {\n ClientMessageSchema,\n CloseCode,\n CreateRoomRequestSchema,\n isSupportedProtocol,\n PROTOCOL_VERSION,\n RejectionCode,\n RoomCode as RoomCodeSchema,\n SUPPORTED_PROTOCOLS,\n type CreateRoomResponse,\n type HostedGames,\n type NoticeCode,\n type RoomInfo,\n type ServerMessage,\n} from '../protocol/index.ts';\nimport { nameReplacedMessage } from '../names.ts';\nimport type { MatchSeeds, RoomCode } from '../types.ts';\nimport { RoomHost, type Connection } from './host.ts';\nimport { inlineJobPool, type JobPool } from './jobs.ts';\nimport type { EngineRegistry } from './registry.ts';\nimport { expiresAt, type RoomStore } from './store.ts';\nimport {\n clientAddress,\n createLogger,\n hashToken,\n newRoomCode,\n newSeatToken,\n parseTrustedProxy,\n systemClock,\n TokenBucket,\n WindowCounter,\n type Clock,\n type Logger,\n} from './support.ts';\n\nexport type ServerLimits = {\n /** Incoming message size in bytes (`ws` maxPayload). */\n readonly maxPayload: number;\n readonly messagesPerSecond: number;\n readonly messageBurst: number;\n /** Over-limit messages tolerated before closing with 4008. */\n readonly rateStrikes: number;\n /** Only over-limit messages within this rolling window count toward closing. */\n readonly rateStrikeWindowMs: number;\n readonly roomsPerIpPerHour: number;\n readonly joinMissesPerIpPer10Min: number;\n readonly connectionsPerIp: number;\n readonly maxConnections: number;\n readonly maxRooms: number;\n readonly outgoingBufferBytes: number;\n readonly heartbeatMs: number;\n readonly idleUnloadMs: number;\n readonly maintenanceMs: number;\n readonly createBodyBytes: number;\n readonly drainJobsMs: number;\n readonly persistRetryMs: number;\n /** How long a heavy AI may choose on the pool before its seat times out. */\n readonly aiTimeoutMs: number;\n};\n\nexport const DEFAULT_SERVER_LIMITS: ServerLimits = {\n maxPayload: 2048,\n messagesPerSecond: 10,\n messageBurst: 20,\n rateStrikes: 20,\n rateStrikeWindowMs: 60_000,\n roomsPerIpPerHour: 20,\n joinMissesPerIpPer10Min: 20,\n connectionsPerIp: 16,\n maxConnections: 2000,\n maxRooms: 1000,\n outgoingBufferBytes: 256 * 1024,\n heartbeatMs: 25_000,\n idleUnloadMs: 10 * 60_000,\n maintenanceMs: 60_000,\n createBodyBytes: 4096,\n drainJobsMs: 10_000,\n persistRetryMs: 5_000,\n aiTimeoutMs: 30_000,\n};\n\nexport type RoomServerOptions = {\n readonly store: RoomStore;\n readonly registry: EngineRegistry;\n /** Fresh engine and AI seeds from the CSPRNG, in the rng algorithm the registry's cores use. */\n readonly seeds: () => MatchSeeds;\n readonly pool?: JobPool;\n readonly clock?: Clock;\n readonly log?: Logger;\n /** Exactly this origin may create rooms and open sockets. Null allows any (development only). */\n readonly publicOrigin?: string | null;\n /** Extra origins allowed in development, such as the Vite dev server. */\n readonly devOrigins?: readonly string[];\n /** Traefik's address or CIDR range; only it may report the client address. */\n readonly trustedProxy?: string | null;\n /** The header Traefik reports the client address in; otherwise the last X-Forwarded-For entry. */\n readonly clientIpHeader?: string | null;\n readonly limits?: Partial<ServerLimits>;\n};\n\nexport interface RoomServer {\n readonly http: Server;\n readonly host: RoomHost;\n /** Listens and resumes rooms with outstanding jobs. Resolves with the bound port. */\n listen(port: number, hostname?: string): Promise<number>;\n /** SIGTERM: refuse new work, let jobs finish, tell clients to reconnect, close the store. */\n drain(): Promise<void>;\n}\n\ntype ConnState = Connection & {\n readonly socket: WebSocket;\n readonly ip: string;\n readonly bucket: TokenBucket;\n room: RoomCode | null;\n strikes: number[];\n alive: boolean;\n};\n\n/** Validates synchronously; schemas that answer with a Promise are refused (they would reorder messages). */\nconst validate = <T>(schema: StandardSchemaV1<unknown, T>, value: unknown): { ok: true; value: T } | { ok: false } => {\n const result = schema['~standard'].validate(value);\n if (result instanceof Promise || result.issues !== undefined) return { ok: false };\n return { ok: true, value: result.value };\n};\n\nexport const createRoomServer = (options: RoomServerOptions): RoomServer => {\n const limits: ServerLimits = { ...DEFAULT_SERVER_LIMITS, ...options.limits };\n const clock = options.clock ?? systemClock;\n const log = options.log ?? createLogger();\n const pool = options.pool ?? inlineJobPool(() => ({ ok: false, reason: 'failed' }));\n const { store, registry } = options;\n const trusted = options.trustedProxy ? parseTrustedProxy(options.trustedProxy) : null;\n const header = options.clientIpHeader ?? null;\n const publicOrigin = options.publicOrigin ?? null;\n const allowedOrigins = new Set([...(publicOrigin === null ? [] : [publicOrigin]), ...(options.devOrigins ?? [])]);\n\n let draining = false;\n let connSequence = 0;\n const host = new RoomHost({\n store,\n registry,\n clock,\n pool,\n log,\n maxRooms: limits.maxRooms,\n outgoingBufferBytes: limits.outgoingBufferBytes,\n persistRetryMs: limits.persistRetryMs,\n aiTimeoutMs: limits.aiTimeoutMs,\n draining: () => draining,\n });\n const creations = new WindowCounter(3_600_000, limits.roomsPerIpPerHour);\n const misses = new WindowCounter(600_000, limits.joinMissesPerIpPer10Min);\n const perIp = new Map<string, number>();\n const sockets = new WebSocketServer({\n noServer: true,\n maxPayload: limits.maxPayload,\n perMessageDeflate: { threshold: 1024 },\n });\n\n const originAllowed = (origin: string | undefined): boolean => {\n if (origin === undefined) return true; // Not a browser: curl, server-to-server, tests.\n if (publicOrigin === null) return true;\n return allowedOrigins.has(origin);\n };\n\n const rateLimited = (limit: string): void => log.write('warn', 'rate_limited', { limit });\n\n // ── HTTP ──────────────────────────────────────────────────────────────────────────────\n\n const json = (res: ServerResponse, status: number, value: Json, headers: Record<string, string> = {}): void => {\n res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers });\n res.end(JSON.stringify(value));\n };\n\n const readBody = (req: IncomingMessage, max: number): Promise<string | null> =>\n new Promise((resolve) => {\n const chunks: Buffer[] = [];\n let size = 0;\n let done = false;\n req.on('data', (chunk: Buffer) => {\n if (done) return;\n size += chunk.length;\n if (size > max) {\n done = true;\n resolve(null);\n req.resume();\n return;\n }\n chunks.push(chunk);\n });\n req.on('end', () => {\n if (!done) resolve(Buffer.concat(chunks).toString('utf8'));\n });\n req.on('error', () => {\n if (!done) resolve(null);\n });\n });\n\n const createRoom = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n if (draining)\n return json(res, 503, { error: 'The server is restarting. Try again in a moment.' }, { 'retry-after': '5' });\n if (!originAllowed(req.headers.origin)) return json(res, 403, { error: 'Origin refused.' });\n const body = await readBody(req, limits.createBodyBytes);\n if (body === null) return json(res, 413, { error: 'The request is too large.' });\n let raw: unknown;\n try {\n raw = JSON.parse(body);\n } catch {\n return json(res, 400, { error: 'The request is not JSON.' });\n }\n const parsed = CreateRoomRequestSchema.safeParse(raw);\n if (!parsed.success) return json(res, 400, { error: 'The request is not valid.' });\n const request = parsed.data;\n const now = clock.now();\n const ip = rateLimitKey(clientAddress(req, trusted, header));\n if (!creations.allow(ip, now)) {\n rateLimited('rooms-per-ip');\n return json(res, 429, { error: 'Too many rooms. Try again later.' }, { 'retry-after': '600' });\n }\n if (host.rooms.size >= limits.maxRooms)\n return json(res, 503, { error: 'The server is full. Try again soon.' }, { 'retry-after': '30' });\n const current = registry.current(request.game);\n if (current === null) return json(res, 404, { error: 'That game has no online rooms.' });\n const config = validate(current.core.engine.schemas.config, request.config ?? {});\n if (!config.ok) return json(res, 400, { error: 'That game configuration is not valid.' });\n // A per-move clock and a bank exclude each other: choosing one turns the other off.\n const { decisionMs, bank } = request.settings ?? {};\n let policy = current.policy;\n if (decisionMs !== undefined) policy = { ...policy, decisionMs, ...(decisionMs === null ? {} : { bank: null }) };\n if (bank !== undefined) policy = { ...policy, bank, ...(bank === null ? {} : { decisionMs: null }) };\n let code = newRoomCode();\n while (store.exists(code)) code = newRoomCode();\n const token = newSeatToken();\n // The core filters the name and generates one in place of a refused name; the check here\n // only words the notice.\n const created = current.core.create(\n {\n code,\n policy,\n config: config.value,\n seeds: options.seeds(),\n creator: { name: request.name, tokenHash: hashToken(token) },\n },\n now,\n );\n if (!created.ok) {\n if (created.error.diagnostic !== undefined)\n log.write('error', 'room_init_failed', { game: current.pin.id, ...created.error.diagnostic });\n return json(res, 400, { error: created.error.message });\n }\n store.insert(created.value);\n log.write('info', 'room_created', { room: code, game: current.pin.id, rules: current.pin.rulesVersion });\n const name = created.value.seats[0]?.claim?.name ?? request.name;\n const checked = checkDisplayName(request.name);\n const response: CreateRoomResponse = {\n room: code,\n token,\n name,\n ...(checked.ok ? {} : { notice: { code: 'name-replaced', message: nameReplacedMessage(checked.error, name) } }),\n game: current.pin,\n expiresAt: expiresAt(created.value),\n };\n json(res, 201, response);\n };\n\n const roomInfo = (req: IncomingMessage, res: ServerResponse, code: string): void => {\n const ip = rateLimitKey(clientAddress(req, trusted, header));\n const now = clock.now();\n if (!misses.allow(ip, now, false)) {\n rateLimited('join-misses');\n return json(res, 429, { error: 'Too many unknown rooms. Try again later.' });\n }\n const record = RoomCodeSchema.safeParse(code).success\n ? (host.rooms.get(code)?.room.record ?? store.load(code))\n : null;\n if (record === null || record.phase === 'closed') {\n misses.hit(ip, now);\n return json(res, 404, { error: 'No such room.' });\n }\n const open =\n record.phase === 'lobby'\n ? record.seats.filter((s) => s.claim === null && s.controller === 'human').length\n : record.policy.lateJoin === 'take-ai-seat'\n ? record.seats.filter((s) => s.claim === null && s.controller === 'ai').length\n : 0;\n const info: RoomInfo = {\n game: record.match?.game ?? record.game,\n phase: record.phase,\n seatsOpen: open,\n spectatorsAllowed: record.policy.maxSpectators > 0,\n };\n json(res, 200, info);\n };\n\n /** The games this build opens rooms for, and its protocol versions. Cheap and public, so it may be cached briefly. */\n const hostedGames = (res: ServerResponse): void => {\n const hosted: HostedGames = {\n protocol: PROTOCOL_VERSION,\n protocols: [...SUPPORTED_PROTOCOLS],\n games: [...registry.games()],\n };\n json(res, 200, hosted, { 'cache-control': 'public, max-age=60' });\n };\n\n const http = createServer((req, res) => {\n const url = new URL(req.url ?? '/', 'http://rooms.invalid');\n const path = url.pathname;\n const route = async (): Promise<void> => {\n if (path === '/healthz') return json(res, 200, { ok: true });\n if (path === '/readyz')\n return draining || !store.ping() ? json(res, 503, { ok: false }) : json(res, 200, { ok: true });\n if (path === '/api/rooms' && req.method === 'POST') return createRoom(req, res);\n // Before the room-code route: \"games\" is never a code (codes are six capitals and digits).\n if (path === '/api/rooms/games' && req.method === 'GET') return hostedGames(res);\n const match = /^\\/api\\/rooms\\/([^/]+)$/.exec(path);\n if (match !== null && req.method === 'GET') return roomInfo(req, res, match[1] ?? '');\n json(res, 404, { error: 'Not found.' });\n };\n route().catch((error: unknown) => {\n log.write('error', 'http_error', { path, error: String(error) });\n if (!res.headersSent) json(res, 500, { error: 'The room server could not complete this request.' });\n else res.end();\n });\n });\n\n // ── WebSockets ────────────────────────────────────────────────────────────────────────\n\n const refuseUpgrade = (socket: Duplex, status: string, extra = ''): void => {\n socket.end(`HTTP/1.1 ${status}\\r\\nConnection: close\\r\\nContent-Length: 0\\r\\n${extra}\\r\\n`);\n };\n\n http.on('upgrade', (req, socket, head) => {\n const path = new URL(req.url ?? '/', 'http://rooms.invalid').pathname;\n if (path !== '/ws') return refuseUpgrade(socket, '404 Not Found');\n if (draining) return refuseUpgrade(socket, '503 Service Unavailable', 'Retry-After: 5\\r\\n');\n if (!originAllowed(req.headers.origin)) return refuseUpgrade(socket, '403 Forbidden');\n if (sockets.clients.size >= limits.maxConnections)\n return refuseUpgrade(socket, '503 Service Unavailable', 'Retry-After: 10\\r\\n');\n const ip = rateLimitKey(clientAddress(req, trusted, header));\n if ((perIp.get(ip) ?? 0) >= limits.connectionsPerIp) {\n rateLimited('connections-per-ip');\n return refuseUpgrade(socket, '429 Too Many Requests');\n }\n sockets.handleUpgrade(req, socket, head, (ws) => accept(ws, ip));\n });\n\n const notice = (conn: ConnState, code: NoticeCode, message: string): void => {\n conn.send(JSON.stringify({ v: conn.protocol, t: 'notice', code, message } satisfies ServerMessage));\n };\n\n /** Connection state per socket, for the heartbeat. */\n const connOf = new WeakMap<WebSocket, ConnState>();\n\n const accept = (ws: WebSocket, ip: string): void => {\n perIp.set(ip, (perIp.get(ip) ?? 0) + 1);\n connSequence += 1;\n const conn: ConnState = {\n id: `conn-${connSequence}`,\n protocol: PROTOCOL_VERSION,\n pendingToken: null,\n socket: ws,\n ip,\n bucket: new TokenBucket(limits.messagesPerSecond, limits.messageBurst, clock.now()),\n room: null,\n strikes: [],\n alive: true,\n get bufferedAmount() {\n return ws.bufferedAmount;\n },\n send: (text) => {\n if (ws.readyState === WebSocket.OPEN) ws.send(text);\n },\n close: (code, reason) => {\n if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)\n ws.close(code, reason.slice(0, 120));\n },\n };\n connOf.set(ws, conn);\n ws.on(\n 'pong', // ip-note: the WebSocket heartbeat frame, not the game\n () => {\n conn.alive = true;\n },\n );\n ws.on('message', (data, isBinary) => onMessage(conn, data, isBinary));\n ws.on('error', () => ws.terminate());\n ws.on('close', () => {\n const count = (perIp.get(ip) ?? 1) - 1;\n if (count <= 0) perIp.delete(ip);\n else perIp.set(ip, count);\n if (conn.room === null) return;\n const live = host.rooms.get(conn.room);\n if (live?.conns.get(conn.id) !== conn) return;\n live.conns.delete(conn.id);\n if (live.conns.size === 0) live.idleSince = clock.now();\n host.dispatch(conn.room, { type: 'leave', conn: conn.id });\n });\n };\n\n const onMessage = (conn: ConnState, data: RawData, isBinary: boolean): void => {\n const now = clock.now();\n if (!conn.bucket.take(now)) {\n conn.strikes = conn.strikes.filter((at) => at > now - limits.rateStrikeWindowMs);\n conn.strikes.push(now);\n if (conn.strikes.length > limits.rateStrikes) {\n rateLimited('messages');\n conn.close(CloseCode.policy, 'too fast');\n } else {\n notice(conn, 'too-fast', 'Slow down.');\n }\n return;\n }\n if (isBinary) return notice(conn, 'bad-message', 'Messages are JSON text.');\n let raw: unknown;\n try {\n raw = JSON.parse(String(data));\n } catch {\n return notice(conn, 'bad-message', 'That message is not JSON.');\n }\n const version = typeof raw === 'object' && raw !== null ? (raw as { v?: unknown }).v : undefined;\n if (typeof version === 'number' && !isSupportedProtocol(version)) {\n notice(conn, 'protocol-unsupported', 'Reload the page to get the latest version.');\n conn.close(CloseCode.policy, 'protocol unsupported');\n return;\n }\n const parsed = ClientMessageSchema.safeParse(raw);\n if (!parsed.success) return notice(conn, 'bad-message', 'That message is not valid.');\n const message = parsed.data;\n if (message.t === 'join') return join(conn, message, now);\n const code = conn.room;\n const live = code === null ? undefined : host.rooms.get(code);\n if (code === null || live === undefined) return notice(conn, 'bad-message', 'Join a room first.');\n switch (message.t) {\n case 'act': {\n const command = validate(live.core.engine.schemas.command, message.command);\n if (!command.ok) {\n conn.send(\n JSON.stringify({\n v: conn.protocol,\n t: 'receipt',\n id: message.id,\n result: {\n status: 'rejected',\n code: RejectionCode.badCommand,\n message: 'That move is not valid.',\n revision: live.room.record.revision,\n },\n } satisfies ServerMessage),\n );\n return;\n }\n host.dispatch(code, {\n type: 'act',\n conn: conn.id,\n id: message.id,\n expectedRevision: message.rev,\n command: command.value,\n });\n return;\n }\n case 'control': {\n let control = message.control;\n if (control.kind === 'settings' && control.config !== undefined) {\n const engine = registry.current(live.room.record.game.id)?.core.engine ?? live.core.engine;\n const config = validate(engine.schemas.config, control.config);\n if (!config.ok) {\n conn.send(\n JSON.stringify({\n v: conn.protocol,\n t: 'receipt',\n id: message.id,\n result: {\n status: 'rejected',\n code: RejectionCode.badCommand,\n message: 'Those settings are not valid.',\n revision: live.room.record.revision,\n },\n } satisfies ServerMessage),\n );\n return;\n }\n control = { ...control, config: config.value };\n }\n host.dispatch(code, {\n type: 'control',\n conn: conn.id,\n id: message.id,\n expectedRevision: message.rev,\n control,\n seeds: options.seeds(),\n });\n return;\n }\n case 'resync':\n host.dispatch(code, { type: 'resync', conn: conn.id });\n return;\n }\n };\n\n const join = (\n conn: ConnState,\n message: Extract<ReturnType<typeof ClientMessageSchema.parse>, { t: 'join' }>,\n now: number,\n ): void => {\n if (conn.room !== null) return notice(conn, 'bad-message', 'This connection already joined a room.');\n conn.protocol = message.v;\n if (!misses.allow(conn.ip, now, false)) {\n rateLimited('join-misses');\n notice(conn, 'too-fast', 'Too many unknown rooms. Try again later.');\n conn.close(CloseCode.policy, 'too many misses');\n return;\n }\n if (draining) {\n conn.send(\n JSON.stringify({\n v: conn.protocol,\n t: 'closing',\n reason: 'restart',\n retryAfterMs: 2000,\n } satisfies ServerMessage),\n );\n conn.close(CloseCode.restarting, 'restarting');\n return;\n }\n let loaded: ReturnType<RoomHost['load']>;\n try {\n loaded = host.load(message.room);\n } catch (error) {\n log.write('error', 'room_load_failed', { room: message.room, error: String(error) });\n loaded = { problem: 'busy' };\n }\n if ('problem' in loaded) {\n if (loaded.problem === 'busy') {\n notice(conn, 'server-busy', 'The server is busy. Try again soon.');\n conn.close(CloseCode.tryAgainLater, 'busy');\n } else if (loaded.problem === 'engine-missing') {\n conn.send(\n JSON.stringify({\n v: conn.protocol,\n t: 'closing',\n reason: 'closed',\n retryAfterMs: null,\n } satisfies ServerMessage),\n );\n conn.close(CloseCode.noSuchRoom, 'closed');\n } else {\n misses.hit(conn.ip, now);\n notice(conn, 'no-such-room', 'This room has closed.');\n conn.close(CloseCode.noSuchRoom, 'no such room');\n }\n return;\n }\n const live = loaded.live;\n if (live.room.record.game.id !== message.game) {\n misses.hit(conn.ip, now);\n notice(conn, 'no-such-room', 'This room has closed.');\n conn.close(CloseCode.noSuchRoom, 'no such room');\n return;\n }\n conn.room = message.room;\n live.conns.set(conn.id, conn);\n const fresh = newSeatToken();\n conn.pendingToken = fresh;\n host.dispatch(message.room, {\n type: 'join',\n conn: conn.id,\n name: message.name,\n protocol: message.v,\n presentedTokenHash: message.token === undefined ? null : hashToken(message.token),\n freshTokenHash: hashToken(fresh),\n watch: message.watch ?? false,\n });\n conn.pendingToken = null;\n };\n\n // ── Timers, start-up and draining ─────────────────────────────────────────────────────\n\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let maintenance: ReturnType<typeof setInterval> | null = null;\n\n const startTimers = (): void => {\n heartbeat = setInterval(() => {\n for (const ws of sockets.clients) {\n const conn = connOf.get(ws);\n if (conn === undefined) continue;\n if (!conn.alive) {\n ws.terminate();\n continue;\n }\n conn.alive = false;\n ws.ping();\n }\n }, limits.heartbeatMs);\n maintenance = setInterval(() => {\n const now = clock.now();\n creations.prune(now);\n misses.prune(now);\n host.sweep(limits.idleUnloadMs);\n try {\n const pruned = store.prune(now);\n if (pruned > 0) log.write('info', 'rooms_pruned', { rooms: pruned });\n } catch (error) {\n log.write('error', 'prune_failed', { error: String(error) });\n }\n }, limits.maintenanceMs);\n heartbeat.unref();\n maintenance.unref();\n };\n\n const listen = async (port: number, hostname = '0.0.0.0'): Promise<number> => {\n await new Promise<void>((resolve, reject) => {\n http.once('error', reject);\n http.listen(port, hostname, () => {\n http.off('error', reject);\n resolve();\n });\n });\n startTimers();\n // Resume on start: rooms with an outstanding job load now, so the job re-runs at once.\n for (const code of store.withPendingJobs()) {\n try {\n host.load(code);\n } catch (error) {\n log.write('error', 'room_load_failed', { room: code, error: String(error) });\n }\n }\n const address = http.address();\n const bound = typeof address === 'object' && address !== null ? address.port : port;\n log.write('info', 'listening', { port: bound });\n return bound;\n };\n\n const drain = async (): Promise<void> => {\n if (draining) return;\n draining = true;\n log.write('info', 'drain_started', { rooms: host.rooms.size, connections: sockets.clients.size });\n await pool.idle(limits.drainJobsMs);\n const closing = JSON.stringify({\n v: PROTOCOL_VERSION,\n t: 'closing',\n reason: 'restart',\n retryAfterMs: 2000,\n } satisfies ServerMessage);\n const open = [...sockets.clients];\n for (const ws of open) {\n if (ws.readyState === WebSocket.OPEN) ws.send(closing);\n ws.close(CloseCode.restarting, 'restarting');\n }\n for (const code of [...host.rooms.keys()]) host.unload(code, 'drain');\n if (heartbeat !== null) clearInterval(heartbeat);\n if (maintenance !== null) clearInterval(maintenance);\n // Give clients a moment to finish the closing handshake, then cut whoever hasn't.\n const closed = Promise.all(\n open.map((ws) =>\n ws.readyState === WebSocket.CLOSED\n ? Promise.resolve()\n : new Promise<void>((resolve) => ws.once('close', () => resolve())),\n ),\n );\n await Promise.race([closed, new Promise<void>((resolve) => setTimeout(resolve, 2_000).unref())]);\n for (const ws of sockets.clients) ws.terminate();\n await new Promise<void>((resolve) => sockets.close(() => resolve()));\n await new Promise<void>((resolve) => {\n http.close(() => resolve());\n http.closeAllConnections();\n });\n await pool.close();\n store.close();\n log.write('info', 'drain_finished', {});\n };\n\n return { http, host, listen, drain };\n};\n"],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import type { LoggedInput, RoomCode, RoomRecord } from '../types.js';
2
+ import type { Logger } from './support.js';
3
+ export interface RoomStore {
4
+ /** The decoded record, or null when missing or quarantined. Throws on a newer record format. */
5
+ load(code: RoomCode): RoomRecord | null;
6
+ exists(code: RoomCode): boolean;
7
+ /** One transaction: update the room where writes = expected, and append the inputs. */
8
+ save(record: RoomRecord, inputs: readonly LoggedInput[], expectedWrites: number): 'saved' | 'conflict';
9
+ insert(record: RoomRecord): void;
10
+ /** Moves a row that can't be used to `room_archive` and deletes it. */
11
+ quarantine(code: RoomCode, reason: string): void;
12
+ liveVersions(): readonly {
13
+ readonly gameId: string;
14
+ readonly rulesVersion: number;
15
+ readonly rooms: number;
16
+ }[];
17
+ /** Rooms with a job outstanding: they resume at start-up, so the job re-runs at once. */
18
+ withPendingJobs(): readonly RoomCode[];
19
+ /** A match's input log, for replays and bug reports. */
20
+ inputs(code: RoomCode, match: number): readonly LoggedInput[];
21
+ /** Deletes rows past expires_at (their inputs cascade). Returns how many. */
22
+ prune(now: number): number;
23
+ /** True when the database accepts a write (for /readyz). */
24
+ ping(): boolean;
25
+ /** An online copy, checked with `PRAGMA integrity_check`. Refuses to overwrite a file. */
26
+ backup(file: string): Promise<void>;
27
+ /** After a restore from backup: bump every room's epoch so clients accept older revisions. */
28
+ bumpEpochs(): number;
29
+ close(): void;
30
+ }
31
+ type Migration = {
32
+ readonly version: number;
33
+ readonly name: string;
34
+ readonly sql: string;
35
+ };
36
+ /**
37
+ * Database migrations, applied in order inside one transaction and tracked with
38
+ * `PRAGMA user_version`. Append; never edit a shipped entry.
39
+ */
40
+ export declare const MIGRATIONS: readonly Migration[];
41
+ export declare const SCHEMA_VERSION: number;
42
+ /** When a room's row may be deleted: retainMs after its last change, at most 7 days after creation. */
43
+ export declare const expiresAt: (record: RoomRecord) => number;
44
+ export type SqliteStoreOptions = {
45
+ readonly log?: Logger;
46
+ readonly now?: () => number;
47
+ };
48
+ /**
49
+ * Opens (and migrates) the database. A database newer than this build is refused, so a
50
+ * rollback can't corrupt newer data; before migrating an existing database, a backup is taken.
51
+ */
52
+ export declare const openSqliteStore: (file: string, options?: SqliteStoreOptions) => Promise<RoomStore>;
53
+ export {};
@@ -0,0 +1,245 @@
1
+ import { MAX_ROOM_AGE_MS } from "../policy.js";
2
+ import { decodeRecord } from "../record.js";
3
+ import { existsSync } from "node:fs";
4
+ import { DatabaseSync, backup } from "node:sqlite";
5
+ //#region src/node/store.ts
6
+ /**
7
+ * Database migrations, applied in order inside one transaction and tracked with
8
+ * `PRAGMA user_version`. Append; never edit a shipped entry.
9
+ */
10
+ var MIGRATIONS = [{
11
+ version: 1,
12
+ name: "init",
13
+ sql: `
14
+ CREATE TABLE rooms (
15
+ code TEXT PRIMARY KEY,
16
+ game_id TEXT NOT NULL,
17
+ rules_version INTEGER NOT NULL,
18
+ game_build TEXT NOT NULL,
19
+ phase TEXT NOT NULL CHECK (phase IN ('lobby', 'playing', 'finished', 'closed')),
20
+ revision INTEGER NOT NULL,
21
+ writes INTEGER NOT NULL,
22
+ record_format INTEGER NOT NULL,
23
+ record TEXT NOT NULL,
24
+ created_at INTEGER NOT NULL,
25
+ updated_at INTEGER NOT NULL,
26
+ expires_at INTEGER NOT NULL
27
+ ) STRICT;
28
+ CREATE INDEX rooms_expires ON rooms (expires_at);
29
+ CREATE INDEX rooms_live_versions ON rooms (game_id, rules_version) WHERE phase IN ('lobby', 'playing');
30
+ CREATE TABLE room_inputs (
31
+ code TEXT NOT NULL REFERENCES rooms (code) ON DELETE CASCADE,
32
+ match INTEGER NOT NULL,
33
+ revision INTEGER NOT NULL,
34
+ at INTEGER NOT NULL,
35
+ input TEXT NOT NULL,
36
+ PRIMARY KEY (code, revision)
37
+ ) STRICT, WITHOUT ROWID;
38
+ CREATE TABLE room_archive (
39
+ code TEXT NOT NULL, record_format INTEGER NOT NULL, record TEXT NOT NULL, reason TEXT NOT NULL, archived_at INTEGER NOT NULL,
40
+ PRIMARY KEY (code, record_format, archived_at)
41
+ ) STRICT;
42
+ `
43
+ }];
44
+ var SCHEMA_VERSION = MIGRATIONS.at(-1)?.version ?? 0;
45
+ /** When a room's row may be deleted: retainMs after its last change, at most 7 days after creation. */
46
+ var expiresAt = (record) => Math.min(record.updatedAt + record.policy.retainMs, record.createdAt + MAX_ROOM_AGE_MS);
47
+ var pinOf = (record) => record.match?.game ?? record.game;
48
+ /**
49
+ * Opens (and migrates) the database. A database newer than this build is refused, so a
50
+ * rollback can't corrupt newer data; before migrating an existing database, a backup is taken.
51
+ */
52
+ var openSqliteStore = async (file, options = {}) => {
53
+ const now = options.now ?? Date.now;
54
+ const db = new DatabaseSync(file);
55
+ const version = Number(db.prepare("PRAGMA user_version").get()?.["user_version"] ?? 0);
56
+ if (version > SCHEMA_VERSION) {
57
+ db.close();
58
+ throw new Error(`The room database is at schema version ${version}, newer than this build's ${SCHEMA_VERSION}. Deploy a newer build.`);
59
+ }
60
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
61
+ if (version < SCHEMA_VERSION) {
62
+ if (version > 0 && file !== ":memory:") {
63
+ const copy = `${file}.pre-v${SCHEMA_VERSION}-${now()}.bak`;
64
+ await backup(db, copy);
65
+ options.log?.write("info", "backup_written", {
66
+ reason: "migration",
67
+ schema: version
68
+ });
69
+ }
70
+ db.exec("BEGIN IMMEDIATE");
71
+ try {
72
+ for (const migration of MIGRATIONS) {
73
+ if (migration.version <= version) continue;
74
+ db.exec(migration.sql);
75
+ db.exec(`PRAGMA user_version = ${migration.version}`);
76
+ options.log?.write("info", "migration_applied", {
77
+ version: migration.version,
78
+ name: migration.name
79
+ });
80
+ }
81
+ db.exec("COMMIT");
82
+ } catch (error) {
83
+ db.exec("ROLLBACK");
84
+ db.close();
85
+ throw error;
86
+ }
87
+ }
88
+ return new SqliteRoomStore(db, options.log ?? null, now);
89
+ };
90
+ var SqliteRoomStore = class {
91
+ statements;
92
+ db;
93
+ log;
94
+ now;
95
+ constructor(db, log, now) {
96
+ this.db = db;
97
+ this.log = log;
98
+ this.now = now;
99
+ this.statements = {
100
+ load: db.prepare("SELECT record FROM rooms WHERE code = ?"),
101
+ exists: db.prepare("SELECT 1 AS found FROM rooms WHERE code = ?"),
102
+ insert: db.prepare(`INSERT INTO rooms (code, game_id, rules_version, game_build, phase, revision, writes, record_format, record, created_at, updated_at, expires_at)
103
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
104
+ update: db.prepare(`UPDATE rooms SET game_id = ?, rules_version = ?, game_build = ?, phase = ?, revision = ?, writes = ?, record_format = ?, record = ?,
105
+ updated_at = ?, expires_at = ? WHERE code = ? AND writes = ?`),
106
+ rewrite: db.prepare("UPDATE rooms SET record_format = ?, record = ? WHERE code = ?"),
107
+ input: db.prepare("INSERT INTO room_inputs (code, match, revision, at, input) VALUES (?, ?, ?, ?, ?)"),
108
+ archive: db.prepare("INSERT OR REPLACE INTO room_archive (code, record_format, record, reason, archived_at) VALUES (?, ?, ?, ?, ?)"),
109
+ remove: db.prepare("DELETE FROM rooms WHERE code = ?"),
110
+ versions: db.prepare("SELECT game_id AS gameId, rules_version AS rulesVersion, COUNT(*) AS rooms FROM rooms WHERE phase IN ('lobby', 'playing') GROUP BY game_id, rules_version"),
111
+ pendingJobs: db.prepare("SELECT code FROM rooms WHERE phase = 'playing' AND json_array_length(record, '$.match.jobs') > 0"),
112
+ inputs: db.prepare("SELECT match, revision, at, input FROM room_inputs WHERE code = ? AND match = ? ORDER BY revision"),
113
+ prune: db.prepare("DELETE FROM rooms WHERE expires_at < ?"),
114
+ epochs: db.prepare("UPDATE rooms SET record = json_set(record, '$.epoch', json_extract(record, '$.epoch') + 1)")
115
+ };
116
+ }
117
+ transaction(work) {
118
+ this.db.exec("BEGIN IMMEDIATE");
119
+ try {
120
+ const result = work();
121
+ this.db.exec("COMMIT");
122
+ return result;
123
+ } catch (error) {
124
+ this.db.exec("ROLLBACK");
125
+ throw error;
126
+ }
127
+ }
128
+ load(code) {
129
+ const row = this.statements.load.get(code);
130
+ if (row === void 0) return null;
131
+ const text = String(row["record"]);
132
+ let raw;
133
+ try {
134
+ raw = JSON.parse(text);
135
+ } catch {
136
+ this.quarantine(code, "json");
137
+ return null;
138
+ }
139
+ const decoded = decodeRecord(raw);
140
+ if (!decoded.ok) {
141
+ if (decoded.error.code === "newer-format") throw new Error(decoded.error.message);
142
+ this.quarantine(code, "schema");
143
+ return null;
144
+ }
145
+ const { record, migrated } = decoded.value;
146
+ if (migrated) {
147
+ const format = raw.format;
148
+ this.transaction(() => {
149
+ this.statements.archive.run(code, format, text, "migration", this.now());
150
+ this.statements.rewrite.run(1, JSON.stringify(record), code);
151
+ });
152
+ }
153
+ return record;
154
+ }
155
+ exists(code) {
156
+ return this.statements.exists.get(code) !== void 0;
157
+ }
158
+ insert(record) {
159
+ const pin = pinOf(record);
160
+ this.statements.insert.run(record.code, pin.id, pin.rulesVersion, pin.build, record.phase, record.revision, record.writes, record.format, JSON.stringify(record), record.createdAt, record.updatedAt, expiresAt(record));
161
+ }
162
+ save(record, inputs, expectedWrites) {
163
+ const pin = pinOf(record);
164
+ return this.transaction(() => {
165
+ const result = this.statements.update.run(pin.id, pin.rulesVersion, pin.build, record.phase, record.revision, record.writes, record.format, JSON.stringify(record), record.updatedAt, expiresAt(record), record.code, expectedWrites);
166
+ if (Number(result.changes) !== 1) return "conflict";
167
+ for (const input of inputs) this.statements.input.run(record.code, input.match, input.revision, input.at, JSON.stringify(input.input));
168
+ return "saved";
169
+ });
170
+ }
171
+ quarantine(code, reason) {
172
+ const row = this.statements.load.get(code);
173
+ if (row === void 0) return;
174
+ const text = String(row["record"]);
175
+ let format = 0;
176
+ try {
177
+ format = Number(JSON.parse(text).format) || 0;
178
+ } catch {
179
+ format = 0;
180
+ }
181
+ this.transaction(() => {
182
+ this.statements.archive.run(code, format, text, reason, this.now());
183
+ this.statements.remove.run(code);
184
+ });
185
+ this.log?.write("error", "room_corrupt", {
186
+ room: code,
187
+ reason
188
+ });
189
+ }
190
+ liveVersions() {
191
+ return this.statements.versions.all().map((row) => ({
192
+ gameId: String(row["gameId"]),
193
+ rulesVersion: Number(row["rulesVersion"]),
194
+ rooms: Number(row["rooms"])
195
+ }));
196
+ }
197
+ withPendingJobs() {
198
+ return this.statements.pendingJobs.all().map((row) => String(row["code"]));
199
+ }
200
+ inputs(code, match) {
201
+ return this.statements.inputs.all(code, match).map((row) => ({
202
+ match: Number(row["match"]),
203
+ revision: Number(row["revision"]),
204
+ at: Number(row["at"]),
205
+ input: JSON.parse(String(row["input"]))
206
+ }));
207
+ }
208
+ prune(now) {
209
+ return Number(this.statements.prune.run(now).changes);
210
+ }
211
+ ping() {
212
+ try {
213
+ this.db.exec("BEGIN IMMEDIATE; ROLLBACK;");
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+ async backup(file) {
220
+ if (existsSync(file)) throw new Error(`Refusing to overwrite ${file}.`);
221
+ await backup(this.db, file);
222
+ const copy = new DatabaseSync(file, { readOnly: true });
223
+ try {
224
+ const result = copy.prepare("PRAGMA integrity_check").get()?.["integrity_check"];
225
+ if (result !== "ok") throw new Error(`The backup failed its integrity check: ${String(result)}`);
226
+ } finally {
227
+ copy.close();
228
+ }
229
+ this.log?.write("info", "backup_written", {});
230
+ }
231
+ bumpEpochs() {
232
+ return Number(this.statements.epochs.run().changes);
233
+ }
234
+ close() {
235
+ try {
236
+ this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
237
+ } finally {
238
+ this.db.close();
239
+ }
240
+ }
241
+ };
242
+ //#endregion
243
+ export { MIGRATIONS, SCHEMA_VERSION, expiresAt, openSqliteStore };
244
+
245
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","names":[],"sources":["../../src/node/store.ts"],"sourcesContent":["// The SQLite room store (room-server.md sections 6.4 to 6.6), on node:sqlite's synchronous\n// DatabaseSync: a step and its write finish before the event loop delivers the next message.\nimport { existsSync } from 'node:fs';\nimport { backup, DatabaseSync } from 'node:sqlite';\nimport type { EngineInput, Json } from '@joystyk/contracts';\nimport { MAX_ROOM_AGE_MS } from '../policy.ts';\nimport { decodeRecord, RECORD_FORMAT } from '../record.ts';\nimport type { LoggedInput, RoomCode, RoomRecord } from '../types.ts';\nimport type { Logger } from './support.ts';\n\nexport interface RoomStore {\n /** The decoded record, or null when missing or quarantined. Throws on a newer record format. */\n load(code: RoomCode): RoomRecord | null;\n exists(code: RoomCode): boolean;\n /** One transaction: update the room where writes = expected, and append the inputs. */\n save(record: RoomRecord, inputs: readonly LoggedInput[], expectedWrites: number): 'saved' | 'conflict';\n insert(record: RoomRecord): void;\n /** Moves a row that can't be used to `room_archive` and deletes it. */\n quarantine(code: RoomCode, reason: string): void;\n liveVersions(): readonly { readonly gameId: string; readonly rulesVersion: number; readonly rooms: number }[];\n /** Rooms with a job outstanding: they resume at start-up, so the job re-runs at once. */\n withPendingJobs(): readonly RoomCode[];\n /** A match's input log, for replays and bug reports. */\n inputs(code: RoomCode, match: number): readonly LoggedInput[];\n /** Deletes rows past expires_at (their inputs cascade). Returns how many. */\n prune(now: number): number;\n /** True when the database accepts a write (for /readyz). */\n ping(): boolean;\n /** An online copy, checked with `PRAGMA integrity_check`. Refuses to overwrite a file. */\n backup(file: string): Promise<void>;\n /** After a restore from backup: bump every room's epoch so clients accept older revisions. */\n bumpEpochs(): number;\n close(): void;\n}\n\ntype Migration = { readonly version: number; readonly name: string; readonly sql: string };\n\n/**\n * Database migrations, applied in order inside one transaction and tracked with\n * `PRAGMA user_version`. Append; never edit a shipped entry.\n */\nexport const MIGRATIONS: readonly Migration[] = [\n {\n version: 1,\n name: 'init',\n sql: `\n CREATE TABLE rooms (\n code TEXT PRIMARY KEY,\n game_id TEXT NOT NULL,\n rules_version INTEGER NOT NULL,\n game_build TEXT NOT NULL,\n phase TEXT NOT NULL CHECK (phase IN ('lobby', 'playing', 'finished', 'closed')),\n revision INTEGER NOT NULL,\n writes INTEGER NOT NULL,\n record_format INTEGER NOT NULL,\n record TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n updated_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n ) STRICT;\n CREATE INDEX rooms_expires ON rooms (expires_at);\n CREATE INDEX rooms_live_versions ON rooms (game_id, rules_version) WHERE phase IN ('lobby', 'playing');\n CREATE TABLE room_inputs (\n code TEXT NOT NULL REFERENCES rooms (code) ON DELETE CASCADE,\n match INTEGER NOT NULL,\n revision INTEGER NOT NULL,\n at INTEGER NOT NULL,\n input TEXT NOT NULL,\n PRIMARY KEY (code, revision)\n ) STRICT, WITHOUT ROWID;\n CREATE TABLE room_archive (\n code TEXT NOT NULL, record_format INTEGER NOT NULL, record TEXT NOT NULL, reason TEXT NOT NULL, archived_at INTEGER NOT NULL,\n PRIMARY KEY (code, record_format, archived_at)\n ) STRICT;\n `,\n },\n];\n\nexport const SCHEMA_VERSION = MIGRATIONS.at(-1)?.version ?? 0;\n\n/** When a room's row may be deleted: retainMs after its last change, at most 7 days after creation. */\nexport const expiresAt = (record: RoomRecord): number =>\n Math.min(record.updatedAt + record.policy.retainMs, record.createdAt + MAX_ROOM_AGE_MS);\n\nconst pinOf = (record: RoomRecord) => record.match?.game ?? record.game;\n\nexport type SqliteStoreOptions = {\n readonly log?: Logger;\n readonly now?: () => number;\n};\n\n/**\n * Opens (and migrates) the database. A database newer than this build is refused, so a\n * rollback can't corrupt newer data; before migrating an existing database, a backup is taken.\n */\nexport const openSqliteStore = async (file: string, options: SqliteStoreOptions = {}): Promise<RoomStore> => {\n const now = options.now ?? Date.now;\n const db = new DatabaseSync(file);\n const version = Number(db.prepare('PRAGMA user_version').get()?.['user_version'] ?? 0);\n if (version > SCHEMA_VERSION) {\n db.close();\n throw new Error(\n `The room database is at schema version ${version}, newer than this build's ${SCHEMA_VERSION}. Deploy a newer build.`,\n );\n }\n db.exec(\n 'PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;',\n );\n if (version < SCHEMA_VERSION) {\n if (version > 0 && file !== ':memory:') {\n const copy = `${file}.pre-v${SCHEMA_VERSION}-${now()}.bak`;\n await backup(db, copy);\n options.log?.write('info', 'backup_written', { reason: 'migration', schema: version });\n }\n db.exec('BEGIN IMMEDIATE');\n try {\n for (const migration of MIGRATIONS) {\n if (migration.version <= version) continue;\n db.exec(migration.sql);\n db.exec(`PRAGMA user_version = ${migration.version}`);\n options.log?.write('info', 'migration_applied', { version: migration.version, name: migration.name });\n }\n db.exec('COMMIT');\n } catch (error) {\n db.exec('ROLLBACK');\n db.close();\n throw error;\n }\n }\n return new SqliteRoomStore(db, options.log ?? null, now);\n};\n\nclass SqliteRoomStore implements RoomStore {\n private readonly statements;\n private readonly db: DatabaseSync;\n private readonly log: Logger | null;\n private readonly now: () => number;\n\n constructor(db: DatabaseSync, log: Logger | null, now: () => number) {\n this.db = db;\n this.log = log;\n this.now = now;\n this.statements = {\n load: db.prepare('SELECT record FROM rooms WHERE code = ?'),\n exists: db.prepare('SELECT 1 AS found FROM rooms WHERE code = ?'),\n insert: db.prepare(\n `INSERT INTO rooms (code, game_id, rules_version, game_build, phase, revision, writes, record_format, record, created_at, updated_at, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n ),\n update: db.prepare(\n `UPDATE rooms SET game_id = ?, rules_version = ?, game_build = ?, phase = ?, revision = ?, writes = ?, record_format = ?, record = ?,\n updated_at = ?, expires_at = ? WHERE code = ? AND writes = ?`,\n ),\n rewrite: db.prepare('UPDATE rooms SET record_format = ?, record = ? WHERE code = ?'),\n input: db.prepare('INSERT INTO room_inputs (code, match, revision, at, input) VALUES (?, ?, ?, ?, ?)'),\n archive: db.prepare(\n 'INSERT OR REPLACE INTO room_archive (code, record_format, record, reason, archived_at) VALUES (?, ?, ?, ?, ?)',\n ),\n remove: db.prepare('DELETE FROM rooms WHERE code = ?'),\n versions: db.prepare(\n \"SELECT game_id AS gameId, rules_version AS rulesVersion, COUNT(*) AS rooms FROM rooms WHERE phase IN ('lobby', 'playing') GROUP BY game_id, rules_version\",\n ),\n pendingJobs: db.prepare(\n \"SELECT code FROM rooms WHERE phase = 'playing' AND json_array_length(record, '$.match.jobs') > 0\",\n ),\n inputs: db.prepare(\n 'SELECT match, revision, at, input FROM room_inputs WHERE code = ? AND match = ? ORDER BY revision',\n ),\n prune: db.prepare('DELETE FROM rooms WHERE expires_at < ?'),\n epochs: db.prepare(\"UPDATE rooms SET record = json_set(record, '$.epoch', json_extract(record, '$.epoch') + 1)\"),\n };\n }\n\n private transaction<T>(work: () => T): T {\n this.db.exec('BEGIN IMMEDIATE');\n try {\n const result = work();\n this.db.exec('COMMIT');\n return result;\n } catch (error) {\n this.db.exec('ROLLBACK');\n throw error;\n }\n }\n\n load(code: RoomCode): RoomRecord | null {\n const row = this.statements.load.get(code);\n if (row === undefined) return null;\n const text = String(row['record']);\n let raw: unknown;\n try {\n raw = JSON.parse(text);\n } catch {\n this.quarantine(code, 'json');\n return null;\n }\n const decoded = decodeRecord(raw);\n if (!decoded.ok) {\n if (decoded.error.code === 'newer-format') throw new Error(decoded.error.message);\n this.quarantine(code, 'schema');\n return null;\n }\n const { record, migrated } = decoded.value;\n if (migrated) {\n const format = (raw as { format: number }).format;\n this.transaction(() => {\n this.statements.archive.run(code, format, text, 'migration', this.now());\n this.statements.rewrite.run(RECORD_FORMAT, JSON.stringify(record), code);\n });\n }\n return record;\n }\n\n exists(code: RoomCode): boolean {\n return this.statements.exists.get(code) !== undefined;\n }\n\n insert(record: RoomRecord): void {\n const pin = pinOf(record);\n this.statements.insert.run(\n record.code,\n pin.id,\n pin.rulesVersion,\n pin.build,\n record.phase,\n record.revision,\n record.writes,\n record.format,\n JSON.stringify(record),\n record.createdAt,\n record.updatedAt,\n expiresAt(record),\n );\n }\n\n save(record: RoomRecord, inputs: readonly LoggedInput[], expectedWrites: number): 'saved' | 'conflict' {\n const pin = pinOf(record);\n return this.transaction(() => {\n const result = this.statements.update.run(\n pin.id,\n pin.rulesVersion,\n pin.build,\n record.phase,\n record.revision,\n record.writes,\n record.format,\n JSON.stringify(record),\n record.updatedAt,\n expiresAt(record),\n record.code,\n expectedWrites,\n );\n if (Number(result.changes) !== 1) return 'conflict';\n for (const input of inputs)\n this.statements.input.run(record.code, input.match, input.revision, input.at, JSON.stringify(input.input));\n return 'saved';\n });\n }\n\n quarantine(code: RoomCode, reason: string): void {\n const row = this.statements.load.get(code);\n if (row === undefined) return;\n const text = String(row['record']);\n let format = 0;\n try {\n format = Number((JSON.parse(text) as { format?: unknown }).format) || 0;\n } catch {\n format = 0;\n }\n this.transaction(() => {\n this.statements.archive.run(code, format, text, reason, this.now());\n this.statements.remove.run(code);\n });\n this.log?.write('error', 'room_corrupt', { room: code, reason });\n }\n\n liveVersions() {\n return this.statements.versions.all().map((row) => ({\n gameId: String(row['gameId']),\n rulesVersion: Number(row['rulesVersion']),\n rooms: Number(row['rooms']),\n }));\n }\n\n withPendingJobs(): readonly RoomCode[] {\n return this.statements.pendingJobs.all().map((row) => String(row['code']));\n }\n\n inputs(code: RoomCode, match: number): readonly LoggedInput[] {\n return this.statements.inputs.all(code, match).map((row) => ({\n match: Number(row['match']),\n revision: Number(row['revision']),\n at: Number(row['at']),\n input: JSON.parse(String(row['input'])) as EngineInput<Json>,\n }));\n }\n\n prune(now: number): number {\n return Number(this.statements.prune.run(now).changes);\n }\n\n ping(): boolean {\n try {\n this.db.exec('BEGIN IMMEDIATE; ROLLBACK;');\n return true;\n } catch {\n return false;\n }\n }\n\n async backup(file: string): Promise<void> {\n if (existsSync(file)) throw new Error(`Refusing to overwrite ${file}.`);\n await backup(this.db, file);\n const copy = new DatabaseSync(file, { readOnly: true });\n try {\n const result = copy.prepare('PRAGMA integrity_check').get()?.['integrity_check'];\n if (result !== 'ok') throw new Error(`The backup failed its integrity check: ${String(result)}`);\n } finally {\n copy.close();\n }\n this.log?.write('info', 'backup_written', {});\n }\n\n bumpEpochs(): number {\n return Number(this.statements.epochs.run().changes);\n }\n\n close(): void {\n try {\n this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');\n } finally {\n this.db.close();\n }\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ import { BlockList } from 'node:net';
2
+ import type { IncomingMessage } from 'node:http';
3
+ import type { Json } from '@joystyk/contracts';
4
+ export type LogLevel = 'info' | 'warn' | 'error';
5
+ /**
6
+ * One JSON object per line (section 9). Callers never pass tokens, token hashes, display
7
+ * names, IP addresses or game state: every visitor may be a child (D-26).
8
+ */
9
+ export interface Logger {
10
+ write(level: LogLevel, event: string, fields?: {
11
+ readonly [k: string]: Json;
12
+ }): void;
13
+ }
14
+ export declare const createLogger: (sink?: (line: string) => void, now?: () => number) => Logger;
15
+ /** A logger that keeps lines in memory, for tests. */
16
+ export declare const memoryLogger: () => Logger & {
17
+ readonly lines: {
18
+ level: LogLevel;
19
+ event: string;
20
+ fields: {
21
+ readonly [k: string]: Json;
22
+ };
23
+ }[];
24
+ };
25
+ export interface Clock {
26
+ now(): number;
27
+ /** Calls `fire` at `at` (epoch ms). Firing early, late or twice is harmless to the core. */
28
+ setTimer(at: number, fire: () => void): {
29
+ cancel(): void;
30
+ };
31
+ }
32
+ export declare const systemClock: Clock;
33
+ /** A seat token: 24 random bytes, base64url (32 characters). Never logged or stored. */
34
+ export declare const newSeatToken: () => string;
35
+ /** The SHA-256 hex digest the room stores in place of a token. */
36
+ export declare const hashToken: (token: string) => string;
37
+ /** A fresh invite code from the room alphabet. */
38
+ export declare const newRoomCode: () => string;
39
+ /** `count` uniformly random 32-bit words from the CSPRNG, for seeding a match's rng. */
40
+ export declare const randomWords: (count: number) => number[];
41
+ /** Parses `TRUSTED_PROXY`: one address or a CIDR range. Throws on anything else. */
42
+ export declare const parseTrustedProxy: (value: string) => BlockList;
43
+ /**
44
+ * The visitor's address. Only a trusted proxy (Traefik) may report it, through the configured
45
+ * header or else the last `X-Forwarded-For` entry; anyone else is taken at their socket's word.
46
+ */
47
+ export declare const clientAddress: (request: IncomingMessage, trusted: BlockList | null, header: string | null) => string;
48
+ /** Counts events per key in a sliding window, in memory only (section 8). */
49
+ export declare class WindowCounter {
50
+ private readonly hits;
51
+ private readonly windowMs;
52
+ private readonly max;
53
+ constructor(windowMs: number, max: number);
54
+ /** True if `key` is still under the limit; records the hit when `record` is set. */
55
+ allow(key: string, now: number, record?: boolean): boolean;
56
+ hit(key: string, now: number): void;
57
+ prune(now: number): void;
58
+ }
59
+ /** A token bucket: `rate` per second, holding at most `burst`. */
60
+ export declare class TokenBucket {
61
+ private tokens;
62
+ private last;
63
+ private readonly rate;
64
+ private readonly burst;
65
+ constructor(rate: number, burst: number, now: number);
66
+ take(now: number): boolean;
67
+ }
@@ -0,0 +1,126 @@
1
+ import { ROOM_ALPHABET } from "../protocol/schemas.js";
2
+ import { createHash, randomBytes, randomInt } from "node:crypto";
3
+ import { BlockList, isIP } from "node:net";
4
+ //#region src/node/support.ts
5
+ var createLogger = (sink = (line) => process.stdout.write(`${line}\n`), now = Date.now) => ({ write: (level, event, fields = {}) => sink(JSON.stringify({
6
+ ts: new Date(now()).toISOString(),
7
+ level,
8
+ event,
9
+ ...fields
10
+ })) });
11
+ /** A logger that keeps lines in memory, for tests. */
12
+ var memoryLogger = () => {
13
+ const lines = [];
14
+ return {
15
+ lines,
16
+ write: (level, event, fields = {}) => void lines.push({
17
+ level,
18
+ event,
19
+ fields
20
+ })
21
+ };
22
+ };
23
+ /** Node's timers cap a delay at about 24.8 days; longer waits re-arm. */
24
+ var MAX_DELAY = 2 ** 31 - 1;
25
+ var systemClock = {
26
+ now: () => Date.now(),
27
+ setTimer: (at, fire) => {
28
+ let handle;
29
+ const arm = () => {
30
+ const delay = Math.max(0, at - Date.now());
31
+ handle = setTimeout(delay > MAX_DELAY ? arm : fire, Math.min(delay, MAX_DELAY));
32
+ handle.unref?.();
33
+ };
34
+ arm();
35
+ return { cancel: () => clearTimeout(handle) };
36
+ }
37
+ };
38
+ /** A seat token: 24 random bytes, base64url (32 characters). Never logged or stored. */
39
+ var newSeatToken = () => randomBytes(24).toString("base64url");
40
+ /** The SHA-256 hex digest the room stores in place of a token. */
41
+ var hashToken = (token) => createHash("sha256").update(token).digest("hex");
42
+ /** A fresh invite code from the room alphabet. */
43
+ var newRoomCode = () => Array.from({ length: 6 }, () => ROOM_ALPHABET[randomInt(ROOM_ALPHABET.length)]).join("");
44
+ /** `count` uniformly random 32-bit words from the CSPRNG, for seeding a match's rng. */
45
+ var randomWords = (count) => {
46
+ const bytes = randomBytes(count * 4);
47
+ return Array.from({ length: count }, (_, i) => bytes.readUInt32LE(i * 4));
48
+ };
49
+ var normalize = (address) => address.replace(/^::ffff:(?=\d+\.\d+\.\d+\.\d+$)/, "");
50
+ /** Parses `TRUSTED_PROXY`: one address or a CIDR range. Throws on anything else. */
51
+ var parseTrustedProxy = (value) => {
52
+ const list = new BlockList();
53
+ const [address = "", prefix] = value.split("/");
54
+ const family = isIP(address);
55
+ if (family === 0) throw new Error("TRUSTED_PROXY must be an IP address or a CIDR range such as 172.18.0.0/16.");
56
+ const type = family === 4 ? "ipv4" : "ipv6";
57
+ if (prefix === void 0) list.addAddress(address, type);
58
+ else {
59
+ const bits = Number(prefix);
60
+ if (!Number.isInteger(bits) || bits < 0 || bits > (family === 4 ? 32 : 128)) throw new Error("TRUSTED_PROXY has an invalid prefix length.");
61
+ list.addSubnet(address, bits, type);
62
+ }
63
+ return list;
64
+ };
65
+ /**
66
+ * The visitor's address. Only a trusted proxy (Traefik) may report it, through the configured
67
+ * header or else the last `X-Forwarded-For` entry; anyone else is taken at their socket's word.
68
+ */
69
+ var clientAddress = (request, trusted, header) => {
70
+ const peer = normalize(request.socket.remoteAddress ?? "unknown");
71
+ const family = isIP(peer);
72
+ if (trusted === null || family === 0 || !trusted.check(peer, family === 4 ? "ipv4" : "ipv6")) return peer;
73
+ const raw = header === null ? String(request.headers["x-forwarded-for"] ?? "").split(",").at(-1) : request.headers[header.toLowerCase()];
74
+ const reported = normalize(String(Array.isArray(raw) ? raw.at(-1) : raw ?? "").trim());
75
+ return isIP(reported) === 0 ? peer : reported;
76
+ };
77
+ /** Counts events per key in a sliding window, in memory only (section 8). */
78
+ var WindowCounter = class {
79
+ hits = /* @__PURE__ */ new Map();
80
+ windowMs;
81
+ max;
82
+ constructor(windowMs, max) {
83
+ this.windowMs = windowMs;
84
+ this.max = max;
85
+ }
86
+ /** True if `key` is still under the limit; records the hit when `record` is set. */
87
+ allow(key, now, record = true) {
88
+ const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
89
+ const ok = recent.length < this.max;
90
+ if (ok && record) recent.push(now);
91
+ this.hits.set(key, recent);
92
+ return ok;
93
+ }
94
+ hit(key, now) {
95
+ const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
96
+ recent.push(now);
97
+ this.hits.set(key, recent);
98
+ }
99
+ prune(now) {
100
+ for (const [key, times] of this.hits) if (!times.some((t) => now - t < this.windowMs)) this.hits.delete(key);
101
+ }
102
+ };
103
+ /** A token bucket: `rate` per second, holding at most `burst`. */
104
+ var TokenBucket = class {
105
+ tokens;
106
+ last;
107
+ rate;
108
+ burst;
109
+ constructor(rate, burst, now) {
110
+ this.rate = rate;
111
+ this.burst = burst;
112
+ this.tokens = burst;
113
+ this.last = now;
114
+ }
115
+ take(now) {
116
+ this.tokens = Math.min(this.burst, this.tokens + (now - this.last) * this.rate / 1e3);
117
+ this.last = now;
118
+ if (this.tokens < 1) return false;
119
+ this.tokens -= 1;
120
+ return true;
121
+ }
122
+ };
123
+ //#endregion
124
+ export { TokenBucket, WindowCounter, clientAddress, createLogger, hashToken, memoryLogger, newRoomCode, newSeatToken, parseTrustedProxy, randomWords, systemClock };
125
+
126
+ //# sourceMappingURL=support.js.map