From c01f3ff076e622b6efea1440ed7f2db3ee758220 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 22 Feb 2026 16:23:46 -0800 Subject: [PATCH] cleanup events + limits + more logs --- broadcast.ts | 14 +++++++--- frontend.tsx | 16 ++++++++---- game.ts | 9 ++++++- server.ts | 74 +++++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/broadcast.ts b/broadcast.ts index 48acb3c..a2cb689 100644 --- a/broadcast.ts +++ b/broadcast.ts @@ -26,20 +26,25 @@ type RoundState = { scoreB?: number; }; type GameState = { - completed: RoundState[]; + lastCompleted: RoundState | null; active: RoundState | null; scores: Record; done: boolean; isPaused: boolean; generation: number; }; -type ServerMessage = { +type StateMessage = { type: "state"; data: GameState; totalRounds: number; viewerCount: number; version?: string; }; +type ViewerCountMessage = { + type: "viewerCount"; + viewerCount: number; +}; +type ServerMessage = StateMessage | ViewerCountMessage; const MODEL_COLORS: Record = { "Gemini 3.1 Pro": "#4285F4", @@ -141,6 +146,8 @@ function setupWebSocket() { : null; viewerCount = msg.viewerCount; lastMessageAt = Date.now(); + } else if (msg.type === "viewerCount") { + viewerCount = msg.viewerCount; } } catch { // Ignore malformed spectator payloads. @@ -521,9 +528,8 @@ function draw() { drawScoreboard(state.scores); - const lastCompleted = state.completed[state.completed.length - 1]; const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt; - const displayRound = isNextPrompting && lastCompleted ? lastCompleted : (state.active ?? lastCompleted ?? null); + const displayRound = isNextPrompting && state.lastCompleted ? state.lastCompleted : (state.active ?? state.lastCompleted ?? null); if (state.done) { drawDone(state.scores); diff --git a/frontend.tsx b/frontend.tsx index 337005f..9357f83 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -32,20 +32,25 @@ type RoundState = { scoreB?: number; }; type GameState = { - completed: RoundState[]; + lastCompleted: RoundState | null; active: RoundState | null; scores: Record; done: boolean; isPaused: boolean; generation: number; }; -type ServerMessage = { +type StateMessage = { type: "state"; data: GameState; totalRounds: number; viewerCount: number; version?: string; }; +type ViewerCountMessage = { + type: "viewerCount"; + viewerCount: number; +}; +type ServerMessage = StateMessage | ViewerCountMessage; // ── Model colors & logos ───────────────────────────────────────────────────── @@ -432,6 +437,8 @@ function App() { setState(msg.data); setTotalRounds(msg.totalRounds); setViewerCount(msg.viewerCount); + } else if (msg.type === "viewerCount") { + setViewerCount(msg.viewerCount); } }; } @@ -445,11 +452,10 @@ function App() { if (!connected || !state) return ; - const lastCompleted = state.completed[state.completed.length - 1]; const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt; const displayRound = - isNextPrompting && lastCompleted ? lastCompleted : state.active; + isNextPrompting && state.lastCompleted ? state.lastCompleted : state.active; return (
@@ -486,7 +492,7 @@ function App() {
)} - {isNextPrompting && lastCompleted && ( + {isNextPrompting && state.lastCompleted && (
is writing the next prompt diff --git a/game.ts b/game.ts index beb0abd..87d10b7 100644 --- a/game.ts +++ b/game.ts @@ -106,9 +106,16 @@ export function log( const ts = new Date().toISOString(); let line = `[${ts}] ${level} [${category}] ${message}`; if (data) { - line += "\n " + JSON.stringify(data, null, 2).replace(/\n/g, "\n "); + line += " " + JSON.stringify(data); } appendFileSync(LOG_FILE, line + "\n"); + if (level === "ERROR") { + console.error(line); + } else if (level === "WARN") { + console.warn(line); + } else { + console.log(line); + } } // ── Helpers ───────────────────────────────────────────────────────────────── diff --git a/server.ts b/server.ts index f8f19db..e7e02c7 100644 --- a/server.ts +++ b/server.ts @@ -64,10 +64,6 @@ const gameState: GameState = { type WsData = { ip: string }; const WINDOW_MS = 60_000; -const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt( - process.env.WS_UPGRADE_LIMIT_PER_MIN, - 20, -); const HISTORY_LIMIT_PER_MIN = parsePositiveInt( process.env.HISTORY_LIMIT_PER_MIN, 120, @@ -221,10 +217,21 @@ function setHistoryCache(key: string, body: string, expiresAt: number) { const clients = new Set>(); +function getClientState() { + return { + active: gameState.active, + lastCompleted: gameState.completed.at(-1) ?? null, + scores: gameState.scores, + done: gameState.done, + isPaused: gameState.isPaused, + generation: gameState.generation, + }; +} + function broadcast() { const msg = JSON.stringify({ type: "state", - data: gameState, + data: getClientState(), totalRounds: runs, viewerCount: clients.size, version: VERSION, @@ -234,6 +241,16 @@ function broadcast() { } } +function broadcastViewerCount() { + const msg = JSON.stringify({ + type: "viewerCount", + viewerCount: clients.size, + }); + for (const ws of clients) { + ws.send(msg); + } +} + function getAdminSnapshot() { return { isPaused: gameState.isPaused, @@ -284,6 +301,7 @@ const server = Bun.serve({ }); } if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) { + log("WARN", "http", "Admin login rate limited", { ip }); return new Response("Too Many Requests", { status: 429 }); } @@ -467,6 +485,7 @@ const server = Bun.serve({ if (url.pathname === "/api/history") { if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) { + log("WARN", "http", "History rate limited", { ip }); return new Response("Too Many Requests", { status: 429 }); } const rawPage = parseInt(url.searchParams.get("page") || "1", 10); @@ -514,21 +533,27 @@ const server = Bun.serve({ headers: { Allow: "GET" }, }); } - if ( - isRateLimited(`ws-upgrade:${ip}`, WS_UPGRADE_LIMIT_PER_MIN, WINDOW_MS) - ) { - return new Response("Too Many Requests", { status: 429 }); - } if (clients.size >= MAX_WS_GLOBAL) { + log("WARN", "ws", "Global WS limit reached, rejecting", { + ip, + clients: clients.size, + limit: MAX_WS_GLOBAL, + }); return new Response("Service Unavailable", { status: 503 }); } const existingForIp = wsByIp.get(ip) ?? 0; if (existingForIp >= MAX_WS_PER_IP) { + log("WARN", "ws", "Per-IP WS limit reached, rejecting", { + ip, + existing: existingForIp, + limit: MAX_WS_PER_IP, + }); return new Response("Too Many Requests", { status: 429 }); } const upgraded = server.upgrade(req, { data: { ip } }); if (!upgraded) { + log("WARN", "ws", "WebSocket upgrade failed", { ip }); return new Response("WebSocket upgrade failed", { status: 400 }); } return undefined; @@ -540,8 +565,26 @@ const server = Bun.serve({ data: {} as WsData, open(ws) { clients.add(ws); - wsByIp.set(ws.data.ip, (wsByIp.get(ws.data.ip) ?? 0) + 1); - broadcast(); + const ipCount = (wsByIp.get(ws.data.ip) ?? 0) + 1; + wsByIp.set(ws.data.ip, ipCount); + log("INFO", "ws", "Client connected", { + ip: ws.data.ip, + ipConns: ipCount, + totalClients: clients.size, + uniqueIps: wsByIp.size, + }); + // Send current state to the new client only + ws.send( + JSON.stringify({ + type: "state", + data: getClientState(), + totalRounds: runs, + viewerCount: clients.size, + version: VERSION, + }), + ); + // Notify everyone else with just the viewer count + broadcastViewerCount(); }, message(_ws, _message) { // Spectator-only, no client messages handled @@ -549,7 +592,12 @@ const server = Bun.serve({ close(ws) { clients.delete(ws); decrementIpConnection(ws.data.ip); - broadcast(); + log("INFO", "ws", "Client disconnected", { + ip: ws.data.ip, + totalClients: clients.size, + uniqueIps: wsByIp.size, + }); + broadcastViewerCount(); }, }, development: