first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
# @suaveplan/api-ws-broker-in-memory - In-memory single-host WebSocket broker for @suaveplan/api-ws. Implements WsBroker via direct in-process fan-out — publish iterates a topic->handlers map and invokes each subscriber's handler. Supports fire-and-forget and await-all delivery modes, idempotent dispose, Zod-validated options, and the WS_BROKER_IN_MEMORY.* error registry (INVALID_OPTIONS, BROKER_DISPOSED). Production-grade for single-host deployments; tests-grade for any plugin manifesting WebSocket endpoints. For multi-host fan-out use @suaveplan/api-ws-broker-redis (Stage 7).
Single-host in-process implementation of the `@suaveplan/api-ws`
`WsBroker` contract. Suitable for production single-host deployments
AND as the default broker wired into `createTestHost` ($2
kit). For multi-host fan-out use `@suaveplan/api-ws-broker-redis`
(Stage 7).
The broker maintains a per-topic `Set<handler>` registry and dispatches
every `publish(topic, envelope)` to every handler currently subscribed
to that topic. There is no network, no message bus, and no persistence —
"in-memory" describes the storage substrate, not the maturity level.
<!-- begin:auto-docs -->
## Documentation tree
- [broker](./src/broker/broker.md) — This module implements the `WsBroker` contract from `@suaveplan/api-ws` by maintaining an in-process `Map<topic, Set<ha…
- [broker](./src/broker/broker.md) — This module implements the `WsBroker` contract from `@suaveplan/api-ws` by maintaining an in-process `Map<topic, Set<ha…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the stable, namespaced error-code registry for the in-memory WebSocket broker. Every public failure…
- [error-codes](./src/error-codes/error-codes.md) — This module defines the stable, namespaced error-code registry for the in-memory WebSocket broker. Every public failure…
- [options](./src/options/options.md) — This module owns the Zod-validated option shape consumed by `createInMemoryWsBroker`. Keeping it in a dedicated feature…
- [options](./src/options/options.md) — This module owns the Zod-validated option shape consumed by `createInMemoryWsBroker`. Keeping it in a dedicated feature…
- [testing](./src/testing/testing.md) — This module exposes a small, opinionated test harness around the in-memory broker. It removes the boilerplate of wiring…
- [testing](./src/testing/testing.md) — This module exposes a small, opinionated test harness around the in-memory broker. It removes the boilerplate of wiring…
<!-- end:auto-docs -->
## What ships
| Subpath | Module | Doc |
|---|---|---|
| `./broker` | `createInMemoryWsBroker(options?)` factory | [`src/broker/broker.md`](src/broker/broker.md) |
| `./error-codes` | `WS_BROKER_IN_MEMORY_CODES` + `WsBrokerInMemoryError` | [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md) |
| `./testing` | `createInMemoryBrokerHarness(options?)` for tests | [`src/testing/testing.md`](src/testing/testing.md) |
## Surface
- `createInMemoryWsBroker(options?)` — factory returning an
`InMemoryWsBroker` (the `WsBroker` contract plus
`subscriberCount(topic)` and `isDisposed()` introspection helpers).
- `InMemoryWsBrokerOptions { deliveryMode? }` — Zod-validated strict
option shape. `deliveryMode` is `"fire-and-forget"` (default) or
`"await-all"`.
- `WS_BROKER_IN_MEMORY_CODES` — closed set of two stable codes:
`INVALID_OPTIONS` and `BROKER_DISPOSED`.
- `createInMemoryBrokerHarness(options?)` — opinionated test harness
wrapping the broker with a captured-envelope observer + idempotent
teardown.
## Quickstart
```ts
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
import type { TenantId } from "@suaveplan/api-types/tenant";
const broker = createInMemoryWsBroker<{ id: number }>();
const sub = broker.subscribe("orders", (envelope) => {
console.log("delivered", envelope.payload.id);
});
await broker.publish("orders", {
topic: "orders",
tenant: "t1" as unknown as TenantId,
payload: { id: 1 },
});
await sub.unsubscribe();
broker.dispose();
```
## Single-host vs multi-host
This package is **single-host only**. Two host replicas instantiated
with separate `createInMemoryWsBroker()` calls each see only their own
publishes; there is no shared substrate. For multi-host topologies use
`@suaveplan/api-ws-broker-redis` (Stage 7), which implements the same
`WsBroker` contract over Redis Pub/Sub.
## Quality bars
- 100% line / branch / function / statement coverage (Istanbul).
- 100% docs coverage — every implementation `.ts` has a sibling `.md`
with the required headings.
- Zero direct imports of `vitest` / `bun:test` — all test files import
from `@suaveplan/testing/runner`.
- Stable, namespaced error codes (`WS_BROKER_IN_MEMORY.*`).

View File

@@ -0,0 +1,123 @@
# `broker` — `createInMemoryWsBroker`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module implements the `WsBroker` contract from
`@suaveplan/api-ws` by maintaining an in-process `Map<topic, Set<handler>>`
table and fanning out every `publish(topic, envelope)` call to every
subscriber currently registered for that topic. The broker is the
single-host reference for the multi-host `api-ws-broker-redis` adapter
and the default broker wired into the API testing kit.
"In-memory" describes the storage substrate, not the maturity level —
this broker is production-grade for any deployment that fits on a
single host (one HTTP server process, one WS engine). Multi-host
topologies need the Redis adapter because in-memory has no
cross-process visibility by definition.
## Features
- Direct in-process subscription map keyed by topic; O(1) subscribe,
unsubscribe, and lookup.
- Snapshot-before-dispatch so a handler that unsubscribes mid-fan-out
does not skip its peers.
- Per-subscriber error isolation — a rejecting handler never aborts
sibling deliveries nor surfaces as a rejected `publish` promise.
- Two delivery modes via `options.deliveryMode`: `"fire-and-forget"`
(default; publish resolves on next microtask) and `"await-all"`
(publish awaits every handler).
- Idempotent `dispose()` that clears the registry and refuses
subsequent `publish` / `subscribe` calls with a typed
`WS_BROKER_IN_MEMORY.BROKER_DISPOSED` error.
- `subscriberCount(topic)` and `isDisposed()` introspection helpers
used by tests, observability adapters, and the testing harness in
`./testing`.
## Usage: Basic
```ts
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory/broker";
import type { TenantId } from "@suaveplan/api-types/tenant";
const broker = createInMemoryWsBroker();
const sub = broker.subscribe("orders", (envelope) => {
console.log("received", envelope.payload);
});
await broker.publish("orders", {
topic: "orders",
tenant: "t1" as unknown as TenantId,
payload: { id: 1 },
});
await sub.unsubscribe();
await broker.dispose();
```
## Usage: Advanced
```ts
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory/broker";
import type { TenantId } from "@suaveplan/api-types/tenant";
// "await-all" mode: publish waits for every handler to settle, even
// async ones. Useful in test scenarios that assert side-effects
// immediately after publish resolves.
const broker = createInMemoryWsBroker<{ id: number }>({
deliveryMode: "await-all",
});
const observed: number[] = [];
broker.subscribe("orders", async (env) => {
await new Promise((r) => setTimeout(r, 5));
observed.push(env.payload.id);
});
broker.subscribe("orders", async (env) => {
observed.push(-env.payload.id);
});
await broker.publish("orders", {
topic: "orders",
tenant: "t1" as unknown as TenantId,
payload: { id: 42 },
});
// observed contains 42 and -42 in some order; both are present
// because publish awaited both handlers before resolving.
```
## API Reference
- `createInMemoryWsBroker<Payload = unknown>(options?): InMemoryWsBroker<Payload>`
— factory.
- `interface InMemoryWsBroker<Payload> extends WsBroker<Payload>`
- `publish(topic, envelope): Promise<void>`
- `subscribe(topic, handler): BrokerSubscription`
- `dispose(): void`
- `subscriberCount(topic): number` — adapter-specific introspection.
- `isDisposed(): boolean` — adapter-specific lifecycle query.
## Implementation Notes
- The registry is a `Map<string, Set<RegistryEntry>>`. Topics with
zero subscribers are removed from the map by `unsubscribe` so the
outer Map does not accumulate dead entries.
- Each subscription gets a unique `RegistryEntry` reference even if
the same `handler` callback is registered twice — duplicate
registrations are allowed and each is independently dispatched and
independently disposed.
- `publish` snapshots the subscriber set into an array before
invoking handlers. Without the snapshot, an unsubscribing handler
could shrink the live `Set` mid-iteration and skip its peers.
- `dispose()` clears the topic map and flips a `disposed` flag.
Subsequent `publish` / `subscribe` throw
`WS_BROKER_IN_MEMORY.BROKER_DISPOSED`. `dispose()` itself is a
no-op on second call so cleanup helpers can call it
defensively.
- Handler rejections are individually caught (`.catch(() => {})`) so
a misbehaving subscriber cannot poison the outer publish. This
matches the multi-host broker's semantics: brokers are fan-out
pipes, not error reporters.

View File

@@ -0,0 +1,94 @@
# `error-codes` — `WS_BROKER_IN_MEMORY.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module defines the stable, namespaced error-code registry for the
in-memory WebSocket broker. Every public failure path the broker can
raise carries a `WS_BROKER_IN_MEMORY.*` code, allowing consumers (test
harnesses, host wiring, observability layers) to branch on a small
finite set of string literals rather than parsing free-form messages.
The registry is intentionally tiny — the broker's surface is small and
deterministic, so only two failure modes are reachable: misconfiguration
at the constructor boundary, and use-after-dispose. Multi-host failure
modes (network unreachable, retry exhausted) live in
`@suaveplan/api-ws-broker-redis`, not here.
## Features
- `WS_BROKER_IN_MEMORY_CODES` — frozen object produced by
`defineCodes("WS_BROKER_IN_MEMORY", [...])`. Each value is the fully
qualified literal (e.g. `"WS_BROKER_IN_MEMORY.BROKER_DISPOSED"`).
- `WsBrokerInMemoryErrorCode` — discriminated string-literal union of
every entry in the registry, suitable for exhaustive `switch`
narrowing.
- `WsBrokerInMemoryError` — concrete subclass of `SuaveplanError`,
exposing the broker-specific code as both `error.code` (from the base
class) and `error.brokerCode` for type-narrowed access.
## Usage: Basic
```ts
import {
WS_BROKER_IN_MEMORY_CODES,
WsBrokerInMemoryError,
} from "@suaveplan/api-ws-broker-in-memory/error-codes";
throw new WsBrokerInMemoryError(
WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED,
"broker disposed; construct a new one"
);
```
## Usage: Advanced
```ts
import type { WsBrokerInMemoryErrorCode } from "@suaveplan/api-ws-broker-in-memory/error-codes";
import {
WS_BROKER_IN_MEMORY_CODES,
WsBrokerInMemoryError,
} from "@suaveplan/api-ws-broker-in-memory/error-codes";
function handle(err: unknown): void {
if (!(err instanceof WsBrokerInMemoryError)) throw err;
const code: WsBrokerInMemoryErrorCode = err.brokerCode;
switch (code) {
case WS_BROKER_IN_MEMORY_CODES.INVALID_OPTIONS:
// Caller bug — surface as a config error to the operator.
break;
case WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED:
// Lifecycle bug — reconstruct.
break;
default: {
const _exhaustive: never = code;
void _exhaustive;
}
}
}
```
## API Reference
- `WS_BROKER_IN_MEMORY_CODES` — `{ INVALID_OPTIONS, BROKER_DISPOSED }`
with fully namespaced string values.
- `WsBrokerInMemoryErrorCode` — `(typeof WS_BROKER_IN_MEMORY_CODES)[keyof ...]`.
- `class WsBrokerInMemoryError extends SuaveplanError<WsBrokerInMemoryErrorCode>`
— constructor `(code, message, options?)`; exposes `.brokerCode`.
## Implementation Notes
- The registry is closed: adding a new failure mode requires an
OpenSpec change. This is deliberate — a stable registry is what
makes the broker safe to depend on from observability and host
wiring.
- Errors raised by the upstream `@suaveplan/api-ws` contract (for
example `WS.BROKER_UNAVAILABLE`) are intentionally NOT re-thrown
from this package: the in-memory broker never has an unavailable
upstream, so that code is structurally unreachable here.
- The class stores `brokerCode` separately from the inherited `code`
field so call sites that destructure can do so without re-asserting
the narrowed type at every use.

View File

@@ -0,0 +1,74 @@
# `options` — `createInMemoryWsBroker` option surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the Zod-validated option shape consumed by
`createInMemoryWsBroker`. Keeping it in a dedicated feature folder lets
the constructor stay focused on lifecycle wiring and lets the option
defaulting logic be tested in isolation without standing up a broker
instance.
The option set is deliberately small: an in-memory broker has no
network knobs, no retry tuning, and no persistence story. The two
levers that matter — delivery semantics and a defensive subscriber cap
— are exposed here.
## Features
- `InMemoryWsBrokerOptions` — the public, all-optional input shape.
- `InMemoryWsBrokerDeliveryMode` — `"fire-and-forget"` (default) or
`"await-all"`.
- `inMemoryWsBrokerOptionsSchema` — strict Zod schema; unknown keys
are rejected so option typos fail at the constructor boundary.
- `resolveInMemoryWsBrokerOptions(opts)` — applies defaults and yields
the `ResolvedInMemoryWsBrokerOptions` shape used internally.
- `ResolvedInMemoryWsBrokerOptions` — every field required.
## Usage: Basic
```ts
import { inMemoryWsBrokerOptionsSchema } from "@suaveplan/api-ws-broker-in-memory/options";
const parsed = inMemoryWsBrokerOptionsSchema.parse({
deliveryMode: "await-all",
});
// parsed.deliveryMode === "await-all"
```
## Usage: Advanced
```ts
import {
inMemoryWsBrokerOptionsSchema,
resolveInMemoryWsBrokerOptions,
} from "@suaveplan/api-ws-broker-in-memory/options";
const raw = { deliveryMode: "await-all" as const };
const safe = inMemoryWsBrokerOptionsSchema.parse(raw);
const resolved = resolveInMemoryWsBrokerOptions(safe);
// resolved === { deliveryMode: "await-all" }
```
## API Reference
- `interface InMemoryWsBrokerOptions { deliveryMode?; }`
- `type InMemoryWsBrokerDeliveryMode = "fire-and-forget" | "await-all"`
- `inMemoryWsBrokerOptionsSchema: z.ZodType<InMemoryWsBrokerOptions>`
- `function resolveInMemoryWsBrokerOptions(opts): ResolvedInMemoryWsBrokerOptions`
- `interface ResolvedInMemoryWsBrokerOptions { deliveryMode; }`
## Implementation Notes
- The schema is `.strict()` because typos in option keys are a common
ops failure mode and silently ignoring them is worse than throwing.
- The split between public `Options` (all optional) and internal
`ResolvedOptions` (all required) lets the broker implementation skip
per-call defaulting work and keeps every field statically narrowed
inside the hot path.
- Future broker tuning knobs (e.g. publish concurrency, slow-handler
detection thresholds) should land here as additional optional
fields, never as global module state.

View File

@@ -0,0 +1,113 @@
# `testing` — `createInMemoryBrokerHarness`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes a small, opinionated test harness around the
in-memory broker. It removes the boilerplate of wiring an observer
subscription and a teardown helper, so most tests collapse to "build
harness, publish, assert on `received`, dispose". The harness deliberately
shares the production broker — there is no separate "fake" broker hiding
underneath — which means every assertion exercises the real fan-out
pipeline and every regression caught in tests is one that would also
manifest in production single-host deployments.
The harness lives under `./testing` (not the main entry) so consumer
production bundles never pay for the helper machinery; only test bundles
import this subpath.
## Features
- `createInMemoryBrokerHarness(opts?)` factory that returns a
ready-to-use bundle of `{ broker, received, observerSubscription,
publishOne, dispose }`.
- A pre-registered observer subscription on the harness's default topic
(`"test.events"`) plus an extensible `extraTopics` list so tests that
probe the registry shape don't need to subscribe manually.
- `publishOne(payload, opts?)` convenience that materialises a full
`BrokerEnvelope` from a payload, defaulting `topic` and `tenant` from
the harness configuration.
- `dispose()` is idempotent and tolerant of partial teardown — it can
be called from `afterEach` even if the test itself disposed the broker
directly.
## Usage: Basic
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemoryBrokerHarness } from "@suaveplan/api-ws-broker-in-memory/testing";
describe("broker harness", () => {
it("captures every published envelope", { timeout: 5000 }, async () => {
const harness = createInMemoryBrokerHarness<{ id: number }>();
try {
await harness.publishOne({ id: 1 });
await harness.publishOne({ id: 2 });
expect(harness.received.map((e) => e.payload.id)).toEqual([1, 2]);
} finally {
harness.dispose();
}
});
});
```
## Usage: Advanced
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { createInMemoryBrokerHarness } from "@suaveplan/api-ws-broker-in-memory/testing";
describe("broker harness — await-all + multi-topic", () => {
it("delivers across additional topics", { timeout: 5000 }, async () => {
const harness = createInMemoryBrokerHarness<string>({
broker: { deliveryMode: "await-all" },
topic: "orders",
tenant: "t1" as unknown as TenantId,
extraTopics: ["audit", "billing"],
});
try {
await harness.publishOne("ok", { topic: "orders" });
// The probe subscribers on audit/billing keep them
// registered for `subscriberCount` introspection.
expect(harness.broker.subscriberCount("orders")).toBe(1);
expect(harness.broker.subscriberCount("audit")).toBe(1);
expect(harness.received).toHaveLength(1);
expect(harness.received[0]?.payload).toBe("ok");
} finally {
harness.dispose();
}
});
});
```
## API Reference
- `createInMemoryBrokerHarness<Payload>(opts?): InMemoryBrokerHarness<Payload>`
- `interface CreateInMemoryBrokerHarnessOptions<Payload>` —
`{ broker?, topic?, tenant?, extraTopics?, initialPayload? }`.
- `interface InMemoryBrokerHarness<Payload>` —
`{ broker, received, observerSubscription, publishOne, dispose }`.
## Implementation Notes
- The harness is deliberately tiny — it intentionally does NOT
attempt to drive the `runWsEngineConformance` harness, because that
one expects a full `(bus, registry, broker, makeConnection)` tuple
while this package only owns the broker leg. The package's
conformance test in `tests/conformance/` runs broker-shaped type
assertions instead, which is the right scope for a broker-only
package.
- `extraTopics` register a no-op subscriber so the topic survives in
the registry for introspection (`subscriberCount(topic) === 1`).
Without a probe subscriber the in-memory broker reaps zero-size
topic entries from its `Map` and `subscriberCount` would report 0,
which is the correct production behaviour but inconvenient when a
test wants to assert "this topic exists right now".
- The harness wraps the observer unsubscribe and broker dispose in
separate `try`/`catch` blocks so an out-of-order teardown (test
disposes the broker, then `afterEach` calls `harness.dispose()`)
does not surface stale `BROKER_DISPOSED` errors.