8.2 KiB
Capability Spec: api-sse-bun
Package: @suaveplan/api-sse-bun
Status: SHIPPED (2026-05-26)
Every requirement carries a
**Verified by:**line.
Requirements
Requirement: Implements the abstract SSE engine contract
The package SHALL export createBunSseEngine(opts) producing an engine value that implements the engine contract from @suaveplan/api-sse. The conformance suite runSseEngineConformance MUST pass against this adapter.
Scenario: conformance suite passes
- WHEN
runSseEngineConformance("bun", createBunSseEngine)runs - THEN every invariant passes
Verified by: src/engine/bun-sse-engine.spec-conformance.test.ts::"createBunSseEngine passes runSseEngineConformance"
Requirement: SSE framing emits valid event-stream output
The engine SHALL emit Content-Type: text/event-stream. Each event MUST be framed as id: <eventId>\nevent: <topic>\ndata: <JSON-encoded payload>\n\n. A retry: <retryMs>\n line MUST be sent at stream start (default 3000) so clients back off appropriately on disconnect. Keep-alive comments :keepalive\n\n MUST be emitted every keepAliveCommentMs (default 15000) — invisible to clients but prevent intermediary proxy timeouts.
Scenario: first emitted event includes id, event, data lines
- WHEN a broadcast triggers an event delivered to a subscriber
- THEN the stream body contains
id: <n>,event: <topic>, anddata: <json>lines terminated by a blank line
Scenario: retry directive emitted at stream start
- WHEN a subscriber connects with default
retryMs: 3000 - THEN the first bytes of the response include
retry: 3000\n
Scenario: keep-alive comment cadence
- WHEN no real events for
keepAliveCommentMs + 1ms - THEN the stream contains at least one
:keepalive\n\nline
Verified by: src/stream/sse-stream.test.ts::"emitted event includes id, event, data lines" AND src/stream/sse-stream.test.ts::"retry directive emitted at stream start" AND src/keep-alive/keep-alive.test.ts::"keep-alive comment emitted every keepAliveCommentMs"
Requirement: Last-Event-Id replay before live delivery
When a client supplies Last-Event-Id: <n> header on connect (HTTP standard, automatically sent by browser EventSource on reconnect), the engine SHALL consult the event buffer for (tenant, topic) AND replay every event with id > n in order BEFORE switching to live delivery. The replay set MUST include at most eventBufferSize events; missed-window cases (buffer evicted older events) MUST set SSE_BUN_BUFFER_MISS on a comment line so the client can react. A malformed Last-Event-Id value MUST be refused with SSE_BUN_LAST_EVENT_ID_INVALID.
Scenario: replay delivers events with id > Last-Event-Id
- WHEN the buffer holds events 1..10 AND a client reconnects with
Last-Event-Id: 7 - THEN events 8, 9, 10 are delivered in order BEFORE any new live event
Scenario: buffer miss surfaces as a comment line
- WHEN the buffer's oldest event is id 5 AND a client reconnects with
Last-Event-Id: 2 - THEN the stream emits a comment line containing
SSE_BUN_BUFFER_MISSAND switches to live delivery without replay
Scenario: malformed Last-Event-Id refused
- WHEN the header value is
"not-a-number" - THEN the connection is refused with
SSE_BUN_LAST_EVENT_ID_INVALID
Verified by: src/last-event-id/last-event-id.test.ts::"replay delivers events with id greater than Last-Event-Id" AND src/last-event-id/last-event-id.test.ts::"buffer miss surfaces SSE_BUN_BUFFER_MISS comment" AND src/last-event-id/last-event-id.test.ts::"malformed Last-Event-Id refused"
Requirement: Event buffer storage follows wired broker
The engine SHALL maintain a per-(tenant, topic) event buffer:
- When a broker (
api-ws-broker-redis) is wired into the host, the buffer lives in the broker (Redis sorted-set shared with the WS engine). - Otherwise the buffer lives in-memory on the local host as a ring buffer capped at
eventBufferSize(default 256) with TTLeventBufferMs(default 300000).
This is the same default as api-ws-bun's reconnect event-buffer strategy (design §11.5 paragraph 4), pinned for the SSE side here.
Scenario: broker-backed buffer used when wired
- WHEN a Redis broker is wired AND an event is broadcast
- THEN the event is observable in the broker's buffer surface (asserted via
broker.readBuffer(tenant, topic, 0))
Scenario: in-memory buffer used when no broker wired
- WHEN no broker is wired AND
eventBufferSize: 4AND 5 events are emitted for the same(tenant, topic) - THEN the in-memory buffer holds the 4 most recent
Verified by: src/event-buffer/event-buffer.test.ts::"broker-backed buffer used when broker wired" AND src/event-buffer/event-buffer.test.ts::"in-memory buffer evicts oldest when broker not wired"
Requirement: Per-tenant connection cap
The engine SHALL track active SSE connections per tenant. A new connection request when a tenant is at maxConnectionsPerTenant (default 1000) MUST return HTTP 429 with SSE_BUN_TENANT_CAP_EXCEEDED.
Scenario: 1001st connection from same tenant rejected
- WHEN
maxConnectionsPerTenant: 1000AND tenant T has 1000 active SSE connections - THEN the 1001st returns 429 with
SSE_BUN_TENANT_CAP_EXCEEDED
Verified by: src/limits/tenant-cap.test.ts::"1001st SSE connection from same tenant rejected with SSE_BUN_TENANT_CAP_EXCEEDED"
Requirement: Same subscription bus as WebSocket engine
conn.subscribe(topic, { tenant?, filter? }) SHALL register the SSE connection against the host's shared subscription bus — the same bus used by api-ws-bun. bus.broadcast(topic, { tenant, payload }) MUST fan out to BOTH SSE and WS subscribers matching (topic, tenant) without any code path duplication between engines.
Scenario: WS and SSE subscribers both receive the same broadcast
- WHEN a WS client and an SSE client are both subscribed to
(topic, tenantT)ANDbus.broadcast(topic, { tenant: T, payload })fires - THEN both clients receive the payload
Verified by: tests/integration/end-to-end.test.ts::"WS and SSE subscribers both receive same broadcast via shared bus"
Requirement: Optional gzip compression
When compressionEnabled: true (default) AND the client sends Accept-Encoding: gzip, the engine SHALL emit Content-Encoding: gzip AND gzip-stream the response body. Compression failure (e.g. encoder error) MUST fall back to uncompressed streaming AND emit SSE_BUN_COMPRESSION_FAILED to logs (NOT to the client — they get a working uncompressed stream).
Scenario: gzip used when client accepts
- WHEN the client sends
Accept-Encoding: gzip - THEN the response has
Content-Encoding: gzipAND the body decompresses to valid SSE framing
Scenario: compression failure falls back to plain
- WHEN the gzip encoder throws AND a client is mid-stream
- THEN the stream falls back to uncompressed delivery AND a log entry with
SSE_BUN_COMPRESSION_FAILEDis emitted
Verified by: src/compression/compression.test.ts::"gzip emitted when client sends Accept-Encoding: gzip" AND src/compression/compression.test.ts::"compression failure falls back to plain with SSE_BUN_COMPRESSION_FAILED log"
Requirement: OTel surface
The engine SHALL emit:
sse.connectionsobservable gauge{ plugin, path, tenant }.sse.events_sent_totalcounter{ plugin, topic }.sse.handshake.duration_mshistogram.sse.buffer_replays_totalcounter{ hit }("hit"when replay returned events,"miss"onSSE_BUN_BUFFER_MISS).sse.compression_ratiohistogram (uncompressed-size / compressed-size when compression is on).
Scenario: event delivery emits events_sent_total
- WHEN a broadcast delivers to one SSE subscriber
- THEN
sse.events_sent_total{ plugin, topic }increments by 1
Scenario: connection lifecycle reflected in gauge
- WHEN a connection opens then closes
- THEN the
sse.connectionsgauge briefly rises then falls
Verified by: src/instruments/instruments.test.ts::"event delivery emits sse.events_sent_total" AND src/instruments/instruments.test.ts::"connection lifecycle reflected in sse.connections gauge"