feat(FN-1145): harden dashboard SSE streams with replay and reconnect
- Add a shared SSE event buffer utility and wire it into planning, subtask, mission interview, and task stream routes - Support Last-Event-ID replay semantics and robust event serialization so clients can recover missed stream events - Add a resilient client-side reconnect wrapper and surface reconnecting state in planning, subtask breakdown, and mission interview modals - Expand test coverage across API routes, SSE buffering behavior, reconnect handling, and mission/planning stream flows
This commit is contained in:
89
packages/dashboard/src/sse-buffer.ts
Normal file
89
packages/dashboard/src/sse-buffer.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Response } from "express";
|
||||
|
||||
export interface SessionBufferedEvent {
|
||||
id: number;
|
||||
event: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session in-memory ring buffer for SSE events.
|
||||
*
|
||||
* Stores only the last N events and assigns monotonically increasing IDs.
|
||||
*/
|
||||
export class SessionEventBuffer {
|
||||
private events: SessionBufferedEvent[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
constructor(private readonly maxCapacity = 100) {
|
||||
if (!Number.isFinite(maxCapacity) || maxCapacity <= 0) {
|
||||
throw new Error("maxCapacity must be a positive finite number");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an event into the buffer and return the assigned event id.
|
||||
*/
|
||||
push(event: string, data: string): number {
|
||||
const id = this.nextId++;
|
||||
this.events.push({ id, event, data });
|
||||
|
||||
if (this.events.length > this.maxCapacity) {
|
||||
this.events.splice(0, this.events.length - this.maxCapacity);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all buffered events with id > lastEventId.
|
||||
*/
|
||||
getEventsSince(lastEventId: number): SessionBufferedEvent[] {
|
||||
if (!Number.isFinite(lastEventId)) {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
return this.events.filter((event) => event.id > lastEventId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.events.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one SSE event payload (with optional id field).
|
||||
*/
|
||||
export function formatSSEEvent(event: string, data: string, id?: number): string {
|
||||
const idLine = id !== undefined ? `id: ${id}\n` : "";
|
||||
return `${idLine}event: ${event}\ndata: ${data}\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely write to an SSE response stream.
|
||||
*/
|
||||
export function safeWriteSSE(res: Pick<Response, "write" | "writableEnded" | "destroyed">, payload: string): boolean {
|
||||
try {
|
||||
if (res.writableEnded || res.destroyed) return false;
|
||||
res.write(payload);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one SSE event to response with optional id field.
|
||||
*/
|
||||
export function writeSSEEvent(
|
||||
res: Pick<Response, "write" | "writableEnded" | "destroyed">,
|
||||
event: string,
|
||||
data: string,
|
||||
id?: number,
|
||||
): boolean {
|
||||
return safeWriteSSE(res, formatSSEEvent(event, data, id));
|
||||
}
|
||||
Reference in New Issue
Block a user