cleanup events + limits + more logs
This commit is contained in:
14
broadcast.ts
14
broadcast.ts
@@ -26,20 +26,25 @@ type RoundState = {
|
|||||||
scoreB?: number;
|
scoreB?: number;
|
||||||
};
|
};
|
||||||
type GameState = {
|
type GameState = {
|
||||||
completed: RoundState[];
|
lastCompleted: RoundState | null;
|
||||||
active: RoundState | null;
|
active: RoundState | null;
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
type ServerMessage = {
|
type StateMessage = {
|
||||||
type: "state";
|
type: "state";
|
||||||
data: GameState;
|
data: GameState;
|
||||||
totalRounds: number;
|
totalRounds: number;
|
||||||
viewerCount: number;
|
viewerCount: number;
|
||||||
version?: string;
|
version?: string;
|
||||||
};
|
};
|
||||||
|
type ViewerCountMessage = {
|
||||||
|
type: "viewerCount";
|
||||||
|
viewerCount: number;
|
||||||
|
};
|
||||||
|
type ServerMessage = StateMessage | ViewerCountMessage;
|
||||||
|
|
||||||
const MODEL_COLORS: Record<string, string> = {
|
const MODEL_COLORS: Record<string, string> = {
|
||||||
"Gemini 3.1 Pro": "#4285F4",
|
"Gemini 3.1 Pro": "#4285F4",
|
||||||
@@ -141,6 +146,8 @@ function setupWebSocket() {
|
|||||||
: null;
|
: null;
|
||||||
viewerCount = msg.viewerCount;
|
viewerCount = msg.viewerCount;
|
||||||
lastMessageAt = Date.now();
|
lastMessageAt = Date.now();
|
||||||
|
} else if (msg.type === "viewerCount") {
|
||||||
|
viewerCount = msg.viewerCount;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore malformed spectator payloads.
|
// Ignore malformed spectator payloads.
|
||||||
@@ -521,9 +528,8 @@ function draw() {
|
|||||||
|
|
||||||
drawScoreboard(state.scores);
|
drawScoreboard(state.scores);
|
||||||
|
|
||||||
const lastCompleted = state.completed[state.completed.length - 1];
|
|
||||||
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
|
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) {
|
if (state.done) {
|
||||||
drawDone(state.scores);
|
drawDone(state.scores);
|
||||||
|
|||||||
16
frontend.tsx
16
frontend.tsx
@@ -32,20 +32,25 @@ type RoundState = {
|
|||||||
scoreB?: number;
|
scoreB?: number;
|
||||||
};
|
};
|
||||||
type GameState = {
|
type GameState = {
|
||||||
completed: RoundState[];
|
lastCompleted: RoundState | null;
|
||||||
active: RoundState | null;
|
active: RoundState | null;
|
||||||
scores: Record<string, number>;
|
scores: Record<string, number>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
generation: number;
|
generation: number;
|
||||||
};
|
};
|
||||||
type ServerMessage = {
|
type StateMessage = {
|
||||||
type: "state";
|
type: "state";
|
||||||
data: GameState;
|
data: GameState;
|
||||||
totalRounds: number;
|
totalRounds: number;
|
||||||
viewerCount: number;
|
viewerCount: number;
|
||||||
version?: string;
|
version?: string;
|
||||||
};
|
};
|
||||||
|
type ViewerCountMessage = {
|
||||||
|
type: "viewerCount";
|
||||||
|
viewerCount: number;
|
||||||
|
};
|
||||||
|
type ServerMessage = StateMessage | ViewerCountMessage;
|
||||||
|
|
||||||
// ── Model colors & logos ─────────────────────────────────────────────────────
|
// ── Model colors & logos ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -432,6 +437,8 @@ function App() {
|
|||||||
setState(msg.data);
|
setState(msg.data);
|
||||||
setTotalRounds(msg.totalRounds);
|
setTotalRounds(msg.totalRounds);
|
||||||
setViewerCount(msg.viewerCount);
|
setViewerCount(msg.viewerCount);
|
||||||
|
} else if (msg.type === "viewerCount") {
|
||||||
|
setViewerCount(msg.viewerCount);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -445,11 +452,10 @@ function App() {
|
|||||||
|
|
||||||
if (!connected || !state) return <ConnectingScreen />;
|
if (!connected || !state) return <ConnectingScreen />;
|
||||||
|
|
||||||
const lastCompleted = state.completed[state.completed.length - 1];
|
|
||||||
const isNextPrompting =
|
const isNextPrompting =
|
||||||
state.active?.phase === "prompting" && !state.active.prompt;
|
state.active?.phase === "prompting" && !state.active.prompt;
|
||||||
const displayRound =
|
const displayRound =
|
||||||
isNextPrompting && lastCompleted ? lastCompleted : state.active;
|
isNextPrompting && state.lastCompleted ? state.lastCompleted : state.active;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
@@ -486,7 +492,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isNextPrompting && lastCompleted && (
|
{isNextPrompting && state.lastCompleted && (
|
||||||
<div className="next-toast">
|
<div className="next-toast">
|
||||||
<ModelTag model={state.active!.prompter} small /> is writing the
|
<ModelTag model={state.active!.prompter} small /> is writing the
|
||||||
next prompt
|
next prompt
|
||||||
|
|||||||
9
game.ts
9
game.ts
@@ -106,9 +106,16 @@ export function log(
|
|||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
let line = `[${ts}] ${level} [${category}] ${message}`;
|
let line = `[${ts}] ${level} [${category}] ${message}`;
|
||||||
if (data) {
|
if (data) {
|
||||||
line += "\n " + JSON.stringify(data, null, 2).replace(/\n/g, "\n ");
|
line += " " + JSON.stringify(data);
|
||||||
}
|
}
|
||||||
appendFileSync(LOG_FILE, line + "\n");
|
appendFileSync(LOG_FILE, line + "\n");
|
||||||
|
if (level === "ERROR") {
|
||||||
|
console.error(line);
|
||||||
|
} else if (level === "WARN") {
|
||||||
|
console.warn(line);
|
||||||
|
} else {
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
74
server.ts
74
server.ts
@@ -64,10 +64,6 @@ const gameState: GameState = {
|
|||||||
type WsData = { ip: string };
|
type WsData = { ip: string };
|
||||||
|
|
||||||
const WINDOW_MS = 60_000;
|
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(
|
const HISTORY_LIMIT_PER_MIN = parsePositiveInt(
|
||||||
process.env.HISTORY_LIMIT_PER_MIN,
|
process.env.HISTORY_LIMIT_PER_MIN,
|
||||||
120,
|
120,
|
||||||
@@ -221,10 +217,21 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
|
|||||||
|
|
||||||
const clients = new Set<ServerWebSocket<WsData>>();
|
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() {
|
function broadcast() {
|
||||||
const msg = JSON.stringify({
|
const msg = JSON.stringify({
|
||||||
type: "state",
|
type: "state",
|
||||||
data: gameState,
|
data: getClientState(),
|
||||||
totalRounds: runs,
|
totalRounds: runs,
|
||||||
viewerCount: clients.size,
|
viewerCount: clients.size,
|
||||||
version: VERSION,
|
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() {
|
function getAdminSnapshot() {
|
||||||
return {
|
return {
|
||||||
isPaused: gameState.isPaused,
|
isPaused: gameState.isPaused,
|
||||||
@@ -284,6 +301,7 @@ const server = Bun.serve<WsData>({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isRateLimited(`admin:${ip}`, ADMIN_LIMIT_PER_MIN, WINDOW_MS)) {
|
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 });
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +485,7 @@ const server = Bun.serve<WsData>({
|
|||||||
|
|
||||||
if (url.pathname === "/api/history") {
|
if (url.pathname === "/api/history") {
|
||||||
if (isRateLimited(`history:${ip}`, HISTORY_LIMIT_PER_MIN, WINDOW_MS)) {
|
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 });
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
}
|
}
|
||||||
const rawPage = parseInt(url.searchParams.get("page") || "1", 10);
|
const rawPage = parseInt(url.searchParams.get("page") || "1", 10);
|
||||||
@@ -514,21 +533,27 @@ const server = Bun.serve<WsData>({
|
|||||||
headers: { Allow: "GET" },
|
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) {
|
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 });
|
return new Response("Service Unavailable", { status: 503 });
|
||||||
}
|
}
|
||||||
const existingForIp = wsByIp.get(ip) ?? 0;
|
const existingForIp = wsByIp.get(ip) ?? 0;
|
||||||
if (existingForIp >= MAX_WS_PER_IP) {
|
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 });
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgraded = server.upgrade(req, { data: { ip } });
|
const upgraded = server.upgrade(req, { data: { ip } });
|
||||||
if (!upgraded) {
|
if (!upgraded) {
|
||||||
|
log("WARN", "ws", "WebSocket upgrade failed", { ip });
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -540,8 +565,26 @@ const server = Bun.serve<WsData>({
|
|||||||
data: {} as WsData,
|
data: {} as WsData,
|
||||||
open(ws) {
|
open(ws) {
|
||||||
clients.add(ws);
|
clients.add(ws);
|
||||||
wsByIp.set(ws.data.ip, (wsByIp.get(ws.data.ip) ?? 0) + 1);
|
const ipCount = (wsByIp.get(ws.data.ip) ?? 0) + 1;
|
||||||
broadcast();
|
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) {
|
message(_ws, _message) {
|
||||||
// Spectator-only, no client messages handled
|
// Spectator-only, no client messages handled
|
||||||
@@ -549,7 +592,12 @@ const server = Bun.serve<WsData>({
|
|||||||
close(ws) {
|
close(ws) {
|
||||||
clients.delete(ws);
|
clients.delete(ws);
|
||||||
decrementIpConnection(ws.data.ip);
|
decrementIpConnection(ws.data.ip);
|
||||||
broadcast();
|
log("INFO", "ws", "Client disconnected", {
|
||||||
|
ip: ws.data.ip,
|
||||||
|
totalClients: clients.size,
|
||||||
|
uniqueIps: wsByIp.size,
|
||||||
|
});
|
||||||
|
broadcastViewerCount();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
development:
|
development:
|
||||||
|
|||||||
Reference in New Issue
Block a user