Initial Commit
This commit is contained in:
158
openspec/specs/api-sse-bun/spec.md
Normal file
158
openspec/specs/api-sse-bun/spec.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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>`, 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 + 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: <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"`
|
||||
Reference in New Issue
Block a user