import type { Request, RequestHandler, Response } from 'express'; import type { LogEntry } from './types.js'; import { createDashboardEventAdapters, createDashboardEventPublisher, RealtimeEventHub, } from './events/index.js'; import { createExpressSseHandler } from './events/express-sse-adapter.js'; import { toJsonSafe } from '../utils/json-safe.js'; const MAX_LOGS = 500; const INITIAL_LOGS_ON_CONNECT = 50; /** * Holds the recent realtime log buffer and fans out new entries to every * connected SSE client. Owns the `/api/logs/stream` and `/api/logs` handlers. */ export class LogBroadcaster { private entries: LogEntry[] = []; private clients = new Set(); private readonly eventHub: RealtimeEventHub; readonly events: RequestHandler; readonly dashboardEvents: ReturnType; constructor(eventHub?: RealtimeEventHub) { this.eventHub = eventHub ?? new RealtimeEventHub({ capacity: MAX_LOGS }); this.events = createExpressSseHandler({ hub: this.eventHub }); this.dashboardEvents = createDashboardEventAdapters(createDashboardEventPublisher(this.eventHub)); } push(level: LogEntry['level'], message: string, data?: unknown): void { const safeData = toJsonSafe(data); const realtimeEvent = this.eventHub.closed ? undefined : this.eventHub.publish('log.created', { level, message, data: safeData }); const entry: LogEntry = { timestamp: realtimeEvent ? Date.parse(realtimeEvent.occurredAt) : Date.now(), level, message, data: safeData, }; this.entries.push(entry); if (this.entries.length > MAX_LOGS) { this.entries = this.entries.slice(-MAX_LOGS); } this.fanout(JSON.stringify({ type: 'log', ...entry })); } /** * Broadcast a non-log event (e.g. sync progress) to all connected clients. * Unlike `push`, these events are not buffered or replayed on reconnect — * they reflect transient state. */ broadcastEvent(type: string, payload: Record): void { this.fanout(JSON.stringify({ type, ...payload })); } private fanout(payload: string): void { for (const client of this.clients) { try { client.write(`data: ${payload}\n\n`); } catch { this.clients.delete(client); } } } /** Express handler for SSE stream. */ stream = (req: Request, res: Response): void => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`); this.clients.add(res); for (const log of this.entries.slice(-INITIAL_LOGS_ON_CONNECT)) { res.write(`data: ${JSON.stringify({ type: 'log', ...log })}\n\n`); } req.on('close', () => { this.clients.delete(res); }); }; /** Express handler for `/api/logs?limit=N`. */ recent = (req: Request, res: Response): void => { const limit = parseInt(req.query.limit as string) || 100; res.json(this.entries.slice(-limit)); }; closeAll(): void { for (const client of this.clients) { try { client.end(); } catch { // ignore } } this.clients.clear(); this.eventHub.close(); } }