6.4 KiB
@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 →
onConnectwithConnectionbound toprincipal+tenantId. Principal frozen for connection lifetime; mid-connection token expiry emitsconnection.principal_expired. ConnectionRegistryindexed bytenantIdfor fan-out lookup.onMessageinvoked per inbound frame.onCloseinvoked on either side's close.
- Handshake → per-request pipeline steps 1–7 →
- ADDED: Backpressure — per-connection send queue capped at
maxQueuedMessages. Exceeding sendsBACKPRESSURE_EVICTEDclose frame and drops the connection. - ADDED: Heartbeats — ping every
heartbeatMs;missedPongLimitmissed pongs in a row → connection considered dead,onClosefires withcode: ABNORMAL. - ADDED: Per-tenant caps —
maxConnectionsPerTenantchecked at handshake; exceeding rejects with 429 andWS_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 followsreconnectEventBufferStrategy: 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 againstapi-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:
./testingsubpath —runWsEngineConformancefromapi-ws/testingbound tocreateBunWsEngine.
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-apolloride this engine viagraphql-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— runsrunWsEngineConformance("bun", createBunWsEngine).src/backpressure/backpressure.test.ts— queue overflow evicts connection withBACKPRESSURE_EVICTED.src/heartbeat/heartbeat.test.ts— 2 missed pongs drops connection; usesFakeClock.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 byws.broadcast-cross-tenantcapability.tests/integration/end-to-end.test.ts(30000ms) — realBun.servelistener, real WS client, full handshake → subscribe → broadcast → close → reconnect → resume.
Migration impact
None — additive only.