import http from 'http' import crypto from 'crypto' import { URL } from 'url' import { WebSocketServer, type WebSocket } from 'ws' type SessionState = Record type Session = { id: string createdAt: number updatedAt: number state: SessionState | null } type ClientInfo = { gameId: string socket: WebSocket } const port = Number(process.env.REALTIME_PORT || 8787) const sessionTtlMs = 1000 * 60 * 60 * 6 const sessions = new Map() const clients = new Map() const json = (res: http.ServerResponse, statusCode: number, body: unknown) => { res.writeHead(statusCode, { 'content-type': 'application/json', 'cache-control': 'no-store' }) res.end(JSON.stringify(body)) } const createGameId = () => crypto.randomBytes(4).toString('hex').toUpperCase() const normalizeGameId = (value: string) => value.toUpperCase().replace(/[^A-Z0-9]/g, '') const ensureSession = (gameId: string) => { const now = Date.now() const existing = sessions.get(gameId) if (existing) return existing const session: Session = { id: gameId, createdAt: now, updatedAt: now, state: null } sessions.set(gameId, session) return session } const broadcast = (gameId: string, payload: unknown, exceptSocket?: WebSocket) => { const message = JSON.stringify(payload) clients.forEach((client) => { if (client.gameId !== gameId) return if (client.socket.readyState !== client.socket.OPEN) return if (exceptSocket && client.socket === exceptSocket) return client.socket.send(message) }) } const server = http.createServer((req, res) => { if (!req.url) { json(res, 400, { error: 'Missing URL' }) return } const requestUrl = new URL(req.url, `http://localhost:${port}`) const pathname = requestUrl.pathname if (req.method === 'GET' && pathname === '/health') { json(res, 200, { ok: true }) return } if (req.method === 'POST' && pathname === '/sessions') { let body = '' req.on('data', (chunk) => { body += chunk.toString() }) req.on('end', () => { let requestedId = '' if (body.trim()) { try { const parsed = JSON.parse(body) as { gameId?: string } if (typeof parsed.gameId === 'string') { requestedId = normalizeGameId(parsed.gameId) } } catch { json(res, 400, { error: 'Invalid JSON payload' }) return } } let gameId = requestedId if (!gameId) { gameId = createGameId() while (sessions.has(gameId)) gameId = createGameId() } else if (sessions.has(gameId)) { json(res, 409, { error: 'Game ID already exists' }) return } const session = ensureSession(gameId) json(res, 201, { gameId: session.id }) }) return } if (req.method === 'GET' && pathname.startsWith('/sessions/')) { const gameId = pathname.split('/').filter(Boolean)[1] if (!gameId) { json(res, 400, { error: 'Missing gameId' }) return } const session = sessions.get(gameId.toUpperCase()) if (!session) { json(res, 404, { error: 'Session not found' }) return } json(res, 200, { gameId: session.id, hasState: !!session.state }) return } json(res, 404, { error: 'Not found' }) }) const wss = new WebSocketServer({ noServer: true }) server.on('upgrade', (req, socket, head) => { if (!req.url) { socket.destroy() return } const requestUrl = new URL(req.url, `http://localhost:${port}`) if (requestUrl.pathname !== '/ws') { socket.destroy() return } const gameId = requestUrl.searchParams.get('gameId')?.toUpperCase() const clientId = requestUrl.searchParams.get('clientId') || crypto.randomUUID() if (!gameId) { socket.destroy() return } const session = ensureSession(gameId) session.updatedAt = Date.now() wss.handleUpgrade(req, socket, head, (ws) => { clients.set(clientId, { gameId, socket: ws }) ws.send( JSON.stringify({ type: 'session:init', gameId, state: session.state }) ) ws.on('message', (buffer) => { let parsed: { type?: string; state?: SessionState; teamId?: string } | null = null try { parsed = JSON.parse(buffer.toString()) as { type?: string state?: SessionState teamId?: string } } catch { return } if (!parsed) return if (parsed.type === 'state:update' && parsed.state) { const targetSession = ensureSession(gameId) targetSession.state = parsed.state targetSession.updatedAt = Date.now() broadcast( gameId, { type: 'state:update', gameId, state: targetSession.state }, ws ) return } if (parsed.type === 'guess:request' && parsed.teamId) { broadcast( gameId, { type: 'guess:request', gameId, teamId: parsed.teamId }, ws ) } }) ws.on('close', () => { clients.delete(clientId) }) }) }) setInterval(() => { const threshold = Date.now() - sessionTtlMs sessions.forEach((session, id) => { if (session.updatedAt >= threshold) return const hasClient = Array.from(clients.values()).some((client) => client.gameId === id) if (!hasClient) sessions.delete(id) }) }, 1000 * 60) server.listen(port, () => { // eslint-disable-next-line no-console console.log(`Realtime server listening on :${port}`) })