Files
2026-05-27 23:29:53 +01:00

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 (default true — uses Content-Encoding: gzip when the client accepts it), retryMs (default 3000 — written into retry: field of the SSE stream so clients back off appropriately).
  • ADDED: Endpoint mount — plugin's manifested SSE endpoints (sse: [{ path, auth, subscriptions }]) are mounted via api-router as GET <path> returning Content-Type: text/event-stream.
  • ADDED: Last-Event-Id resume — when the client supplies Last-Event-Id header (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 of eventBufferSize events with eventBufferMs TTL. For multi-host deployments, delegates to the wired broker (api-ws-broker-redis exposes the same buffer surface; SSE consumes it identically to WS).
  • ADDED: Keep-alive comments — :keepalive\n\n emitted every keepAliveCommentMs to 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.broadcast triggers fan-out to SSE connections matching (topic, tenant).
  • ADDED: Per-tenant cap — maxConnectionsPerTenant enforced at request time; exceeding returns 429 with SSE_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 adds sse.handshake.duration_ms, sse.buffer_replays_total { hit }, sse.compression_ratio histogram.
  • ADDED: ./testing subpath — re-export of runSseEngineConformance from api-sse/testing bound to createBunSseEngine.

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 — runs runSseEngineConformance("bun", createBunSseEngine).
  • src/last-event-id/last-event-id.test.ts — supply Last-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) — :keepalive comments emitted every keepAliveCommentMs.
  • tests/integration/end-to-end.test.ts (30000ms) — real Bun.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 behind api-ws-broker-redis; broadcast from one delivers on the other.

Migration impact

None — additive only.