Files
golfgame/tests/soak/dashboard/server.ts
adlee-was-taken 9d1d4f899b feat(soak): DashboardServer — vanilla http + ws
Serves one static HTML page, accepts WS connections, broadcasts
room_state/log/metric messages to all clients. Replays current
state to late-joining clients so refreshing the dashboard during
a run shows the right grid. Exposes a reporter() method that
returns a DashboardReporter scenarios can call without knowing
about sockets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:53:59 -04:00

155 lines
5.2 KiB
TypeScript

/**
* DashboardServer — vanilla http + ws, serves one static HTML page
* and broadcasts room_state/log/metric events to all connected clients.
*
* Scenarios never touch this directly — they call the DashboardReporter
* returned by `server.reporter()`, which forwards over WS.
*/
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import { WebSocketServer, WebSocket } from 'ws';
import type { DashboardReporter, Logger, RoomState } from '../core/types';
export type DashboardIncoming =
| { type: 'start_stream'; sessionKey: string }
| { type: 'stop_stream'; sessionKey: string };
export type DashboardOutgoing =
| { type: 'room_state'; roomId: string; state: Partial<RoomState> }
| { type: 'log'; level: string; msg: string; meta?: object; timestamp: number }
| { type: 'metric'; name: string; value: number }
| { type: 'frame'; sessionKey: string; jpegBase64: string };
export interface DashboardHandlers {
onStartStream?(sessionKey: string): void;
onStopStream?(sessionKey: string): void;
onDisconnect?(): void;
}
export class DashboardServer {
private httpServer!: http.Server;
private wsServer!: WebSocketServer;
private clients = new Set<WebSocket>();
private metrics: Record<string, number> = {};
private roomStates: Record<string, Partial<RoomState>> = {};
constructor(
private port: number,
private logger: Logger,
private handlers: DashboardHandlers = {},
) {}
async start(): Promise<void> {
const htmlPath = path.resolve(__dirname, 'index.html');
const cssPath = path.resolve(__dirname, 'dashboard.css');
const jsPath = path.resolve(__dirname, 'dashboard.js');
this.httpServer = http.createServer((req, res) => {
const url = req.url ?? '/';
if (url === '/' || url === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
fs.createReadStream(htmlPath).pipe(res);
} else if (url === '/dashboard.css') {
res.writeHead(200, { 'Content-Type': 'text/css' });
fs.createReadStream(cssPath).pipe(res);
} else if (url === '/dashboard.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
fs.createReadStream(jsPath).pipe(res);
} else {
res.writeHead(404);
res.end('not found');
}
});
this.wsServer = new WebSocketServer({ server: this.httpServer });
this.wsServer.on('connection', (ws) => {
this.clients.add(ws);
this.logger.info('dashboard_client_connected', { count: this.clients.size });
// Replay current state to the new client so late joiners see
// everything that has happened since the run started.
for (const [roomId, state] of Object.entries(this.roomStates)) {
ws.send(
JSON.stringify({ type: 'room_state', roomId, state } as DashboardOutgoing),
);
}
for (const [name, value] of Object.entries(this.metrics)) {
ws.send(JSON.stringify({ type: 'metric', name, value } as DashboardOutgoing));
}
ws.on('message', (data) => {
try {
const parsed = JSON.parse(data.toString()) as DashboardIncoming;
if (parsed.type === 'start_stream' && this.handlers.onStartStream) {
this.handlers.onStartStream(parsed.sessionKey);
} else if (parsed.type === 'stop_stream' && this.handlers.onStopStream) {
this.handlers.onStopStream(parsed.sessionKey);
}
} catch (err) {
this.logger.warn('dashboard_ws_parse_error', {
error: err instanceof Error ? err.message : String(err),
});
}
});
ws.on('close', () => {
this.clients.delete(ws);
this.logger.info('dashboard_client_disconnected', { count: this.clients.size });
if (this.clients.size === 0 && this.handlers.onDisconnect) {
this.handlers.onDisconnect();
}
});
});
await new Promise<void>((resolve) => {
this.httpServer.listen(this.port, () => resolve());
});
this.logger.info('dashboard_listening', { url: `http://localhost:${this.port}` });
}
async stop(): Promise<void> {
for (const ws of this.clients) {
try {
ws.close();
} catch {
// ignore
}
}
this.clients.clear();
await new Promise<void>((resolve) => {
this.wsServer.close(() => resolve());
});
await new Promise<void>((resolve) => {
this.httpServer.close(() => resolve());
});
}
broadcast(msg: DashboardOutgoing): void {
const payload = JSON.stringify(msg);
for (const ws of this.clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(payload);
}
}
}
/** Create a DashboardReporter wired to this server. */
reporter(): DashboardReporter {
return {
update: (roomId, state) => {
this.roomStates[roomId] = { ...this.roomStates[roomId], ...state };
this.broadcast({ type: 'room_state', roomId, state });
},
log: (level, msg, meta) => {
this.broadcast({ type: 'log', level, msg, meta, timestamp: Date.now() });
},
incrementMetric: (name, by = 1) => {
this.metrics[name] = (this.metrics[name] ?? 0) + by;
this.broadcast({ type: 'metric', name, value: this.metrics[name] });
},
};
}
}