Initial Commit
This commit is contained in:
86
packages/api-stack/api/api-ws-broker-in-memory/README.md
Normal file
86
packages/api-stack/api/api-ws-broker-in-memory/README.md
Normal 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.*`).
|
||||
|
||||
76
packages/api-stack/api/api-ws-broker-in-memory/package.json
Normal file
76
packages/api-stack/api/api-ws-broker-in-memory/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
WS_BROKER_IN_MEMORY_CODES,
|
||||
WsBrokerInMemoryError,
|
||||
type WsBrokerInMemoryErrorCode,
|
||||
} from "./error-codes.js";
|
||||
16
packages/api-stack/api/api-ws-broker-in-memory/src/index.ts
Normal file
16
packages/api-stack/api/api-ws-broker-in-memory/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type InMemoryWsBrokerDeliveryMode,
|
||||
type InMemoryWsBrokerOptions,
|
||||
inMemoryWsBrokerOptionsSchema,
|
||||
type ResolvedInMemoryWsBrokerOptions,
|
||||
resolveInMemoryWsBrokerOptions,
|
||||
} from "./options.js";
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateInMemoryBrokerHarnessOptions,
|
||||
createInMemoryBrokerHarness,
|
||||
type InMemoryBrokerHarness,
|
||||
} from "./testing.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user