Files
suaveplan/openspec/archive/2026-05-26-api-sse-bun/specs/api-sse-bun/spec.md
2026-05-27 23:29:53 +01:00

8.2 KiB

Capability Spec Delta: api-sse-bun (api-sse-bun)

Package: @suaveplan/api-sse-bun Status: PROPOSED

Every requirement carries a **Verified by:** line.

ADDED 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>, and data: <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\n line

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_MISS AND 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 TTL eventBufferMs (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: 4 AND 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: 1000 AND 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) AND bus.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: gzip AND 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_FAILED is 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.connections observable gauge { plugin, path, tenant }.
  • sse.events_sent_total counter { plugin, topic }.
  • sse.handshake.duration_ms histogram.
  • sse.buffer_replays_total counter { hit } ("hit" when replay returned events, "miss" on SSE_BUN_BUFFER_MISS).
  • sse.compression_ratio histogram (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.connections gauge 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"