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,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();
},
};
}