Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createInMemoryWsBroker, type InMemoryWsBroker } from "./broker.js";
|
||||
Reference in New Issue
Block a user