5.9 KiB
@suaveplan/api-sse-bun — Bun.serve Server-Sent Events engine
Package: @suaveplan/api-sse-bun
Status: PROPOSED
Type: new-package
Stage: 7 (GraphQL + WebSocket / SSE engines) — see umbrella openspec/changes/api-plugin-foundation/
Why
Per design doc §11.6 SSE is the simpler cousin of WebSocket — useful for activity feeds, dashboards, and notifications where no client-to-server channel and no ack semantics are needed. SSE falls back gracefully on networks that block WebSocket (corporate proxies, restrictive firewalls), making it a deliberately important second transport. This package implements the abstract api-sse contract on top of Bun.serve and the same broker abstraction as api-ws-bun, so multi-host fan-out is handled by the same Redis broker (api-ws-broker-redis) without duplication.
Without this adapter the foundation has no production SSE story and falls back to single-transport-only deployments.
What changes
Net-new package depending on @suaveplan/api-types, @suaveplan/api-sse (abstract contract), @suaveplan/api-router (HTTP endpoint mount), @suaveplan/error, @suaveplan/logger, @suaveplan/opentelemetry, and zod.
- ADDED:
createBunSseEngine(opts)— factory returning the engine value implementing@suaveplan/api-sse's contract. - ADDED:
BunSseEngineOptions— Zod-validated config:eventBufferSize(default 256 events per(tenant, topic)),eventBufferMs(default 300000 = 5 min, pairs with WS reconnect TTL),keepAliveCommentMs(default 15000 — emits SSE comments to prevent intermediary timeouts),maxConnectionsPerTenant(default 1000),compressionEnabled(defaulttrue— usesContent-Encoding: gzipwhen the client accepts it),retryMs(default 3000 — written intoretry:field of the SSE stream so clients back off appropriately). - ADDED: Endpoint mount — plugin's manifested SSE endpoints (
sse: [{ path, auth, subscriptions }]) are mounted viaapi-routerasGET <path>returningContent-Type: text/event-stream. - ADDED:
Last-Event-Idresume — when the client suppliesLast-Event-Idheader (HTTP standard), the engine consults the event buffer (broker-backed when Redis broker wired, in-memory otherwise) and replays events with id > supplied value before resuming live delivery. - ADDED: Per-
(tenant, topic)event buffer — for single-host deployments, in-memory ring buffer ofeventBufferSizeevents witheventBufferMsTTL. For multi-host deployments, delegates to the wired broker (api-ws-broker-redisexposes the same buffer surface; SSE consumes it identically to WS). - ADDED: Keep-alive comments —
:keepalive\n\nemitted everykeepAliveCommentMsto prevent intermediary proxy timeouts. This is invisible to clients. - ADDED: Subscription model —
conn.subscribe(topic, { tenant?, filter? })registers against the host's subscription bus (same bus used by WS).bus.broadcasttriggers fan-out to SSE connections matching(topic, tenant). - ADDED: Per-tenant cap —
maxConnectionsPerTenantenforced at request time; exceeding returns 429 withSSE_BUN_TENANT_CAP_EXCEEDED. - ADDED:
SSE_BUN_*error codes:SSE_BUN_HANDSHAKE_REJECTED,SSE_BUN_TENANT_CAP_EXCEEDED,SSE_BUN_BUFFER_MISS,SSE_BUN_EVENT_TOO_LARGE,SSE_BUN_LAST_EVENT_ID_INVALID,SSE_BUN_COMPRESSION_FAILED,SSE_BUN_STREAM_INTERRUPTED,SSE_BUN_PRINCIPAL_EXPIRED,SSE_BUN_BROKER_UNAVAILABLE,SSE_BUN_INVALID_TOPIC. - ADDED: OTel surface from design §11.7 —
sse.connections { plugin, path, tenant }observable gauge,sse.events_sent_total { plugin, topic }counter, plus this package addssse.handshake.duration_ms,sse.buffer_replays_total { hit },sse.compression_ratiohistogram. - ADDED:
./testingsubpath — re-export ofrunSseEngineConformancefromapi-sse/testingbound tocreateBunSseEngine.
Out of scope
- A non-Bun SSE engine — the contract lives in
api-sse; other engines can implement it. - Cross-host fan-out logic — delegated to
api-ws-broker-redis(which is also the SSE broker, per §11.6). - Bi-directional channels — SSE is server→client only by design.
- Browser polyfills — modern browsers + recent Node clients support SSE natively.
Source mapping
Genesis-original. Source of design intent: docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md.
| Requirement | Design doc section |
|---|---|
| SSE endpoint declared in manifest | §3 (sse block) + §11.6 |
Last-Event-Id resume |
§11.5 (paragraph 3) + §11.6 |
Event buffer per (tenant, topic) with size + ttl |
§11.5 (paragraph 3) |
| Shared broker with WS for multi-host fan-out | §11.6 |
| Same subscription bus as WS | §11.6 |
OTel sse.connections gauge + sse.events_sent_total counter |
§11.7 |
| Graceful fall-back when WS blocked | §11.6 |
Verification approach
src/engine/bun-sse-engine.test.ts— handshake, event emission framing, keep-alive comment cadence, principal frozen at handshake.src/engine/bun-sse-engine.spec-conformance.test.ts— runsrunSseEngineConformance("bun", createBunSseEngine).src/last-event-id/last-event-id.test.ts— supplyLast-Event-Id: 7, assert events 8..N replay before live delivery.src/event-buffer/event-buffer.test.ts— in-memory mode: ring-buffer cap, TTL eviction. Broker mode (integration, 30000ms): delegation to broker-backed buffer.src/limits/tenant-cap.test.ts— 1001st connection rejected with 429.src/keep-alive/keep-alive.test.ts(FakeClock) —:keepalivecomments emitted everykeepAliveCommentMs.tests/integration/end-to-end.test.ts(30000ms) — realBun.serve, real SSE client, full subscribe → broadcast → keep-alive → disconnect → reconnect-with-Last-Event-Id.tests/integration/multi-host-fan-out.test.ts(60000ms) — two SSE engines behindapi-ws-broker-redis; broadcast from one delivers on the other.
Migration impact
None — additive only.