Files
2026-05-27 23:29:53 +01:00

70 lines
6.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@suaveplan/api-ws-bun` — `Bun.serve` WebSocket engine
**Package**: `@suaveplan/api-ws-bun`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 7 (GraphQL + WebSocket / SSE engines) — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Per design doc §11 persistent connections are first-class in the foundation: plugins declare WS endpoints in their manifest and the host runs the engine, manages connection lifecycle, fans out subscriptions. This package implements the abstract `api-ws` contract on top of `Bun.serve`'s WebSocket primitives — the fastest native path on the foundation's target runtime. Without this adapter the `api-ws` contract has no production implementation and Stage-7 acceptance ("publishes a tenant-scoped WebSocket broadcast") cannot be reached.
## What changes
Net-new package depending on `@suaveplan/api-types`, `@suaveplan/api-ws` (abstract contract), `@suaveplan/api-router` (handshake upgrade route), `@suaveplan/error`, `@suaveplan/logger`, `@suaveplan/opentelemetry`, `@suaveplan/crypto-hash` (HMAC for reconnect tokens), and `zod`.
- ADDED: `createBunWsEngine(opts)` — factory returning the engine value implementing `@suaveplan/api-ws`'s contract.
- ADDED: `BunWsEngineOptions` — Zod-validated config: `maxQueuedMessages` (default 256), `heartbeatMs` (default 30000, min 5000), `missedPongLimit` (default 2), `maxConnectionsPerTenant` (default 1000), `reconnectTokenTtlMs` (default 300000 = 5 min), `reconnectTokenSecret` (required, used for HMAC), `reconnectEventBufferStrategy` (`"auto"` | `"memory"` | `"broker"`; default `"auto"` resolves to broker-backed when a Redis broker is wired, in-memory otherwise — this is the §14 open-question default), `subprotocols`.
- ADDED: Connection lifecycle:
- Handshake → per-request pipeline steps 1–7 → `onConnect` with `Connection` bound to `principal` + `tenantId`. Principal frozen for connection lifetime; mid-connection token expiry emits `connection.principal_expired`.
- `ConnectionRegistry` indexed by `tenantId` for fan-out lookup.
- `onMessage` invoked per inbound frame.
- `onClose` invoked on either side's close.
- ADDED: Backpressure — per-connection send queue capped at `maxQueuedMessages`. Exceeding sends `BACKPRESSURE_EVICTED` close frame and drops the connection.
- ADDED: Heartbeats — ping every `heartbeatMs`; `missedPongLimit` missed pongs in a row → connection considered dead, `onClose` fires with `code: ABNORMAL`.
- ADDED: Per-tenant caps — `maxConnectionsPerTenant` checked at handshake; exceeding rejects with 429 and `WS_BUN_TENANT_CAP_EXCEEDED`.
- ADDED: Reconnect tokens — on `onClose`, host issues an HMAC-signed token (5-min TTL) carrying `{ tenant, principalId, lastEventId }`. Client reconnects with `?reconnectToken=...`; engine validates and resumes subscriptions automatically. Reconnect-event buffer storage follows `reconnectEventBufferStrategy`: when the host has a Redis broker wired (Redis-backed), the event buffer lives in Redis; otherwise it lives in-memory on the local host (default per design §11.5 paragraph 4, which this package pins as the operating default).
- ADDED: Subscription bus integration — `conn.subscribe(topic, { tenant?, filter? })` registers against `api-ws`'s subscription bus; `bus.broadcast(topic, { tenant, payload })` triggers fan-out.
- ADDED: `WS_BUN_*` error codes: `WS_BUN_HANDSHAKE_REJECTED`, `WS_BUN_TENANT_CAP_EXCEEDED`, `WS_BUN_BACKPRESSURE_EVICTED`, `WS_BUN_HEARTBEAT_FAILED`, `WS_BUN_RECONNECT_TOKEN_INVALID`, `WS_BUN_RECONNECT_TOKEN_EXPIRED`, `WS_BUN_RECONNECT_BUFFER_MISS`, `WS_BUN_SUBPROTOCOL_UNSUPPORTED`, `WS_BUN_CLOSE_TIMEOUT`, `WS_BUN_PRINCIPAL_EXPIRED`.
- ADDED: OTel surface from design §11.7 — `ws.handshake.duration_ms { plugin, path }`, `ws.connections { plugin, tenant }` gauge, `ws.messages_in_total`, `ws.messages_out_total`, `ws.broadcasts_total { topic, tenant, recipients }`, `ws.backpressure_evicted_total`, `ws.heartbeat_failures_total`.
- ADDED: `./testing` subpath — `runWsEngineConformance` from `api-ws/testing` bound to `createBunWsEngine`.
## Out of scope
- Cross-host broadcast — that's `api-ws-broker-redis`. This engine integrates with whichever broker the host wires.
- GraphQL subscription transport — `api-graphql-yoga`/`api-graphql-apollo` ride this engine via `graphql-ws`; the transport bridging is in those packages.
- SSE — separate engine `api-sse-bun`. Reuses the same broker abstraction but the transport differs.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| Connection lifecycle (`onConnect` / `onMessage` / `onClose`) | §11.1 |
| Handshake flow steps 1–7 + frozen principal | §11.1 (paragraph 2) |
| `ConnectionRegistry` indexed by `tenantId` | §11.1 (paragraph 3) |
| Subscription bus + per-tenant fan-out | §11.2 |
| Backpressure: `maxQueuedMessages` default 256 + eviction close frame | §11.4 |
| Heartbeats: `heartbeatMs` default 30000, 2 missed pongs → drop | §11.4 |
| Per-tenant caps `maxConnectionsPerTenant` | §11.4 |
| Reconnect tokens: HMAC-signed, 5-min TTL, `{tenant, principalId, lastEventId}` | §11.5 |
| Reconnect-event-buffer storage default (broker-when-Redis / memory-otherwise) | §11.5 (paragraph 4) + §14 |
| OTel instruments listed above | §11.7 |
## Verification approach
- `src/engine/bun-ws-engine.test.ts` — handshake, message dispatch, close handling, principal frozen at handshake.
- `src/engine/bun-ws-engine.spec-conformance.test.ts` — runs `runWsEngineConformance("bun", createBunWsEngine)`.
- `src/backpressure/backpressure.test.ts` — queue overflow evicts connection with `BACKPRESSURE_EVICTED`.
- `src/heartbeat/heartbeat.test.ts` — 2 missed pongs drops connection; uses `FakeClock`.
- `src/limits/tenant-cap.test.ts` — 1001st connection from same tenant rejected.
- `src/reconnect/reconnect-token.test.ts` — token issue + validation + expiry + replay-rejection.
- `src/reconnect/event-buffer.test.ts` — strategy `"auto"` resolves to broker-backed when a Redis broker is wired, in-memory otherwise.
- `src/registry/connection-registry.test.ts` — index by tenant; cross-tenant fan-out gated by `ws.broadcast-cross-tenant` capability.
- `tests/integration/end-to-end.test.ts` (30000ms) — real `Bun.serve` listener, real WS client, full handshake → subscribe → broadcast → close → reconnect → resume.
## Migration impact
None — additive only.