cleanup events + limits + more logs

This commit is contained in:
Theo Browne
2026-02-22 16:23:46 -08:00
parent 43003a5b01
commit c01f3ff076
4 changed files with 90 additions and 23 deletions

View File

@@ -26,20 +26,25 @@ type RoundState = {
scoreB?: number;
};
type GameState = {
completed: RoundState[];
lastCompleted: RoundState | null;
active: RoundState | null;
scores: Record<string, number>;
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<string, string> = {
"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);

View File

@@ -32,20 +32,25 @@ type RoundState = {
scoreB?: number;
};
type GameState = {
completed: RoundState[];
lastCompleted: RoundState | null;
active: RoundState | null;
scores: Record<string, number>;
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 <ConnectingScreen />;
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 (
<div className="app">
@@ -486,7 +492,7 @@ function App() {
</div>
)}
{isNextPrompting && lastCompleted && (
{isNextPrompting && state.lastCompleted && (
<div className="next-toast">
<ModelTag model={state.active!.prompter} small /> is writing the
next prompt

View File

@@ -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 ─────────────────────────────────────────────────────────────────

View File

@@ -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<ServerWebSocket<WsData>>();
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<WsData>({
});
}
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<WsData>({
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<WsData>({
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<WsData>({
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<WsData>({
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: