# Capability Spec: api-ws-broker-in-memory **Package**: `@suaveplan/api-ws-broker-in-memory` **Status**: SHIPPED 2026-05-26 (Stage 5 — Testing kit + conformance suites) ## Purpose Single-host in-process implementation of the `@suaveplan/api-ws` `WsBroker` contract. Maintains a per-topic `Set` registry and fans `publish(topic, envelope)` out to every currently-subscribed handler. Production-grade for single-host deployments AND the default broker for `createTestHost` (Stage 5). Multi-host topologies use `@suaveplan/api-ws-broker-redis` (Stage 7) over the same contract. ## ADDED Requirements ### Requirement: Fan-out to every subscriber on publish The broker SHALL deliver `publish(topic, envelope)` to every handler currently subscribed to `topic`. Delivery order across subscribers is unspecified; per-subscriber message order is FIFO. #### Scenario: every subscriber receives every message in publish order - **WHEN** three handlers subscribe to `"orders"` AND two envelopes are published in sequence - **THEN** each handler receives both envelopes in publish order **Verified by:** `src/broker/broker.test.ts::"fans out a single envelope to every subscriber"` --- ### Requirement: Subscription lifecycle (subscribe / unsubscribe / dispose) The broker SHALL expose `subscribe(topic, handler)` returning a `BrokerSubscription` whose `unsubscribe()` removes the handler. The broker SHALL expose `dispose()` which clears every registered handler and refuses subsequent `publish` / `subscribe` calls. #### Scenario: unsubscribe stops delivery - **WHEN** a handler subscribes, then unsubscribes, then a new envelope is published - **THEN** the handler is NOT invoked #### Scenario: unsubscribe is idempotent - **WHEN** `BrokerSubscription.unsubscribe()` is called twice - **THEN** the second call is a no-op (no throw) #### Scenario: dispose is idempotent - **WHEN** `broker.dispose()` is called twice - **THEN** the second call is a no-op and `isDisposed()` returns `true` **Verified by:** `src/broker/broker.test.ts::"unsubscribe stops further delivery"`, `src/broker/broker.test.ts::"unsubscribe is idempotent"`, `src/broker/broker.test.ts::"dispose is idempotent"` --- ### Requirement: Use-after-dispose raises BROKER_DISPOSED When `publish` or `subscribe` is invoked after `dispose()` has resolved, the broker SHALL throw a `WsBrokerInMemoryError` carrying the `WS_BROKER_IN_MEMORY.BROKER_DISPOSED` code. #### Scenario: publish after dispose throws - **WHEN** `broker.dispose()` is called then `broker.publish(...)` is awaited - **THEN** the call throws `WS_BROKER_IN_MEMORY.BROKER_DISPOSED` #### Scenario: subscribe after dispose throws - **WHEN** `broker.dispose()` is called then `broker.subscribe(...)` is called - **THEN** the call throws `WS_BROKER_IN_MEMORY.BROKER_DISPOSED` **Verified by:** `src/broker/broker.test.ts::"dispose throws BROKER_DISPOSED on subsequent publish/subscribe"` --- ### Requirement: Invalid options raise INVALID_OPTIONS `createInMemoryWsBroker(options)` SHALL validate `options` against `inMemoryWsBrokerOptionsSchema` (strict-mode Zod). Any failure SHALL throw a `WsBrokerInMemoryError` carrying `WS_BROKER_IN_MEMORY.INVALID_OPTIONS`. #### Scenario: unknown options key throws - **WHEN** `createInMemoryWsBroker({ unknown: true })` is called - **THEN** the call throws `WS_BROKER_IN_MEMORY.INVALID_OPTIONS` #### Scenario: invalid deliveryMode value throws - **WHEN** `createInMemoryWsBroker({ deliveryMode: "blocking" })` is called - **THEN** the call throws `WS_BROKER_IN_MEMORY.INVALID_OPTIONS` **Verified by:** `src/broker/broker.test.ts::"invalid options throws INVALID_OPTIONS"`, `src/broker/broker.test.ts::"invalid deliveryMode value throws INVALID_OPTIONS"` --- ### Requirement: Delivery mode semantics `options.deliveryMode: "await-all"` SHALL await every handler before `publish` resolves. `"fire-and-forget"` (default) SHALL dispatch handlers without awaiting them. Per-subscriber failures in either mode SHALL be swallowed individually and SHALL NOT reject the outer publish nor abort sibling deliveries. #### Scenario: await-all blocks publish until every handler settles - **WHEN** `deliveryMode: "await-all"` AND two handlers each return a delayed Promise - **THEN** `publish` resolves only after both promises settle #### Scenario: fire-and-forget isolates a throwing handler - **WHEN** one handler throws synchronously AND another is correct - **THEN** the correct handler still runs AND `publish` resolves cleanly **Verified by:** `src/broker/broker.test.ts::"isolates handler rejections in fire-and-forget mode"`, `src/broker/broker.test.ts::"isolates handler rejections in await-all mode"` --- ### Requirement: Conforms to WsBroker type contract #### Scenario: type-shape matches WsBroker - **WHEN** `expectTypeOf(broker)` is compared to `WsBroker` - **THEN** the comparison passes — every method declared on the abstract contract exists with a compatible signature **Verified by:** `tests/conformance/api-ws-broker.conformance.test.ts::"type-shape matches WsBroker"` --- ### Requirement: Testing harness A `./testing` subpath SHALL export `createInMemoryBrokerHarness` that wraps the broker with a captured-envelope observer, a `publishOne` convenience, and an idempotent `dispose()` for use in `afterEach`. #### Scenario: harness captures every published envelope - **WHEN** `harness.publishOne(payload)` is called twice - **THEN** `harness.received` contains both envelopes in publish order **Verified by:** `src/testing/testing.test.ts::"captures every envelope published on the default topic"`