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

9.5 KiB
Raw Permalink Blame History

Capability Spec Delta: api-ws-bun (api-ws-bun)

Package: @suaveplan/api-ws-bun Status: PROPOSED

Every requirement carries a **Verified by:** line.

ADDED Requirements

Requirement: Implements the abstract WebSocket engine contract

The package SHALL export createBunWsEngine(opts) producing an engine value that implements the engine contract from @suaveplan/api-ws. The conformance suite runWsEngineConformance MUST pass against this adapter.

Scenario: conformance suite passes

  • WHEN runWsEngineConformance("bun", createBunWsEngine) runs
  • THEN every invariant passes

Verified by: src/engine/bun-ws-engine.spec-conformance.test.ts::"createBunWsEngine passes runWsEngineConformance"


Requirement: Handshake freezes the principal for the connection lifetime

At handshake the engine SHALL run per-request pipeline steps 1–7 (auth + tenant resolution + capability scoping) and bind the resolved principal and tenantId to the Connection. The principal MUST be immutable for the connection's lifetime. Mid-connection token expiry MUST emit connection.principal_expired on the host event bus; the plugin's onClose MUST then be invokable to drop the connection cleanly. WS_BUN_PRINCIPAL_EXPIRED is the code emitted with that event.

Scenario: principal bound at handshake survives subsequent token rotation

  • WHEN the handshake resolves principal P AND the underlying token rotates server-side
  • THEN conn.principal still reads P AND connection.principal_expired fires once the original token's expiry passes

Verified by: src/principal/principal-expiry.test.ts::"principal frozen at handshake; expiry emits connection.principal_expired"


Requirement: Per-connection backpressure with default 256-message queue

The engine SHALL enforce a per-connection send queue capped at maxQueuedMessages (default 256). When the queue would exceed the cap, the connection MUST receive a BACKPRESSURE_EVICTED close frame AND onClose fires with code: WS_BUN_BACKPRESSURE_EVICTED. The eviction MUST be observable via ws.backpressure_evicted_total counter.

Scenario: queue overflow evicts the connection

  • WHEN maxQueuedMessages: 4 AND 5 sends are issued before the client reads any
  • THEN the connection receives a BACKPRESSURE_EVICTED close frame AND ws.backpressure_evicted_total increments by 1

Verified by: src/backpressure/backpressure.test.ts::"queue overflow evicts connection with BACKPRESSURE_EVICTED"


Requirement: Heartbeat with default 30000ms interval and 2-missed-pong drop

The engine SHALL send a ping frame every heartbeatMs (default 30000). Two consecutive missed pongs (default missedPongLimit: 2) MUST be treated as a dead connection — onClose fires with code: ABNORMAL AND ws.heartbeat_failures_total increments.

Scenario: 2 missed pongs drops the connection

  • WHEN the client never responds to pings AND missedPongLimit: 2
  • THEN after the 2nd missed-pong window, the connection is dropped AND ws.heartbeat_failures_total increments by 1

Verified by: src/heartbeat/heartbeat.test.ts::"2 missed pongs drops the connection with code ABNORMAL"


Requirement: Per-tenant connection cap

The engine SHALL track active connections per tenant and reject new handshakes when a tenant's count is >= maxConnectionsPerTenant (default 1000). Rejection returns HTTP 429 AND WS_BUN_TENANT_CAP_EXCEEDED.

Scenario: 1001st connection from same tenant rejected with 429

  • WHEN maxConnectionsPerTenant: 1000 AND tenant T already has 1000 active connections
  • THEN the 1001st handshake returns 429 AND WS_BUN_TENANT_CAP_EXCEEDED

Verified by: src/limits/tenant-cap.test.ts::"1001st connection from same tenant rejected with WS_BUN_TENANT_CAP_EXCEEDED"


Requirement: Reconnect tokens with HMAC signing and 5-minute TTL

On onClose the engine SHALL issue a reconnect token: HMAC-signed (key from reconnectTokenSecret), 5-minute TTL (reconnectTokenTtlMs default 300000), payload { tenant, principalId, lastEventId }. A client reconnecting with ?reconnectToken=... MUST be validated; on success the engine resumes subscriptions automatically and the plugin's onConnect runs in resume mode with the pre-restored subscription set. Invalid token → WS_BUN_RECONNECT_TOKEN_INVALID. Expired token → WS_BUN_RECONNECT_TOKEN_EXPIRED. Replayed token (already consumed) → WS_BUN_RECONNECT_TOKEN_INVALID.

Scenario: valid token resumes subscriptions

  • WHEN the client reconnects with a valid token within 5 minutes
  • THEN the resumed connection has the same subscription set as before close

Scenario: expired token is rejected

  • WHEN the client reconnects 6 minutes after close
  • THEN the handshake fails with WS_BUN_RECONNECT_TOKEN_EXPIRED

Scenario: tampered token is rejected

  • WHEN the client modifies the token's payload before reconnecting
  • THEN HMAC validation fails AND the handshake fails with WS_BUN_RECONNECT_TOKEN_INVALID

Verified by: src/reconnect/reconnect-token.test.ts::"valid token resumes subscriptions in resume mode" AND src/reconnect/reconnect-token.test.ts::"expired token rejected with WS_BUN_RECONNECT_TOKEN_EXPIRED" AND src/reconnect/reconnect-token.test.ts::"tampered token rejected with WS_BUN_RECONNECT_TOKEN_INVALID"


Requirement: Reconnect event-buffer storage default

The engine SHALL provide a per-(tenant, topic) event buffer used during reconnect-resume to replay events with id > lastEventId. Storage MUST follow reconnectEventBufferStrategy:

  • "auto" (default): when a Redis broker (api-ws-broker-redis) is wired into the host, the buffer lives in Redis; otherwise it lives in-memory on the local host. This pins the design doc §11.5 paragraph 4 default ("Redis when the broker is Redis, in-memory otherwise") — explicitly resolving the §14 open question for the foundation's shipping default.
  • "memory": always in-memory regardless of broker (single-host deployments).
  • "broker": always broker-backed; requires a broker capable of supporting the buffer (currently only api-ws-broker-redis); refuses with WS_BUN_RECONNECT_BUFFER_MISS at boot if no compatible broker is wired.

Scenario: auto resolves to broker when Redis broker is wired

  • WHEN reconnectEventBufferStrategy: "auto" AND api-ws-broker-redis is wired
  • THEN the engine uses the broker-backed buffer

Scenario: auto resolves to memory when no broker is wired

  • WHEN reconnectEventBufferStrategy: "auto" AND no broker is wired (single-host)
  • THEN the engine uses the in-memory buffer

Scenario: broker strategy without a compatible broker refuses at boot

  • WHEN reconnectEventBufferStrategy: "broker" AND no compatible broker is wired
  • THEN boot fails with WS_BUN_RECONNECT_BUFFER_MISS

Verified by: src/reconnect/event-buffer.test.ts::"auto resolves to broker when Redis broker wired" AND src/reconnect/event-buffer.test.ts::"auto resolves to memory when no broker wired" AND src/reconnect/event-buffer.test.ts::"broker strategy without compatible broker refuses at boot with WS_BUN_RECONNECT_BUFFER_MISS"


Requirement: Subscription bus with cross-tenant capability gate

conn.subscribe(topic, { tenant?, filter? }) SHALL register the connection against the host's subscription bus, indexed by (topic, tenant). bus.broadcast(topic, { tenant, payload }) MUST fan out only to matching connections AND run optional per-connection filter predicates. Cross-tenant fan-out (a bus.broadcast whose tenant value differs from the broadcasting plugin's tenant) SHALL require the plugin to declare the ws.broadcast-cross-tenant capability; absence → refuse + audit log emitted.

Scenario: tenant-scoped broadcast delivered only to matching tenant

  • WHEN a broadcast for tenant T fires AND connections C1 (tenant T) and C2 (tenant U) are subscribed
  • THEN C1 receives the payload AND C2 does not

Scenario: cross-tenant broadcast without capability is refused

  • WHEN a plugin without ws.broadcast-cross-tenant issues bus.broadcast(topic, { tenant: "other" })
  • THEN the broadcast is refused AND an audit log entry is emitted

Verified by: src/registry/connection-registry.test.ts::"tenant-scoped broadcast delivered only to matching tenant" AND src/registry/connection-registry.test.ts::"cross-tenant broadcast without capability refused and audit-logged"


Requirement: OTel surface

The engine SHALL emit every instrument listed in design §11.7:

  • ws.handshake.duration_ms histogram { plugin, path }.
  • ws.connections observable gauge { plugin, tenant }.
  • ws.messages_in_total counter { plugin, path }.
  • ws.messages_out_total counter { plugin, path }.
  • ws.broadcasts_total counter { topic, tenant, recipients }.
  • ws.backpressure_evicted_total counter { plugin }.
  • ws.heartbeat_failures_total counter { plugin }.

Scenario: handshake emits duration histogram

  • WHEN a connection completes its handshake
  • THEN an in-memory MeterProvider observes one observation in ws.handshake.duration_ms

Scenario: broadcast emits broadcasts_total

  • WHEN a tenant-scoped broadcast is issued with 3 recipients
  • THEN ws.broadcasts_total{recipients=3} increments by 1

Verified by: src/instruments/instruments.test.ts::"handshake emits ws.handshake.duration_ms" AND src/instruments/instruments.test.ts::"broadcast emits ws.broadcasts_total with recipient count"