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";