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,64 @@
# `@suaveplan/api-sse-bun` — `Bun.serve` Server-Sent Events engine
**Package**: `@suaveplan/api-sse-bun`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 7 (GraphQL + WebSocket / SSE engines) — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Per design doc §11.6 SSE is the simpler cousin of WebSocket — useful for activity feeds, dashboards, and notifications where no client-to-server channel and no ack semantics are needed. SSE falls back gracefully on networks that block WebSocket (corporate proxies, restrictive firewalls), making it a deliberately important second transport. This package implements the abstract `api-sse` contract on top of `Bun.serve` and the same broker abstraction as `api-ws-bun`, so multi-host fan-out is handled by the same Redis broker (`api-ws-broker-redis`) without duplication.
Without this adapter the foundation has no production SSE story and falls back to single-transport-only deployments.
## What changes
Net-new package depending on `@suaveplan/api-types`, `@suaveplan/api-sse` (abstract contract), `@suaveplan/api-router` (HTTP endpoint mount), `@suaveplan/error`, `@suaveplan/logger`, `@suaveplan/opentelemetry`, and `zod`.
- ADDED: `createBunSseEngine(opts)` — factory returning the engine value implementing `@suaveplan/api-sse`'s contract.
- ADDED: `BunSseEngineOptions` — Zod-validated config: `eventBufferSize` (default 256 events per `(tenant, topic)`), `eventBufferMs` (default 300000 = 5 min, pairs with WS reconnect TTL), `keepAliveCommentMs` (default 15000 — emits SSE comments to prevent intermediary timeouts), `maxConnectionsPerTenant` (default 1000), `compressionEnabled` (default `true` — uses `Content-Encoding: gzip` when the client accepts it), `retryMs` (default 3000 — written into `retry:` field of the SSE stream so clients back off appropriately).
- ADDED: Endpoint mount — plugin's manifested SSE endpoints (`sse: [{ path, auth, subscriptions }]`) are mounted via `api-router` as `GET <path>` returning `Content-Type: text/event-stream`.
- ADDED: `Last-Event-Id` resume — when the client supplies `Last-Event-Id` header (HTTP standard), the engine consults the event buffer (broker-backed when Redis broker wired, in-memory otherwise) and replays events with id > supplied value before resuming live delivery.
- ADDED: Per-`(tenant, topic)` event buffer — for single-host deployments, in-memory ring buffer of `eventBufferSize` events with `eventBufferMs` TTL. For multi-host deployments, delegates to the wired broker (`api-ws-broker-redis` exposes the same buffer surface; SSE consumes it identically to WS).
- ADDED: Keep-alive comments — `:keepalive\n\n` emitted every `keepAliveCommentMs` to prevent intermediary proxy timeouts. This is invisible to clients.
- ADDED: Subscription model — `conn.subscribe(topic, { tenant?, filter? })` registers against the host's subscription bus (same bus used by WS). `bus.broadcast` triggers fan-out to SSE connections matching `(topic, tenant)`.
- ADDED: Per-tenant cap — `maxConnectionsPerTenant` enforced at request time; exceeding returns 429 with `SSE_BUN_TENANT_CAP_EXCEEDED`.
- ADDED: `SSE_BUN_*` error codes: `SSE_BUN_HANDSHAKE_REJECTED`, `SSE_BUN_TENANT_CAP_EXCEEDED`, `SSE_BUN_BUFFER_MISS`, `SSE_BUN_EVENT_TOO_LARGE`, `SSE_BUN_LAST_EVENT_ID_INVALID`, `SSE_BUN_COMPRESSION_FAILED`, `SSE_BUN_STREAM_INTERRUPTED`, `SSE_BUN_PRINCIPAL_EXPIRED`, `SSE_BUN_BROKER_UNAVAILABLE`, `SSE_BUN_INVALID_TOPIC`.
- ADDED: OTel surface from design §11.7 — `sse.connections { plugin, path, tenant }` observable gauge, `sse.events_sent_total { plugin, topic }` counter, plus this package adds `sse.handshake.duration_ms`, `sse.buffer_replays_total { hit }`, `sse.compression_ratio` histogram.
- ADDED: `./testing` subpath — re-export of `runSseEngineConformance` from `api-sse/testing` bound to `createBunSseEngine`.
## Out of scope
- A non-Bun SSE engine — the contract lives in `api-sse`; other engines can implement it.
- Cross-host fan-out logic — delegated to `api-ws-broker-redis` (which is also the SSE broker, per §11.6).
- Bi-directional channels — SSE is server→client only by design.
- Browser polyfills — modern browsers + recent Node clients support SSE natively.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| SSE endpoint declared in manifest | §3 (`sse` block) + §11.6 |
| `Last-Event-Id` resume | §11.5 (paragraph 3) + §11.6 |
| Event buffer per `(tenant, topic)` with size + ttl | §11.5 (paragraph 3) |
| Shared broker with WS for multi-host fan-out | §11.6 |
| Same subscription bus as WS | §11.6 |
| OTel `sse.connections` gauge + `sse.events_sent_total` counter | §11.7 |
| Graceful fall-back when WS blocked | §11.6 |
## Verification approach
- `src/engine/bun-sse-engine.test.ts` — handshake, event emission framing, keep-alive comment cadence, principal frozen at handshake.
- `src/engine/bun-sse-engine.spec-conformance.test.ts` — runs `runSseEngineConformance("bun", createBunSseEngine)`.
- `src/last-event-id/last-event-id.test.ts` — supply `Last-Event-Id: 7`, assert events 8..N replay before live delivery.
- `src/event-buffer/event-buffer.test.ts` — in-memory mode: ring-buffer cap, TTL eviction. Broker mode (integration, 30000ms): delegation to broker-backed buffer.
- `src/limits/tenant-cap.test.ts` — 1001st connection rejected with 429.
- `src/keep-alive/keep-alive.test.ts` (FakeClock) — `:keepalive` comments emitted every `keepAliveCommentMs`.
- `tests/integration/end-to-end.test.ts` (30000ms) — real `Bun.serve`, real SSE client, full subscribe → broadcast → keep-alive → disconnect → reconnect-with-Last-Event-Id.
- `tests/integration/multi-host-fan-out.test.ts` (60000ms) — two SSE engines behind `api-ws-broker-redis`; broadcast from one delivers on the other.
## Migration impact
None — additive only.

View File

@@ -0,0 +1,158 @@
<!-- markdownlint-disable MD024 -->
# 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 + 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"`

View File

@@ -0,0 +1,77 @@
# Tasks — `@suaveplan/api-sse-bun`
> Every checkbox MUST reflect work actually done in this change.
## 0. Pre-flight (non-negotiable)
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [x] 1.1 Read design doc §11.5, §11.6, §11.7
- [x] 1.2 Read umbrella Stage 7 acceptance
- [x] 1.3 Read sibling `openspec/changes/api-ws-bun/` and `openspec/changes/api-ws-broker-redis/` for broker integration
- [x] 1.4 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.5 Draft `proposal.md`
- [x] 1.6 Draft `specs/api-sse-bun/spec.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line
## 2. Implementation
- [x] 2.1 Scaffold `packages/api-stack/api/api-sse-bun/`
- [x] 2.2 `src/options/options.ts` — `BunSseEngineOptions` Zod schema
- [x] 2.3 `src/engine/bun-sse-engine.ts` — `createBunSseEngine(opts)`
- [x] 2.4 `src/stream/sse-stream.ts` — SSE framing (`id:`, `event:`, `data:`, `retry:`, `:keepalive` comments)
- [x] 2.5 `src/handshake/handshake.ts` — pipeline steps 1–7 wiring; principal freeze
- [x] 2.6 `src/last-event-id/last-event-id.ts` — parse `Last-Event-Id` header; consult event buffer
- [x] 2.7 `src/event-buffer/event-buffer.ts` — in-memory ring buffer; delegate to broker when wired
- [x] 2.8 `src/keep-alive/keep-alive.ts` — `keepAliveCommentMs` scheduler
- [x] 2.9 `src/limits/tenant-cap.ts` — `maxConnectionsPerTenant` enforcement
- [x] 2.10 `src/compression/compression.ts` — gzip stream when client accepts; emit `Content-Encoding: gzip`
- [x] 2.11 `src/subscription/subscription-bus.ts` — registers against the shared subscription bus
- [x] 2.12 `src/instruments/instruments.ts` — `sse.*` OTel instruments per §11.7 + this package's extensions
- [x] 2.13 `src/error-codes/error-codes.ts` — 10 `SSE_BUN_*` codes
- [x] 2.14 `./testing` subpath — re-export `runSseEngineConformance` bound to factory
- [x] 2.15 `src/index.ts` — explicit named re-exports
## 3. Tests
- [x] 3.1 Unit tests, every `it(...)` `{ timeout: 5000 }`
- [x] 3.2 Conformance test runs `runSseEngineConformance`
- [x] 3.3 Keep-alive test uses injectable `schedule` (FakeClock-style) and asserts cadence
- [x] 3.4 Last-Event-Id replay test pins replay-before-live ordering
- [x] 3.5 End-to-end test against real `Bun.serve` (`{ timeout: 30000 }`) — gracefully skips under non-Bun runtimes (vitest+node) since `Bun.serve` is the only transport; the in-process handshake suite drives the same `ReadableStream` path.
- [ ] 3.6 Multi-host fan-out test against real Redis broker — deferred to `api-ws-broker-redis` keystone (the broker package is the natural owner of the integration); the engine's broker-backed event buffer is unit-tested with a real `SseBroker` implementation in this package.
- [x] 3.7 All tests import from `@suaveplan/testing/runner`
## 4. Documentation
- [x] 4.1 Sibling `.md` per implementation file (13/13 = 100%)
- [x] 4.2 `README.md` with `## Modules`; document defaults table and explain SSE's "no client→server channel, no acks" boundary vs WS
## 5. Pre-archive verification
- [x] 5.1 `bun run check` — 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-sse-bun` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-sse-bun` — 100/100/100/100 (306 stmts, 102 branches, 73 funcs, 304 lines)
- [x] 5.4 `bun scripts/openspec-conformance.ts packages/api-stack/api/api-sse-bun` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-sse-bun` — green
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics on engine surface (`BunSseEngine.activeConnections` is `ReadonlyMap<string, ActiveConnection>`); literal-type claims pinned via `as const` on `SSE_BUN_CODES`.
- [x] 6.2 Zod schemas on options (`resolveOptions` strict-parses every primitive knob); subscription payloads carry the typed `SseEvent` schema from `@suaveplan/api-sse`.
- [x] 6.3 Every `SSE_BUN_*` code thrown by a production path (HANDSHAKE_REJECTED, TENANT_CAP_EXCEEDED, BUFFER_MISS, EVENT_TOO_LARGE, LAST_EVENT_ID_INVALID, COMPRESSION_FAILED, STREAM_INTERRUPTED, PRINCIPAL_EXPIRED, BROKER_UNAVAILABLE, INVALID_TOPIC) — the registry-name-vs-throw audit is part of the conformance run.
- [x] 6.4 Strict-mode tested (compression on vs off; broker wired vs in-memory event buffer)
- [x] 6.5 OTel instruments asserted against fake `MeterProvider` (`src/instruments/instruments.test.ts`)
- [ ] 6.6 Lifecycle dedup: reconnect with same `Last-Event-Id` does not re-deliver already-delivered events — partially verified (replay test pins replay-before-live ordering); explicit "no double-delivery on reconnect" race is deferred to a later integration suite that owns the multi-host fan-out path.
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-sse-bun/spec.md`
- [x] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-sse-bun/`
- [x] 7.3 Tick Stage-7 row in umbrella tasks
- [x] 7.4 Refresh `MANIFEST.md`