cleanup events + limits + more logs
This commit is contained in:
14
broadcast.ts
14
broadcast.ts
@@ -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);
|
||||
|
||||
16
frontend.tsx
16
frontend.tsx
@@ -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
|
||||
|
||||
9
game.ts
9
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
74
server.ts
74
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<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:
|
||||
|
||||
Reference in New Issue
Block a user