Shaheer Sarfaraz 032626bd7d
Fix #162: real-time bulk action streaming progress (#187)
* initial

* refactor: centralize SSE plumbing for client and server

* docs: add centralized SSE usage standards to agents guide

* use sse to stream actions to the client

* ui: align bulk progress toast with default sonner style

* ui: remove hide action from bulk progress toast

* full width progress bar

* fix(stream): track client disconnect and writability

* fix(stream): stop bulk loop when SSE client disconnects

* fix(stream): avoid writing error/end to closed SSE response

* fix(stream): gate started/progress frames on writable SSE socket

* types(api): narrow SSE stream payload input contract

* refactor(ui): share clamp helper for bulk progress

* fix(stream): add heartbeat to bulk action SSE route

* feat(stream): include completed count in bulk completion event

* fix(client-sse): separate parse vs handler errors and cancel reader
2026-02-18 15:54:39 +00:00

46 lines
1.1 KiB
TypeScript

import type { Response } from "express";
interface SetupSseOptions {
cacheControl?: string;
disableBuffering?: boolean;
flushHeaders?: boolean;
}
const DEFAULT_HEARTBEAT_MS = 30_000;
export function setupSse(res: Response, options: SetupSseOptions = {}): void {
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", options.cacheControl ?? "no-cache");
res.setHeader("Connection", "keep-alive");
if (options.disableBuffering) {
res.setHeader("X-Accel-Buffering", "no");
}
if (options.flushHeaders) {
res.flushHeaders?.();
}
}
export function writeSseData(res: Response, data: unknown): void {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
export function writeSseComment(res: Response, comment: string): void {
res.write(`: ${comment}\n\n`);
}
export function startSseHeartbeat(
res: Response,
intervalMs = DEFAULT_HEARTBEAT_MS,
): () => void {
const heartbeat = setInterval(() => {
writeSseComment(res, "heartbeat");
}, intervalMs);
return () => {
clearInterval(heartbeat);
};
}