9.5 KiB
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.principalstill reads P ANDconnection.principal_expiredfires 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: 4AND 5 sends are issued before the client reads any - THEN the connection receives a
BACKPRESSURE_EVICTEDclose frame ANDws.backpressure_evicted_totalincrements 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_totalincrements 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: 1000AND 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 onlyapi-ws-broker-redis); refuses withWS_BUN_RECONNECT_BUFFER_MISSat boot if no compatible broker is wired.
Scenario: auto resolves to broker when Redis broker is wired
- WHEN
reconnectEventBufferStrategy: "auto"ANDapi-ws-broker-redisis 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-tenantissuesbus.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_mshistogram{ plugin, path }.ws.connectionsobservable gauge{ plugin, tenant }.ws.messages_in_totalcounter{ plugin, path }.ws.messages_out_totalcounter{ plugin, path }.ws.broadcasts_totalcounter{ topic, tenant, recipients }.ws.backpressure_evicted_totalcounter{ plugin }.ws.heartbeat_failures_totalcounter{ plugin }.
Scenario: handshake emits duration histogram
- WHEN a connection completes its handshake
- THEN an in-memory
MeterProviderobserves one observation inws.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"