Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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"`