# 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: \nevent: \ndata: \n\n`. A `retry: \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: `, `event: `, and `data: ` 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 + 1`ms - **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: ` 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"`