/** * 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 } | { 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(); private metrics: Record = {}; private roomStates: Record> = {}; constructor( private port: number, private logger: Logger, private handlers: DashboardHandlers = {}, ) {} async start(): Promise { 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((resolve) => { this.httpServer.listen(this.port, () => resolve()); }); this.logger.info('dashboard_listening', { url: `http://localhost:${this.port}` }); } async stop(): Promise { for (const ws of this.clients) { try { ws.close(); } catch { // ignore } } this.clients.clear(); await new Promise((resolve) => { this.wsServer.close(() => resolve()); }); await new Promise((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] }); }, }; } }