diff --git a/src/commands/serve-http.ts b/src/commands/serve-http.ts index 85ac0307f..75c7b6a8e 100644 --- a/src/commands/serve-http.ts +++ b/src/commands/serve-http.ts @@ -200,6 +200,24 @@ export type ProbeHealthResult = | { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } } | { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } }; +type AdminSseResponse = Pick; + +/** + * Complete the admin EventSource handshake immediately. + * + * `flushHeaders()` alone can leave reverse proxies and browsers waiting for + * the first response body bytes. An SSE comment is protocol-valid, ignored by + * EventSource consumers, and makes the stream observable end-to-end without + * fabricating an application event. + */ +export function openAdminSseStream(res: AdminSseResponse): void { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + res.write(': connected\n\n'); +} + /** * Pure async health probe. Races `engine.getStats()` against a timeout, * returns a tagged result. No Express coupling — easy to unit-test with a @@ -1697,10 +1715,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption // SSE live activity feed // --------------------------------------------------------------------------- app.get('/admin/events', requireAdmin, (req: Request, res: Response) => { - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.flushHeaders(); + openAdminSseStream(res); sseClients.add(res); req.on('close', () => sseClients.delete(res)); diff --git a/test/admin-sse-handshake.test.ts b/test/admin-sse-handshake.test.ts new file mode 100644 index 000000000..b78a0802c --- /dev/null +++ b/test/admin-sse-handshake.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { openAdminSseStream } from '../src/commands/serve-http.ts'; + +describe('admin SSE handshake', () => { + test('flushes a protocol-valid comment immediately after the headers', () => { + const calls: string[] = []; + const headers = new Map(); + + openAdminSseStream({ + setHeader(name: string, value: string | number | readonly string[]) { + headers.set(name, String(value)); + calls.push(`header:${name}`); + return this; + }, + flushHeaders() { + calls.push('flush'); + }, + write(chunk: unknown) { + calls.push(`write:${String(chunk)}`); + return true; + }, + }); + + expect(headers).toEqual(new Map([ + ['Content-Type', 'text/event-stream'], + ['Cache-Control', 'no-cache'], + ['Connection', 'keep-alive'], + ])); + expect(calls).toEqual([ + 'header:Content-Type', + 'header:Cache-Control', + 'header:Connection', + 'flush', + 'write:: connected\n\n', + ]); + }); +});