5.7 KiB
@suaveplan/api-ws — abstract WebSocket Connection, Registry, Bus, Broker contract
Package: @suaveplan/api-ws
Status: PROPOSED
Type: new-package
Stage: 1 (Contracts) — see umbrella openspec/changes/api-plugin-foundation/
Why
Persistent connections are first-class in the API foundation (design §11). Plugins declare WebSocket endpoints in their manifest; the host runs the engine, manages connection lifecycle, fans out subscriptions. Concrete engines (api-ws-bun) and multi-host brokers (api-ws-broker-redis, api-ws-broker-in-memory) plug behind a stable contract. This package owns that contract: Connection, ConnectionRegistry, SubscriptionBus, WsBroker, plus the cross-tenant gate that requires ws.broadcast-cross-tenant capability.
Per design §11.2: "conn.subscribe(topic, { tenant?, filter? }) registers the connection with the host's subscription bus." Per §11.3: brokers make bus.broadcast visible across host replicas without migrating connections. Both shapes live here.
What changes
Net-new package. Contract types + Zod schemas for the wire envelope + the cross-tenant capability gate + the conformance harness.
- ADDED:
./connection— abstractWsConnection:principal,tenantId,subscribe(topic, opts),unsubscribe(topic),send(message),close(code, reason),ack(id),id,lastEventId,state(open|closing|closed). - ADDED:
./connection-registry— abstractConnectionRegistry:add(conn),remove(conn),lookupByTenant(tenant),lookupByTopic(topic, tenant?),countByTenant(tenant). Backed by a multi-index. - ADDED:
./subscription-bus— abstractSubscriptionBus:subscribe(conn, topic, { tenant?, filter? }),unsubscribe(conn, topic),broadcast(topic, { tenant, payload, filter? }),asyncIterator(topic, opts). - ADDED:
./broker— abstractWsBrokerfor multi-host fan-out:publish(topic, envelope),subscribe(topic, handler),dispose(). Connections are NOT migrated across hosts — the broker only makes broadcasts visible. - ADDED:
./envelope— Zod schema for the wireWsEnvelope({ id, type, topic?, payload? }); type-narrowing helpers. - ADDED:
./backpressure—BackpressurePolicy: bounded send queue (maxQueuedMessages, default 256); eviction emitsBACKPRESSURE_EVICTEDclose. - ADDED:
./heartbeat—HeartbeatPolicy:heartbeatMs(default 30000), two missed pongs → closeABNORMAL. - ADDED:
./reconnect-token—ReconnectTokenHMAC envelope ({ tenant, principalId, lastEventId, exp }); 5-min TTL constant. - ADDED:
./capability-id— adds"ws"AND"ws.broadcast-cross-tenant"to theCapabilityIdunion via type-level augmentation. - ADDED:
./error-codes— frozenWS_CODES:HANDSHAKE_FAILED,PRINCIPAL_EXPIRED,BACKPRESSURE_EVICTED,HEARTBEAT_FAILED,CONNECTION_CAP_EXCEEDED,CROSS_TENANT_BROADCAST_DENIED,RECONNECT_TOKEN_INVALID,BROKER_UNAVAILABLE. - ADDED:
./testing—runWsEngineConformance(factory)reusable harness.
CapabilityId additions
This package contributes "ws" and "ws.broadcast-cross-tenant" to the CapabilityId union. The cross-tenant id is a separate capability so the manifest declares it explicitly and the host audit-logs every use (design §11.2 "audit-logged on every use").
Out of scope
- Concrete engines (
api-ws-bun) — Stage 7. - Concrete brokers (
api-ws-broker-redis,api-ws-broker-in-memory) — Stages 5 and 7. - The
graphql-wsprotocol wiring (subscriptions over WebSocket) — that lives inapi-graphqlengine adapters, which read this package'sSubscriptionBus.
Source mapping
Genesis-original. Source of design intent: docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md.
| Requirement | Design doc section |
|---|---|
WsConnection shape + handshake flow |
§11.1 |
SubscriptionBus.broadcast + filter predicate |
§11.2 |
| Multi-host fan-out via broker | §11.3 |
Backpressure (maxQueuedMessages: 256) |
§11.4 |
Heartbeat (heartbeatMs: 30000, two missed pongs) |
§11.4 |
Per-tenant caps (maxConnectionsPerTenant) |
§11.4 |
| Reconnect tokens (HMAC, 5-min TTL) | §11.5 |
Cross-tenant gate (ws.broadcast-cross-tenant) |
§11.2 |
Requirements with no source line
| Requirement | Justification |
|---|---|
runWsEngineConformance harness |
Stage-5 deliverable (§13.4). |
Verification approach
src/connection/connection.spec-conformance.test.ts—expectTypeOf<WsConnection>()declares every documented method.src/connection-registry/connection-registry.test.ts— add/remove/lookup; multi-index correctness; count-by-tenant.src/subscription-bus/subscription-bus.test.ts— subscribe/unsubscribe; broadcast fans out to matching(topic, tenant); filter predicate applied per-connection.src/subscription-bus/cross-tenant.test.ts— broadcast WITHOUTws.broadcast-cross-tenantis rejected withWS.CROSS_TENANT_BROADCAST_DENIEDwhentenantarg is omitted or differs from emitter's tenant.src/broker/broker.spec-conformance.test.ts—WsBrokerinterface declared.src/envelope/envelope.test.ts— Zod accept/reject suite over the wire envelope.src/backpressure/backpressure.test.ts— queue saturation triggersBACKPRESSURE_EVICTED.src/heartbeat/heartbeat.test.ts— two missed pongs → closeABNORMAL.src/reconnect-token/reconnect-token.test.ts— HMAC verify, TTL boundary.src/capability-id/capability-id.spec-conformance.test.ts— both ids inCapabilityId.tests/dep-graph.test.ts— deps:@suaveplan/api-types,@suaveplan/error,@suaveplan/types,zod. No transport deps.
Migration impact
None — additive only.
Impact
Affected dependents
- (none) — [x] no action needed (reason: net-new; engines + brokers land Stage 5/7)