Files
suaveplan/openspec/archive/2026-05-26-api-ws-bun/proposal.md
2026-05-27 23:29:53 +01:00

6.4 KiB
Raw Blame History

@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.