6.9 KiB
Capability Spec Delta: api-ws-broker-redis (api-ws-broker-redis)
Package: @suaveplan/api-ws-broker-redis
Status: PROPOSED
Every requirement carries a
**Verified by:**line.
ADDED Requirements
Requirement: Implements the WsBroker contract
The package SHALL export createRedisWsBroker(opts) producing a broker value implementing the WsBroker contract from @suaveplan/api-ws. The conformance suite from @suaveplan/api-ws/testing MUST pass against this broker.
Scenario: conformance suite passes
- WHEN the conformance suite runs against
createRedisWsBroker(opts)backed by a real Redis container - THEN every invariant passes
Verified by: src/broker/redis-broker.spec-conformance.test.ts::"createRedisWsBroker passes WsBroker conformance against real Redis"
Requirement: Cross-host broadcast via Redis pub/sub
broker.broadcast(topic, { tenant, payload }) SHALL publish to <channelPrefix><topic> on Redis. Every host subscribed to the channel pattern MUST receive the message and deliver it to its locally-connected subscribers via the local ConnectionRegistry. Connections are NOT migrated across hosts — the broker only makes broadcasts visible everywhere, and each host independently delivers to its own subscribers.
Scenario: broadcast from host A is delivered to subscribers on host B
- WHEN two
api-ws-bunengines run on different processes pointed at the same Redis AND a client C subscribed on host B AND host A issuesbroker.broadcast(topic, { tenant: T, payload: ... })with C's tenant - THEN C receives the payload
Scenario: sticky sessions are NOT required
- WHEN a client connects to host A AND later reconnects and lands on host B
- THEN subscriptions resume on host B from the event buffer with no requirement on routing back to host A
Verified by: tests/integration/two-host-broadcast.test.ts::"broadcast from host A delivered to subscriber on host B" AND tests/integration/two-host-broadcast.test.ts::"reconnect lands on different host and resumes from event buffer"
Requirement: Reconnect event-buffer storage in Redis sorted-sets
The broker SHALL maintain a per-(tenant, topic) event buffer in Redis sorted-sets: <keyPrefix>buffer:<tenant>:<topic>, scored by lastEventId. Each entry SHALL be capped at eventBufferMaxEvents (default 256, oldest evicted on overflow) AND TTL'd at eventBufferTtlSec (default 300s). On reconnect, api-ws-bun calls broker.readBuffer(tenant, topic, sinceEventId) to replay events; SSE calls the same surface for Last-Event-Id resume.
Scenario: buffer cap evicts oldest entries on overflow
- WHEN
eventBufferMaxEvents: 4AND 5 events are written for the same(tenant, topic) - THEN the buffer holds the 4 most recent events AND the oldest was evicted
Scenario: TTL expires the buffer
- WHEN no writes for
eventBufferTtlSec + 1 - THEN the buffer key has been removed from Redis
Scenario: replay returns events with id > sinceEventId
- WHEN the buffer holds events 1..10 AND
readBuffer(tenant, topic, 7)is called - THEN events 8, 9, 10 are returned in that order
Verified by: src/event-buffer/event-buffer.test.ts::"buffer cap evicts oldest on overflow" AND src/event-buffer/event-buffer.test.ts::"buffer TTL expires keys" AND src/event-buffer/event-buffer.test.ts::"readBuffer returns events with id greater than sinceEventId"
Requirement: Payload compression above a configurable threshold
When a payload exceeds compressionThresholdBytes (default 1024), the broker SHALL gzip the payload before publishing. Subscribers SHALL gunzip on receive transparently. A payload that exceeds 4_194_304 bytes (4 MiB) hard cap MUST be refused with WS_BROKER_REDIS_PAYLOAD_TOO_LARGE — operators see the misuse instead of silently flooding Redis.
Scenario: payload above threshold is compressed
- WHEN a 2 KiB payload is broadcast
- THEN the bytes published to Redis are gzip-prefixed AND the subscriber receives the decompressed payload identical to input
Scenario: payload above 4 MiB hard cap is refused
- WHEN a 5 MiB payload is broadcast
- THEN the call fails with
WS_BROKER_REDIS_PAYLOAD_TOO_LARGEAND nothing is published
Verified by: src/publish/publish.test.ts::"payload above threshold is gzip-compressed end-to-end" AND src/publish/publish.test.ts::"payload above hard cap refused with WS_BROKER_REDIS_PAYLOAD_TOO_LARGE"
Requirement: Reconnect after Redis bounce
If the underlying Redis connection drops, the broker SHALL attempt auto-reconnect per reconnectStrategy (exponential backoff default). During the outage, healthz() returns degraded; in-flight publishes either queue (for short outages, configurable) or fail with WS_BROKER_REDIS_CONNECTION_LOST. On reconnect, pattern subscriptions MUST be re-established AND ws.broker.connection_lost_total reflects the outage count.
Scenario: short Redis bounce recovers transparently
- WHEN Redis is restarted (under 5 seconds) AND publishes continue
- THEN publishes resume after reconnect AND
ws.broker.connection_lost_totalincrements by 1
Scenario: healthz reflects connection state
- WHEN Redis is unreachable
- THEN
broker.healthz()returns{ status: "degraded" | "down" }
Verified by: src/reconnect/reconnect.test.ts::"short Redis bounce recovers transparently and counter increments" AND src/health/health.test.ts::"healthz reflects Redis connection state"
Requirement: Same broker serves SSE and WebSocket
The broker SHALL be consumable by both api-ws-bun (WebSocket) and api-sse-bun (SSE). Both engines use broker.broadcast for fan-out AND broker.readBuffer for reconnect resume. The broker MUST NOT assume one transport over the other.
Scenario: SSE engine and WS engine share the same broker
- WHEN an integration test boots
api-sse-bunandapi-ws-bunagainst the same broker AND a broadcast fires - THEN both engines deliver to their respective subscribers
Verified by: tests/integration/two-host-broadcast.test.ts::"single broker serves both WS and SSE engines"
Requirement: OTel surface
The broker SHALL emit:
ws.broker.publishes_totalcounter{ topic, outcome }.ws.broker.publish_duration_mshistogram.ws.broker.receives_totalcounter{ topic }.ws.broker.buffer_writes_totalcounter.ws.broker.buffer_reads_totalcounter{ hit }("hit"or"miss"based on whether events were returned).ws.broker.connection_lost_totalcounter.
Scenario: successful publish emits counter + histogram
- WHEN a publish completes successfully
- THEN
ws.broker.publishes_total{outcome="ok"}increments AND one observation lands inws.broker.publish_duration_ms
Verified by: src/instruments/instruments.test.ts::"successful publish emits publishes_total + publish_duration_ms"