Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,76 @@
{
"name": "@suaveplan/api-ws-broker-in-memory",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./broker": {
"import": {
"types": "./dist/broker/broker.d.ts",
"default": "./dist/broker/broker.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-ws": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,261 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type {
BrokerEnvelope,
BrokerHandler,
BrokerSubscription,
WsBroker,
} from "@suaveplan/api-ws/broker";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
WS_BROKER_IN_MEMORY_CODES,
WsBrokerInMemoryError,
} from "../error-codes/error-codes.js";
import { createInMemoryWsBroker, type InMemoryWsBroker } from "./broker.js";
const T = "t-broker" as unknown as TenantId;
function envelope<P>(topic: string, payload: P): BrokerEnvelope<P> {
return { topic, tenant: T, payload };
}
describe("createInMemoryWsBroker", () => {
it("returns a WsBroker-shaped instance", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker();
expectTypeOf<InMemoryWsBroker<unknown>>().toMatchTypeOf<
WsBroker<unknown>
>();
expect(typeof broker.publish).toBe("function");
expect(typeof broker.subscribe).toBe("function");
expect(typeof broker.dispose).toBe("function");
expect(broker.isDisposed()).toBe(false);
});
it("fans out a single envelope to every subscriber", {
timeout: 5000,
}, async () => {
const broker = createInMemoryWsBroker<number>();
const a: number[] = [];
const b: number[] = [];
const c: number[] = [];
broker.subscribe("orders", (e) => void a.push(e.payload));
broker.subscribe("orders", (e) => void b.push(e.payload));
broker.subscribe("orders", (e) => void c.push(e.payload));
await broker.publish("orders", envelope("orders", 1));
await broker.publish("orders", envelope("orders", 2));
await new Promise((r) => setTimeout(r, 5));
expect(a).toEqual([1, 2]);
expect(b).toEqual([1, 2]);
expect(c).toEqual([1, 2]);
broker.dispose();
});
it("does not deliver to other topics", { timeout: 5000 }, async () => {
const broker = createInMemoryWsBroker<string>();
const ordersSeen: string[] = [];
const billingSeen: string[] = [];
broker.subscribe("orders", (e) => void ordersSeen.push(e.payload));
broker.subscribe("billing", (e) => void billingSeen.push(e.payload));
await broker.publish("orders", envelope("orders", "o1"));
await new Promise((r) => setTimeout(r, 5));
expect(ordersSeen).toEqual(["o1"]);
expect(billingSeen).toEqual([]);
broker.dispose();
});
it("publish with no subscribers is a no-op", {
timeout: 5000,
}, async () => {
const broker = createInMemoryWsBroker();
await broker.publish("ghost", envelope("ghost", "x"));
expect(broker.subscriberCount("ghost")).toBe(0);
broker.dispose();
});
it("unsubscribe stops further delivery", { timeout: 5000 }, async () => {
const broker = createInMemoryWsBroker<number>();
const seen: number[] = [];
const sub = broker.subscribe("x", (e) => void seen.push(e.payload));
await broker.publish("x", envelope("x", 1));
sub.unsubscribe();
await broker.publish("x", envelope("x", 2));
await new Promise((r) => setTimeout(r, 5));
expect(seen).toEqual([1]);
expect(broker.subscriberCount("x")).toBe(0);
broker.dispose();
});
it("unsubscribe is idempotent", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker();
const sub = broker.subscribe("x", () => undefined);
sub.unsubscribe();
expect(() => sub.unsubscribe()).not.toThrow();
broker.dispose();
});
it("publish snapshot is stable against mid-fan-out unsubscribe", {
timeout: 5000,
}, async () => {
const broker = createInMemoryWsBroker<number>({
deliveryMode: "await-all",
});
const seenA: number[] = [];
const seenB: number[] = [];
let subB: BrokerSubscription | null = null;
broker.subscribe("x", (e) => {
seenA.push(e.payload);
subB?.unsubscribe();
});
subB = broker.subscribe("x", (e) => void seenB.push(e.payload));
await broker.publish("x", envelope("x", 7));
// Both handlers ran for this publish — the snapshot was taken
// before A's handler removed B.
expect(seenA).toEqual([7]);
expect(seenB).toEqual([7]);
// Second publish: B is gone.
await broker.publish("x", envelope("x", 8));
expect(seenA).toEqual([7, 8]);
expect(seenB).toEqual([7]);
broker.dispose();
});
it("dispose throws BROKER_DISPOSED on subsequent publish/subscribe", {
timeout: 5000,
}, async () => {
const broker = createInMemoryWsBroker();
broker.dispose();
expect(broker.isDisposed()).toBe(true);
const publishErr = await (async () => {
try {
await broker.publish("x", envelope("x", "z"));
return null;
} catch (e) {
return e;
}
})();
expect(publishErr).toBeInstanceOf(WsBrokerInMemoryError);
expect((publishErr as WsBrokerInMemoryError).brokerCode).toBe(
WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED
);
const subErr = (() => {
try {
broker.subscribe("x", () => undefined);
return null;
} catch (e) {
return e;
}
})();
expect(subErr).toBeInstanceOf(WsBrokerInMemoryError);
expect((subErr as WsBrokerInMemoryError).brokerCode).toBe(
WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED
);
});
it("dispose is idempotent", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker();
broker.dispose();
expect(() => broker.dispose()).not.toThrow();
expect(broker.isDisposed()).toBe(true);
});
it("invalid options throws INVALID_OPTIONS", { timeout: 5000 }, () => {
// Strict-mode rejects unknown keys.
const err = (() => {
try {
createInMemoryWsBroker({
// @ts-expect-error — testing runtime rejection
unknown: true,
});
return null;
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(WsBrokerInMemoryError);
expect((err as WsBrokerInMemoryError).brokerCode).toBe(
WS_BROKER_IN_MEMORY_CODES.INVALID_OPTIONS
);
});
it("invalid deliveryMode value throws INVALID_OPTIONS", {
timeout: 5000,
}, () => {
const err = (() => {
try {
createInMemoryWsBroker({
// @ts-expect-error — testing runtime rejection
deliveryMode: "blocking",
});
return null;
} catch (e) {
return e;
}
})();
expect(err).toBeInstanceOf(WsBrokerInMemoryError);
expect((err as WsBrokerInMemoryError).brokerCode).toBe(
WS_BROKER_IN_MEMORY_CODES.INVALID_OPTIONS
);
});
it("isolates handler rejections in fire-and-forget mode", {
timeout: 5000,
}, async () => {
const broker = createInMemoryWsBroker<string>();
const good: string[] = [];
broker.subscribe("x", () => {
throw new Error("subscriber blew up");
});
broker.subscribe("x", (e) => void good.push(e.payload));
// Publish must NOT reject even though a handler throws.
await broker.publish("x", envelope("x", "ok"));
await new Promise((r) => setTimeout(r, 5));
expect(good).toEqual(["ok"]);
broker.dispose();
});
it("isolates handler rejections in await-all mode", {
timeout: 5000,
}, async () => {
const broker = createInMemoryWsBroker<string>({
deliveryMode: "await-all",
});
const good: string[] = [];
broker.subscribe("x", async () => {
await Promise.resolve();
throw new Error("async boom");
});
broker.subscribe("x", async (e) => {
await Promise.resolve();
good.push(e.payload);
});
await broker.publish("x", envelope("x", "ok"));
expect(good).toEqual(["ok"]);
broker.dispose();
});
it("BrokerHandler typing flows through subscribe", {
timeout: 5000,
}, () => {
const broker = createInMemoryWsBroker<{ n: number }>();
const handler: BrokerHandler<{ n: number }> = (e) => {
expectTypeOf(e.payload).toEqualTypeOf<{ n: number }>();
};
const sub = broker.subscribe("typed", handler);
sub.unsubscribe();
broker.dispose();
});
it("subscriberCount tracks insertion and removal", {
timeout: 5000,
}, () => {
const broker = createInMemoryWsBroker();
expect(broker.subscriberCount("x")).toBe(0);
const a = broker.subscribe("x", () => undefined);
const b = broker.subscribe("x", () => undefined);
expect(broker.subscriberCount("x")).toBe(2);
a.unsubscribe();
expect(broker.subscriberCount("x")).toBe(1);
b.unsubscribe();
expect(broker.subscriberCount("x")).toBe(0);
broker.dispose();
});
});

View File

@@ -0,0 +1,185 @@
import type {
BrokerEnvelope,
BrokerHandler,
BrokerSubscription,
WsBroker,
} from "@suaveplan/api-ws/broker";
import {
WS_BROKER_IN_MEMORY_CODES,
WsBrokerInMemoryError,
} from "../error-codes/error-codes.js";
import {
type InMemoryWsBrokerOptions,
inMemoryWsBrokerOptionsSchema,
type ResolvedInMemoryWsBrokerOptions,
resolveInMemoryWsBrokerOptions,
} from "../options/options.js";
/**
* Concrete shape returned by {@link createInMemoryWsBroker}. Extends
* the abstract {@link WsBroker} contract with two introspection helpers
* that are deliberately scoped to this adapter: `subscriberCount(topic)`
* for assertions and `isDisposed()` for lifecycle checks.
*/
export interface InMemoryWsBroker<Payload = unknown> extends WsBroker<Payload> {
/** In-memory subscribe is always synchronous — narrows the abstract union. */
subscribe(
topic: string,
handler: BrokerHandler<Payload>
): BrokerSubscription;
/** In-memory publish always returns a promise (resolves next microtask). */
publish(topic: string, envelope: BrokerEnvelope<Payload>): Promise<void>;
/** In-memory dispose is synchronous. */
dispose(): void;
subscriberCount(topic: string): number;
isDisposed(): boolean;
}
interface RegistryEntry<Payload> {
readonly handler: BrokerHandler<Payload>;
readonly topic: string;
}
/**
* Build a single-host WebSocket broker that fans out
* `publish(topic, envelope)` to every handler currently subscribed to
* `topic`. Suitable for production single-host deployments AND for
* tests — there is no other "in-memory test double" hiding underneath.
*
* Internal layout:
*
* - `topics: Map<string, Set<RegistryEntry>>` — the subscription
* table. `Set` membership gives O(1) subscribe/unsubscribe and
* iteration order matching insertion order.
* - `disposed: boolean` — flipped by `dispose()`. Every public method
* re-checks this flag and throws
* `WS_BROKER_IN_MEMORY.BROKER_DISPOSED` if the broker has already
* been torn down.
*
* Delivery semantics are governed by `options.deliveryMode`:
*
* - `"fire-and-forget"` (default) — `publish` invokes every handler
* synchronously, wraps each in `Promise.resolve(...)` and attaches a
* silent `.catch(noop)` so a misbehaving handler can never reject
* the outer publish nor abort sibling subscribers. The publish
* promise resolves on the next microtask.
* - `"await-all"` — `publish` awaits every handler. Handler rejections
* are still individually swallowed (one bad subscriber must not
* poison the whole publish), but timing is observable from the
* caller.
*/
export function createInMemoryWsBroker<Payload = unknown>(
options: InMemoryWsBrokerOptions = {}
): InMemoryWsBroker<Payload> {
const parsed = inMemoryWsBrokerOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new WsBrokerInMemoryError(
WS_BROKER_IN_MEMORY_CODES.INVALID_OPTIONS,
`createInMemoryWsBroker received invalid options: ${parsed.error.message}`
);
}
const resolved: ResolvedInMemoryWsBrokerOptions =
resolveInMemoryWsBrokerOptions(
parsed.data.deliveryMode === undefined
? {}
: { deliveryMode: parsed.data.deliveryMode }
);
const topics = new Map<string, Set<RegistryEntry<Payload>>>();
let disposed = false;
function assertLive(operation: string): void {
if (disposed) {
throw new WsBrokerInMemoryError(
WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED,
`cannot ${operation}: broker has been disposed`
);
}
}
function getEntries(topic: string): Set<RegistryEntry<Payload>> {
let set = topics.get(topic);
if (set === undefined) {
set = new Set<RegistryEntry<Payload>>();
topics.set(topic, set);
}
return set;
}
async function dispatch(
envelope: BrokerEnvelope<Payload>,
entries: ReadonlyArray<RegistryEntry<Payload>>
): Promise<void> {
if (resolved.deliveryMode === "await-all") {
await Promise.all(
entries.map((entry) =>
Promise.resolve()
.then(() => entry.handler(envelope))
.catch(() => {
/* swallow per-subscriber failures */
})
)
);
return;
}
for (const entry of entries) {
Promise.resolve()
.then(() => entry.handler(envelope))
.catch(() => {
/* swallow per-subscriber failures */
});
}
}
const broker: InMemoryWsBroker<Payload> = {
publish(
topic: string,
envelope: BrokerEnvelope<Payload>
): Promise<void> {
assertLive("publish");
const set = topics.get(topic);
if (set === undefined || set.size === 0) {
return Promise.resolve();
}
// Snapshot before dispatch so a handler that unsubscribes
// (or a peer that subscribes mid-fan-out) does not mutate
// the iteration we are walking.
const snapshot = Array.from(set);
return dispatch(envelope, snapshot);
},
subscribe(
topic: string,
handler: BrokerHandler<Payload>
): BrokerSubscription {
assertLive("subscribe");
const set = getEntries(topic);
const entry: RegistryEntry<Payload> = { handler, topic };
set.add(entry);
let active = true;
return {
topic,
unsubscribe(): void {
if (!active) return;
active = false;
const current = topics.get(topic);
if (current === undefined) return;
current.delete(entry);
if (current.size === 0) {
topics.delete(topic);
}
},
};
},
dispose(): void {
if (disposed) return;
disposed = true;
topics.clear();
},
subscriberCount(topic: string): number {
return topics.get(topic)?.size ?? 0;
},
isDisposed(): boolean {
return disposed;
},
};
return broker;
}

View File

@@ -0,0 +1 @@
export { createInMemoryWsBroker, type InMemoryWsBroker } from "./broker.js";

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,48 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
WS_BROKER_IN_MEMORY_CODES,
WsBrokerInMemoryError,
} from "./error-codes.js";
describe("WS_BROKER_IN_MEMORY_CODES", () => {
it("exposes a fully namespaced code registry", { timeout: 5000 }, () => {
expect(WS_BROKER_IN_MEMORY_CODES.INVALID_OPTIONS).toBe(
"WS_BROKER_IN_MEMORY.INVALID_OPTIONS"
);
expect(WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED).toBe(
"WS_BROKER_IN_MEMORY.BROKER_DISPOSED"
);
});
it("registry has exactly the two declared codes", { timeout: 5000 }, () => {
expect(Object.keys(WS_BROKER_IN_MEMORY_CODES).sort()).toEqual([
"BROKER_DISPOSED",
"INVALID_OPTIONS",
]);
});
});
describe("WsBrokerInMemoryError", () => {
it("extends SuaveplanError and narrows the code", { timeout: 5000 }, () => {
const err = new WsBrokerInMemoryError(
WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED,
"test message"
);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(WsBrokerInMemoryError);
expect(err.code).toBe(WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED);
expect(err.brokerCode).toBe(WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED);
expect(err.message).toBe("test message");
});
it("preserves cause via options", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new WsBrokerInMemoryError(
WS_BROKER_IN_MEMORY_CODES.INVALID_OPTIONS,
"wrapped",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,45 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-ws-broker-in-memory`.
*
* Every failure path raised directly by the in-memory broker carries one
* of these `WS_BROKER_IN_MEMORY.*` codes:
*
* - `INVALID_OPTIONS` — `createInMemoryWsBroker(options)` received a
* shape that fails {@link inMemoryWsBrokerOptionsSchema}. Caught at
* the constructor boundary so misconfiguration fails fast rather
* than producing surprising runtime behaviour later.
* - `BROKER_DISPOSED` — `publish` or `subscribe` invoked after
* `dispose()` has resolved. The broker is single-use; consumers MUST
* construct a fresh instance instead of recycling a disposed one.
*
* Consumers branch on the string value, e.g.
* `error.code === WS_BROKER_IN_MEMORY_CODES.BROKER_DISPOSED`.
*/
export const WS_BROKER_IN_MEMORY_CODES = defineCodes("WS_BROKER_IN_MEMORY", [
"INVALID_OPTIONS",
"BROKER_DISPOSED",
] as const);
export type WsBrokerInMemoryErrorCode =
(typeof WS_BROKER_IN_MEMORY_CODES)[keyof typeof WS_BROKER_IN_MEMORY_CODES];
/**
* Concrete error class for the in-memory broker. Extends
* {@link SuaveplanError} so `instanceof WsBrokerInMemoryError` narrows
* `code` to {@link WsBrokerInMemoryErrorCode}.
*/
export class WsBrokerInMemoryError extends SuaveplanError<WsBrokerInMemoryErrorCode> {
public readonly brokerCode: WsBrokerInMemoryErrorCode;
public constructor(
code: WsBrokerInMemoryErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.brokerCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
WS_BROKER_IN_MEMORY_CODES,
WsBrokerInMemoryError,
type WsBrokerInMemoryErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,16 @@
export {
createInMemoryWsBroker,
type InMemoryWsBroker,
} from "./broker/broker.js";
export {
WS_BROKER_IN_MEMORY_CODES,
WsBrokerInMemoryError,
type WsBrokerInMemoryErrorCode,
} from "./error-codes/error-codes.js";
export {
type InMemoryWsBrokerDeliveryMode,
type InMemoryWsBrokerOptions,
inMemoryWsBrokerOptionsSchema,
type ResolvedInMemoryWsBrokerOptions,
resolveInMemoryWsBrokerOptions,
} from "./options/options.js";

View File

@@ -0,0 +1,7 @@
export {
type InMemoryWsBrokerDeliveryMode,
type InMemoryWsBrokerOptions,
inMemoryWsBrokerOptionsSchema,
type ResolvedInMemoryWsBrokerOptions,
resolveInMemoryWsBrokerOptions,
} from "./options.js";

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,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
inMemoryWsBrokerOptionsSchema,
resolveInMemoryWsBrokerOptions,
} from "./options.js";
describe("InMemoryWsBrokerOptions", () => {
it("accepts an empty object", { timeout: 5000 }, () => {
const parsed = inMemoryWsBrokerOptionsSchema.parse({});
expect(parsed).toEqual({});
});
it("accepts fire-and-forget and await-all", { timeout: 5000 }, () => {
expect(
inMemoryWsBrokerOptionsSchema.parse({
deliveryMode: "fire-and-forget",
})
).toEqual({ deliveryMode: "fire-and-forget" });
expect(
inMemoryWsBrokerOptionsSchema.parse({ deliveryMode: "await-all" })
).toEqual({ deliveryMode: "await-all" });
});
it("rejects unknown keys in strict mode", { timeout: 5000 }, () => {
const res = inMemoryWsBrokerOptionsSchema.safeParse({
// @ts-expect-error — testing strict-mode rejection
unexpected: 1,
});
expect(res.success).toBe(false);
});
it("rejects invalid deliveryMode values", { timeout: 5000 }, () => {
const res = inMemoryWsBrokerOptionsSchema.safeParse({
// @ts-expect-error — invalid literal
deliveryMode: "sometimes",
});
expect(res.success).toBe(false);
});
});
describe("resolveInMemoryWsBrokerOptions", () => {
it("defaults deliveryMode to fire-and-forget", { timeout: 5000 }, () => {
expect(resolveInMemoryWsBrokerOptions({})).toEqual({
deliveryMode: "fire-and-forget",
});
});
it("preserves an explicit deliveryMode", { timeout: 5000 }, () => {
expect(
resolveInMemoryWsBrokerOptions({ deliveryMode: "await-all" })
).toEqual({ deliveryMode: "await-all" });
});
});

View File

@@ -0,0 +1,59 @@
import z from "zod";
/**
* Delivery mode for `publish(topic, envelope)`.
*
* - `"fire-and-forget"` (default) — dispatch to every subscriber and
* resolve immediately. Handler rejections are swallowed (they do
* NOT reject the `publish` promise) so a single misbehaving
* subscriber cannot block the broadcast pipeline.
* - `"await-all"` — wait for every subscriber handler to settle before
* resolving `publish`. Useful in tests that need to observe
* side-effects synchronously; in production prefer fire-and-forget
* because one slow subscriber stalls the whole publish.
*/
export type InMemoryWsBrokerDeliveryMode = "fire-and-forget" | "await-all";
/**
* Public options accepted by `createInMemoryWsBroker(opts?)`.
*
* Every field is optional; the broker has sensible defaults for
* single-host development and tests:
*
* - `deliveryMode` — `"fire-and-forget"`.
*/
export interface InMemoryWsBrokerOptions {
readonly deliveryMode?: InMemoryWsBrokerDeliveryMode;
}
/**
* Zod schema applied at the constructor boundary. Strict-mode rejects
* unknown keys so typos in option names are caught at the boundary
* rather than silently ignored.
*/
export const inMemoryWsBrokerOptionsSchema = z
.object({
deliveryMode: z.enum(["fire-and-forget", "await-all"]).optional(),
})
.strict();
/**
* Resolved (defaulted) option bundle the broker uses internally.
* Every field is required — defaults are applied at parse time.
*/
export interface ResolvedInMemoryWsBrokerOptions {
readonly deliveryMode: InMemoryWsBrokerDeliveryMode;
}
/**
* Apply defaults to a successfully-parsed option bundle. Kept as a
* standalone helper so tests can exercise the defaulting logic
* without going through the full broker factory.
*/
export function resolveInMemoryWsBrokerOptions(
opts: InMemoryWsBrokerOptions
): ResolvedInMemoryWsBrokerOptions {
return {
deliveryMode: opts.deliveryMode ?? "fire-and-forget",
};
}

View File

@@ -0,0 +1,5 @@
export {
type CreateInMemoryBrokerHarnessOptions,
createInMemoryBrokerHarness,
type InMemoryBrokerHarness,
} from "./testing.js";

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.

View File

@@ -0,0 +1,80 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInMemoryBrokerHarness } from "./testing.js";
describe("createInMemoryBrokerHarness", () => {
it("captures every envelope published on the default topic", {
timeout: 5000,
}, async () => {
const harness = createInMemoryBrokerHarness<{ id: number }>();
try {
await harness.publishOne({ id: 1 });
await harness.publishOne({ id: 2 });
await new Promise((r) => setTimeout(r, 5));
expect(harness.received.map((e) => e.payload.id)).toEqual([1, 2]);
expect(harness.observerSubscription.topic).toBe("test.events");
} finally {
harness.dispose();
}
});
it("supports a custom topic and tenant", { timeout: 5000 }, async () => {
const harness = createInMemoryBrokerHarness<string>({
topic: "orders",
tenant: "ten" as unknown as TenantId,
});
try {
await harness.publishOne("v");
await new Promise((r) => setTimeout(r, 5));
expect(harness.received[0]?.topic).toBe("orders");
expect(harness.received[0]?.tenant).toBe("ten");
expect(harness.received[0]?.payload).toBe("v");
} finally {
harness.dispose();
}
});
it("publishOne can override topic + tenant per call", {
timeout: 5000,
}, async () => {
const harness = createInMemoryBrokerHarness<string>({
extraTopics: ["alt"],
});
try {
await harness.publishOne("redirected", {
topic: "alt",
tenant: "z" as unknown as TenantId,
});
// Default-topic observer does NOT see the override.
expect(harness.received).toHaveLength(0);
expect(harness.broker.subscriberCount("alt")).toBe(1);
} finally {
harness.dispose();
}
});
it("propagates broker options (await-all mode)", {
timeout: 5000,
}, async () => {
const harness = createInMemoryBrokerHarness<number>({
broker: { deliveryMode: "await-all" },
});
try {
await harness.publishOne(99);
// In await-all mode the observer has settled before
// publishOne resolves, so no extra timer wait is needed.
expect(harness.received.map((e) => e.payload)).toEqual([99]);
} finally {
harness.dispose();
}
});
it("dispose is idempotent and tolerates already-torn-down broker", {
timeout: 5000,
}, () => {
const harness = createInMemoryBrokerHarness({ extraTopics: ["extra"] });
harness.broker.dispose();
expect(() => harness.dispose()).not.toThrow();
expect(() => harness.dispose()).not.toThrow();
});
});

View File

@@ -0,0 +1,113 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type { BrokerEnvelope } from "@suaveplan/api-ws/broker";
import {
createInMemoryWsBroker,
type InMemoryWsBroker,
} from "../broker/broker.js";
import type { InMemoryWsBrokerOptions } from "../options/options.js";
/**
* Result returned by {@link createInMemoryBrokerHarness}.
*
* The harness ships everything a test needs to drive the broker:
*
* - `broker` — the broker instance under test.
* - `received` — a chronological array of every envelope delivered
* to the auto-subscribed observer, useful for `expect(...).toEqual`.
* - `observerSubscription` — disposal handle for the observer.
* - `publishOne(payload, opts?)` — convenience for publishing a
* single envelope on the harness's default topic + tenant.
* - `dispose()` — tears down the observer and disposes the broker
* (idempotent).
*/
export interface InMemoryBrokerHarness<Payload = unknown> {
readonly broker: InMemoryWsBroker<Payload>;
readonly received: ReadonlyArray<BrokerEnvelope<Payload>>;
readonly observerSubscription: { readonly topic: string };
publishOne(
payload: Payload,
opts?: { readonly topic?: string; readonly tenant?: TenantId }
): Promise<void>;
dispose(): void;
}
/**
* Options accepted by {@link createInMemoryBrokerHarness}. The defaults
* cover the 90% test path; supply `topic` / `tenant` only when the
* scenario needs to vary them.
*/
export interface CreateInMemoryBrokerHarnessOptions<Payload = unknown> {
readonly broker?: InMemoryWsBrokerOptions;
readonly topic?: string;
readonly tenant?: TenantId;
readonly extraTopics?: ReadonlyArray<string>;
readonly initialPayload?: Payload;
}
const DEFAULT_TOPIC = "test.events";
const DEFAULT_TENANT = "tenant-test" as unknown as TenantId;
/**
* Build a ready-to-use broker harness for tests. The harness wires up
* a single observer subscription on `topic` so callers can `publish`
* and assert on `received` without any boilerplate.
*
* The harness is intentionally additive: it does NOT replace the
* broker's public surface — `harness.broker` is the real
* {@link InMemoryWsBroker} returned by
* {@link createInMemoryWsBroker}, so any test can drop down to direct
* subscribe/publish/dispose when the convenience helpers don't fit.
*/
export function createInMemoryBrokerHarness<Payload = unknown>(
options: CreateInMemoryBrokerHarnessOptions<Payload> = {}
): InMemoryBrokerHarness<Payload> {
const topic = options.topic ?? DEFAULT_TOPIC;
const tenant = options.tenant ?? DEFAULT_TENANT;
const broker = createInMemoryWsBroker<Payload>(options.broker);
const received: BrokerEnvelope<Payload>[] = [];
const observerSubscription = broker.subscribe(topic, (envelope) => {
received.push(envelope);
});
// Pre-warm extra topics by registering a no-op subscriber so
// subscriberCount reports them. Useful for tests that probe the
// registry shape before publishing.
const extraHandles = (options.extraTopics ?? []).map((extra) =>
broker.subscribe(extra, () => {
/* probe subscriber */
})
);
let disposed = false;
return {
broker,
received,
observerSubscription: { topic: observerSubscription.topic },
async publishOne(
payload: Payload,
opts?: { readonly topic?: string; readonly tenant?: TenantId }
): Promise<void> {
const envelope: BrokerEnvelope<Payload> = {
topic: opts?.topic ?? topic,
tenant: opts?.tenant ?? tenant,
payload,
};
await broker.publish(envelope.topic, envelope);
},
dispose(): void {
if (disposed) return;
disposed = true;
try {
observerSubscription.unsubscribe();
} catch {
/* already torn down by broker.dispose */
}
for (const handle of extraHandles) {
try {
handle.unsubscribe();
} catch {
/* same */
}
}
broker.dispose();
},
};
}

View File

@@ -0,0 +1,68 @@
/**
* §16 conformance proof — the in-memory broker MUST satisfy every
* documented invariant of the `@suaveplan/api-ws` `WsBroker` contract.
*
* The api-ws package's `runWsEngineConformance` harness needs a full
* `(bus, registry, broker, makeConnection)` tuple — broker-only
* packages cannot drive it. Instead, this conformance suite mirrors
* the broker-shaped assertions from `api-ws/broker/broker.spec-conformance.test.ts`
* against the concrete `createInMemoryWsBroker` factory.
*/
import type { TenantId } from "@suaveplan/api-types/tenant";
import type {
BrokerEnvelope,
BrokerHandler,
BrokerSubscription,
WsBroker,
} from "@suaveplan/api-ws/broker";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { createInMemoryWsBroker } from "../../src/broker/broker.js";
describe("WsBroker conformance: in-memory-direct", () => {
it("type-shape matches WsBroker", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker<number>();
expectTypeOf(broker).toMatchTypeOf<WsBroker<number>>();
expectTypeOf(broker.publish).parameters.toEqualTypeOf<
[topic: string, envelope: BrokerEnvelope<number>]
>();
expectTypeOf(broker.subscribe).parameters.toEqualTypeOf<
[topic: string, handler: BrokerHandler<number>]
>();
broker.dispose();
});
it("subscribe returns a BrokerSubscription", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker();
const sub: BrokerSubscription = broker.subscribe("x", () => undefined);
expect(sub.topic).toBe("x");
expect(typeof sub.unsubscribe).toBe("function");
sub.unsubscribe();
broker.dispose();
});
it("publish + subscribe round-trip delivers an envelope", {
timeout: 5000,
}, async () => {
const broker = createInMemoryWsBroker<{ ok: true }>({
deliveryMode: "await-all",
});
let delivered: BrokerEnvelope<{ ok: true }> | null = null;
broker.subscribe("orders", (e) => {
delivered = e;
});
await broker.publish("orders", {
topic: "orders",
tenant: "t" as unknown as TenantId,
payload: { ok: true },
});
expect(delivered).not.toBeNull();
expect(delivered?.payload.ok).toBe(true);
broker.dispose();
});
it("dispose is mandatory and idempotent", { timeout: 5000 }, () => {
const broker = createInMemoryWsBroker();
broker.dispose();
broker.dispose();
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-ws-broker-in-memory",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});