107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
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<Response>();
|
|
private readonly eventHub: RealtimeEventHub;
|
|
readonly events: RequestHandler;
|
|
readonly dashboardEvents: ReturnType<typeof createDashboardEventAdapters>;
|
|
|
|
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<string, unknown>): 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();
|
|
}
|
|
}
|