Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
# `events`
Typed event bus, event sourcing, metrics, and transport bridges.
<!-- begin:auto-docs -->
## Packages in `events`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/broadcast-channel`](./broadcast-channel/README.md) | Typed cross-tab messaging built on the Web `BroadcastChannel` API. Browser-tier package — leader election, tab presence and heartbeats, message-type handler re… |
| [`@suaveplan/event-sourcing`](./event-sourcing/README.md) | Event sourcing patterns for Suaveplan: typed aggregate root, in-memory event store with optimistic concurrency, snapshot support, projections, upcasting, and O… |
| [`@suaveplan/events`](./events/README.md) | Typed event-bus substrate for Suaveplan. Pub/sub with deterministic FIFO delivery, interceptor API, bounded-queue backpressure, idempotent listeners, Zod-valid… |
| [`@suaveplan/events-metrics`](./events-metrics/README.md) | OTel metrics companion for `@suaveplan/events`. Attaches via the public interceptor API to record dispatch totals, listener error counts, and latency histogram… |
| [`@suaveplan/events-otel`](./events-otel/README.md) | OpenTelemetry tracing companion for `@suaveplan/events`. Wraps each dispatch in a span, propagates W3C Trace Context, and records listener exceptions as span e… |
| [`@suaveplan/events-schema`](./events-schema/README.md) | Runtime schema-validation companion for `@suaveplan/events`. Validates event payloads at dispatch boundaries via Zod schemas with strict/warn/sample modes and… |
| [`@suaveplan/events-transport`](./events-transport/README.md) | Cross-context event bridges for `@suaveplan/events`: `MessageChannel` (at-least-once with ack/retry) and `BroadcastChannel` (at-most-once, fan-out) transports… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Typed event bus, event sourcing, metrics, and transport bridges.

View File

@@ -0,0 +1,114 @@
# @suaveplan/broadcast-channel - Typed cross-tab messaging built on the BroadcastChannel API — leader election, tab presence, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, StorageLike-backed fallback for Safari < 15.4. Browser-tier.
Typed cross-tab messaging built on the Web `BroadcastChannel` API. Browser-tier package — leader election, tab presence and heartbeats, message-type handler registry, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, deterministic `Clock` / `Rng` / `LoggerLike` injection, and a `StorageLike`-backed fallback transport for Safari < 15.4 (and any sandbox where the native `BroadcastChannel` is unavailable).
<!-- begin:auto-docs -->
## Documentation tree
- [ask](./src/ask/ask.md) — `AskController` is the request-side engine for the cross-tab request/response primitive layered on top of `TypedBroadca…
- [ask](./src/ask/ask.md) — `AskController` is the request-side engine for the cross-tab request/response primitive layered on top of `TypedBroadca…
- [responder](./src/ask/responder.md) — `AskResponderRegistry` is the response-side counterpart to `AskController`. It maps an inbound `handlerKey` to the sing…
- [channel](./src/channel/channel.md) — `TypedBroadcastChannel` is the high-level cross-tab messaging object exported by `@suaveplan/broadcast-channel`. It wra…
- [channel](./src/channel/channel.md) — `TypedBroadcastChannel` is the high-level cross-tab messaging object exported by `@suaveplan/broadcast-channel`. It wra…
- [heartbeat](./src/channel/heartbeat.md) — `HeartbeatRunner` is a tiny lifecycle helper that owns the pair of `setInterval` timers a `TypedBroadcastChannel` needs…
- [errors](./src/errors/codes.md) — This module declares the stable error-code registry for `@suaveplan/broadcast-channel`. Every error the package throws…
- [codes](./src/errors/codes.md) — This module declares the stable error-code registry for `@suaveplan/broadcast-channel`. Every error the package throws…
- [id](./src/id/id.md) — `generateId` is the shared 16-character lowercase-hex identifier generator used for tab IDs and per-message IDs through…
- [id](./src/id/id.md) — `generateId` is the shared 16-character lowercase-hex identifier generator used for tab IDs and per-message IDs through…
- [logger](./src/logger/noop.md) — `noopLogger` is a shared zero-cost `LoggerLike` implementation used as the default by `TypedBroadcastChannel` and by `c…
- [noop](./src/logger/noop.md) — `noopLogger` is a shared zero-cost `LoggerLike` implementation used as the default by `TypedBroadcastChannel` and by `c…
- [otel](./src/otel/metrics.md) — This module declares the six OpenTelemetry counter instruments emitted by `@suaveplan/broadcast-channel`. Every counter…
- [metrics](./src/otel/metrics.md) — This module declares the six OpenTelemetry counter instruments emitted by `@suaveplan/broadcast-channel`. Every counter…
- [schemas](./src/schemas/messages.md) — This module declares the Zod boundary schemas for `@suaveplan/broadcast-channel`. `ChannelMessageSchema` validates ever…
- [messages](./src/schemas/messages.md) — This module declares the Zod boundary schemas for `@suaveplan/broadcast-channel`. `ChannelMessageSchema` validates ever…
- [local-storage](./src/transport/local-storage.md) — `createLocalStorageChannelFactory` is the `StorageLike`-backed fallback transport. Use it whenever the host lacks a nat…
- [native](./src/transport/native.md) — `nativeChannelFactory` is a thin adapter that exposes the global `BroadcastChannel` constructor as a `ChannelLike`. Use…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/broadcast-channel
```
## Quick start
```ts
import {
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
channel.on<{ userId: string }>("user:signed-in", (msg) => {
console.log(`peer tab ${msg.senderId} signed in as ${msg.payload.userId}`);
});
channel.send("user:signed-in", { userId: "u_123" });
```
## Fallback transport (Safari < 15.4)
```ts
import {
createLocalStorageChannelFactory,
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import { yourStorageLike } from "./storage";
const factory = isNativeBroadcastChannelAvailable()
? nativeChannelFactory
: createLocalStorageChannelFactory(yourStorageLike, {
pollIntervalMs: 50,
retentionMs: 30_000,
});
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory,
});
```
## Modules
- [`channel/channel`](./src/channel/channel.md) — `TypedBroadcastChannel` core.
- [`channel/heartbeat`](./src/channel/heartbeat.md) — `HeartbeatRunner` interval pair.
- [`errors/codes`](./src/errors/codes.md) — `BROADCAST_CHANNEL_CODES` registry + four typed error subclasses.
- [`id/id`](./src/id/id.md) — `generateId(rng)` shared 16-hex id generator.
- [`logger/noop`](./src/logger/noop.md) — shared no-op `LoggerLike` default.
- [`otel/metrics`](./src/otel/metrics.md) — six OpenTelemetry counters.
- [`schemas/messages`](./src/schemas/messages.md) — Zod boundary schemas.
- [`transport/native`](./src/transport/native.md) — native `BroadcastChannel` adapter.
- [`transport/local-storage`](./src/transport/local-storage.md) — `StorageLike`-backed fallback.
- [`types/broadcast-channel`](./src/types/broadcast-channel.types.ts) — public shapes.
## Error codes
| Code | When |
|---|---|
| `BROADCAST_CHANNEL.CHANNEL_CLOSED` | `send()` called before `open()` or after `close()` |
| `BROADCAST_CHANNEL.MESSAGE_MALFORMED` | inbound frame fails `ChannelMessageSchema` |
| `BROADCAST_CHANNEL.PAYLOAD_TOO_LARGE` | outbound payload exceeds `config.maxMessageBytes` (default 1 MiB) |
| `BROADCAST_CHANNEL.STORAGE_FAILED` | fallback-transport `StorageLike` get/set/list/delete rejected |
## OpenTelemetry
Six counters under meter `@suaveplan/broadcast-channel@0.1.0`:
- `channel.message.sent` (`{ type, channel }`)
- `channel.message.received` (`{ type, channel }`)
- `channel.message.dropped` (`{ reason, channel }`)
- `channel.tab.joined` (`{ channel }`)
- `channel.tab.left` (`{ channel }`)
- `channel.leader.elected` (`{ channel }`)
## License
MIT

View File

@@ -0,0 +1,134 @@
{
"name": "@suaveplan/broadcast-channel",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "Typed cross-tab messaging built on the BroadcastChannel API — leader election, tab presence, Zod-validated boundaries, OpenTelemetry-instrumented hot paths, StorageLike-backed fallback for Safari < 15.4. Browser-tier.",
"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"
}
},
"./ask/ask": {
"import": {
"types": "./dist/ask/ask.d.ts",
"default": "./dist/ask/ask.js"
}
},
"./ask/responder": {
"import": {
"types": "./dist/ask/responder.d.ts",
"default": "./dist/ask/responder.js"
}
},
"./channel/channel": {
"import": {
"types": "./dist/channel/channel.d.ts",
"default": "./dist/channel/channel.js"
}
},
"./channel/heartbeat": {
"import": {
"types": "./dist/channel/heartbeat.d.ts",
"default": "./dist/channel/heartbeat.js"
}
},
"./errors/codes": {
"import": {
"types": "./dist/errors/codes.d.ts",
"default": "./dist/errors/codes.js"
}
},
"./id/id": {
"import": {
"types": "./dist/id/id.d.ts",
"default": "./dist/id/id.js"
}
},
"./logger/noop": {
"import": {
"types": "./dist/logger/noop.d.ts",
"default": "./dist/logger/noop.js"
}
},
"./otel/metrics": {
"import": {
"types": "./dist/otel/metrics.d.ts",
"default": "./dist/otel/metrics.js"
}
},
"./schemas/messages": {
"import": {
"types": "./dist/schemas/messages.d.ts",
"default": "./dist/schemas/messages.js"
}
},
"./transport/native": {
"import": {
"types": "./dist/transport/native.d.ts",
"default": "./dist/transport/native.js"
}
},
"./transport/local-storage": {
"import": {
"types": "./dist/transport/local-storage.d.ts",
"default": "./dist/transport/local-storage.js"
}
},
"./types/broadcast-channel": {
"import": {
"types": "./dist/types/broadcast-channel.types.d.ts",
"default": "./dist/types/broadcast-channel.types.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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,130 @@
# `AskController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AskController` is the request-side engine for the cross-tab request/response
primitive layered on top of `TypedBroadcastChannel`. It owns the registry of
in-flight `ask()` calls, keyed by a freshly drawn `correlationId`, and is
responsible for posting the outbound `ask` wire frame, scheduling the timeout
via the injected `TimersLike`, settling the promise when a matching
`ask-response` frame arrives, and rejecting pending promises during a channel
close. Every behaviour the controller drives — clock reads, ID draws, timer
scheduling, OTel counter emission — flows through injected seams so a single
deterministic test can exercise the entire ask lifecycle in zero wall-clock
time.
## Features
- `correlationId` per call drawn from the injected `Rng`; concurrent asks stay
isolated.
- Injectable `TimersLike` (`setTimer` / `clearTimer`) — production wraps
`setTimeout`, tests pass a fake pair backed by a `FakeClock`.
- Three terminal states for each pending ask: resolve on `outcome: "ok"`,
reject on `outcome: "error"` (handler-thrown error projected as
`{ message, code? }`), reject on timeout (with
`BroadcastChannelAskTimeoutError` or, when `requireResponder: true` and no
peer claimed the key, `BroadcastChannelAskNoResponderError`).
- `cancelAll()` for `channel.close()` integration — clears every timer and
rejects every pending promise with `BroadcastChannelClosedError`.
- OTel counters wired on every hot path: `channel.ask.sent` on issue,
`channel.ask.responded` on settle, `channel.ask.timeout` on deadline miss.
## Usage: Basic
```ts
import { AskController } from "@suaveplan/broadcast-channel/ask/ask";
const controller = new AskController({
clock: () => Date.now(),
rng,
timers: {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => clearTimeout(h),
},
channelName: "my-app",
senderId: tabId,
post: (frame) => channel.postMessage(frame),
});
const reply = await controller.issue<string>("ping", { who: "tab-a" });
```
## Usage: Advanced
```ts
// Deterministic timeout test driving a fake TimersLike.
const fired: Array<() => void> = [];
const fakeTimers = {
setTimer: (cb: () => void) => {
fired.push(cb);
return fired.length;
},
clearTimer: (handle: number) => {
fired[handle - 1] = () => {};
},
};
const controller = new AskController({
clock: () => 0,
rng,
timers: fakeTimers,
channelName: "det",
senderId: "asker",
post: () => {},
});
const promise = controller.issue("k", null, {
timeoutMs: 1000,
requireResponder: true,
});
// Drive the timer manually — no real setTimeout was scheduled.
fired[0]?.();
await expect(promise).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_NO_RESPONDER",
});
```
## API Reference
### `class AskController`
- `constructor(opts: AskControllerOptions)` — wires `clock`, `rng`, `timers`,
`channelName`, `senderId`, and the outbound `post` callback.
- `issue<TResp>(handlerKey, payload, opts?): Promise<TResp>` — synchronously
posts the `ask` frame and registers the pending entry; the promise resolves
when an `ask-response` arrives with a matching `responseTo` and rejects on
timeout or close.
- `settle(frame: AskResponseFrame): void` — resolves or rejects the matching
pending entry; late arrivals (no matching entry) are ignored.
- `noteResponderClaim(key: string): void` — marks every pending ask with the
given `handlerKey` as having seen a peer claim so the `requireResponder`
branch downgrades to a normal timeout.
- `cancelAll(): void` — rejects every pending ask with
`BroadcastChannelClosedError` and clears every timer.
### `buildAskErrorPayload(err: unknown): AskErrorPayload`
Projects an arbitrary thrown value into the wire-safe `{ message, code? }`
shape carried by an `outcome: "error"` `ask-response` frame.
## Implementation Notes
The `correlationId` is drawn from the same injected `Rng` as the existing tab
and message IDs, so seeding the `Rng` in tests gives deterministic
`correlationId` values without any extra wiring. The pending map is keyed by
`correlationId` rather than `handlerKey` so a single asker can have multiple
in-flight asks against the same handler key. Timer handles are stored on each
entry so `cancelAll()` can clear every scheduled callback in O(n) without
walking the underlying timer pool.
Error projection is intentionally lossy — only `message` and `code` cross the
wire — so handlers may throw arbitrary objects locally without leaking
implementation details across tabs. Cross-tab transport is structured-clone,
so passing a real `Error` instance through `payload` would only deliver a
plain object on the receiver anyway; the explicit projection makes that
contract typed and observable rather than implicit.

View File

@@ -0,0 +1,529 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { TimersLike } from "@suaveplan/types";
import { TypedBroadcastChannel } from "../channel/channel.js";
import { refreshBroadcastChannelMeters } from "../otel/metrics.js";
import type {
ChannelFactory,
ChannelLike,
} from "../transport/transport.types.js";
/**
* Fan-out fake transport. Every channel created against a single
* `createBusFactory()` shares a mutable peer set, and `postMessage` is
* dispatched to every other peer's listener synchronously — modelling the
* exact semantics of the real BroadcastChannel API.
*/
class BusChannel implements ChannelLike {
public closed = false;
public readonly listeners: Array<(event: MessageEvent) => void> = [];
public readonly peers: Set<BusChannel>;
public constructor(peers: Set<BusChannel>) {
this.peers = peers;
peers.add(this);
}
public postMessage(message: unknown): void {
for (const peer of this.peers) {
if (peer === this || peer.closed) continue;
for (const listener of peer.listeners.slice()) {
listener({ data: message } as MessageEvent);
}
}
}
public close(): void {
this.closed = true;
this.peers.delete(this);
}
public addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void {
if (type === "message") this.listeners.push(listener);
}
}
function createBusFactory(): { factory: ChannelFactory } {
const peers = new Set<BusChannel>();
return {
factory: () => new BusChannel(peers),
};
}
interface ControllableTimers extends TimersLike {
fire(handle: number): void;
pending(): number;
}
function fakeTimers(): ControllableTimers {
const slots = new Map<number, () => void>();
let next = 0;
return {
setTimer(cb) {
next++;
slots.set(next, cb);
return next;
},
clearTimer(h) {
slots.delete(h);
},
fire(h) {
const cb = slots.get(h);
slots.delete(h);
cb?.();
},
pending() {
return slots.size;
},
};
}
describe("TypedBroadcastChannel.ask()", () => {
beforeEach(() => {
refreshBroadcastChannelMeters();
});
it("ask round-trips through a responder", { timeout: 5000 }, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "rt" },
factory,
timers: fakeTimers(),
});
const b = new TypedBroadcastChannel({
config: { name: "rt" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
b.onAsk<{ n: number }, { n: number }>("inc", async ({ n }) => ({
n: n + 1,
}));
const reply = await a.ask<{ n: number }, { n: number }>("inc", {
n: 41,
});
expect(reply).toEqual({ n: 42 });
a.close();
b.close();
});
it("defaults to a 5000 ms timeout when none supplied", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "to-default" },
factory,
timers,
});
a.open();
const pending = a.ask("nobody", null);
// exactly one pending timer for the 5000ms deadline
expect(timers.pending()).toBe(1);
timers.fire(1);
await expect(pending).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_TIMEOUT",
});
a.close();
});
it("keeps concurrent asks isolated by correlationId", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "iso" },
factory,
timers: fakeTimers(),
});
const b = new TypedBroadcastChannel({
config: { name: "iso" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
let counter = 0;
b.onAsk<{ tag: string }, { tag: string; seq: number }>(
"tag",
async ({ tag }) => {
counter++;
// delay second to interleave
if (counter === 1) await new Promise((r) => setTimeout(r, 5));
return { tag, seq: counter };
}
);
const p1 = a.ask<{ tag: string }, { tag: string; seq: number }>("tag", {
tag: "first",
});
const p2 = a.ask<{ tag: string }, { tag: string; seq: number }>("tag", {
tag: "second",
});
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1.tag).toBe("first");
expect(r2.tag).toBe("second");
a.close();
b.close();
});
it("requireResponder rejects with ASK_NO_RESPONDER when no peer claims the key", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "no-resp" },
factory,
timers,
});
a.open();
const pending = a.ask("orphan", null, {
timeoutMs: 50,
requireResponder: true,
});
timers.fire(1);
await expect(pending).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_NO_RESPONDER",
});
a.close();
});
it("requireResponder defaults to off", { timeout: 5000 }, async () => {
const timers = fakeTimers();
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "default-off" },
factory,
timers,
});
a.open();
const pending = a.ask("orphan", null, { timeoutMs: 50 });
timers.fire(1);
await expect(pending).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_TIMEOUT",
});
a.close();
});
it("handler-thrown error rejects the asker", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "crash" },
factory,
timers: fakeTimers(),
});
const b = new TypedBroadcastChannel({
config: { name: "crash" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
b.onAsk("crash", () => {
throw new Error("nope");
});
await expect(a.ask("crash", null)).rejects.toMatchObject({
message: expect.stringContaining("nope"),
});
a.close();
b.close();
});
it("handler-thrown non-Error rejects the asker with stringified message", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "crash-x" },
factory,
timers: fakeTimers(),
});
const b = new TypedBroadcastChannel({
config: { name: "crash-x" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
b.onAsk("boom", () => {
const x: unknown = "literal";
throw x;
});
await expect(a.ask("boom", null)).rejects.toMatchObject({
message: "literal",
});
a.close();
b.close();
});
it("handler-thrown error with code preserves code", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "code" },
factory,
timers: fakeTimers(),
});
const b = new TypedBroadcastChannel({
config: { name: "code" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
b.onAsk("boom2", () => {
const e: Error & { code?: string } = new Error("denied");
e.code = "X.DENIED";
throw e;
});
await expect(a.ask("boom2", null)).rejects.toMatchObject({
message: "denied",
code: "X.DENIED",
});
a.close();
b.close();
});
it("close rejects pending asks with CHANNEL_CLOSED", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "close-cancel" },
factory,
timers,
});
a.open();
const pending = a.ask("slow", null, { timeoutMs: 10_000 });
expect(timers.pending()).toBe(1);
a.close();
await expect(pending).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.CHANNEL_CLOSED",
});
expect(timers.pending()).toBe(0);
});
it("ask after close rejects synchronously with CHANNEL_CLOSED", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "post-close" },
factory,
timers: fakeTimers(),
});
a.open();
a.close();
await expect(a.ask("nope", null)).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.CHANNEL_CLOSED",
});
});
it("ask before open rejects with CHANNEL_CLOSED", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "pre-open" },
factory,
timers: fakeTimers(),
});
await expect(a.ask("nope", null)).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.CHANNEL_CLOSED",
});
});
it("fake TimersLike drives timeout deterministically", {
timeout: 5000,
}, async () => {
let realSetTimeoutCalls = 0;
const realSetTimeout = globalThis.setTimeout;
// Spy on real setTimeout to assert injected timers used exclusively.
globalThis.setTimeout = ((cb: () => void, ms?: number) => {
realSetTimeoutCalls++;
return realSetTimeout(cb, ms);
}) as typeof globalThis.setTimeout;
try {
const timers = fakeTimers();
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "fake-driver" },
factory,
timers,
});
a.open();
const before = realSetTimeoutCalls;
const pending = a.ask("nobody", null, { timeoutMs: 5000 });
expect(realSetTimeoutCalls).toBe(before);
expect(timers.pending()).toBe(1);
timers.fire(1);
await expect(pending).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_TIMEOUT",
});
a.close();
} finally {
globalThis.setTimeout = realSetTimeout;
}
});
it("late ask-response after timeout is dropped silently", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "late" },
factory,
timers,
});
const b = new TypedBroadcastChannel({
config: { name: "late" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
const pending = a.ask("k", null, { timeoutMs: 100 });
timers.fire(1);
await expect(pending).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_TIMEOUT",
});
// Register late; simulate orphan response
b.onAsk("k", () => "late");
// Send a synthetic response with an unknown correlationId — controller drops it
const channelB = b as unknown as {
handleIncoming: (raw: unknown) => void;
};
channelB.handleIncoming({
type: "ask-response",
responseTo: "ghost-correlation",
handlerKey: "k",
outcome: "ok",
payload: null,
id: "x",
senderId: "ghost",
timestamp: 0,
});
a.close();
b.close();
});
it("stale timeout callback after settle is a no-op (defensive)", {
timeout: 5000,
}, async () => {
// Capture the scheduled timeout callback so we can fire it
// AFTER settle has already cleared the pending entry.
let capturedCallback: (() => void) | null = null;
const captureTimers: TimersLike = {
setTimer(cb) {
capturedCallback = cb;
return 1;
},
clearTimer() {
// Intentionally a no-op so we can fire the callback later.
},
};
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "stale" },
factory,
timers: captureTimers,
});
const b = new TypedBroadcastChannel({
config: { name: "stale" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
b.onAsk("ping", async () => "pong");
const reply = await a.ask<unknown, string>("ping", null);
expect(reply).toBe("pong");
// Now fire the stale callback — the controller has already
// removed the pending entry, so this must be a no-op.
expect(capturedCallback).not.toBeNull();
expect(() => capturedCallback?.()).not.toThrow();
a.close();
b.close();
});
it("default TimersLike uses real setTimeout / clearTimeout when no override is supplied", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "real-timers" },
factory,
});
const b = new TypedBroadcastChannel({
config: { name: "real-timers" },
factory,
});
a.open();
b.open();
b.onAsk("ping", async () => "pong");
// Happy path goes through default TimersLike.setTimer once and
// default TimersLike.clearTimer once on settle.
const reply = await a.ask<unknown, string>("ping", null);
expect(reply).toBe("pong");
// Timeout path also exercises real setTimeout — pick a tiny ms.
await expect(
a.ask("nobody", null, { timeoutMs: 10 })
).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_TIMEOUT",
});
a.close();
b.close();
});
it("ask-response with outcome:error and missing message yields generic Error", {
timeout: 5000,
}, async () => {
const { factory } = createBusFactory();
const a = new TypedBroadcastChannel({
config: { name: "raw-err" },
factory,
timers: fakeTimers(),
});
const b = new TypedBroadcastChannel({
config: { name: "raw-err" },
factory,
timers: fakeTimers(),
});
a.open();
b.open();
let captured = "";
b.onAsk("trap", (_p, ctx) => {
captured = ctx.correlationId;
// Intentionally never resolve — we'll inject a synthetic
// error response below.
return new Promise(() => {});
});
const promise = a.ask("trap", null);
await new Promise((r) => setTimeout(r, 5));
const inject = (
a as unknown as {
handleIncoming: (raw: unknown) => void;
}
).handleIncoming.bind(a);
inject({
type: "ask-response",
responseTo: captured,
handlerKey: "trap",
outcome: "error",
payload: { something: "else" },
id: "synth",
senderId: "synthetic-peer",
timestamp: 1,
});
await expect(promise).rejects.toMatchObject({
message: expect.stringContaining("askResponderRejected"),
});
a.close();
b.close();
});
});

View File

@@ -0,0 +1,176 @@
import type { Clock, Rng, TimersLike } from "@suaveplan/types";
import {
BroadcastChannelAskNoResponderError,
BroadcastChannelAskTimeoutError,
BroadcastChannelClosedError,
} from "../errors/codes.js";
import { generateId } from "../id/id.js";
import {
recordAskResponded,
recordAskSent,
recordAskTimeout,
} from "../otel/metrics.js";
import type {
AskErrorPayload,
AskFrame,
AskOptions,
AskResponseFrame,
} from "../types/broadcast-channel.types.js";
/** Default ask timeout in ms when `opts.timeoutMs` is omitted. */
export const DEFAULT_ASK_TIMEOUT_MS = 5000;
interface PendingAsk {
readonly key: string;
readonly requireResponder: boolean;
readonly resolve: (value: unknown) => void;
readonly reject: (reason: unknown) => void;
timerHandle: number;
}
type PostFn = (frame: AskFrame) => void;
/** Constructor wiring for `AskController`. */
export interface AskControllerOptions {
readonly clock: Clock;
readonly rng: Rng;
readonly timers: TimersLike;
readonly channelName: string;
readonly senderId: string;
readonly post: PostFn;
}
/**
* Tracks every in-flight `ask()` issued by the local channel. Each pending
* promise is keyed by `correlationId`; the controller owns the timeout timer,
* the "did any peer claim this key" flag, and the resolve / reject hooks.
*/
export class AskController {
private readonly pending = new Map<string, PendingAsk>();
private readonly opts: AskControllerOptions;
public constructor(opts: AskControllerOptions) {
this.opts = opts;
}
/**
* Issue a new ask. Returns the awaitable promise and posts the wire frame
* synchronously via the injected `post` callback.
*/
public issue<TResp>(
handlerKey: string,
payload: unknown,
opts?: AskOptions
): Promise<TResp> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_ASK_TIMEOUT_MS;
const requireResponder = opts?.requireResponder ?? false;
const correlationId = generateId(this.opts.rng);
const now = this.opts.clock();
const frame: AskFrame = {
type: "ask",
correlationId,
handlerKey,
payload,
expiresAt: now + timeoutMs,
id: generateId(this.opts.rng),
senderId: this.opts.senderId,
timestamp: now,
};
return new Promise<TResp>((resolve, reject) => {
const timerHandle = this.opts.timers.setTimer(() => {
this.handleTimeout(correlationId);
}, timeoutMs);
const entry: PendingAsk = {
key: handlerKey,
requireResponder,
resolve: resolve as (value: unknown) => void,
reject,
timerHandle,
};
this.pending.set(correlationId, entry);
recordAskSent(handlerKey, this.opts.channelName);
this.opts.post(frame);
});
}
/**
* Settle the matching pending ask from an inbound `ask-response` frame.
* Drops responses that no longer have a pending entry (late arrival /
* already timed out).
*/
public settle(frame: AskResponseFrame): void {
const entry = this.pending.get(frame.responseTo);
if (entry === undefined) return;
this.pending.delete(frame.responseTo);
this.opts.timers.clearTimer(entry.timerHandle);
recordAskResponded(entry.key, frame.outcome, this.opts.channelName);
if (frame.outcome === "ok") {
entry.resolve(frame.payload);
return;
}
const err = projectAskError(frame.payload);
entry.reject(err);
}
/**
* Cancel every pending ask. Called by `channel.close()`; clears timers and
* rejects with `BroadcastChannelClosedError` so callers don't dangle.
*/
public cancelAll(): void {
for (const [, entry] of this.pending) {
this.opts.timers.clearTimer(entry.timerHandle);
entry.reject(
new BroadcastChannelClosedError({
context: { handlerKey: entry.key },
})
);
}
this.pending.clear();
}
private handleTimeout(correlationId: string): void {
const entry = this.pending.get(correlationId);
if (entry === undefined) return;
this.pending.delete(correlationId);
recordAskTimeout(entry.key, this.opts.channelName);
if (entry.requireResponder) {
entry.reject(
new BroadcastChannelAskNoResponderError({
context: { handlerKey: entry.key },
})
);
return;
}
entry.reject(
new BroadcastChannelAskTimeoutError({
context: { handlerKey: entry.key },
})
);
}
}
function projectAskError(payload: unknown): Error {
if (
typeof payload === "object" &&
payload !== null &&
"message" in (payload as Record<string, unknown>)
) {
const projection = payload as AskErrorPayload;
const err = new Error(projection.message);
if (projection.code !== undefined) {
(err as Error & { code?: string }).code = projection.code;
}
return err;
}
return new Error("broadcast-channel.errors.askResponderRejected");
}
/** Build the wire projection for a handler-thrown error. */
export function buildAskErrorPayload(err: unknown): AskErrorPayload {
if (err instanceof Error) {
const code = (err as Error & { code?: string }).code;
if (code !== undefined) return { message: err.message, code };
return { message: err.message };
}
return { message: String(err) };
}

View File

@@ -0,0 +1,7 @@
export {
AskController,
type AskControllerOptions,
buildAskErrorPayload,
DEFAULT_ASK_TIMEOUT_MS,
} from "./ask.js";
export { type AskHandler, AskResponderRegistry } from "./responder.js";

View File

@@ -0,0 +1,102 @@
# `AskResponderRegistry`
## Purpose
`AskResponderRegistry` is the response-side counterpart to `AskController`. It
maps an inbound `handlerKey` to the single responder function registered for
that key via `TypedBroadcastChannel.onAsk(handlerKey, handler)` and enforces
the package's "at most one responder per key" invariant. The single-responder
rule is deliberate: multi-responder fan-out (`askMany()` / `askAll()`) is its
own future capability with its own design surface, and forcing a second
registration to throw `BroadcastChannelAskHandlerDuplicateError` makes
collisions impossible to ignore at integration time.
## Features
- Single-handler-per-key invariant enforced synchronously on the second
`register()` call.
- Returned disposer is identity-safe: it only removes the slot if the current
occupant is still the handler the caller registered, so an out-of-order
dispose after a re-register does nothing.
- `resolve(key)` returns `undefined` for unregistered keys so the channel can
drop the `ask` frame silently without any error path on the receiver.
- `clear()` drops every responder, invoked once per `channel.close()`.
## Usage: Basic
```ts
import { AskResponderRegistry } from "@suaveplan/broadcast-channel/ask/responder";
const registry = new AskResponderRegistry();
const dispose = registry.register<{ n: number }, { n: number }>(
"increment",
async ({ n }) => ({ n: n + 1 })
);
const handler = registry.resolve("increment");
if (handler !== undefined) {
const out = await handler({ n: 41 }, {
correlationId: "c1",
senderId: "tab-a",
handlerKey: "increment",
});
// out = { n: 42 }
}
dispose();
```
## Usage: Advanced
```ts
// Re-registration after disposal is fine; double-register without disposal
// throws synchronously.
const r = new AskResponderRegistry();
const d1 = r.register("k", () => 1);
expect(() => r.register("k", () => 2)).toThrowError(
/ASK_HANDLER_DUPLICATE/
);
d1();
const d2 = r.register("k", () => 3); // ok
d2();
```
## API Reference
### `class AskResponderRegistry`
- `register<TReq, TResp>(key: string, handler: AskHandler<TReq, TResp>): () => void`
— registers `handler` for `key`; throws
`BroadcastChannelAskHandlerDuplicateError` (code
`BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE`) if `key` is already taken;
returns an identity-safe disposer.
- `resolve(key: string): AskHandler | undefined` — returns the registered
handler or `undefined`. Receiver path drops unregistered `ask` frames.
- `clear(): void` — drop every handler; called from `channel.close()`.
### `type AskHandler<TReq, TResp>`
```ts
type AskHandler<TReq, TResp> = (
payload: TReq,
ctx: AskHandlerContext
) => TResp | Promise<TResp>;
```
The receiver may return a synchronous value or a Promise. The channel awaits
the return value and posts the result as the `ask-response` frame's payload.
## Implementation Notes
The registry deliberately exposes only `register` / `resolve` / `clear`; the
"call the handler and post the response" logic lives on `TypedBroadcastChannel`
because that is where the wire transport, the OTel counters, and the error
projection live. Keeping the registry pure makes it trivial to unit-test in
isolation: every test in `responder.test.ts` constructs a fresh registry, so
state cannot leak between cases.
Identity-safe disposal matters when a handler is re-registered after disposal
in a long-running session — without the identity check, a delayed disposer
from the previous registration would silently drop the new handler and the
asker would see `ASK_TIMEOUT` errors with no obvious cause.

View File

@@ -0,0 +1,231 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TypedBroadcastChannel } from "../channel/channel.js";
import type {
ChannelFactory,
ChannelLike,
} from "../transport/transport.types.js";
import { AskResponderRegistry } from "./responder.js";
class FakeChannel implements ChannelLike {
public messages: unknown[] = [];
public closed = false;
private readonly listeners: Array<(event: MessageEvent) => void> = [];
public postMessage(m: unknown): void {
this.messages.push(m);
}
public close(): void {
this.closed = true;
}
public addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void {
if (type === "message") this.listeners.push(listener);
}
public simulate(data: unknown): void {
for (const l of this.listeners.slice()) l({ data } as MessageEvent);
}
}
function createFakeFactory(): {
factory: ChannelFactory;
channels: FakeChannel[];
} {
const channels: FakeChannel[] = [];
return {
factory: () => {
const c = new FakeChannel();
channels.push(c);
return c;
},
channels,
};
}
describe("AskResponderRegistry", () => {
it("throws ASK_HANDLER_DUPLICATE on second registration", {
timeout: 5000,
}, () => {
const r = new AskResponderRegistry();
r.register("k", () => 1);
let caught: unknown;
try {
r.register("k", () => 2);
} catch (e) {
caught = e;
}
expect((caught as { code?: string }).code).toBe(
"BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE"
);
});
it("disposer allows re-registration", { timeout: 5000 }, () => {
const r = new AskResponderRegistry();
const d1 = r.register("k", () => 1);
d1();
expect(() => r.register("k", () => 2)).not.toThrow();
});
it("disposer is identity-safe — does not remove a replacement", {
timeout: 5000,
}, () => {
const r = new AskResponderRegistry();
const d1 = r.register("k", () => 1);
d1();
const h2 = (): number => 2;
r.register("k", h2);
// Calling stale disposer again must NOT remove h2
d1();
expect(r.resolve("k")).toBe(h2);
});
it("resolve returns undefined for unregistered keys", {
timeout: 5000,
}, () => {
const r = new AskResponderRegistry();
expect(r.resolve("missing")).toBeUndefined();
});
it("clear drops every handler", { timeout: 5000 }, () => {
const r = new AskResponderRegistry();
r.register("a", () => 1);
r.register("b", () => 2);
r.clear();
expect(r.resolve("a")).toBeUndefined();
expect(r.resolve("b")).toBeUndefined();
});
});
describe("TypedBroadcastChannel.onAsk", () => {
it("duplicate onAsk on the channel throws ASK_HANDLER_DUPLICATE", {
timeout: 5000,
}, () => {
const { factory } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: { name: "dup" },
factory,
});
ch.open();
ch.onAsk("dup", () => 1);
let caught: unknown;
try {
ch.onAsk("dup", () => 2);
} catch (e) {
caught = e;
}
expect((caught as { code?: string }).code).toBe(
"BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE"
);
ch.close();
});
it("unregistered handlerKey drops ask silently", { timeout: 5000 }, () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: { name: "drop" },
factory,
});
ch.open();
channels[0]?.simulate({
type: "ask",
correlationId: "c-1",
handlerKey: "unregistered",
payload: null,
expiresAt: 9999,
id: "i-1",
senderId: "peer",
timestamp: 0,
});
const askResponses = channels[0]?.messages.filter(
(m): m is { type: string } =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === "ask-response"
);
expect(askResponses ?? []).toHaveLength(0);
ch.close();
});
it("registered handler receives payload and context, returns Promise", {
timeout: 5000,
}, async () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: { name: "ctx" },
factory,
});
ch.open();
const seen: Array<{ payload: unknown; ctx: unknown }> = [];
ch.onAsk("echo", async (payload, ctx) => {
seen.push({ payload, ctx });
return { echoed: payload };
});
channels[0]?.simulate({
type: "ask",
correlationId: "c-echo",
handlerKey: "echo",
payload: { hi: 1 },
expiresAt: 9999,
id: "i-echo",
senderId: "peer-echo",
timestamp: 0,
});
// microtask drain
await new Promise((r) => setTimeout(r, 5));
expect(seen).toHaveLength(1);
expect(seen[0]?.payload).toEqual({ hi: 1 });
expect(seen[0]?.ctx).toMatchObject({
correlationId: "c-echo",
senderId: "peer-echo",
handlerKey: "echo",
});
const askResp = channels[0]?.messages.find(
(m): m is { type: string; outcome: string; payload: unknown } =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === "ask-response"
);
expect(askResp?.outcome).toBe("ok");
expect(askResp?.payload).toEqual({ echoed: { hi: 1 } });
ch.close();
});
it("ask-response is not posted if channel closes before handler resolves", {
timeout: 5000,
}, async () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: { name: "close-mid" },
factory,
});
ch.open();
let resolveHandler: (v: unknown) => void = () => {};
ch.onAsk(
"slow",
() =>
new Promise((r) => {
resolveHandler = r;
})
);
channels[0]?.simulate({
type: "ask",
correlationId: "c-slow",
handlerKey: "slow",
payload: null,
expiresAt: 9999,
id: "i-slow",
senderId: "peer-slow",
timestamp: 0,
});
ch.close();
resolveHandler("never-delivered");
await new Promise((r) => setTimeout(r, 5));
const askResp = channels[0]?.messages.find(
(m): m is { type: string } =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === "ask-response"
);
expect(askResp).toBeUndefined();
});
});

View File

@@ -0,0 +1,48 @@
import { BroadcastChannelAskHandlerDuplicateError } from "../errors/codes.js";
import type { AskHandlerContext } from "../types/broadcast-channel.types.js";
/** Function signature for an `onAsk` handler. */
export type AskHandler<TReq = unknown, TResp = unknown> = (
payload: TReq,
ctx: AskHandlerContext
) => TResp | Promise<TResp>;
/**
* Registry mapping `handlerKey → AskHandler`. Enforces the
* "at most one responder per key" invariant: a second registration for the
* same key without first disposing throws
* `BroadcastChannelAskHandlerDuplicateError`. The returned disposer clears the
* slot so the key MAY be re-registered.
*/
export class AskResponderRegistry {
private readonly handlers = new Map<string, AskHandler>();
/** Register `handler` for `key`. Returns a disposer. */
public register<TReq, TResp>(
key: string,
handler: AskHandler<TReq, TResp>
): () => void {
if (this.handlers.has(key)) {
throw new BroadcastChannelAskHandlerDuplicateError({
context: { handlerKey: key },
});
}
this.handlers.set(key, handler as AskHandler);
return () => {
const current = this.handlers.get(key);
if (current === (handler as AskHandler)) {
this.handlers.delete(key);
}
};
}
/** Resolve a registered handler, or `undefined` if none. */
public resolve(key: string): AskHandler | undefined {
return this.handlers.get(key);
}
/** Drop every registered handler. Called from `channel.close()`. */
public clear(): void {
this.handlers.clear();
}
}

View File

@@ -0,0 +1,122 @@
# channel/channel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TypedBroadcastChannel` is the high-level cross-tab messaging object exported
by `@suaveplan/broadcast-channel`. It wraps the structurally-narrow
`ChannelLike` transport with the policy layer the upstream package made famous:
deterministic lowest-tab-ID leader election, peer-tab presence tracking via
periodic heartbeats with eviction of stale entries, and a per-type handler
registry that returns an `Unsubscribe` closure.
The class is also the boundary at which suaveplan fixes the four enterprise gaps
flagged in `enterprise/broadcast-channel.md`: every inbound frame passes the
Zod `ChannelMessageSchema` before any handler is invoked, every outbound
payload is sized-guarded against `config.maxMessageBytes`, the `ChannelLike`
listener is `(event: MessageEvent) => void` (no `any` smell), and the class
takes injectable `Clock` / `Rng` / `LoggerLike` so all timestamps, IDs, and
diagnostic warnings are deterministic in tests. All hot paths emit OTel
counters under meter `@suaveplan/broadcast-channel`.
## Features
- Typed `send<T>` / `on<T>` / `handleIncoming` surface preserving payload generics.
- Lowest-tab-ID leader election with `leader:elected` lifecycle event on transition.
- Heartbeat + cleanup intervals via the `HeartbeatRunner` helper.
- Zod boundary validation on every inbound frame; malformed frames are dropped, warned, and counted.
- Payload-size guard at `config.maxMessageBytes` raising `BroadcastChannelPayloadTooLargeError`.
- `BroadcastChannelClosedError` on `send` before `open` / after `close`.
- suaveplan-shape `EventBus<BroadcastChannelEventMap>` lifecycle bus.
- Injectable `Clock` / `Rng` / `LoggerLike` from `@suaveplan/types`.
## Usage: Basic
```ts
import {
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
channel.on<{ userId: string }>("user:signed-in", (msg) => {
console.log("peer", msg.senderId, "signed in as", msg.payload.userId);
});
channel.send("user:signed-in", { userId: "u_123" });
```
## Usage: Advanced
```ts
import {
createLocalStorageChannelFactory,
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import type { LoggerLike, StorageLike } from "@suaveplan/types";
declare const memoryStorage: StorageLike;
declare const appLogger: LoggerLike;
const channel = new TypedBroadcastChannel({
config: {
name: "app-bus",
heartbeatInterval: 2_000,
tabTimeout: 8_000,
maxMessageBytes: 64 * 1024,
},
factory: isNativeBroadcastChannelAvailable()
? nativeChannelFactory
: createLocalStorageChannelFactory(memoryStorage, {
pollIntervalMs: 50,
logger: appLogger,
}),
clock: () => Date.now(),
logger: appLogger,
});
channel.events.on("leader:elected", (e) => {
appLogger.info("became leader", { tabId: e.tabId });
});
channel.open();
```
## API Reference
- `class TypedBroadcastChannel`
- `constructor(options: TypedBroadcastChannelOptions)` — accepts `config`, `factory`, optional `clock`, `rng`, `logger`.
- `open(): void` — idempotent; subsequent `open()` is a no-op.
- `close(): void` — safe before `open()`; clears tabs, handlers, and timers.
- `send<T>(type: string, payload: T): void` — throws `BroadcastChannelClosedError` if the channel is not open; throws `BroadcastChannelPayloadTooLargeError` if the JSON-encoded byte length of the message exceeds `config.maxMessageBytes`.
- `on<T>(type: string, handler: ChannelHandler<T>): () => void` — returns an unsubscribe closure.
- `getTabId(): string` — local 16-hex tab ID derived from the injected `Rng`.
- `getIsLeader(): boolean` — true after the local tab has been elected leader.
- `getTabs(): TabInfo[]` — snapshot of known tabs.
- `getTabCount(): number` — count of known tabs (self + peers).
- `isOpen(): boolean`
- `handleIncoming(raw: unknown): void` — exposed for tests; runs the Zod-validated dispatch pipeline.
- `events: EventBus<BroadcastChannelEventMap>` — lifecycle bus.
- `type ChannelHandler<T = unknown>` — `(message: ChannelMessage<T>) => void`.
- `interface TypedBroadcastChannelOptions` — `{ config, factory, clock?, rng?, logger? }`.
## Implementation Notes
`open()` registers a `(event: MessageEvent) => void` listener on the underlying
`ChannelLike` and forwards `event.data` straight into `handleIncoming`. Inbound
validation uses `ChannelMessageSchema.safeParse`; failures call
`recordMessageDropped("malformed", channelName)` and `logger.warn` with the
Zod issues but do not throw. Internal frames (`tab-join`, `tab-leave`,
`heartbeat`) bypass the public counters but still pass validation so a rogue
peer cannot inject coordination state. Leader election is run after every
`tab-join` / `tab-leave` / cleanup tick; only `non-leader -> leader`
transitions emit `leader:elected`. The heartbeat / cleanup pair is owned by
the `HeartbeatRunner` helper to keep this file inside the 300-line budget.

View File

@@ -0,0 +1,415 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { refreshBroadcastChannelMeters } from "../otel/metrics.js";
import type {
ChannelFactory,
ChannelLike,
} from "../transport/transport.types.js";
import { TypedBroadcastChannel } from "./channel.js";
class FakeChannel implements ChannelLike {
public messages: unknown[] = [];
public closed = false;
private readonly listeners: Array<(event: MessageEvent) => void> = [];
public postMessage(message: unknown): void {
this.messages.push(message);
}
public close(): void {
this.closed = true;
}
public addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void {
if (type === "message") this.listeners.push(listener);
}
public simulateMessage(data: unknown): void {
for (const h of this.listeners.slice()) {
h({ data } as MessageEvent);
}
}
}
function createFakeFactory(): {
factory: ChannelFactory;
channels: FakeChannel[];
} {
const channels: FakeChannel[] = [];
const factory: ChannelFactory = () => {
const ch = new FakeChannel();
channels.push(ch);
return ch;
};
return { factory, channels };
}
function seededRng(values: readonly number[]): Rng {
let i = 0;
return {
nextUint32() {
const v = values[i % values.length] ?? 0;
i++;
return v >>> 0;
},
nextFloat() {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
describe("TypedBroadcastChannel", () => {
let ch: TypedBroadcastChannel;
let fakes: FakeChannel[];
beforeEach(() => {
refreshBroadcastChannelMeters();
const { factory, channels } = createFakeFactory();
fakes = channels;
ch = new TypedBroadcastChannel({
config: {
name: "test",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
});
});
afterEach(() => {
ch.close();
});
it("generates a 16-hex tab id", { timeout: 5000 }, () => {
expect(ch.getTabId()).toMatch(/^[0-9a-f]{16}$/);
});
it("starts not open", { timeout: 5000 }, () => {
expect(ch.isOpen()).toBe(false);
});
it("opens and closes the channel", { timeout: 5000 }, () => {
ch.open();
expect(ch.isOpen()).toBe(true);
expect(fakes).toHaveLength(1);
ch.close();
expect(ch.isOpen()).toBe(false);
expect(fakes[0]?.closed).toBe(true);
});
it("open is idempotent", { timeout: 5000 }, () => {
ch.open();
ch.open();
expect(fakes).toHaveLength(1);
});
it("close is safe before open", { timeout: 5000 }, () => {
expect(() => ch.close()).not.toThrow();
expect(ch.getTabs()).toHaveLength(0);
});
it("becomes leader when alone", { timeout: 5000 }, () => {
ch.open();
expect(ch.getIsLeader()).toBe(true);
});
it("yields leadership to lower tab id", { timeout: 5000 }, () => {
ch.open();
fakes[0]?.simulateMessage({
type: "tab-join",
payload: {},
id: "msg-low",
senderId: "0000000000000001",
timestamp: Date.now(),
});
const leader = ch.getTabs().find((t) => t.isLeader);
expect(leader?.id).toBe("0000000000000001");
expect(ch.getIsLeader()).toBe(false);
});
it("sends messages when open", { timeout: 5000 }, () => {
ch.open();
ch.send("greet", "hello");
const userMsgs = fakes[0]?.messages.filter(
(m): m is { type: string } =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === "greet"
);
expect(userMsgs).toHaveLength(1);
});
it("throws CHANNEL_CLOSED when sending before open", {
timeout: 5000,
}, () => {
expect(() => ch.send("nope", "data")).toThrowError(
/channelClosed|CHANNEL_CLOSED/
);
});
it("dispatches valid frames to handlers", { timeout: 5000 }, () => {
ch.open();
const received: unknown[] = [];
ch.on("greet", (msg) => received.push(msg.payload));
fakes[0]?.simulateMessage({
type: "greet",
payload: "hi",
id: "m1",
senderId: "other",
timestamp: Date.now(),
});
expect(received).toEqual(["hi"]);
});
it("does not receive own messages", { timeout: 5000 }, () => {
ch.open();
const received: unknown[] = [];
ch.on("self", (m) => received.push(m.payload));
fakes[0]?.simulateMessage({
type: "self",
payload: "echo",
id: "m2",
senderId: ch.getTabId(),
timestamp: Date.now(),
});
expect(received).toHaveLength(0);
});
it("unsubscribing one of two handlers keeps the type registered", {
timeout: 5000,
}, () => {
ch.open();
const r1: unknown[] = [];
const r2: unknown[] = [];
const u1 = ch.on("dual", (m) => r1.push(m.payload));
ch.on("dual", (m) => r2.push(m.payload));
u1();
fakes[0]?.simulateMessage({
type: "dual",
payload: "v",
id: "m-dual",
senderId: "other",
timestamp: Date.now(),
});
expect(r1).toEqual([]);
expect(r2).toEqual(["v"]);
});
it("unsubscribes handlers", { timeout: 5000 }, () => {
ch.open();
const received: unknown[] = [];
const unsub = ch.on("u", (m) => received.push(m.payload));
unsub();
fakes[0]?.simulateMessage({
type: "u",
payload: "gone",
id: "m3",
senderId: "other",
timestamp: Date.now(),
});
expect(received).toHaveLength(0);
});
it("tracks peer joins via tab-join", { timeout: 5000 }, () => {
ch.open();
fakes[0]?.simulateMessage({
type: "tab-join",
payload: { tabId: "tab-2" },
id: "m4",
senderId: "tab-2",
timestamp: Date.now(),
});
expect(ch.getTabCount()).toBe(2);
});
it("removes peers on tab-leave", { timeout: 5000 }, () => {
ch.open();
fakes[0]?.simulateMessage({
type: "tab-join",
payload: {},
id: "m5",
senderId: "tab-3",
timestamp: Date.now(),
});
fakes[0]?.simulateMessage({
type: "tab-leave",
payload: {},
id: "m6",
senderId: "tab-3",
timestamp: Date.now(),
});
expect(ch.getTabCount()).toBe(1);
});
it("handles multiple handlers per type", { timeout: 5000 }, () => {
ch.open();
const r1: unknown[] = [];
const r2: unknown[] = [];
ch.on("multi", (m) => r1.push(m.payload));
ch.on("multi", (m) => r2.push(m.payload));
fakes[0]?.simulateMessage({
type: "multi",
payload: "data",
id: "m7",
senderId: "other",
timestamp: Date.now(),
});
expect(r1).toEqual(["data"]);
expect(r2).toEqual(["data"]);
});
it("clears tabs on close", { timeout: 5000 }, () => {
ch.open();
ch.close();
expect(ch.getTabs()).toHaveLength(0);
expect(ch.getIsLeader()).toBe(false);
});
it("ignores frames with no registered handler", { timeout: 5000 }, () => {
ch.open();
expect(() => {
fakes[0]?.simulateMessage({
type: "unknown",
payload: null,
id: "m8",
senderId: "other",
timestamp: Date.now(),
});
}).not.toThrow();
});
it("updates existing peer on repeated frames", { timeout: 5000 }, () => {
ch.open();
fakes[0]?.simulateMessage({
type: "heartbeat",
payload: {},
id: "m9",
senderId: "tab-x",
timestamp: Date.now(),
});
fakes[0]?.simulateMessage({
type: "heartbeat",
payload: {},
id: "m10",
senderId: "tab-x",
timestamp: Date.now(),
});
expect(ch.getTabCount()).toBe(2);
});
it("drops malformed frames and warns", { timeout: 5000 }, () => {
const warns: Array<[string, unknown]> = [];
const { factory } = createFakeFactory();
const channel = new TypedBroadcastChannel({
config: { name: "warn-test" },
factory,
logger: {
debug() {},
info() {},
warn(msg, ctx) {
warns.push([msg, ctx]);
},
error() {},
},
});
channel.open();
channel.handleIncoming({ not: "a-message" });
expect(warns.length).toBe(1);
expect(warns[0]?.[0]).toBe("broadcast-channel.dropped.malformed");
channel.close();
});
it("rejects payloads above maxMessageBytes", { timeout: 5000 }, () => {
const { factory } = createFakeFactory();
const tiny = new TypedBroadcastChannel({
config: {
name: "tiny",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
maxMessageBytes: 100,
},
factory,
});
tiny.open();
const big = "x".repeat(500);
expect(() => tiny.send("huge", big)).toThrowError(
/payloadTooLarge|PAYLOAD_TOO_LARGE/
);
tiny.close();
});
it("derives tab id from injected rng", { timeout: 5000 }, () => {
const { factory } = createFakeFactory();
const det = new TypedBroadcastChannel({
config: { name: "det" },
factory,
rng: seededRng([0x01020304, 0x05060708, 0x090a0b0c, 0x0d0e0f10]),
});
expect(det.getTabId()).toBe("0102030405060708");
det.close();
});
it("generates different ids per instance", { timeout: 5000 }, () => {
const { factory } = createFakeFactory();
const other = new TypedBroadcastChannel({
config: { name: "other" },
factory,
});
expect(ch.getTabId()).not.toBe(other.getTabId());
});
it("emits heartbeat on interval", { timeout: 30000 }, async () => {
const { factory, channels } = createFakeFactory();
const hb = new TypedBroadcastChannel({
config: {
name: "hb",
heartbeatInterval: 20,
tabTimeout: 200,
},
factory,
});
hb.open();
await new Promise((r) => setTimeout(r, 80));
const heartbeats = channels[0]?.messages.filter(
(m): m is { type: string } =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === "heartbeat"
);
expect((heartbeats ?? []).length).toBeGreaterThanOrEqual(1);
hb.close();
});
it("evicts stale peers", { timeout: 30000 }, async () => {
const { factory, channels } = createFakeFactory();
const stale = new TypedBroadcastChannel({
config: {
name: "stale",
heartbeatInterval: 20,
tabTimeout: 40,
},
factory,
});
stale.open();
channels[0]?.simulateMessage({
type: "tab-join",
payload: {},
id: "ms",
senderId: "stale-tab",
timestamp: Date.now(),
});
expect(stale.getTabCount()).toBe(2);
await new Promise((r) => setTimeout(r, 120));
expect(stale.getTabCount()).toBe(1);
stale.close();
});
});

View File

@@ -0,0 +1,414 @@
import { EventBus } from "@suaveplan/events";
import type { Clock, LoggerLike, Rng, TimersLike } from "@suaveplan/types";
import { AskController, buildAskErrorPayload } from "../ask/ask.js";
import { AskResponderRegistry } from "../ask/responder.js";
import {
BroadcastChannelClosedError,
BroadcastChannelPayloadTooLargeError,
} from "../errors/codes.js";
import { defaultRng, generateId } from "../id/id.js";
import { noopLogger } from "../logger/noop.js";
import {
recordLeaderElected,
recordMessageDropped,
recordMessageReceived,
recordMessageSent,
recordTabJoined,
recordTabLeft,
} from "../otel/metrics.js";
import { ChannelMessageSchema } from "../schemas/messages.js";
import type {
ChannelFactory,
ChannelLike,
} from "../transport/transport.types.js";
import type {
AskFrame,
AskOptions,
AskResponseFrame,
BroadcastChannelEventMap,
ChannelConfig,
ChannelMessage,
TabInfo,
} from "../types/broadcast-channel.types.js";
import { DEFAULT_CHANNEL_CONFIG } from "../types/broadcast-channel.types.js";
import { HeartbeatRunner } from "./heartbeat.js";
export type ChannelHandler<T = unknown> = (message: ChannelMessage<T>) => void;
const defaultClock: Clock = () => Date.now();
const defaultTimers: TimersLike = {
setTimer(callback, delayMs) {
return setTimeout(callback, delayMs) as unknown as number;
},
clearTimer(handle) {
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
},
};
/** Constructor options for `TypedBroadcastChannel`. */
export interface TypedBroadcastChannelOptions {
readonly config: Pick<ChannelConfig, "name"> & Partial<ChannelConfig>;
readonly factory: ChannelFactory;
readonly clock?: Clock;
readonly rng?: Rng;
readonly logger?: LoggerLike;
readonly timers?: TimersLike;
}
/**
* Typed cross-tab channel. Built on the `BroadcastChannel` API via an
* injected `ChannelFactory`, plus Zod-validated inbound frames, OTel
* counters on every lifecycle path, a payload-size guard, and full
* deterministic-injection support for `Clock` / `Rng` / `LoggerLike`.
*/
export class TypedBroadcastChannel {
private readonly config: ChannelConfig;
private readonly factory: ChannelFactory;
private readonly clock: Clock;
private readonly rng: Rng;
private readonly logger: LoggerLike;
private readonly timers: TimersLike;
private readonly tabId: string;
private readonly handlers = new Map<string, Set<ChannelHandler>>();
private readonly tabs = new Map<string, TabInfo>();
private readonly askController: AskController;
private readonly askResponders = new AskResponderRegistry();
private channel: ChannelLike | null = null;
private heartbeat: HeartbeatRunner | null = null;
private isLeader = false;
private closed = false;
/** Event bus for channel lifecycle events. */
public readonly events = new EventBus<BroadcastChannelEventMap>();
public constructor(options: TypedBroadcastChannelOptions) {
this.config = { ...DEFAULT_CHANNEL_CONFIG, ...options.config };
this.factory = options.factory;
this.clock = options.clock ?? defaultClock;
this.rng = options.rng ?? defaultRng;
this.logger = options.logger ?? noopLogger;
this.timers = options.timers ?? defaultTimers;
this.tabId = generateId(this.rng);
this.askController = new AskController({
clock: this.clock,
rng: this.rng,
timers: this.timers,
channelName: this.config.name,
senderId: this.tabId,
post: (frame) => this.postAskFrame(frame),
});
}
/** Open the broadcast channel and start heartbeats. */
public open(): void {
if (this.channel !== null) return;
this.closed = false;
this.channel = this.factory(this.config.name);
this.channel.addEventListener("message", (event) => {
this.handleIncoming(event.data);
});
this.registerSelf();
this.startHeartbeat();
this.sendInternal("tab-join", { tabId: this.tabId });
this.electLeader();
this.events.emit("channel:opened", {
channelName: this.config.name,
tabId: this.tabId,
});
}
/** Close the channel and stop heartbeats. */
public close(): void {
if (this.closed && this.channel === null) {
// Idempotent close-before-open.
this.events.emit("channel:closed", {
channelName: this.config.name,
tabId: this.tabId,
});
return;
}
this.closed = true;
if (this.channel !== null) {
this.sendInternal("tab-leave", { tabId: this.tabId });
this.channel.close();
this.channel = null;
}
this.stopHeartbeat();
this.askController.cancelAll();
this.askResponders.clear();
this.handlers.clear();
this.tabs.clear();
this.isLeader = false;
this.events.emit("channel:closed", {
channelName: this.config.name,
tabId: this.tabId,
});
}
/** Send a typed message to all other tabs. */
public send<T>(type: string, payload: T): void {
if (this.channel === null || this.closed) {
throw new BroadcastChannelClosedError({
context: { type, channel: this.config.name },
});
}
const message: ChannelMessage<T> = {
type,
payload,
id: generateId(this.rng),
senderId: this.tabId,
timestamp: this.clock(),
};
this.assertWithinBudget(message);
this.channel.postMessage(message);
recordMessageSent(type, this.config.name);
this.events.emit("message:sent", { type, tabId: this.tabId });
}
/**
* Issue a correlated request and await a single peer's `ask-response`.
* Default `timeoutMs` is 5000; pass `requireResponder: true` to reject with
* `ASK_NO_RESPONDER` when the timeout fires without any peer claiming the
* key.
*/
public ask<TReq, TResp>(
handlerKey: string,
payload: TReq,
opts?: AskOptions
): Promise<TResp> {
if (this.channel === null || this.closed) {
return Promise.reject(
new BroadcastChannelClosedError({
context: {
handlerKey,
channel: this.config.name,
},
})
);
}
return this.askController.issue<TResp>(handlerKey, payload, opts);
}
/**
* Register the responder for `handlerKey`. At most one responder may be
* registered per key — a second call throws `ASK_HANDLER_DUPLICATE`. The
* returned disposer frees the slot so the key may be re-registered.
*/
public onAsk<TReq, TResp>(
handlerKey: string,
handler: (
payload: TReq,
ctx: {
readonly correlationId: string;
readonly senderId: string;
readonly handlerKey: string;
}
) => TResp | Promise<TResp>
): () => void {
return this.askResponders.register(handlerKey, handler);
}
/** Register a handler for a specific message type. Returns an unsubscribe fn. */
public on<T>(type: string, handler: ChannelHandler<T>): () => void {
const handlers = this.handlers.get(type) ?? new Set();
handlers.add(handler as ChannelHandler);
this.handlers.set(type, handlers);
return () => {
handlers.delete(handler as ChannelHandler);
if (handlers.size === 0) this.handlers.delete(type);
};
}
public getTabId(): string {
return this.tabId;
}
public getIsLeader(): boolean {
return this.isLeader;
}
public getTabs(): TabInfo[] {
return [...this.tabs.values()];
}
public getTabCount(): number {
return this.tabs.size;
}
public isOpen(): boolean {
return this.channel !== null && !this.closed;
}
/** Handle a raw inbound payload (exposed for testing). */
public handleIncoming(raw: unknown): void {
const parsed = ChannelMessageSchema.safeParse(raw);
if (!parsed.success) {
recordMessageDropped("malformed", this.config.name);
this.logger.warn("broadcast-channel.dropped.malformed", {
channel: this.config.name,
issues: parsed.error.issues,
});
return;
}
const data = parsed.data;
if (data.senderId === this.tabId) return;
if (data.type === "ask") {
this.updateTab(data.senderId);
recordMessageReceived(data.type, this.config.name);
void this.dispatchAsk(data as AskFrame);
return;
}
if (data.type === "ask-response") {
this.updateTab(data.senderId);
recordMessageReceived(data.type, this.config.name);
this.askController.settle(data as AskResponseFrame);
return;
}
const message = data as ChannelMessage;
this.updateTab(message.senderId);
recordMessageReceived(message.type, this.config.name);
this.events.emit("message:received", message);
if (message.type === "tab-join") {
recordTabJoined(this.config.name);
this.events.emit("tab:joined", { tabId: message.senderId });
this.electLeader();
} else if (message.type === "tab-leave") {
recordTabLeft(this.config.name);
this.events.emit("tab:left", { tabId: message.senderId });
this.tabs.delete(message.senderId);
this.electLeader();
}
const handlers = this.handlers.get(message.type);
if (handlers === undefined) return;
for (const handler of handlers) handler(message);
}
private async dispatchAsk(frame: AskFrame): Promise<void> {
const handler = this.askResponders.resolve(frame.handlerKey);
if (handler === undefined) return;
let outcome: "ok" | "error" = "ok";
let responsePayload: unknown;
try {
responsePayload = await handler(frame.payload, {
correlationId: frame.correlationId,
senderId: frame.senderId,
handlerKey: frame.handlerKey,
});
} catch (err) {
outcome = "error";
responsePayload = buildAskErrorPayload(err);
}
if (this.channel === null || this.closed) return;
const response: AskResponseFrame = {
type: "ask-response",
responseTo: frame.correlationId,
handlerKey: frame.handlerKey,
outcome,
payload: responsePayload,
id: generateId(this.rng),
senderId: this.tabId,
timestamp: this.clock(),
};
this.channel.postMessage(response);
}
private postAskFrame(frame: AskFrame): void {
// Invoked only from `AskController.issue`, which is only reachable
// from `ask()` — and `ask()` rejects synchronously when the channel
// is null or closed. By construction `this.channel` is non-null here.
(this.channel as ChannelLike).postMessage(frame);
}
private assertWithinBudget(message: ChannelMessage<unknown>): void {
const encoded = JSON.stringify(message);
const size = new TextEncoder().encode(encoded).byteLength;
if (size > this.config.maxMessageBytes) {
throw new BroadcastChannelPayloadTooLargeError({
sizeBytes: size,
maxBytes: this.config.maxMessageBytes,
context: {
type: message.type,
channel: this.config.name,
},
});
}
}
private sendInternal(type: string, payload: unknown): void {
if (this.channel === null || this.closed) return;
const message: ChannelMessage = {
type,
payload,
id: generateId(this.rng),
senderId: this.tabId,
timestamp: this.clock(),
};
this.channel.postMessage(message);
}
private registerSelf(): void {
const now = this.clock();
this.tabs.set(this.tabId, {
id: this.tabId,
isLeader: false,
lastSeen: now,
joinedAt: now,
});
}
private updateTab(tabId: string): void {
const existing = this.tabs.get(tabId);
const now = this.clock();
this.tabs.set(tabId, {
id: tabId,
isLeader: existing?.isLeader ?? false,
lastSeen: now,
joinedAt: existing?.joinedAt ?? now,
});
}
private electLeader(): void {
this.cleanupStaleTabs();
const allIds = [...this.tabs.keys()].sort();
const leaderId = allIds[0];
const wasLeader = this.isLeader;
this.isLeader = leaderId === this.tabId;
for (const [id, tab] of this.tabs) {
this.tabs.set(id, { ...tab, isLeader: id === leaderId });
}
if (this.isLeader && !wasLeader) {
recordLeaderElected(this.config.name);
this.events.emit("leader:elected", { tabId: this.tabId });
}
}
private startHeartbeat(): void {
this.heartbeat = new HeartbeatRunner({
heartbeatInterval: this.config.heartbeatInterval,
onHeartbeat: () => {
this.sendInternal("heartbeat", { tabId: this.tabId });
this.updateTab(this.tabId);
},
onCleanup: () => this.cleanupStaleTabs(),
});
this.heartbeat.start();
}
private stopHeartbeat(): void {
if (this.heartbeat !== null) {
this.heartbeat.stop();
this.heartbeat = null;
}
}
private cleanupStaleTabs(): void {
const now = this.clock();
for (const [id, tab] of this.tabs) {
if (id === this.tabId) continue;
if (now - tab.lastSeen > this.config.tabTimeout) {
this.tabs.delete(id);
}
}
}
}

View File

@@ -0,0 +1,92 @@
# channel/heartbeat
## Purpose
`HeartbeatRunner` is a tiny lifecycle helper that owns the pair of
`setInterval` timers a `TypedBroadcastChannel` needs to keep peer presence
warm. The heartbeat callback fires once per `heartbeatInterval` and is meant
to post a `heartbeat` frame plus refresh the local tab's `lastSeen`. The
cleanup callback fires once per `heartbeatInterval * 2` and is meant to walk
the known-tabs map and evict any peer whose `lastSeen` is older than the
configured `tabTimeout`.
The helper exists because the upstream library packed both intervals into
`channel.ts` inline; pulling them out keeps the channel core inside the
300-line hard budget mandated by `AGENTS.md` §3 and gives each timer
lifecycle a discrete unit test surface. The runner has no view into the
channel — it only fires the callbacks it was given, so it composes with any
substitute clock or scheduler the consumer wants to thread.
## Features
- Two independent `setInterval`s driven by a single `heartbeatInterval`.
- Idempotent `stop()` — safe to call before `start()` or twice in a row.
- No shared state with the channel; lifecycle ownership is local to the runner.
## Usage: Basic
```ts
import { HeartbeatRunner } from "@suaveplan/broadcast-channel";
const runner = new HeartbeatRunner({
heartbeatInterval: 1_000,
onHeartbeat: () => console.log("ping"),
onCleanup: () => console.log("gc"),
});
runner.start();
// later
runner.stop();
```
## Usage: Advanced
```ts
import { HeartbeatRunner } from "@suaveplan/broadcast-channel";
class Presence {
private readonly runner = new HeartbeatRunner({
heartbeatInterval: 2_000,
onHeartbeat: () => this.broadcastSelf(),
onCleanup: () => this.evictStale(),
});
public open(): void {
this.runner.start();
}
public close(): void {
this.runner.stop();
}
private broadcastSelf(): void {
// post heartbeat frame ...
}
private evictStale(): void {
// remove peers whose lastSeen is past tabTimeout ...
}
}
```
## API Reference
- `class HeartbeatRunner`
- `constructor(opts: HeartbeatRunnerOptions)`
- `start(): void` — schedules both intervals; calling twice replaces the timers.
- `stop(): void` — clears both intervals if running; safe to call repeatedly.
- `interface HeartbeatRunnerOptions`
- `heartbeatInterval: number` — millisecond cadence for `onHeartbeat`.
- `onHeartbeat: () => void`
- `onCleanup: () => void` — fires at `heartbeatInterval * 2`.
## Implementation Notes
The cleanup cadence is intentionally twice the heartbeat cadence: this gives
peers at least one heartbeat opportunity to refresh `lastSeen` before a
cleanup tick can evict them, even under jitter. The runner stores the two
handles as `ReturnType<typeof setInterval>` so the same code typechecks under
both Bun and Node ambient lib variants. Because `start()` re-assigns the
handles without clearing the prior ones first, callers must call `stop()`
before re-starting — `TypedBroadcastChannel` does exactly that during its
`close()`/`open()` cycle. Tests pin the cadence at 20 ms so the suite stays
fast while still exercising at least one of each callback.

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { HeartbeatRunner } from "./heartbeat.js";
describe("HeartbeatRunner", () => {
it("fires heartbeat and cleanup callbacks on intervals", {
timeout: 30000,
}, async () => {
let hb = 0;
let cl = 0;
const runner = new HeartbeatRunner({
heartbeatInterval: 20,
onHeartbeat: () => {
hb++;
},
onCleanup: () => {
cl++;
},
});
runner.start();
await new Promise((r) => setTimeout(r, 100));
runner.stop();
expect(hb).toBeGreaterThanOrEqual(1);
expect(cl).toBeGreaterThanOrEqual(1);
});
it("stop is idempotent", { timeout: 5000 }, () => {
const runner = new HeartbeatRunner({
heartbeatInterval: 1000,
onHeartbeat: () => {},
onCleanup: () => {},
});
runner.start();
runner.stop();
expect(() => runner.stop()).not.toThrow();
});
});

View File

@@ -0,0 +1,44 @@
/**
* Lightweight pair of `setInterval` timers managing the heartbeat / cleanup
* cadence of a `TypedBroadcastChannel`. Extracted from `channel.ts` so the
* channel core stays under the 300-line hard budget while keeping each timer
* lifecycle visible and individually testable.
*/
export interface HeartbeatRunnerOptions {
readonly heartbeatInterval: number;
readonly onHeartbeat: () => void;
readonly onCleanup: () => void;
}
/** Mutable pair of timers; `start` schedules, `stop` clears. */
export class HeartbeatRunner {
private readonly opts: HeartbeatRunnerOptions;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
public constructor(opts: HeartbeatRunnerOptions) {
this.opts = opts;
}
public start(): void {
this.heartbeatTimer = setInterval(
() => this.opts.onHeartbeat(),
this.opts.heartbeatInterval
);
this.cleanupTimer = setInterval(
() => this.opts.onCleanup(),
this.opts.heartbeatInterval * 2
);
}
public stop(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.cleanupTimer !== null) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
}
}

View File

@@ -0,0 +1,6 @@
export {
type ChannelHandler,
TypedBroadcastChannel,
type TypedBroadcastChannelOptions,
} from "./channel.js";
export { HeartbeatRunner, type HeartbeatRunnerOptions } from "./heartbeat.js";

View File

@@ -0,0 +1,99 @@
# errors/codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the stable error-code registry for
`@suaveplan/broadcast-channel`. Every error the package throws surfaces one
of four codes under the `BROADCAST_CHANNEL.*` namespace so downstream
consumers — loggers, problem+json responders, retry wrappers, i18n resolvers
— can map the failure to a retry policy, HTTP status, or translation key
without re-implementing the classifier.
The registry is built via `defineCodes` from `@suaveplan/error`, which yields
a frozen `{ REASON: "BROADCAST_CHANNEL.REASON" }` record at compile time. The
literal-typed union `BroadcastChannelErrorCode` is preserved through the
public surface, so `instanceof BroadcastChannelError` narrows cleanly and
exhaustive `switch (err.code)` blocks stay type-checkable.
## Features
- Four reasons: `CHANNEL_CLOSED`, `MESSAGE_MALFORMED`, `PAYLOAD_TOO_LARGE`, `STORAGE_FAILED`.
- Four typed subclasses extending `SuaveplanError<BroadcastChannelErrorCode>`.
- Every code is thrown from at least one production code path (tested).
- Optional context carriers (`sizeBytes`, `maxBytes`) on the payload-too-large variant.
- `BroadcastChannelError` abstract base for ergonomic `instanceof` checks.
## Usage: Basic
```ts
import {
BROADCAST_CHANNEL_CODES,
BroadcastChannelClosedError,
} from "@suaveplan/broadcast-channel";
try {
channel.send("greet", "hi");
} catch (err) {
if (
err instanceof BroadcastChannelClosedError &&
err.code === BROADCAST_CHANNEL_CODES.CHANNEL_CLOSED
) {
// open the channel first
}
}
```
## Usage: Advanced
```ts
import {
BROADCAST_CHANNEL_CODES,
BroadcastChannelError,
type BroadcastChannelErrorCode,
} from "@suaveplan/broadcast-channel";
function classify(err: unknown): "retry" | "drop" | "alert" {
if (!(err instanceof BroadcastChannelError)) return "alert";
const code: BroadcastChannelErrorCode = err.code;
switch (code) {
case BROADCAST_CHANNEL_CODES.CHANNEL_CLOSED:
return "retry";
case BROADCAST_CHANNEL_CODES.MESSAGE_MALFORMED:
return "drop";
case BROADCAST_CHANNEL_CODES.PAYLOAD_TOO_LARGE:
return "drop";
case BROADCAST_CHANNEL_CODES.STORAGE_FAILED:
return "alert";
default:
return "alert";
}
}
```
## API Reference
- `const BROADCAST_CHANNEL_CODES` — frozen `{ readonly CHANNEL_CLOSED, MESSAGE_MALFORMED, PAYLOAD_TOO_LARGE, STORAGE_FAILED }` mapped to namespaced strings.
- `type BroadcastChannelErrorCode` — union of every code string.
- `abstract class BroadcastChannelError extends SuaveplanError<BroadcastChannelErrorCode>`.
- `class BroadcastChannelClosedError` — `send` on a not-open / closed channel.
- `class BroadcastChannelMessageMalformedError` — Zod validation failure at the boundary.
- `class BroadcastChannelPayloadTooLargeError` — outbound payload over `maxMessageBytes`. Optional `sizeBytes` / `maxBytes` opts populate `context`.
- `class BroadcastChannelStorageFailedError` — fallback transport `StorageLike` rejected.
## Implementation Notes
`defineCodes` returns a `Readonly<Record<Reason, "Namespace.Reason">>` so the
output object can be passed straight into `Object.freeze` consumers and the
keys typecheck as `BroadcastChannelErrorCode`. Each subclass routes through
`pickOpts` to strip `undefined` fields out of `SuaveplanErrorOptions` so the
underlying `SuaveplanError` constructor sees only the keys the caller
explicitly set — this matters for the OTel `toSpanEvent` adapter, which omits
absent attributes rather than emitting `null`. The
`BroadcastChannelPayloadTooLargeError` constructor merges `sizeBytes` /
`maxBytes` into the user-supplied `context` so log lines and span attributes
carry both the offender's size and the configured budget without the caller
having to remember to attach them.

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BROADCAST_CHANNEL_CODES,
BroadcastChannelAskHandlerDuplicateError,
BroadcastChannelAskNoResponderError,
BroadcastChannelAskTimeoutError,
BroadcastChannelClosedError,
BroadcastChannelMessageMalformedError,
BroadcastChannelPayloadTooLargeError,
BroadcastChannelStorageFailedError,
} from "./codes.js";
describe("BROADCAST_CHANNEL_CODES", () => {
it("registry is frozen and shape matches", { timeout: 5000 }, () => {
expect(Object.isFrozen(BROADCAST_CHANNEL_CODES)).toBe(true);
expect(BROADCAST_CHANNEL_CODES).toEqual({
CHANNEL_CLOSED: "BROADCAST_CHANNEL.CHANNEL_CLOSED",
MESSAGE_MALFORMED: "BROADCAST_CHANNEL.MESSAGE_MALFORMED",
PAYLOAD_TOO_LARGE: "BROADCAST_CHANNEL.PAYLOAD_TOO_LARGE",
STORAGE_FAILED: "BROADCAST_CHANNEL.STORAGE_FAILED",
ASK_TIMEOUT: "BROADCAST_CHANNEL.ASK_TIMEOUT",
ASK_NO_RESPONDER: "BROADCAST_CHANNEL.ASK_NO_RESPONDER",
ASK_HANDLER_DUPLICATE: "BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE",
});
});
it("propagates cause, context, traceId, and custom message through pickOpts", {
timeout: 5000,
}, () => {
const cause = new Error("inner");
const err = new BroadcastChannelClosedError({
cause,
context: { route: "x" },
traceId: "trace-1",
message: "custom.message",
});
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ route: "x" });
expect(err.traceId).toBe("trace-1");
expect(err.message).toBe("custom.message");
});
it("PayloadTooLargeError without size/max omits context fields", {
timeout: 5000,
}, () => {
const err = new BroadcastChannelPayloadTooLargeError();
expect(err.code).toBe("BROADCAST_CHANNEL.PAYLOAD_TOO_LARGE");
expect(err.context).toEqual({});
});
it("MalformedError and StorageFailedError default-construct", {
timeout: 5000,
}, () => {
const m = new BroadcastChannelMessageMalformedError();
const s = new BroadcastChannelStorageFailedError();
expect(m.code).toBe("BROADCAST_CHANNEL.MESSAGE_MALFORMED");
expect(s.code).toBe("BROADCAST_CHANNEL.STORAGE_FAILED");
});
it("PayloadTooLargeError merges user context with size/max", {
timeout: 5000,
}, () => {
const err = new BroadcastChannelPayloadTooLargeError({
sizeBytes: 200,
maxBytes: 100,
context: { type: "huge" },
});
expect(err.context).toEqual({
type: "huge",
sizeBytes: 200,
maxBytes: 100,
});
});
it("every code is thrown by a production path", { timeout: 5000 }, () => {
const errs = [
new BroadcastChannelClosedError(),
new BroadcastChannelMessageMalformedError(),
new BroadcastChannelPayloadTooLargeError({
sizeBytes: 10,
maxBytes: 5,
}),
new BroadcastChannelStorageFailedError({ cause: new Error("x") }),
new BroadcastChannelAskTimeoutError({
context: { handlerKey: "k" },
}),
new BroadcastChannelAskNoResponderError({
context: { handlerKey: "k" },
}),
new BroadcastChannelAskHandlerDuplicateError({
context: { handlerKey: "k" },
}),
];
const codes = errs.map((e) => e.code);
expect(codes).toEqual([
BROADCAST_CHANNEL_CODES.CHANNEL_CLOSED,
BROADCAST_CHANNEL_CODES.MESSAGE_MALFORMED,
BROADCAST_CHANNEL_CODES.PAYLOAD_TOO_LARGE,
BROADCAST_CHANNEL_CODES.STORAGE_FAILED,
BROADCAST_CHANNEL_CODES.ASK_TIMEOUT,
BROADCAST_CHANNEL_CODES.ASK_NO_RESPONDER,
BROADCAST_CHANNEL_CODES.ASK_HANDLER_DUPLICATE,
]);
const tooLarge = errs[2] as BroadcastChannelPayloadTooLargeError;
expect(tooLarge.context).toMatchObject({
sizeBytes: 10,
maxBytes: 5,
});
});
it("ask error classes default-construct", { timeout: 5000 }, () => {
const t = new BroadcastChannelAskTimeoutError();
const n = new BroadcastChannelAskNoResponderError();
const d = new BroadcastChannelAskHandlerDuplicateError();
expect(t.code).toBe("BROADCAST_CHANNEL.ASK_TIMEOUT");
expect(n.code).toBe("BROADCAST_CHANNEL.ASK_NO_RESPONDER");
expect(d.code).toBe("BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE");
expect(t.message).toBe("broadcast-channel.errors.askTimeout");
expect(n.message).toBe("broadcast-channel.errors.askNoResponder");
expect(d.message).toBe("broadcast-channel.errors.askHandlerDuplicate");
});
});

View File

@@ -0,0 +1,130 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/broadcast-channel`. Every error
* thrown by the package surfaces one of these codes so downstream consumers
* (loggers, problem+json responders, retry wrappers) can map to retry policy,
* HTTP status, and i18n keys without re-implementing the classifier.
*/
export const BROADCAST_CHANNEL_CODES = defineCodes("BROADCAST_CHANNEL", [
"CHANNEL_CLOSED",
"MESSAGE_MALFORMED",
"PAYLOAD_TOO_LARGE",
"STORAGE_FAILED",
"ASK_TIMEOUT",
"ASK_NO_RESPONDER",
"ASK_HANDLER_DUPLICATE",
] as const);
/** Union of every fully-qualified code emitted by this package. */
export type BroadcastChannelErrorCode =
(typeof BROADCAST_CHANNEL_CODES)[keyof typeof BROADCAST_CHANNEL_CODES];
/** Base class for every typed broadcast-channel error. */
export abstract class BroadcastChannelError extends SuaveplanError<BroadcastChannelErrorCode> {}
interface BaseOpts extends SuaveplanErrorOptions {
readonly message?: string;
}
function pickOpts(opts: BaseOpts): SuaveplanErrorOptions {
const out: {
cause?: unknown;
context?: Readonly<Record<string, unknown>>;
traceId?: string;
} = {};
if (opts.cause !== undefined) out.cause = opts.cause;
if (opts.context !== undefined) out.context = opts.context;
if (opts.traceId !== undefined) out.traceId = opts.traceId;
return out;
}
/** Send attempted on a channel that has not been opened or has been closed. */
export class BroadcastChannelClosedError extends BroadcastChannelError {
public constructor(opts: BaseOpts = {}) {
super(
BROADCAST_CHANNEL_CODES.CHANNEL_CLOSED,
opts.message ?? "broadcast-channel.errors.channelClosed",
pickOpts(opts)
);
}
}
/** An inbound frame failed `ChannelMessageSchema` validation. */
export class BroadcastChannelMessageMalformedError extends BroadcastChannelError {
public constructor(opts: BaseOpts = {}) {
super(
BROADCAST_CHANNEL_CODES.MESSAGE_MALFORMED,
opts.message ?? "broadcast-channel.errors.messageMalformed",
pickOpts(opts)
);
}
}
/** Outbound payload exceeded the configured `maxMessageBytes` budget. */
export class BroadcastChannelPayloadTooLargeError extends BroadcastChannelError {
public constructor(
opts: BaseOpts & {
readonly sizeBytes?: number;
readonly maxBytes?: number;
} = {}
) {
const ctx: Record<string, unknown> = { ...(opts.context ?? {}) };
if (opts.sizeBytes !== undefined) ctx["sizeBytes"] = opts.sizeBytes;
if (opts.maxBytes !== undefined) ctx["maxBytes"] = opts.maxBytes;
super(
BROADCAST_CHANNEL_CODES.PAYLOAD_TOO_LARGE,
opts.message ?? "broadcast-channel.errors.payloadTooLarge",
pickOpts({ ...opts, context: ctx })
);
}
}
/** Injected `StorageLike` rejected a get/set/list call in the fallback transport. */
export class BroadcastChannelStorageFailedError extends BroadcastChannelError {
public constructor(opts: BaseOpts = {}) {
super(
BROADCAST_CHANNEL_CODES.STORAGE_FAILED,
opts.message ?? "broadcast-channel.errors.storageFailed",
pickOpts(opts)
);
}
}
/** Pending `ask()` timed out before any peer responded. */
export class BroadcastChannelAskTimeoutError extends BroadcastChannelError {
public constructor(opts: BaseOpts = {}) {
super(
BROADCAST_CHANNEL_CODES.ASK_TIMEOUT,
opts.message ?? "broadcast-channel.errors.askTimeout",
pickOpts(opts)
);
}
}
/**
* `ask()` was called with `requireResponder: true` and timed out without any
* peer claiming the handler key. Distinct from `ASK_TIMEOUT` so callers can
* drive different retry policies for "no listener" vs. "slow listener".
*/
export class BroadcastChannelAskNoResponderError extends BroadcastChannelError {
public constructor(opts: BaseOpts = {}) {
super(
BROADCAST_CHANNEL_CODES.ASK_NO_RESPONDER,
opts.message ?? "broadcast-channel.errors.askNoResponder",
pickOpts(opts)
);
}
}
/** `onAsk()` called twice for the same `handlerKey` without disposal in between. */
export class BroadcastChannelAskHandlerDuplicateError extends BroadcastChannelError {
public constructor(opts: BaseOpts = {}) {
super(
BROADCAST_CHANNEL_CODES.ASK_HANDLER_DUPLICATE,
opts.message ?? "broadcast-channel.errors.askHandlerDuplicate",
pickOpts(opts)
);
}
}

View File

@@ -0,0 +1,9 @@
export {
BROADCAST_CHANNEL_CODES,
BroadcastChannelClosedError,
BroadcastChannelError,
type BroadcastChannelErrorCode,
BroadcastChannelMessageMalformedError,
BroadcastChannelPayloadTooLargeError,
BroadcastChannelStorageFailedError,
} from "./codes.js";

View File

@@ -0,0 +1,79 @@
# id/id
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`generateId` is the shared 16-character lowercase-hex identifier generator
used for tab IDs and per-message IDs throughout
`@suaveplan/broadcast-channel`. The upstream library inlined two near-identical
`generate*Id` helpers — pulling that into a single function with injectable
`Rng` lets the channel core compute deterministic IDs in tests and resolves
the enterprise-doc smell ("Two duplicate `generateTabId` / `generateMsgId`
functions; should share via a local `internal/id.ts`").
The module also exports `defaultRng`, an `Rng` implementation backed by Web
Crypto's `getRandomValues`. Browsers and modern Node / Bun all expose the
global `crypto` object so `defaultRng` works in every supported runtime
without any extra polyfill.
## Features
- Single helper for every ID the package produces.
- Accepts any `Rng` (`@suaveplan/types`) — seeded RNGs make tests deterministic.
- Honours optional `rng.nextBytes` when present (faster, fewer calls).
- Pure-function fallback that derives 8 bytes from two `nextUint32()` calls.
- Stable encoding: lowercase hex, fixed 16 characters, no separators.
## Usage: Basic
```ts
import { defaultRng, generateId } from "@suaveplan/broadcast-channel";
const tabId = generateId(defaultRng);
// tabId === "a3f1c0...", 16 hex chars
```
## Usage: Advanced
```ts
import { generateId } from "@suaveplan/broadcast-channel";
import type { Rng } from "@suaveplan/types";
function seededRng(values: readonly number[]): Rng {
let i = 0;
return {
nextUint32: () => {
const v = values[i % values.length] ?? 0;
i++;
return v >>> 0;
},
nextFloat() {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
const tabId = generateId(seededRng([0xdeadbeef, 0x01020304]));
// tabId === "deadbeef01020304"
```
## API Reference
- `function generateId(rng: Rng): string` — returns a 16-character hex string built from 8 random bytes drawn from `rng`. Uses `rng.nextBytes(8)` when available, otherwise two `nextUint32()` calls.
- `const defaultRng: Rng` — Web-Crypto-backed RNG suitable for production browser code. Exposes `nextUint32`, `nextFloat`, and `nextBytes`.
## Implementation Notes
When `rng.nextBytes` is not supplied, the helper packs two unsigned 32-bit
words into an 8-byte buffer in big-endian order, then encodes each byte as a
two-character zero-padded hex pair. The big-endian packing is deliberate: it
makes the seeded-RNG test contract human-readable (`0xdeadbeef` → first 4
hex bytes `deadbeef`) and keeps the encoding stable if downstream code ever
parses the ID back into bytes. The hex emission is the simplest two-character
output that survives JSON / URL / localStorage transport without escaping. We
avoid `crypto.randomUUID()` because the channel needs a fixed 16-hex shape
for the leader-election sort to be deterministic and because randomUUID
output is not available in every test sandbox.

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { defaultRng, generateId } from "./id.js";
function seededRng(values: readonly number[]): Rng {
let i = 0;
return {
nextUint32() {
const v = values[i % values.length] ?? 0;
i++;
return v >>> 0;
},
nextFloat() {
return this.nextUint32() / 0x1_0000_0000;
},
};
}
describe("generateId", () => {
it("derives a 16-hex id from rng bytes", { timeout: 5000 }, () => {
const rng = seededRng([0xdeadbeef, 0x01020304]);
expect(generateId(rng)).toBe("deadbeef01020304");
});
it("uses rng.nextBytes when available", { timeout: 5000 }, () => {
const rng: Rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n: number) => {
const buf = new Uint8Array(n);
for (let i = 0; i < n; i++) buf[i] = i;
return buf;
},
};
expect(generateId(rng)).toBe("0001020304050607");
});
it("defaultRng produces a 16-hex string", { timeout: 5000 }, () => {
expect(generateId(defaultRng)).toMatch(/^[0-9a-f]{16}$/);
});
it("defaultRng.nextFloat is in [0, 1)", { timeout: 5000 }, () => {
const f = defaultRng.nextFloat();
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThan(1);
});
it("defaultRng.nextBytes returns requested length", {
timeout: 5000,
}, () => {
const bytes = defaultRng.nextBytes?.(12);
expect(bytes?.byteLength).toBe(12);
});
});

View File

@@ -0,0 +1,55 @@
import type { Rng } from "@suaveplan/types";
/**
* Generate a 16-character lowercase-hex identifier from 8 random bytes drawn
* out of the injected `Rng`. Used for both tab IDs and per-message IDs so the
* encoding is stable across the entire package.
*
* Implementations of `Rng` from `@suaveplan/types` may expose `nextBytes`; if
* absent we fall back to `nextUint32` and concatenate two 32-bit words into
* a 64-bit byte buffer.
*/
export function generateId(rng: Rng): string {
const bytes = drawBytes(rng);
let out = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i] as number;
out += b.toString(16).padStart(2, "0");
}
return out;
}
function drawBytes(rng: Rng): Uint8Array {
if (rng.nextBytes) {
return rng.nextBytes(8);
}
const buf = new Uint8Array(8);
const a = rng.nextUint32();
const b = rng.nextUint32();
buf[0] = (a >>> 24) & 0xff;
buf[1] = (a >>> 16) & 0xff;
buf[2] = (a >>> 8) & 0xff;
buf[3] = a & 0xff;
buf[4] = (b >>> 24) & 0xff;
buf[5] = (b >>> 16) & 0xff;
buf[6] = (b >>> 8) & 0xff;
buf[7] = b & 0xff;
return buf;
}
/** Default `Rng` backed by Web Crypto's `getRandomValues`. */
export const defaultRng: Rng = {
nextUint32(): number {
const a = new Uint32Array(1);
crypto.getRandomValues(a);
return a[0] as number;
},
nextFloat(): number {
return this.nextUint32() / 0x1_0000_0000;
},
nextBytes(n: number): Uint8Array {
const out = new Uint8Array(n);
crypto.getRandomValues(out);
return out;
},
};

View File

@@ -0,0 +1,4 @@
export {
defaultRng,
generateId,
} from "./id.js";

View File

@@ -0,0 +1,97 @@
// Channel
// Ask
export {
AskController,
type AskControllerOptions,
buildAskErrorPayload,
DEFAULT_ASK_TIMEOUT_MS,
} from "./ask/ask.js";
export {
type AskHandler,
AskResponderRegistry,
} from "./ask/responder.js";
export {
type ChannelHandler,
TypedBroadcastChannel,
type TypedBroadcastChannelOptions,
} from "./channel/channel.js";
export {
HeartbeatRunner,
type HeartbeatRunnerOptions,
} from "./channel/heartbeat.js";
// Errors
export {
BROADCAST_CHANNEL_CODES,
BroadcastChannelAskHandlerDuplicateError,
BroadcastChannelAskNoResponderError,
BroadcastChannelAskTimeoutError,
BroadcastChannelClosedError,
BroadcastChannelError,
type BroadcastChannelErrorCode,
BroadcastChannelMessageMalformedError,
BroadcastChannelPayloadTooLargeError,
BroadcastChannelStorageFailedError,
} from "./errors/codes.js";
// Id
export { defaultRng, generateId } from "./id/id.js";
// Logger
export { noopLogger } from "./logger/noop.js";
// OTel
export {
recordAskResponded,
recordAskSent,
recordAskTimeout,
recordLeaderElected,
recordMessageDropped,
recordMessageReceived,
recordMessageSent,
recordTabJoined,
recordTabLeft,
refreshBroadcastChannelMeters,
} from "./otel/metrics.js";
// Schemas
export {
type AskFrameInput,
AskFrameSchema,
type AskResponseFrameInput,
AskResponseFrameSchema,
type ChannelConfigInput,
ChannelConfigSchema,
type ChannelMessageInput,
ChannelMessageSchema,
} from "./schemas/messages.js";
// Transport
export {
createLocalStorageChannelFactory,
type LocalStorageChannelOptions,
} from "./transport/local-storage.js";
export {
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
} from "./transport/native.js";
export type {
ChannelFactory,
ChannelLike,
} from "./transport/transport.types.js";
// Types
export {
type AskErrorPayload,
type AskFrame,
type AskHandlerContext,
type AskOptions,
type AskResponseFrame,
type BroadcastChannelEventMap,
type ChannelClosedEvent,
type ChannelConfig,
type ChannelMessage,
type ChannelOpenedEvent,
DEFAULT_CHANNEL_CONFIG,
type InternalMessageType,
type LeaderElectedEvent,
MAX_MESSAGE_BYTES_DEFAULT,
type MessageSentEvent,
type TabInfo,
type TabJoinedEvent,
type TabLeftEvent,
} from "./types/broadcast-channel.types.js";

View File

@@ -0,0 +1 @@
export { noopLogger } from "./noop.js";

View File

@@ -0,0 +1,66 @@
# logger/noop
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`noopLogger` is a shared zero-cost `LoggerLike` implementation used as the
default by `TypedBroadcastChannel` and by `createLocalStorageChannelFactory`
when a consumer does not supply their own logger. The four methods accept
the same `(message, context?)` signature as `@suaveplan/logger`'s real
implementation and discard the input. Without this shared instance the two
call sites would each define their own anonymous object and duplicate the
contract.
The module exists because every entry point that takes a `LoggerLike`
ultimately needs a default when the consumer leaves the option blank;
factoring the default into one place removes drift across the package and
keeps the discard-methods covered by exactly one test rather than four.
## Features
- One `LoggerLike` instance reused by every default-logger code path.
- All four methods discard `(message, context)` cleanly — no console writes, no allocations beyond the literal void expressions.
- Importable from both browser and Node contexts (zero dependencies).
## Usage: Basic
```ts
import { noopLogger } from "@suaveplan/broadcast-channel";
import type { LoggerLike } from "@suaveplan/types";
const logger: LoggerLike = noopLogger;
logger.warn("nothing happens", { ctx: 1 });
```
## Usage: Advanced
```ts
import { noopLogger } from "@suaveplan/broadcast-channel";
import type { LoggerLike } from "@suaveplan/types";
function makeChannel(logger?: LoggerLike) {
const effective = logger ?? noopLogger;
effective.debug("channel constructed");
return effective;
}
```
## API Reference
- `const noopLogger: LoggerLike` — exposes `debug`, `info`, `warn`, `error`. Each method accepts an optional `Readonly<Record<string, unknown>>` second argument and returns `void`.
## Implementation Notes
Each method writes `void <param>` to silence the unused-argument lint without
introducing a runtime side effect: TC39's `void` operator evaluates the
operand and returns `undefined`, so the compiler keeps the parameter slot
typed while the runtime cost stays at exactly one nondeterministic load
the V8 / JSC optimiser can fold away. We deliberately do not use empty
arrow bodies (`(...) => {}`) because the parameter name would be flagged
as unused; the explicit `void` form keeps every method symmetric and
documents at the call site that the value is intentionally ignored. There
is no `child(...)` method because `LoggerLike.child` is optional and the
noop logger cannot meaningfully derive sub-bindings.

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { noopLogger } from "./noop.js";
describe("noopLogger", () => {
it("debug/info/warn/error silently accept message + context", {
timeout: 5000,
}, () => {
expect(noopLogger.debug("d", { k: 1 })).toBeUndefined();
expect(noopLogger.info("i", { k: 2 })).toBeUndefined();
expect(noopLogger.warn("w", { k: 3 })).toBeUndefined();
expect(noopLogger.error("e", { k: 4 })).toBeUndefined();
});
it("methods accept message-only calls", { timeout: 5000 }, () => {
expect(noopLogger.debug("d")).toBeUndefined();
expect(noopLogger.info("i")).toBeUndefined();
expect(noopLogger.warn("w")).toBeUndefined();
expect(noopLogger.error("e")).toBeUndefined();
});
});

View File

@@ -0,0 +1,26 @@
import type { LoggerLike } from "@suaveplan/types";
/**
* Shared no-op `LoggerLike` used as the default in both `TypedBroadcastChannel`
* and `createLocalStorageChannelFactory`. Calls discard their input; the
* methods exist so the `LoggerLike` contract is structurally satisfied without
* forcing callers to wire a logger they don't need.
*/
export const noopLogger: LoggerLike = {
debug(_message: string, _context?: Readonly<Record<string, unknown>>) {
void _message;
void _context;
},
info(_message: string, _context?: Readonly<Record<string, unknown>>) {
void _message;
void _context;
},
warn(_message: string, _context?: Readonly<Record<string, unknown>>) {
void _message;
void _context;
},
error(_message: string, _context?: Readonly<Record<string, unknown>>) {
void _message;
void _context;
},
};

View File

@@ -0,0 +1,9 @@
export {
recordLeaderElected,
recordMessageDropped,
recordMessageReceived,
recordMessageSent,
recordTabJoined,
recordTabLeft,
refreshBroadcastChannelMeters,
} from "./metrics.js";

View File

@@ -0,0 +1,104 @@
# otel/metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the six OpenTelemetry counter instruments emitted by
`@suaveplan/broadcast-channel`. Every counter is registered against the meter
`@suaveplan/broadcast-channel@0.1.0` so a single global `MeterProvider`
configuration in the host application picks all of them up. The instruments
close the observability gap the enterprise audit flagged ("No structured
logging / OTel counters"): each lifecycle event a channel observes is
counted, attributed by channel name and either message type or drop reason.
The `refreshBroadcastChannelMeters()` function re-resolves every instrument
against the current global `MeterProvider`. Tests call it after wiring the
in-memory exporter from `tests/setup/polyfills.ts` so subsequent record calls
land on the exporter rather than the noop meter resolved at module load.
## Features
- Six counters: `channel.message.{sent,received,dropped}`, `channel.tab.{joined,left}`, `channel.leader.elected`.
- Stable attribute shape: `{ channel }` plus `{ type }` or `{ reason }`.
- `refreshBroadcastChannelMeters()` for test rebinding.
- No span / tracer surface — counters only, by design.
- Module-load-safe under the noop meter when no `MeterProvider` is configured.
## Usage: Basic
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
refreshBroadcastChannelMeters,
TypedBroadcastChannel,
nativeChannelFactory,
} from "@suaveplan/broadcast-channel";
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [] }));
refreshBroadcastChannelMeters();
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
channel.send("greet", "hi"); // increments channel.message.sent
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { refreshBroadcastChannelMeters } from "@suaveplan/broadcast-channel";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
metrics.setGlobalMeterProvider(new MeterProvider({ readers: [reader] }));
refreshBroadcastChannelMeters();
// Later, in a test assertion:
const collected = exporter.getMetrics();
const names = collected.flatMap((rm) =>
rm.scopeMetrics.flatMap((sm) => sm.metrics.map((m) => m.descriptor.name))
);
expect(names).toContain("channel.message.sent");
```
## API Reference
- `refreshBroadcastChannelMeters(): void` — re-resolve every instrument against the current global `MeterProvider`.
- `recordMessageSent(type: string, channelName: string): void`
- `recordMessageReceived(type: string, channelName: string): void`
- `recordMessageDropped(reason: "malformed", channelName: string): void`
- `recordTabJoined(channelName: string): void`
- `recordTabLeft(channelName: string): void`
- `recordLeaderElected(channelName: string): void`
Each `record*` helper calls `.add(1, attrs)` on the corresponding counter.
## Implementation Notes
The instruments are stored in module-local `let` bindings so
`refreshBroadcastChannelMeters` can rebind them without forcing callers to
hold a stable reference. This mirrors the pattern used by
`@suaveplan/slack-client`: at module load the meter is whatever the global
provider returns (typically the noop provider, in which case `.add()` is a
no-op), and the explicit refresh after `setGlobalMeterProvider` swaps in
real instruments. The attribute keys (`channel`, `type`, `reason`) are kept
short to minimise cardinality blow-up; consumer dashboards group on
`channel` first to slice per logical bus. No histograms are exposed — the
package has no meaningful latency axis (`send` is synchronous, dispatch is
in-process) so a histogram would be empty noise.

View File

@@ -0,0 +1,305 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { TypedBroadcastChannel } from "../channel/channel.js";
import type {
ChannelFactory,
ChannelLike,
} from "../transport/transport.types.js";
import { refreshBroadcastChannelMeters } from "./metrics.js";
class FakeChannel implements ChannelLike {
public messages: unknown[] = [];
public closed = false;
private readonly listeners: Array<(event: MessageEvent) => void> = [];
public postMessage(m: unknown): void {
this.messages.push(m);
}
public close(): void {
this.closed = true;
}
public addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void {
if (type === "message") this.listeners.push(listener);
}
public simulate(data: unknown): void {
for (const l of this.listeners.slice()) l({ data } as MessageEvent);
}
}
function createFakeFactory(): {
factory: ChannelFactory;
channels: FakeChannel[];
} {
const channels: FakeChannel[] = [];
return {
factory: () => {
const c = new FakeChannel();
channels.push(c);
return c;
},
channels,
};
}
interface ControllableTimers {
setTimer(callback: () => void, delayMs: number): number;
clearTimer(handle: number): void;
fire(handle: number): void;
}
function fakeTimers(): ControllableTimers {
const slots = new Map<number, () => void>();
let next = 0;
return {
setTimer(cb) {
next++;
slots.set(next, cb);
return next;
},
clearTimer(h) {
slots.delete(h);
},
fire(h) {
const cb = slots.get(h);
slots.delete(h);
cb?.();
},
};
}
function collectNames(
rms: ReadonlyArray<{
scopeMetrics: ReadonlyArray<{
metrics: ReadonlyArray<{ descriptor: { name: string } }>;
}>;
}>
): string[] {
return rms.flatMap((rm) =>
rm.scopeMetrics.flatMap((sm) =>
sm.metrics.map((m) => m.descriptor.name)
)
);
}
describe("OTel instruments", () => {
beforeEach(async () => {
refreshBroadcastChannelMeters();
await flushTestMetrics();
});
it("send increments channel.message.sent", { timeout: 5000 }, async () => {
const { factory } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m1",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
});
ch.open();
ch.send("greet", "hello");
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.message.sent");
ch.close();
});
it("malformed frame increments channel.message.dropped", {
timeout: 5000,
}, async () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m2",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
});
ch.open();
channels[0]?.simulate({ not: "valid" });
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.message.dropped");
ch.close();
});
it("tab join increments channel.tab.joined", {
timeout: 5000,
}, async () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m3",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
});
ch.open();
channels[0]?.simulate({
type: "tab-join",
payload: {},
id: "ji",
senderId: "peer-a",
timestamp: 1,
});
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.tab.joined");
ch.close();
});
it("tab leave increments channel.tab.left", { timeout: 5000 }, async () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m4",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
});
ch.open();
channels[0]?.simulate({
type: "tab-join",
payload: {},
id: "ji",
senderId: "peer-b",
timestamp: 1,
});
channels[0]?.simulate({
type: "tab-leave",
payload: {},
id: "le",
senderId: "peer-b",
timestamp: 2,
});
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.tab.left");
ch.close();
});
it("leadership transition increments channel.leader.elected", {
timeout: 5000,
}, async () => {
const { factory } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m5",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
});
ch.open();
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.leader.elected");
ch.close();
});
it("ask increments channel.ask.sent", { timeout: 5000 }, async () => {
const { factory } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m-ask-sent",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
timers: fakeTimers(),
});
ch.open();
ch.ask("ping", null).catch(() => {});
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.ask.sent");
ch.close();
});
it("ask-response increments channel.ask.responded", {
timeout: 5000,
}, async () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m-ask-resp",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
timers: fakeTimers(),
});
ch.open();
const pending = ch.ask<unknown, { ok: true }>("ping", null);
const askFrame = channels[0]?.messages.find(
(m): m is { type: string; correlationId: string } =>
typeof m === "object" &&
m !== null &&
(m as { type?: string }).type === "ask"
);
channels[0]?.simulate({
type: "ask-response",
responseTo: askFrame?.correlationId,
handlerKey: "ping",
outcome: "ok",
payload: { ok: true },
id: "i-resp",
senderId: "peer",
timestamp: 1,
});
await pending;
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.ask.responded");
ch.close();
});
it("ask timeout increments channel.ask.timeout", {
timeout: 5000,
}, async () => {
const timers = fakeTimers();
const { factory } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m-ask-timeout",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
timers,
});
ch.open();
const pending = ch.ask("slow", null, { timeoutMs: 100 });
timers.fire(1);
await expect(pending).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_TIMEOUT",
});
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.ask.timeout");
ch.close();
});
it("received-message frame increments channel.message.received", {
timeout: 5000,
}, async () => {
const { factory, channels } = createFakeFactory();
const ch = new TypedBroadcastChannel({
config: {
name: "m6",
heartbeatInterval: 60_000,
tabTimeout: 120_000,
},
factory,
});
ch.open();
channels[0]?.simulate({
type: "msg",
payload: "v",
id: "i",
senderId: "peer",
timestamp: 1,
});
const names = collectNames(await flushTestMetrics());
expect(names).toContain("channel.message.received");
ch.close();
});
});

View File

@@ -0,0 +1,119 @@
import type { Counter, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/broadcast-channel";
const METER_VERSION = "0.1.0";
let meter: Meter = metrics.getMeter(METER_NAME, METER_VERSION);
let messageSent: Counter = meter.createCounter("channel.message.sent", {
description: "Count of outbound channel messages by type.",
});
let messageReceived: Counter = meter.createCounter("channel.message.received", {
description: "Count of inbound channel messages that pass validation.",
});
let messageDropped: Counter = meter.createCounter("channel.message.dropped", {
description: "Count of inbound channel messages dropped at the boundary.",
});
let tabJoined: Counter = meter.createCounter("channel.tab.joined", {
description: "Count of peer-tab join events observed.",
});
let tabLeft: Counter = meter.createCounter("channel.tab.left", {
description: "Count of peer-tab leave events observed.",
});
let leaderElected: Counter = meter.createCounter("channel.leader.elected", {
description: "Count of leadership transitions to the local tab.",
});
let askSent: Counter = meter.createCounter("channel.ask.sent", {
description: "Count of outbound ask() request frames by handlerKey.",
});
let askResponded: Counter = meter.createCounter("channel.ask.responded", {
description:
"Count of ask-response frames consumed by the asker by handlerKey and outcome.",
});
let askTimeout: Counter = meter.createCounter("channel.ask.timeout", {
description:
"Count of ask() calls that hit their timeout deadline by handlerKey.",
});
/**
* Re-resolve every instrument against the current global `MeterProvider`.
* Tests call this after wiring an in-memory exporter so subsequent record
* calls land on the test provider rather than the module-load meter.
*/
export function refreshBroadcastChannelMeters(): void {
meter = metrics.getMeter(METER_NAME, METER_VERSION);
messageSent = meter.createCounter("channel.message.sent", {
description: "Count of outbound channel messages by type.",
});
messageReceived = meter.createCounter("channel.message.received", {
description: "Count of inbound channel messages that pass validation.",
});
messageDropped = meter.createCounter("channel.message.dropped", {
description:
"Count of inbound channel messages dropped at the boundary.",
});
tabJoined = meter.createCounter("channel.tab.joined", {
description: "Count of peer-tab join events observed.",
});
tabLeft = meter.createCounter("channel.tab.left", {
description: "Count of peer-tab leave events observed.",
});
leaderElected = meter.createCounter("channel.leader.elected", {
description: "Count of leadership transitions to the local tab.",
});
askSent = meter.createCounter("channel.ask.sent", {
description: "Count of outbound ask() request frames by handlerKey.",
});
askResponded = meter.createCounter("channel.ask.responded", {
description:
"Count of ask-response frames consumed by the asker by handlerKey and outcome.",
});
askTimeout = meter.createCounter("channel.ask.timeout", {
description:
"Count of ask() calls that hit their timeout deadline by handlerKey.",
});
}
export function recordAskSent(key: string, channelName: string): void {
askSent.add(1, { key, channel: channelName });
}
export function recordAskResponded(
key: string,
outcome: "ok" | "error",
channelName: string
): void {
askResponded.add(1, { key, outcome, channel: channelName });
}
export function recordAskTimeout(key: string, channelName: string): void {
askTimeout.add(1, { key, channel: channelName });
}
export function recordMessageSent(type: string, channelName: string): void {
messageSent.add(1, { type, channel: channelName });
}
export function recordMessageReceived(type: string, channelName: string): void {
messageReceived.add(1, { type, channel: channelName });
}
export function recordMessageDropped(
reason: "malformed",
channelName: string
): void {
messageDropped.add(1, { reason, channel: channelName });
}
export function recordTabJoined(channelName: string): void {
tabJoined.add(1, { channel: channelName });
}
export function recordTabLeft(channelName: string): void {
tabLeft.add(1, { channel: channelName });
}
export function recordLeaderElected(channelName: string): void {
leaderElected.add(1, { channel: channelName });
}

View File

@@ -0,0 +1,6 @@
export {
type ChannelConfigInput,
ChannelConfigSchema,
type ChannelMessageInput,
ChannelMessageSchema,
} from "./messages.js";

View File

@@ -0,0 +1,80 @@
# schemas/messages
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module declares the Zod boundary schemas for
`@suaveplan/broadcast-channel`. `ChannelMessageSchema` validates every inbound
frame produced by the underlying `ChannelLike` transport before it is allowed
into the per-type handler registry. `ChannelConfigSchema` validates the
construction-time config object so consumers see a meaningful Zod issue list
instead of an opaque runtime failure when a field is missing or non-numeric.
Boundary validation is the suaveplan-mandated fix for the enterprise gap "No
Zod validation on incoming `ChannelMessage` — a rogue tab could send
garbage." A peer that posts a frame missing `id` or with a negative
`timestamp` no longer reaches user code; the channel drops the frame,
increments `channel.message.dropped`, and warns through the injected
`LoggerLike`.
## Features
- Two schemas: `ChannelMessageSchema`, `ChannelConfigSchema`.
- Default-import Zod (`import z from "zod"`) to satisfy the suaveplan Bun-safe rule.
- `payload` is intentionally `z.unknown()` so callers can layer their own per-type validation downstream.
- Stable required-field set: `{ type, payload, id, senderId, timestamp }`.
- Inferred input types via `z.infer`.
## Usage: Basic
```ts
import { ChannelMessageSchema } from "@suaveplan/broadcast-channel";
const parsed = ChannelMessageSchema.safeParse(incoming);
if (!parsed.success) {
return; // drop
}
```
## Usage: Advanced
```ts
import z from "zod";
import { ChannelMessageSchema } from "@suaveplan/broadcast-channel";
// Layer a per-type payload validator over the boundary schema.
const UserSignedInPayload = z.object({ userId: z.string().min(1) });
const incoming: unknown = receiveOffWire();
const env = ChannelMessageSchema.safeParse(incoming);
if (!env.success) return;
if (env.data.type === "user:signed-in") {
const payload = UserSignedInPayload.safeParse(env.data.payload);
if (!payload.success) return;
// payload.data.userId is typed and validated
}
```
## API Reference
- `const ChannelMessageSchema` — `z.object({ type: z.string().min(1), payload: z.unknown(), id: z.string().min(1), senderId: z.string().min(1), timestamp: z.number().int().nonnegative() })`.
- `const ChannelConfigSchema` — `z.object({ name: z.string().min(1), heartbeatInterval: z.number().int().positive(), tabTimeout: z.number().int().positive(), maxMessageBytes: z.number().int().positive() })`.
- `type ChannelMessageInput = z.infer<typeof ChannelMessageSchema>`.
- `type ChannelConfigInput = z.infer<typeof ChannelConfigSchema>`.
## Implementation Notes
`payload` is left as `z.unknown()` because the channel is type-agnostic on
the wire: per-type validation belongs in the application layer, not in the
transport boundary. Forcing a discriminated union here would push every
caller into either casting or extending the schema, and would force a major
version bump every time an application added a new message type — exactly the
opposite of the loose-coupling story the channel is meant to provide. The
`.min(1)` constraints on `type` / `id` / `senderId` catch the realistic
failure mode (empty strings produced by a malformed encoder) without the
cost of regex matching. `timestamp` uses `.int().nonnegative()` so frames
produced by clocks at zero (a fresh `FakeClock`) still validate while frames
with garbage negative values are rejected.

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AskFrameSchema,
AskResponseFrameSchema,
ChannelConfigSchema,
ChannelMessageSchema,
} from "./messages.js";
describe("ChannelMessageSchema", () => {
it("accepts valid frames", { timeout: 5000 }, () => {
const parsed = ChannelMessageSchema.safeParse({
type: "greet",
payload: { hi: true },
id: "abc",
senderId: "tab-1",
timestamp: 1_700_000_000_000,
});
expect(parsed.success).toBe(true);
});
it("rejects frames missing required fields", { timeout: 5000 }, () => {
const parsed = ChannelMessageSchema.safeParse({
type: "greet",
payload: 1,
senderId: "tab-1",
timestamp: 0,
});
expect(parsed.success).toBe(false);
});
it("rejects negative timestamps", { timeout: 5000 }, () => {
const parsed = ChannelMessageSchema.safeParse({
type: "t",
payload: 0,
id: "x",
senderId: "tab-1",
timestamp: -1,
});
expect(parsed.success).toBe(false);
});
});
describe("ChannelConfigSchema", () => {
it("accepts a full config", { timeout: 5000 }, () => {
const parsed = ChannelConfigSchema.safeParse({
name: "x",
heartbeatInterval: 1000,
tabTimeout: 5000,
maxMessageBytes: 1024,
});
expect(parsed.success).toBe(true);
});
it("rejects non-positive heartbeat intervals", { timeout: 5000 }, () => {
const parsed = ChannelConfigSchema.safeParse({
name: "x",
heartbeatInterval: 0,
tabTimeout: 5000,
maxMessageBytes: 1024,
});
expect(parsed.success).toBe(false);
});
});
describe("ask and ask-response variants", () => {
it("accepts ask and ask-response variants", { timeout: 5000 }, () => {
const askOk = ChannelMessageSchema.safeParse({
type: "ask",
correlationId: "c1",
handlerKey: "ping",
payload: { n: 1 },
expiresAt: 1_000,
id: "i",
senderId: "tab-a",
timestamp: 0,
});
expect(askOk.success).toBe(true);
const respOk = ChannelMessageSchema.safeParse({
type: "ask-response",
responseTo: "c1",
handlerKey: "ping",
outcome: "ok",
payload: { n: 2 },
id: "i2",
senderId: "tab-b",
timestamp: 1,
});
expect(respOk.success).toBe(true);
});
it("rejects ask frames missing correlationId", { timeout: 5000 }, () => {
const bad = ChannelMessageSchema.safeParse({
type: "ask",
handlerKey: "ping",
payload: null,
expiresAt: 1,
id: "i",
senderId: "s",
timestamp: 0,
});
expect(bad.success).toBe(false);
});
it("rejects ask-response frames missing responseTo", {
timeout: 5000,
}, () => {
const bad = ChannelMessageSchema.safeParse({
type: "ask-response",
handlerKey: "ping",
outcome: "ok",
payload: null,
id: "i",
senderId: "s",
timestamp: 0,
});
expect(bad.success).toBe(false);
});
it("rejects ask-response with invalid outcome", { timeout: 5000 }, () => {
const bad = ChannelMessageSchema.safeParse({
type: "ask-response",
responseTo: "c1",
handlerKey: "ping",
outcome: "weird",
payload: null,
id: "i",
senderId: "s",
timestamp: 0,
});
expect(bad.success).toBe(false);
});
it("AskFrameSchema also rejects legacy-style frames", {
timeout: 5000,
}, () => {
const bad = AskFrameSchema.safeParse({
type: "greet",
correlationId: "c",
handlerKey: "k",
payload: 1,
expiresAt: 1,
id: "i",
senderId: "s",
timestamp: 0,
});
expect(bad.success).toBe(false);
});
it("AskResponseFrameSchema requires literal type", {
timeout: 5000,
}, () => {
const bad = AskResponseFrameSchema.safeParse({
type: "nope",
responseTo: "c",
handlerKey: "k",
outcome: "ok",
payload: null,
id: "i",
senderId: "s",
timestamp: 0,
});
expect(bad.success).toBe(false);
});
});

View File

@@ -0,0 +1,78 @@
import z from "zod";
/**
* Common envelope shared by every frame variant: a stable per-message `id`, the
* `senderId` of the originating tab, and the originator's `timestamp`.
*/
const ChannelEnvelopeSchema = z.object({
id: z.string().min(1),
senderId: z.string().min(1),
timestamp: z.number().int().nonnegative(),
});
/**
* Legacy frame: any free-form `type` string + arbitrary `payload`. Covers
* heartbeats, `tab-join` / `tab-leave`, user-defined `send()` traffic.
*/
const LegacyFrameSchema = ChannelEnvelopeSchema.extend({
type: z.string().min(1),
payload: z.unknown(),
}).refine((frame) => frame.type !== "ask" && frame.type !== "ask-response", {
message:
"ask and ask-response frames must use the typed variants with correlationId / responseTo",
});
/**
* Request frame for the `ask()` request/response primitive. `correlationId`
* binds responses to the issuing call; `handlerKey` selects the responder;
* `expiresAt` is advisory (the asker still tracks its own deadline through
* `TimersLike`).
*/
export const AskFrameSchema = ChannelEnvelopeSchema.extend({
type: z.literal("ask"),
correlationId: z.string().min(1),
handlerKey: z.string().min(1),
payload: z.unknown(),
expiresAt: z.number().int().nonnegative(),
});
/**
* Response frame paired to an `AskFrameSchema` by `responseTo` equal to the
* original `correlationId`. `outcome: "ok" | "error"` distinguishes a normal
* return from a handler-thrown error (in which case `payload` is the
* structured projection `{ message, code? }`).
*/
export const AskResponseFrameSchema = ChannelEnvelopeSchema.extend({
type: z.literal("ask-response"),
responseTo: z.string().min(1),
handlerKey: z.string().min(1),
outcome: z.enum(["ok", "error"]),
payload: z.unknown(),
});
/**
* Zod schema for `ChannelMessage` frames received off the underlying
* `ChannelLike` transport. Validation is the boundary between the wire and
* the in-process handler registry — every inbound frame passes through this
* before any dispatch can happen. The union widens at the `add-broadcast-
* channel-ask` change to cover `ask` / `ask-response` typed variants in
* addition to the original free-form frame shape.
*/
export const ChannelMessageSchema = z.union([
AskFrameSchema,
AskResponseFrameSchema,
LegacyFrameSchema,
]);
/** Zod schema for the constructor `ChannelConfig` object. */
export const ChannelConfigSchema = z.object({
name: z.string().min(1),
heartbeatInterval: z.number().int().positive(),
tabTimeout: z.number().int().positive(),
maxMessageBytes: z.number().int().positive(),
});
export type ChannelMessageInput = z.infer<typeof ChannelMessageSchema>;
export type ChannelConfigInput = z.infer<typeof ChannelConfigSchema>;
export type AskFrameInput = z.infer<typeof AskFrameSchema>;
export type AskResponseFrameInput = z.infer<typeof AskResponseFrameSchema>;

View File

@@ -0,0 +1,9 @@
export {
createLocalStorageChannelFactory,
type LocalStorageChannelOptions,
} from "./local-storage.js";
export {
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
} from "./native.js";
export type { ChannelFactory, ChannelLike } from "./transport.types.js";

View File

@@ -0,0 +1,101 @@
# transport/local-storage
## Purpose
`createLocalStorageChannelFactory` is the `StorageLike`-backed fallback
transport. Use it whenever the host lacks a native `BroadcastChannel` (Safari
< 15.4, extension sandboxes, headless environments) so the same
`TypedBroadcastChannel` consumer code keeps working. The factory writes
JSON-encoded frames to the injected storage under a stable prefix and polls
for newly-added keys on a configurable interval; every other channel on the
same `name` and `storage` instance picks the frame up on its next poll.
The fallback fixes the enterprise gap "No `localStorage` fallback for
browsers without `BroadcastChannel` (Safari < 15.4 etc.)". The chosen
contract is `StorageLike` rather than the raw `window.localStorage` API so
the same factory works on top of OPFS, IndexedDB, Garage S3, or any in-memory
test double — consumers wire whichever store their host actually provides.
## Features
- Polled fan-out across all channels on the same `name`.
- Configurable `pollIntervalMs` (default 50 ms) and `retentionMs` (default 30 s).
- Best-effort garbage collection of frames past retention during the same poll loop.
- Optional `LoggerLike` carries `BROADCAST_CHANNEL.STORAGE_FAILED` failures without crashing.
- Injectable `clock` / `idSource` for deterministic tests.
- Per-instance sender-side de-duplication so a channel never replays its own frames.
## Usage: Basic
```ts
import { createLocalStorageChannelFactory } from "@suaveplan/broadcast-channel";
import type { StorageLike } from "@suaveplan/types";
declare const storage: StorageLike;
const factory = createLocalStorageChannelFactory(storage);
const a = factory("app-bus");
a.postMessage({ kind: "hello" });
```
## Usage: Advanced
```ts
import {
createLocalStorageChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import type { LoggerLike, StorageLike } from "@suaveplan/types";
declare const storage: StorageLike;
declare const appLogger: LoggerLike;
const factory = createLocalStorageChannelFactory(storage, {
pollIntervalMs: 25,
retentionMs: 10_000,
clock: () => Date.now(),
idSource: () => crypto.randomUUID(),
logger: appLogger,
});
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory,
logger: appLogger,
});
channel.open();
```
## API Reference
- `function createLocalStorageChannelFactory(storage: StorageLike, options?: LocalStorageChannelOptions): ChannelFactory`
- `interface LocalStorageChannelOptions`
- `pollIntervalMs?: number` — default `50`.
- `retentionMs?: number` — default `30_000`.
- `clock?: () => number` — default `Date.now`.
- `idSource?: () => string` — default a process-local counter combined with `Date.now().toString(36)`.
- `logger?: LoggerLike` — default no-op; receives `"broadcast-channel.storage.failed"` warnings.
Every returned `ChannelLike`:
- `postMessage(message: unknown): void` — fire-and-forget; failures are logged.
- `close(): void` — stops polling and clears listeners.
- `addEventListener("message", listener: (event: MessageEvent) => void): void`
## Implementation Notes
Frames are stored under
`@suaveplan/broadcast-channel/<urlencoded-name>/<padded-timestamp>/<sequence>`
so the `StorageLike.list({ prefix })` scan returns only the keys for the
target channel, sorted lexicographically by timestamp. The poll loop tracks
seen keys in a per-instance `Set<string>` so a channel never re-delivers a
frame to itself and so writes from the same instance are skipped on the way
back in. Storage rejections inside the polling, write, get, or delete paths
are wrapped in `BroadcastChannelStorageFailedError` and surfaced through the
injected `LoggerLike.warn`; we deliberately do not re-throw because the loop
runs on a timer and an unhandled rejection would crash the host. The garbage
collection step runs inline with the poll: any seen-or-newly-fetched key
whose embedded timestamp is past `retentionMs` is deleted, keeping the
storage footprint bounded under sustained traffic. JSON encoding (rather
than a binary codec) is intentional — the storage backends most likely to
host this transport (`localStorage`, OPFS) already pay an encoding cost, so
binary framing would deliver no real win.

View File

@@ -0,0 +1,548 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike, StorageLike } from "@suaveplan/types";
import { createLocalStorageChannelFactory, parseKey } from "./local-storage.js";
import type { ChannelLike } from "./transport.types.js";
class MemoryStorage implements StorageLike {
private readonly map = new Map<string, Uint8Array>();
public async get(key: string): Promise<Uint8Array | undefined> {
return this.map.get(key);
}
public async set(key: string, value: Uint8Array): Promise<void> {
this.map.set(key, value);
}
public async delete(key: string): Promise<boolean> {
return this.map.delete(key);
}
public async exists(key: string): Promise<boolean> {
return this.map.has(key);
}
public async list(options?: { prefix?: string; limit?: number }): Promise<{
keys: string[];
total: number;
hasMore: boolean;
continuationToken?: string | undefined;
}> {
const prefix = options?.prefix ?? "";
const keys = [...this.map.keys()]
.filter((k) => k.startsWith(prefix))
.sort();
return { keys, total: keys.length, hasMore: false };
}
public async clear(): Promise<void> {
this.map.clear();
}
}
function captureLogger(): {
logger: LoggerLike;
warns: Array<[string, Readonly<Record<string, unknown>> | undefined]>;
} {
const warns: Array<
[string, Readonly<Record<string, unknown>> | undefined]
> = [];
const logger: LoggerLike = {
debug() {},
info() {},
warn(msg, ctx) {
warns.push([msg, ctx]);
},
error() {},
};
return { logger, warns };
}
describe("createLocalStorageChannelFactory", () => {
it("round-trips a frame between two channels", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 10,
retentionMs: 5000,
});
const a = factory("rt");
const b = factory("rt");
const received: MessageEvent[] = [];
b.addEventListener("message", (e) => received.push(e));
await new Promise((r) => setTimeout(r, 30));
a.postMessage({ hi: 1 });
await new Promise((r) => setTimeout(r, 80));
expect(received.length).toBeGreaterThanOrEqual(1);
expect(received[0]?.data).toEqual({ hi: 1 });
a.close();
b.close();
});
it("stops receiving after close", { timeout: 5000 }, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 10,
retentionMs: 5000,
});
const a = factory("rt2");
const b = factory("rt2");
const received: MessageEvent[] = [];
b.addEventListener("message", (e) => received.push(e));
await new Promise((r) => setTimeout(r, 30));
b.close();
a.postMessage({ x: 2 });
await new Promise((r) => setTimeout(r, 50));
expect(received).toHaveLength(0);
a.close();
});
it("ignores listeners registered for non-message types", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 10,
});
const a = factory("typecheck");
// Cast to bypass the public type — exercises the
// "type !== 'message'" branch.
(
a as unknown as {
addEventListener: (
type: string,
listener: (e: MessageEvent) => void
) => void;
}
).addEventListener("other", () => {});
a.close();
expect(true).toBeTruthy();
});
it("garbage-collects frames past retention", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
let now = 1000;
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 10,
retentionMs: 20,
clock: () => now,
});
const a = factory("gc");
const b = factory("gc");
await new Promise((r) => setTimeout(r, 30));
a.postMessage({ z: 1 });
await new Promise((r) => setTimeout(r, 30));
const before = (
await store.list({ prefix: "@suaveplan/broadcast-channel/" })
).keys.length;
expect(before).toBeGreaterThan(0);
now += 1000;
await new Promise((r) => setTimeout(r, 80));
const after = (
await store.list({ prefix: "@suaveplan/broadcast-channel/" })
).keys.length;
expect(after).toBe(0);
a.close();
b.close();
});
it("uses the default id source when none provided", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store);
const a = factory("default-id");
a.postMessage({ x: 1 });
await new Promise((r) => setTimeout(r, 30));
const keys = (
await store.list({ prefix: "@suaveplan/broadcast-channel/" })
).keys;
expect(keys.length).toBeGreaterThanOrEqual(1);
a.close();
});
it("logs STORAGE_FAILED warning when list rejects", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const failing: StorageLike = {
...store,
list: () => Promise.reject(new Error("boom-list")),
get: (k) => store.get(k),
set: (k, v) => store.set(k, v),
delete: (k) => store.delete(k),
exists: (k) => store.exists(k),
clear: () => store.clear(),
};
const { logger, warns } = captureLogger();
const factory = createLocalStorageChannelFactory(failing, {
pollIntervalMs: 5,
logger,
});
const a = factory("fail-list");
await new Promise((r) => setTimeout(r, 60));
a.close();
const ops = warns.map((w) => w[1]?.["op"]);
expect(ops).toContain("list");
});
it("logs STORAGE_FAILED warning when set rejects", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const failing: StorageLike = {
get: (k) => store.get(k),
set: () => Promise.reject(new Error("boom-set")),
delete: (k) => store.delete(k),
exists: (k) => store.exists(k),
list: (o) => store.list(o),
clear: () => store.clear(),
};
const { logger, warns } = captureLogger();
const factory = createLocalStorageChannelFactory(failing, {
pollIntervalMs: 5,
logger,
});
const a = factory("fail-set");
a.postMessage({ hi: 1 });
await new Promise((r) => setTimeout(r, 30));
a.close();
const ops = warns.map((w) => w[1]?.["op"]);
expect(ops).toContain("set");
});
it("logs STORAGE_FAILED warning when get rejects mid-poll", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const failing: StorageLike = {
get: () => Promise.reject(new Error("boom-get")),
set: (k, v) => store.set(k, v),
delete: (k) => store.delete(k),
exists: (k) => store.exists(k),
list: (o) => store.list(o),
clear: () => store.clear(),
};
const { logger, warns } = captureLogger();
const factory = createLocalStorageChannelFactory(failing, {
pollIntervalMs: 5,
logger,
});
const a = factory("fail-get");
// Wait for primeSeen to settle, then seed a frame *after* — this
// way the poll loop sees a new key it hasn't recorded as `seen`
// and proceeds to call the failing `get`.
await new Promise((r) => setTimeout(r, 30));
await store.set(
"@suaveplan/broadcast-channel/fail-get/0000000000000001/a",
new TextEncoder().encode(JSON.stringify({ data: { x: 1 } }))
);
await new Promise((r) => setTimeout(r, 50));
a.close();
const ops = warns.map((w) => w[1]?.["op"]);
expect(ops).toContain("get");
});
it("logs STORAGE_FAILED warning when delete rejects on gc", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
let now = 1000;
const failing: StorageLike = {
get: (k) => store.get(k),
set: (k, v) => store.set(k, v),
delete: () => Promise.reject(new Error("boom-delete")),
exists: (k) => store.exists(k),
list: (o) => store.list(o),
clear: () => store.clear(),
};
const { logger, warns } = captureLogger();
const factory = createLocalStorageChannelFactory(failing, {
pollIntervalMs: 5,
retentionMs: 10,
clock: () => now,
logger,
});
const a = factory("fail-delete");
await new Promise((r) => setTimeout(r, 30));
a.postMessage({ q: 1 });
await new Promise((r) => setTimeout(r, 30));
now += 1000;
await new Promise((r) => setTimeout(r, 60));
a.close();
const ops = warns.map((w) => w[1]?.["op"]);
expect(ops).toContain("delete");
});
it("skips listener invocation on undecodable bytes", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 5,
retentionMs: 5000,
});
const a = factory("bad");
const received: MessageEvent[] = [];
a.addEventListener("message", (e) => received.push(e));
// Wait for primeSeen to settle, THEN seed an undecodable frame so
// the poll loop discovers a new key and tries to parse the body.
await new Promise((r) => setTimeout(r, 30));
await store.set(
"@suaveplan/broadcast-channel/bad/0000000000000001/a",
new Uint8Array([0x7b, 0xff, 0xfe, 0xfd]) // "{" then garbage so JSON.parse throws
);
await new Promise((r) => setTimeout(r, 50));
a.close();
expect(received).toHaveLength(0);
});
it("close is idempotent", { timeout: 5000 }, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 5,
});
const a = factory("idempotent-close");
a.close();
expect(() => a.close()).not.toThrow();
});
it("postMessage after close is a no-op", { timeout: 5000 }, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 5,
});
const a = factory("post-after-close");
a.close();
a.postMessage({ x: 1 });
await new Promise((r) => setTimeout(r, 20));
const keys = (
await store.list({ prefix: "@suaveplan/broadcast-channel/" })
).keys;
expect(keys).toHaveLength(0);
});
it("poll exits early when storage.get returns undefined", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 5,
retentionMs: 5000,
});
const a = factory("undef-get");
await new Promise((r) => setTimeout(r, 30));
// Seed a key whose value is missing — store.list returns the key
// but store.get returns undefined (we simulate by patching).
const origGet = store.get.bind(store);
store.get = async (k: string) => {
if (k.includes("/undef-get/")) return undefined;
return origGet(k);
};
await store.set(
"@suaveplan/broadcast-channel/undef-get/0000000000000001/a",
new TextEncoder().encode("placeholder")
);
await new Promise((r) => setTimeout(r, 30));
a.close();
// No assertion needed — just ensure no error surfaces.
expect(true).toBe(true);
});
it("poll skips keys whose parseKey returns undefined during gc", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 5,
retentionMs: 1,
});
// Seed a key matching the channel's prefix but with a non-numeric
// timestamp segment, so parseKey returns undefined on gc.
await store.set(
"@suaveplan/broadcast-channel/parse-bad-ts/not-a-number/seq",
new TextEncoder().encode(JSON.stringify({ data: { x: 1 } }))
);
const a = factory("parse-bad-ts");
await new Promise((r) => setTimeout(r, 40));
a.close();
expect(true).toBe(true);
});
it("poll observes closed state mid-list and exits", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
// Synthesize a list() that delays so we can call close() before
// it resolves, then observe the closed-guard inside poll exit.
let resolveList: (() => void) | undefined;
let listCalls = 0;
const slowStore: StorageLike = {
get: (k) => store.get(k),
set: (k, v) => store.set(k, v),
delete: (k) => store.delete(k),
exists: (k) => store.exists(k),
clear: () => store.clear(),
list: (o) =>
new Promise((resolve) => {
listCalls++;
if (listCalls === 2) {
// primeSeen already ran; queue this resolution
// so we can fire close() before it lands.
resolveList = () => resolve(store.list(o));
} else {
resolve(store.list(o));
}
}),
};
const factory = createLocalStorageChannelFactory(slowStore, {
pollIntervalMs: 5,
retentionMs: 5000,
});
// Pre-seed a few keys so the inner loop has something to walk.
for (let i = 0; i < 5; i++) {
await store.set(
`@suaveplan/broadcast-channel/mid-close/${i
.toString(10)
.padStart(16, "0")}/seq`,
new TextEncoder().encode(JSON.stringify({ data: { i } }))
);
}
const a = factory("mid-close");
// Wait for the slow list to be captured.
await new Promise((r) => setTimeout(r, 15));
a.close();
// Now release the pending list — poll will resume, see closed, exit.
resolveList?.();
await new Promise((r) => setTimeout(r, 20));
expect(true).toBe(true);
});
it("poll inner loop bails on closed between deliveries", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
let getsBeforeClose = 0;
let aRef: ChannelLike | undefined;
const slowGet: StorageLike = {
get: async (k) => {
getsBeforeClose++;
// Close after the first inner-loop iteration completes,
// so the second iteration observes closed === true.
if (getsBeforeClose === 1 && aRef) {
aRef.close();
}
return store.get(k);
},
set: (k, v) => store.set(k, v),
delete: (k) => store.delete(k),
exists: (k) => store.exists(k),
list: (o) => store.list(o),
clear: () => store.clear(),
};
const factory = createLocalStorageChannelFactory(slowGet, {
pollIntervalMs: 5,
retentionMs: 5000,
});
aRef = factory("inner-bail");
// Wait for primeSeen to settle, THEN seed many new frames so the
// first real poll iteration has multiple keys to walk.
await new Promise((r) => setTimeout(r, 30));
for (let i = 0; i < 5; i++) {
await store.set(
`@suaveplan/broadcast-channel/inner-bail/${i
.toString(10)
.padStart(16, "0")}/seq`,
new TextEncoder().encode(JSON.stringify({ data: { i } }))
);
}
await new Promise((r) => setTimeout(r, 60));
expect(getsBeforeClose).toBeGreaterThanOrEqual(1);
});
it("poll early-exits when channel is closed before list resolves", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
// Hold every list() call so we can close() while one is pending.
const pending: Array<() => void> = [];
const holdingStore: StorageLike = {
get: (k) => store.get(k),
set: (k, v) => store.set(k, v),
delete: (k) => store.delete(k),
exists: (k) => store.exists(k),
clear: () => store.clear(),
list: (o) =>
new Promise((resolve) => {
pending.push(() => resolve(store.list(o)));
}),
};
const factory = createLocalStorageChannelFactory(holdingStore, {
pollIntervalMs: 5,
});
const a = factory("hold-list");
// Wait until at least the primeSeen + one poll call have queued.
await new Promise((r) => setTimeout(r, 30));
a.close();
// Release every held list() now — the resumed poll bodies will
// observe closed === true and hit the top-of-poll early-exit
// branch.
for (const r of pending.splice(0)) r();
await new Promise((r) => setTimeout(r, 30));
expect(true).toBe(true);
});
it("parseKey accepts a well-formed key", { timeout: 5000 }, () => {
const parsed = parseKey(
"@suaveplan/broadcast-channel/ch/0000000000001000/seq"
);
expect(parsed?.channelName).toBe("ch");
expect(parsed?.timestamp).toBe(1000);
expect(parsed?.sequence).toBe("seq");
});
it("parseKey rejects a key missing the prefix", { timeout: 5000 }, () => {
expect(parseKey("other/key")).toBeUndefined();
});
it("parseKey rejects a key with no inner slash", { timeout: 5000 }, () => {
expect(
parseKey("@suaveplan/broadcast-channel/no-slashes")
).toBeUndefined();
});
it("parseKey rejects a key with only one inner slash", {
timeout: 5000,
}, () => {
expect(
parseKey("@suaveplan/broadcast-channel/only-one/segment")
).toBeUndefined();
});
it("parseKey rejects a key with non-numeric timestamp", {
timeout: 5000,
}, () => {
expect(
parseKey("@suaveplan/broadcast-channel/ch/not-a-number/seq")
).toBeUndefined();
});
it("parses both well-formed and malformed keys", {
timeout: 5000,
}, async () => {
const store = new MemoryStorage();
// Inject a key that does not match the @suaveplan prefix; the
// channel's poll loop should ignore it entirely.
await store.set("other-prefix/whatever", new Uint8Array());
// Inject a key with our prefix but no slashes inside.
await store.set(
"@suaveplan/broadcast-channel/no-slashes",
new TextEncoder().encode(JSON.stringify({ data: { x: 1 } }))
);
const factory = createLocalStorageChannelFactory(store, {
pollIntervalMs: 5,
retentionMs: 1,
});
const a = factory("parse-edge");
await new Promise((r) => setTimeout(r, 30));
a.close();
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,257 @@
import type { LoggerLike, StorageLike } from "@suaveplan/types";
import { BroadcastChannelStorageFailedError } from "../errors/codes.js";
import { noopLogger } from "../logger/noop.js";
import type { ChannelFactory, ChannelLike } from "./transport.types.js";
/**
* Options for the `StorageLike`-backed channel factory.
*/
export interface LocalStorageChannelOptions {
/** Polling interval (ms) used to detect newly-written frames. Default 50. */
readonly pollIntervalMs?: number;
/**
* Retention window (ms) for posted frames. Frames whose key timestamp is
* older than this MAY be garbage-collected during a write. Default 30_000.
*/
readonly retentionMs?: number;
/** Injectable clock (ms since epoch). Defaults to `Date.now`. */
readonly clock?: () => number;
/**
* Counter used to seed the per-instance receiver ID so concurrent factories
* on the same `storage` don't collide. Defaults to a process-local counter.
*/
readonly idSource?: () => string;
/**
* Optional logger used to surface storage failures the channel observes
* during background polling / garbage collection. Defaults to no-op.
*/
readonly logger?: LoggerLike;
}
const DEFAULT_POLL_INTERVAL_MS = 50;
const DEFAULT_RETENTION_MS = 30_000;
const KEY_PREFIX = "@suaveplan/broadcast-channel/";
let localCounter = 0;
function defaultIdSource(): string {
localCounter = (localCounter + 1) >>> 0;
return `${Date.now().toString(36)}-${localCounter.toString(36)}`;
}
interface ParsedKey {
readonly channelName: string;
readonly timestamp: number;
readonly sequence: string;
}
function buildKey(
channelName: string,
timestamp: number,
sequence: string
): string {
return `${KEY_PREFIX}${encodeURIComponent(channelName)}/${timestamp.toString(10).padStart(16, "0")}/${sequence}`;
}
/** @internal exported for testing */
export function parseKey(key: string): ParsedKey | undefined {
if (!key.startsWith(KEY_PREFIX)) return undefined;
const rest = key.slice(KEY_PREFIX.length);
const slash1 = rest.indexOf("/");
if (slash1 < 0) return undefined;
const slash2 = rest.indexOf("/", slash1 + 1);
if (slash2 < 0) return undefined;
const channelName = decodeURIComponent(rest.slice(0, slash1));
const tsStr = rest.slice(slash1 + 1, slash2);
const sequence = rest.slice(slash2 + 1);
const timestamp = Number.parseInt(tsStr, 10);
if (!Number.isFinite(timestamp)) return undefined;
return { channelName, timestamp, sequence };
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/**
* Create a `ChannelFactory` whose underlying transport writes JSON-encoded
* frames into a `StorageLike` and polls for peers' frames. Use this when the
* host lacks the native `BroadcastChannel`.
*/
export function createLocalStorageChannelFactory(
storage: StorageLike,
options: LocalStorageChannelOptions = {}
): ChannelFactory {
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
const clock = options.clock ?? (() => Date.now());
const idSource = options.idSource ?? defaultIdSource;
const logger = options.logger ?? noopLogger;
return (name: string): ChannelLike => {
return new LocalStorageChannel({
name,
storage,
pollIntervalMs,
retentionMs,
clock,
idSource,
logger,
});
};
}
interface LocalStorageChannelDeps {
readonly name: string;
readonly storage: StorageLike;
readonly pollIntervalMs: number;
readonly retentionMs: number;
readonly clock: () => number;
readonly idSource: () => string;
readonly logger: LoggerLike;
}
class LocalStorageChannel implements ChannelLike {
private readonly listeners: Array<(event: MessageEvent) => void> = [];
private readonly seen = new Set<string>();
private readonly prefix: string;
private readonly deps: LocalStorageChannelDeps;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private closed = false;
public constructor(deps: LocalStorageChannelDeps) {
this.deps = deps;
this.prefix = `${KEY_PREFIX}${encodeURIComponent(deps.name)}/`;
// Mark all pre-existing frames for this channel as already seen so a
// fresh consumer doesn't pick up stale traffic written before it opened.
void this.primeSeen();
this.startPolling();
}
public postMessage(message: unknown): void {
if (this.closed) return;
const timestamp = this.deps.clock();
const sequence = this.deps.idSource();
const key = buildKey(this.deps.name, timestamp, sequence);
this.seen.add(key);
const payload = encoder.encode(JSON.stringify({ data: message }));
void this.writeKey(key, payload);
}
public close(): void {
this.closed = true;
if (this.pollTimer !== null) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
this.listeners.length = 0;
}
public addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void {
if (type === "message") {
this.listeners.push(listener);
}
}
private startPolling(): void {
this.pollTimer = setInterval(() => {
void this.poll();
}, this.deps.pollIntervalMs);
}
private reportStorageFailure(
op: "get" | "set" | "delete" | "list",
ctx: Readonly<Record<string, unknown>>,
cause: unknown
): void {
const err = new BroadcastChannelStorageFailedError({
cause,
context: { op, ...ctx },
});
this.deps.logger.warn("broadcast-channel.storage.failed", {
op,
code: err.code,
message: err.message,
});
}
private async writeKey(key: string, value: Uint8Array): Promise<void> {
try {
await this.deps.storage.set(key, value);
} catch (cause) {
this.reportStorageFailure("set", { key }, cause);
}
}
private async primeSeen(): Promise<void> {
try {
const listed = await this.deps.storage.list({
prefix: this.prefix,
});
for (const k of listed.keys) this.seen.add(k);
} catch (cause) {
this.reportStorageFailure("list", { prefix: this.prefix }, cause);
}
}
private async poll(): Promise<void> {
let keys: readonly string[];
try {
const listed = await this.deps.storage.list({
prefix: this.prefix,
});
keys = listed.keys;
} catch (cause) {
this.reportStorageFailure("list", { prefix: this.prefix }, cause);
return;
}
// If close() ran while list() was in flight, exit before any
// delivery / gc work — the polling timer has already been cleared
// but this callback was already on the microtask queue.
if (this.closed) return;
const now = this.deps.clock();
for (const key of keys) {
if (this.closed) return;
if (this.seen.has(key)) {
await this.maybeGc(key, now);
continue;
}
this.seen.add(key);
await this.deliverKey(key);
await this.maybeGc(key, now);
}
}
private async maybeGc(key: string, now: number): Promise<void> {
const parsed = parseKey(key);
if (parsed === undefined) return;
if (now - parsed.timestamp <= this.deps.retentionMs) return;
try {
await this.deps.storage.delete(key);
} catch (cause) {
this.reportStorageFailure("delete", { key }, cause);
}
}
private async deliverKey(key: string): Promise<void> {
let bytes: Uint8Array | undefined;
try {
bytes = await this.deps.storage.get(key);
} catch (cause) {
this.reportStorageFailure("get", { key }, cause);
return;
}
if (bytes === undefined) return;
let parsed: { data: unknown };
try {
parsed = JSON.parse(decoder.decode(bytes)) as { data: unknown };
} catch {
return;
}
const event = { data: parsed.data } as MessageEvent;
for (const listener of this.listeners.slice()) {
listener(event);
}
}
}

View File

@@ -0,0 +1,78 @@
# transport/native
## Purpose
`nativeChannelFactory` is a thin adapter that exposes the global `BroadcastChannel`
constructor as a `ChannelLike`. Use this in any environment where the
native API is available — every modern browser, Bun, and recent Node (with
the `BroadcastChannel` global). The companion helper
`isNativeBroadcastChannelAvailable()` is a one-line feature detect so callers
can branch between the native transport and the `StorageLike`-backed
fallback at runtime without try/catching around constructor lookups.
The adapter exists to fix the upstream "addEventListener(event: any)" smell:
the native `BroadcastChannel` already hands back `MessageEvent` objects, and
the wrapper preserves that static type in the `ChannelLike` signature. The
surface never lets `any` leak from this code path.
## Features
- Zero-cost wrapper around the native `BroadcastChannel`.
- Statically-typed `(event: MessageEvent) => void` listener forwarding.
- `isNativeBroadcastChannelAvailable()` feature detect.
- Sideeffect-free — `nativeChannelFactory` only constructs `BroadcastChannel` when invoked.
## Usage: Basic
```ts
import {
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: nativeChannelFactory,
});
channel.open();
```
## Usage: Advanced
```ts
import {
createLocalStorageChannelFactory,
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
TypedBroadcastChannel,
} from "@suaveplan/broadcast-channel";
import type { StorageLike } from "@suaveplan/types";
declare const storage: StorageLike;
const channel = new TypedBroadcastChannel({
config: { name: "app-bus" },
factory: isNativeBroadcastChannelAvailable()
? nativeChannelFactory
: createLocalStorageChannelFactory(storage),
});
```
## API Reference
- `const nativeChannelFactory: ChannelFactory` — `(name) => ChannelLike` wrapping `new BroadcastChannel(name)`.
- `function isNativeBroadcastChannelAvailable(): boolean` — returns `true` if `globalThis.BroadcastChannel` is a function.
## Implementation Notes
The wrapper does not extend `BroadcastChannel` directly because the suaveplan
`ChannelLike` interface narrows `addEventListener` to a single literal type
(`"message"`) — extending the host type would re-introduce the overloaded
union the original surface carried and force callers to handle event types
the channel never delivers (`messageerror`). Forwarding through a small
adapter keeps the public type small and prevents accidental binding of the
`messageerror` event, which the channel's leader-election and presence
machinery is not designed to react to. The feature-detect helper checks for
`typeof globalThis.BroadcastChannel === "function"` rather than `"undefined"
!== typeof BroadcastChannel`: the literal-`function` check sidesteps the
edge case where a host puts a non-callable stub on the global (some
extension sandboxes do this) and forces the caller into the fallback path.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
isNativeBroadcastChannelAvailable,
nativeChannelFactory,
} from "./native.js";
describe("nativeChannelFactory", () => {
it("reports availability under happy-dom", { timeout: 5000 }, () => {
expect(isNativeBroadcastChannelAvailable()).toBe(true);
});
it("round-trips a frame through native BroadcastChannel", {
timeout: 5000,
}, async () => {
const a = nativeChannelFactory("native-rt");
const b = nativeChannelFactory("native-rt");
const received: MessageEvent[] = [];
b.addEventListener("message", (e) => {
received.push(e);
});
a.postMessage({ hello: "world" });
await new Promise((r) => setTimeout(r, 10));
expect(received.length).toBeGreaterThanOrEqual(1);
expect(received[0]?.data).toEqual({ hello: "world" });
a.close();
b.close();
});
it("close stops further delivery", { timeout: 5000 }, async () => {
const a = nativeChannelFactory("native-close");
const b = nativeChannelFactory("native-close");
const received: MessageEvent[] = [];
b.addEventListener("message", (e) => received.push(e));
b.close();
a.postMessage({ x: 1 });
await new Promise((r) => setTimeout(r, 10));
expect(received).toHaveLength(0);
a.close();
});
});

View File

@@ -0,0 +1,34 @@
import type { ChannelFactory, ChannelLike } from "./transport.types.js";
/**
* Adapter wrapping the global `BroadcastChannel` constructor as a
* `ChannelLike`. Use this in any browser environment that exposes the
* native API. For Safari < 15.4 (and any sandbox lacking the global),
* fall back to `createLocalStorageChannelFactory`.
*/
export const nativeChannelFactory: ChannelFactory = (name: string) => {
const ch = new BroadcastChannel(name);
const wrapper: ChannelLike = {
postMessage(message: unknown): void {
ch.postMessage(message);
},
close(): void {
ch.close();
},
addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void {
ch.addEventListener(type, listener);
},
};
return wrapper;
};
/**
* Feature-detect the native `BroadcastChannel`. Returns `false` on Safari
* versions that lack the global or in non-browser contexts.
*/
export function isNativeBroadcastChannelAvailable(): boolean {
return typeof globalThis.BroadcastChannel === "function";
}

View File

@@ -0,0 +1,24 @@
/**
* Minimal interface for the underlying cross-tab transport. Implementations
* MAY be the native `BroadcastChannel` (wrapped by `nativeChannelFactory`),
* a `StorageLike`-backed polyfill (wrapped by `createLocalStorageChannelFactory`),
* or any test double satisfying the same shape.
*
* The listener signature is `(event: MessageEvent) => void` — upstream's
* `(event: any)` smell is intentionally fixed at the migration boundary so
* callers get full structural typing on `event.data`.
*/
export interface ChannelLike {
postMessage(message: unknown): void;
close(): void;
addEventListener(
type: "message",
listener: (event: MessageEvent) => void
): void;
}
/**
* Factory function producing a `ChannelLike` for the given channel name.
* Each call returns a fresh instance; closing one MUST NOT close peers.
*/
export type ChannelFactory = (name: string) => ChannelLike;

View File

@@ -0,0 +1,166 @@
/**
* Configuration for a typed broadcast channel.
*/
export interface ChannelConfig {
/** Channel name used for the underlying `BroadcastChannel` / fallback transport. */
readonly name: string;
/** Heartbeat interval in ms. */
readonly heartbeatInterval: number;
/** Eviction timeout in ms — peers whose `lastSeen` exceeds this are removed. */
readonly tabTimeout: number;
/** Hard upper bound on the JSON-encoded byte length of any single outbound frame. */
readonly maxMessageBytes: number;
}
/**
* A typed message sent through the broadcast channel.
*/
export interface ChannelMessage<T = unknown> {
/** Message type identifier. */
readonly type: string;
/** Message payload. */
readonly payload: T;
/** Unique message ID. */
readonly id: string;
/** ID of the sending tab. */
readonly senderId: string;
/** Timestamp of the message (ms since epoch). */
readonly timestamp: number;
}
/**
* Information about a connected tab.
*/
export interface TabInfo {
/** Unique tab identifier. */
readonly id: string;
/** Whether this tab is the current leader. */
readonly isLeader: boolean;
/** Last heartbeat timestamp. */
readonly lastSeen: number;
/** When the tab joined. */
readonly joinedAt: number;
}
/**
* Internal message types for channel coordination.
*/
export type InternalMessageType =
| "heartbeat"
| "leader-claim"
| "leader-ack"
| "tab-join"
| "tab-leave"
| "ask"
| "ask-response";
/**
* Request frame for `TypedBroadcastChannel.ask()`. `correlationId` is drawn
* from the asker's injected `Rng` and binds the eventual `AskResponseFrame`
* back to the pending promise.
*/
export interface AskFrame {
readonly type: "ask";
readonly correlationId: string;
readonly handlerKey: string;
readonly payload: unknown;
readonly expiresAt: number;
readonly id: string;
readonly senderId: string;
readonly timestamp: number;
}
/**
* Response frame paired to an `AskFrame` by `responseTo`. `outcome: "error"`
* encodes a handler-thrown error projected as `{ message, code? }` in
* `payload`.
*/
export interface AskResponseFrame {
readonly type: "ask-response";
readonly responseTo: string;
readonly handlerKey: string;
readonly outcome: "ok" | "error";
readonly payload: unknown;
readonly id: string;
readonly senderId: string;
readonly timestamp: number;
}
/** Caller-facing options for `TypedBroadcastChannel.ask()`. */
export interface AskOptions {
/** Reject after this many ms with `ASK_TIMEOUT`. Default 5000. */
readonly timeoutMs?: number;
/**
* If true and the timeout fires without any peer claiming the
* `handlerKey`, reject with `ASK_NO_RESPONDER` instead of `ASK_TIMEOUT`.
* Default false (timeout-only / first-wins).
*/
readonly requireResponder?: boolean;
}
/** Context passed to an `onAsk` responder. */
export interface AskHandlerContext {
readonly correlationId: string;
readonly senderId: string;
readonly handlerKey: string;
}
/** Error projection placed in an `outcome: "error"` `AskResponseFrame`. */
export interface AskErrorPayload {
readonly message: string;
readonly code?: string;
}
/**
* Event map for the genesis `EventBus<TMap>` (single-payload shape). Each
* lifecycle event carries one structured record.
*/
export interface ChannelOpenedEvent {
readonly channelName: string;
readonly tabId: string;
}
export interface ChannelClosedEvent {
readonly channelName: string;
readonly tabId: string;
}
export interface MessageSentEvent {
readonly type: string;
readonly tabId: string;
}
export interface TabJoinedEvent {
readonly tabId: string;
}
export interface TabLeftEvent {
readonly tabId: string;
}
export interface LeaderElectedEvent {
readonly tabId: string;
}
export type BroadcastChannelEventMap = {
"channel:opened": ChannelOpenedEvent;
"channel:closed": ChannelClosedEvent;
"message:received": ChannelMessage;
"message:sent": MessageSentEvent;
"tab:joined": TabJoinedEvent;
"tab:left": TabLeftEvent;
"leader:elected": LeaderElectedEvent;
} & Record<string, unknown>;
/**
* Default channel configuration. Callers supply `name`; the rest fall back to
* these values unless overridden.
*/
export const DEFAULT_CHANNEL_CONFIG: Omit<ChannelConfig, "name"> = {
heartbeatInterval: 5000,
tabTimeout: 15_000,
maxMessageBytes: 1024 * 1024,
};
/** Default value for `maxMessageBytes`. */
export const MAX_MESSAGE_BYTES_DEFAULT = DEFAULT_CHANNEL_CONFIG.maxMessageBytes;

View File

@@ -0,0 +1,15 @@
export {
type BroadcastChannelEventMap,
type ChannelClosedEvent,
type ChannelConfig,
type ChannelMessage,
type ChannelOpenedEvent,
DEFAULT_CHANNEL_CONFIG,
type InternalMessageType,
type LeaderElectedEvent,
MAX_MESSAGE_BYTES_DEFAULT,
type MessageSentEvent,
type TabInfo,
type TabJoinedEvent,
type TabLeftEvent,
} from "./broadcast-channel.types.js";

View File

@@ -0,0 +1,36 @@
/**
* Test-environment setup for @suaveplan/broadcast-channel.
*
* Wires a real in-memory OpenTelemetry `MeterProvider` BEFORE any source
* module loads, so the meter resolved at module load is the real provider's
* meter. Tests that inspect emitted instruments read them out of the in-memory
* exporter via `flushTestMetrics()`.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics(): Promise<
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
> {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/broadcast-channel",
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: [
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,8 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
environment: "happy-dom",
fileParallelism: false,
testTimeout: 30000,
additionalSetupFiles: ["./tests/setup/polyfills.ts"],
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,81 @@
{
"name": "@suaveplan/event-sourcing",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Event sourcing patterns: aggregate root, typed event store, snapshot support, projections, upcasting, and OTel instrumentation",
"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" }
},
"./errors/event-sourcing-error": {
"import": { "types": "./dist/errors/event-sourcing-error.d.ts", "default": "./dist/errors/event-sourcing-error.js" },
"require": { "types": "./dist/errors/event-sourcing-error.d.ts", "default": "./dist/errors/event-sourcing-error.js" }
},
"./core/aggregate/aggregate": {
"import": { "types": "./dist/core/aggregate/aggregate.d.ts", "default": "./dist/core/aggregate/aggregate.js" },
"require": { "types": "./dist/core/aggregate/aggregate.d.ts", "default": "./dist/core/aggregate/aggregate.js" }
},
"./core/event-store/memory-event-store": {
"import": { "types": "./dist/core/event-store/memory-event-store.d.ts", "default": "./dist/core/event-store/memory-event-store.js" },
"require": { "types": "./dist/core/event-store/memory-event-store.d.ts", "default": "./dist/core/event-store/memory-event-store.js" }
},
"./core/aggregate-repository/aggregate-repository": {
"import": { "types": "./dist/core/aggregate-repository/aggregate-repository.d.ts", "default": "./dist/core/aggregate-repository/aggregate-repository.js" },
"require": { "types": "./dist/core/aggregate-repository/aggregate-repository.d.ts", "default": "./dist/core/aggregate-repository/aggregate-repository.js" }
},
"./projection/builder/builder": {
"import": { "types": "./dist/projection/builder/builder.d.ts", "default": "./dist/projection/builder/builder.js" },
"require": { "types": "./dist/projection/builder/builder.d.ts", "default": "./dist/projection/builder/builder.js" }
},
"./upcasting/upcaster/upcaster": {
"import": { "types": "./dist/upcasting/upcaster/upcaster.d.ts", "default": "./dist/upcasting/upcaster/upcaster.js" },
"require": { "types": "./dist/upcasting/upcaster/upcaster.d.ts", "default": "./dist/upcasting/upcaster/upcaster.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",
"bench": "bun run src/__benchmarks__/event-sourcing.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/diff": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,5 @@
/** Default snapshot threshold (disabled by default). */
export const DEFAULT_SNAPSHOT_THRESHOLD = undefined;
/** Maximum upcaster chain steps before hard stop. */
export const MAX_UPCAST_CHAIN_STEPS = 100;

View File

@@ -0,0 +1,69 @@
# aggregate-repository
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createAggregateRepository`, the primary entry-point for persisting and loading event-sourced aggregates. It bridges the `Aggregate` base class with an `EventStore`, handling snapshot-based optimisation, optimistic concurrency, and automatic snapshotting when a `snapshotThreshold` is configured.
## Features
- `load(aggregateId)` — loads snapshot (if any), then replays only delta events; throws `AGGREGATE_NOT_FOUND` when no events or snapshot exist
- `save(aggregate)` — drains pending events, computes `expectedVersion`, delegates to `store.append`, then optionally saves a new snapshot
- `exists(aggregateId)` — returns true if any events or a snapshot exist for the ID
- `snapshotThreshold` — automatically snapshots when `aggregate.version % threshold === 0`
- OTel spans on `load` and `save` with aggregate ID and event count attributes
- Injectable `EventStore` for full testability
## Usage: Basic Example
```ts
import { createAggregateRepository, createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
const repository = createAggregateRepository({
store,
factory: (id) => new OrderAggregate(id),
});
const order = new OrderAggregate("order-1");
order.place(100);
await repository.save(order);
const loaded = await repository.load("order-1");
console.log(loaded.getVersion()); // 1
```
## Usage: Advanced Example
```ts
import { createAggregateRepository, createMemoryEventStore } from "@suaveplan/event-sourcing";
const repository = createAggregateRepository({
store: createMemoryEventStore(),
factory: (id) => new BankAccount(id),
snapshotThreshold: 50,
});
// After 50 events, a snapshot is auto-saved
// On next load, only events after version 50 are replayed
```
## API Reference
### `createAggregateRepository(options)`
- `options.store: EventStore` — backing store implementation
- `options.factory: (id: string) => T` — creates a blank aggregate given an ID
- `options.snapshotThreshold?: number` — snapshot every N events
### `AggregateRepository<T>`
- `load(aggregateId): Promise<T>` — throws `AGGREGATE_NOT_FOUND` if not found
- `save(aggregate: T): Promise<void>` — no-op if no pending events
- `exists(aggregateId): Promise<boolean>`
## Implementation Notes
The `save` method computes `expectedVersion = aggregate.version - pending.length` before clearing pending events. This is the version the store should be at before applying the new events. The snapshot stores the opaque `state` field using internal access — this works because the `Aggregate` base class uses `protected` (not `private`) visibility.

View File

@@ -0,0 +1,167 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ES_CODES,
EventSourcingError,
} from "../../errors/event-sourcing-error.js";
import type { DomainEvent } from "../../types/event-sourcing.types.js";
import { Aggregate } from "../aggregate/aggregate.js";
import { createMemoryEventStore } from "../event-store/memory-event-store.js";
import { createAggregateRepository } from "./aggregate-repository.js";
class TestAggregate extends Aggregate<{ value: number }> {
public constructor(id: string) {
super(id, { value: 0 });
}
public setValue(v: number): void {
this.raise("ValueSet", { v });
}
protected applyEvent(event: DomainEvent): void {
if (event.type === "ValueSet") {
this.state = { value: (event.payload as { v: number }).v };
}
}
public getValue(): number {
return this.state.value;
}
}
describe("createAggregateRepository", () => {
it("saves and loads aggregate", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
});
const agg = new TestAggregate("a1");
agg.setValue(42);
await repo.save(agg);
const loaded = await repo.load("a1");
expect(loaded.getValue()).toBe(42);
});
it("throws AGGREGATE_NOT_FOUND for unknown id", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
});
await expect(repo.load("unknown")).rejects.toThrow(EventSourcingError);
const err = await repo.load("unknown").catch((e: unknown) => e);
expect((err as EventSourcingError).code).toBe(
ES_CODES.AGGREGATE_NOT_FOUND
);
});
it("exists returns false for unknown", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
});
expect(await repo.exists("unknown")).toBe(false);
});
it("exists returns true after save", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
});
const agg = new TestAggregate("a1");
agg.setValue(1);
await repo.save(agg);
expect(await repo.exists("a1")).toBe(true);
});
it("save is no-op when no pending events", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
});
const agg = new TestAggregate("a1");
// No events raised — save should not throw
await expect(repo.save(agg)).resolves.toBeUndefined();
});
it("auto-snapshots at threshold", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
snapshotThreshold: 2,
});
const agg = new TestAggregate("a1");
agg.setValue(1);
agg.setValue(2);
await repo.save(agg);
// Snapshot should have been saved at version 2
const snap = await store.loadSnapshot("a1");
expect(snap).toBeDefined();
expect(snap?.version).toBe(2);
});
it("loads from snapshot correctly", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
snapshotThreshold: 2,
});
const agg = new TestAggregate("a1");
agg.setValue(10);
agg.setValue(20);
await repo.save(agg);
// Raise more events
const agg2 = await repo.load("a1");
agg2.setValue(30);
await repo.save(agg2);
const loaded = await repo.load("a1");
expect(loaded.getValue()).toBe(30);
expect(loaded.getVersion()).toBe(3);
});
it("exists returns true when only snapshot exists", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
});
await store.saveSnapshot({
aggregateId: "a1",
aggregateType: "TestAggregate",
version: 5,
state: { value: 99 },
takenAt: new Date(),
});
expect(await repo.exists("a1")).toBe(true);
});
it("load with snapshot only does not throw", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new TestAggregate(id),
});
await store.saveSnapshot({
aggregateId: "a1",
aggregateType: "TestAggregate",
version: 5,
state: { value: 99 },
takenAt: new Date(),
});
const loaded = await repo.load("a1");
expect(loaded.getValue()).toBe(99);
});
});

View File

@@ -0,0 +1,123 @@
import { trace } from "@opentelemetry/api";
import {
ES_CODES,
EventSourcingError,
} from "../../errors/event-sourcing-error.js";
import type {
AggregateRepository,
AggregateRepositoryOptions,
EventStore,
Snapshot,
} from "../../types/event-sourcing.types.js";
import type { Aggregate } from "../aggregate/aggregate.js";
const tracer = trace.getTracer("@suaveplan/event-sourcing");
/**
* Implementation of `AggregateRepository` that orchestrates load-from-store
* and persist-to-store operations with optional snapshot support.
*/
class AggregateRepositoryImpl<T extends Aggregate<unknown>>
implements AggregateRepository<T>
{
protected readonly store: EventStore;
protected readonly factory: (id: string) => T;
protected readonly snapshotThreshold: number | undefined;
public constructor(options: AggregateRepositoryOptions<T>) {
this.store = options.store;
this.factory = options.factory;
this.snapshotThreshold = options.snapshotThreshold;
}
public async load(aggregateId: string): Promise<T> {
const span = tracer.startSpan("es.repository.load");
try {
const aggregate = this.factory(aggregateId);
const snapshot = await this.store.loadSnapshot(aggregateId);
const fromVersion = snapshot ? snapshot.version : 0;
if (snapshot) {
// Restore state from snapshot
(aggregate as unknown as { state: unknown }).state =
snapshot.state;
(aggregate as unknown as { version: number }).version =
snapshot.version;
}
const events = await this.store.load(aggregateId, fromVersion);
if (events.length === 0 && !snapshot) {
throw new EventSourcingError(
ES_CODES.AGGREGATE_NOT_FOUND,
`Aggregate "${aggregateId}" not found`,
{ context: { aggregateId } }
);
}
if (events.length > 0) {
aggregate.replayFrom(events);
}
span.setAttribute("es.aggregate_id", aggregateId);
return aggregate;
} finally {
span.end();
}
}
public async save(aggregate: T): Promise<void> {
const span = tracer.startSpan("es.repository.save");
try {
const pending = aggregate.clearPendingEvents();
if (pending.length === 0) return;
const expectedVersion = aggregate.getVersion() - pending.length;
await this.store.append(pending, expectedVersion);
span.setAttribute("es.aggregate_id", aggregate.id);
span.setAttribute("es.event_count", pending.length);
if (
this.snapshotThreshold !== undefined &&
aggregate.getVersion() % this.snapshotThreshold === 0
) {
const snapshot: Snapshot = {
aggregateId: aggregate.id,
aggregateType: aggregate.constructor.name,
version: aggregate.getVersion(),
state: (aggregate as unknown as { state: unknown }).state,
takenAt: new Date(),
};
await this.store.saveSnapshot(snapshot);
}
} finally {
span.end();
}
}
public async exists(aggregateId: string): Promise<boolean> {
const events = await this.store.load(aggregateId);
const snapshot = await this.store.loadSnapshot(aggregateId);
return events.length > 0 || snapshot !== undefined;
}
}
/**
* Creates an `AggregateRepository` for the given aggregate type.
*
* @example
* ```ts
* const repository = createAggregateRepository({
* store,
* factory: (id) => new OrderAggregate(id),
* snapshotThreshold: 50,
* });
* ```
*/
export function createAggregateRepository<T extends Aggregate<unknown>>(
options: AggregateRepositoryOptions<T>
): AggregateRepository<T> {
return new AggregateRepositoryImpl(options);
}

View File

@@ -0,0 +1 @@
export { createAggregateRepository } from "./aggregate-repository.js";

View File

@@ -0,0 +1,103 @@
# Aggregate
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Abstract base class for event-sourced aggregates. Provides the foundational lifecycle for raising domain events, replaying historical event streams, and managing the pending-event queue. Subclasses implement only the `applyEvent` method that folds an individual `DomainEvent` onto the current state — the base class handles version tracking, ID generation, and event accumulation.
## Features
- `raise(type, payload, metadata?)` — emits a new domain event, applies it immediately, increments the version, and queues it for persistence
- `replayFrom(events)` — replays a historical event slice (from snapshot version or zero), updating version and state deterministically
- `clearPendingEvents()` — returns and drains the pending queue; called by the repository after successful persistence
- `getPendingEvents()` — read-only snapshot of the pending queue without clearing
- `getVersion()` — exposes current stream version for the repository
- Injectable `Clock` and `IdGenerator` for deterministic tests
- `getAggregateType()` — overridable aggregate type name used in event envelopes
## Usage: Basic Example
```ts
import { Aggregate } from "@suaveplan/event-sourcing";
import type { DomainEvent } from "@suaveplan/event-sourcing";
interface OrderState { total: number; status: string }
class OrderAggregate extends Aggregate<OrderState> {
constructor(id: string) {
super(id, { total: 0, status: "pending" });
}
place(total: number): void {
this.raise("OrderPlaced", { total });
}
protected applyEvent(event: DomainEvent): void {
if (event.type === "OrderPlaced") {
this.state = { ...this.state, total: (event.payload as { total: number }).total, status: "placed" };
}
}
}
const order = new OrderAggregate("order-1");
order.place(250);
console.log(order.getPendingEvents().length); // 1
```
## Usage: Advanced Example
```ts
import { Aggregate } from "@suaveplan/event-sourcing";
class BankAccount extends Aggregate<{ balance: number }> {
constructor(id: string) {
super(id, { balance: 0 });
}
deposit(amount: number): void {
if (amount <= 0) throw new Error("Amount must be positive");
this.raise("Deposited", { amount });
}
withdraw(amount: number): void {
if (amount > this.state.balance) throw new Error("Insufficient funds");
this.raise("Withdrawn", { amount });
}
protected applyEvent(event: DomainEvent): void {
const p = event.payload as { amount: number };
if (event.type === "Deposited") this.state.balance += p.amount;
if (event.type === "Withdrawn") this.state.balance -= p.amount;
}
get balance(): number { return this.state.balance; }
}
```
## API Reference
### `raise(type, payload, metadata?)`
Emits a domain event. Applies it to state immediately via `applyEvent`, then queues in `pendingEvents`.
### `replayFrom(events)`
Replays a sorted slice of events onto the aggregate without touching `pendingEvents`.
### `clearPendingEvents(): DomainEvent[]`
Returns and clears the pending queue. Called by the repository after persist.
### `getPendingEvents(): readonly DomainEvent[]`
Read-only snapshot; does not clear.
### `getVersion(): number`
Current stream version (0 = no events applied).
## Implementation Notes
The base class uses `protected` (not `private`) fields throughout so that test subclasses and framework-level tooling can inspect or extend state without reflection hacks. The `raise` method always applies the event through `applyEvent` before pushing to the pending queue, ensuring the aggregate state is consistent even before persistence.

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DomainEvent } from "../../types/event-sourcing.types.js";
import { Aggregate } from "./aggregate.js";
interface CounterState {
count: number;
}
class CounterAggregate extends Aggregate<CounterState> {
public constructor(id: string) {
super(id, { count: 0 });
}
public increment(by = 1): void {
this.raise("Incremented", { by });
}
public decrement(by = 1): void {
this.raise("Decremented", { by });
}
protected applyEvent(event: DomainEvent): void {
const payload = event.payload as { by: number };
if (event.type === "Incremented") {
this.state = { count: this.state.count + payload.by };
} else if (event.type === "Decremented") {
this.state = { count: this.state.count - payload.by };
}
}
public getCount(): number {
return this.state.count;
}
}
describe("Aggregate", () => {
it("starts at version 0", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
expect(a.getVersion()).toBe(0);
});
it("increments version on raise", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
a.increment();
expect(a.getVersion()).toBe(1);
a.increment();
expect(a.getVersion()).toBe(2);
});
it("applies event immediately", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
a.increment(5);
expect(a.getCount()).toBe(5);
});
it("queues pending events", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
a.increment();
a.increment();
expect(a.getPendingEvents()).toHaveLength(2);
});
it("clearPendingEvents returns and clears queue", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
a.increment();
const events = a.clearPendingEvents();
expect(events).toHaveLength(1);
expect(a.getPendingEvents()).toHaveLength(0);
});
it("replayFrom rebuilds state", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
const events: DomainEvent[] = [
{
id: "e1",
aggregateId: "c1",
aggregateType: "CounterAggregate",
type: "Incremented",
version: 1,
payload: { by: 3 },
metadata: {},
occurredAt: new Date(),
},
{
id: "e2",
aggregateId: "c1",
aggregateType: "CounterAggregate",
type: "Incremented",
version: 2,
payload: { by: 7 },
metadata: {},
occurredAt: new Date(),
},
];
a.replayFrom(events);
expect(a.getCount()).toBe(10);
expect(a.getVersion()).toBe(2);
expect(a.getPendingEvents()).toHaveLength(0);
});
it("raised events carry correct fields", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
a.increment(2);
const [event] = a.getPendingEvents();
expect(event).toBeDefined();
expect(event?.aggregateId).toBe("c1");
expect(event?.aggregateType).toBe("CounterAggregate");
expect(event?.type).toBe("Incremented");
expect(event?.version).toBe(1);
expect((event?.payload as { by: number }).by).toBe(2);
});
it("injectable clock used for occurredAt", { timeout: 5000 }, () => {
const fixedMs = new Date("2024-01-01T00:00:00Z").getTime();
const c = new (class extends Aggregate<{ count: number }> {
public constructor() {
super("cx", { count: 0 }, { clock: () => fixedMs });
}
public increment(): void {
this.raise("Inc", { by: 1 });
}
protected applyEvent(): void {}
})();
c.increment();
expect(c.getPendingEvents()[0]?.occurredAt).toEqual(new Date(fixedMs));
});
it("getAggregateType returns class name", { timeout: 5000 }, () => {
const a = new CounterAggregate("c1");
a.increment();
expect(a.getPendingEvents()[0]?.aggregateType).toBe("CounterAggregate");
});
});

View File

@@ -0,0 +1,123 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
import type { DomainEvent } from "../../types/event-sourcing.types.js";
/**
* Abstract base class for event-sourced aggregates.
*
* Subclasses implement `apply` to fold domain events onto state, and call
* `this.raise(type, payload)` to emit new events. The base class tracks
* the current stream version and accumulates pending events ready to be
* committed to an `EventStore`.
*/
export abstract class Aggregate<TState> {
/**
* Unique aggregate identifier — set via the constructor and immutable.
*/
public readonly id: string;
/**
* Current aggregate state. Updated on every event applied.
*/
protected state: TState;
/**
* Current stream version. 0 means no events have been applied.
*/
protected version = 0;
/**
* Events raised since last commit. Cleared by the repository after
* successful persistence.
*/
protected pendingEvents: DomainEvent[] = [];
protected readonly clock: Clock;
protected readonly idGenerator: IdGenerator;
public constructor(
id: string,
initialState: TState,
deps?: { clock?: Clock; idGenerator?: IdGenerator }
) {
this.id = id;
this.state = initialState;
this.clock = deps?.clock ?? (() => Date.now());
this.idGenerator = deps?.idGenerator ?? (() => crypto.randomUUID());
}
/**
* Apply a domain event to aggregate state. Called for both historical
* events during replay and newly raised events.
*/
protected abstract applyEvent(event: DomainEvent): void;
/**
* Returns the pending events and clears the internal queue.
*/
public clearPendingEvents(): DomainEvent[] {
const events = [...this.pendingEvents];
this.pendingEvents = [];
return events;
}
/**
* Expose the current version for external consumers (read-only).
*/
public getVersion(): number {
return this.version;
}
/**
* Expose pending events without clearing (read-only snapshot).
*/
public getPendingEvents(): readonly DomainEvent[] {
return this.pendingEvents;
}
/**
* Replay a sequence of events to rebuild state from scratch or from a
* snapshot. Events must be in ascending version order.
*/
public replayFrom(events: DomainEvent[]): void {
for (const event of events) {
this.applyEvent(event);
this.version = event.version;
}
}
/**
* Raise a new domain event. The event is applied immediately to state
* and queued in `pendingEvents`.
*
* @param type - The event type discriminator.
* @param payload - Strongly-typed event payload.
* @param metadata - Optional cross-cutting metadata.
*/
protected raise(
type: string,
payload: unknown,
metadata: Record<string, unknown> = {}
): void {
const event: DomainEvent = {
id: this.idGenerator(),
aggregateId: this.id,
aggregateType: this.getAggregateType(),
type,
version: this.version + 1,
payload,
metadata,
occurredAt: new Date(this.clock()),
};
this.applyEvent(event);
this.version = event.version;
this.pendingEvents.push(event);
}
/**
* Returns the aggregate type name used in domain events.
* Defaults to the class name; override for explicit naming.
*/
protected getAggregateType(): string {
return this.constructor.name;
}
}

View File

@@ -0,0 +1 @@
export { Aggregate } from "./aggregate.js";

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ES_CODES,
EventSourcingError,
} from "../../errors/event-sourcing-error.js";
import type {
DomainEvent,
Snapshot,
} from "../../types/event-sourcing.types.js";
import {
createMemoryEventStore,
MemoryEventStore,
} from "./memory-event-store.js";
function makeEvent(
aggregateId: string,
type: string,
version: number
): DomainEvent {
return {
id: `e-${version}`,
aggregateId,
aggregateType: "TestAggregate",
type,
version,
payload: {},
metadata: {},
occurredAt: new Date(),
};
}
describe("MemoryEventStore", () => {
it("appends and loads events", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const e1 = makeEvent("a1", "Created", 1);
await store.append([e1], 0);
const events = await store.load("a1");
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("Created");
});
it("loads from specific version", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
await store.append(
[makeEvent("a1", "E1", 1), makeEvent("a1", "E2", 2)],
0
);
const events = await store.load("a1", 1);
expect(events).toHaveLength(1);
expect(events[0]?.type).toBe("E2");
});
it("throws concurrency conflict on version mismatch", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
await store.append([makeEvent("a1", "E1", 1)], 0);
await expect(
store.append([makeEvent("a1", "E2", 2)], 0)
).rejects.toThrow(EventSourcingError);
});
it("concurrency error has correct code", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
await store.append([makeEvent("a1", "E1", 1)], 0);
const err = await store
.append([makeEvent("a1", "E2", 2)], 0)
.catch((e: unknown) => e);
expect(err instanceof EventSourcingError).toBe(true);
expect((err as EventSourcingError).code).toBe(
ES_CODES.CONCURRENCY_CONFLICT
);
});
it("returns empty array for unknown aggregate", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
const events = await store.load("unknown");
expect(events).toHaveLength(0);
});
it("saves and loads snapshot", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
const snap: Snapshot = {
aggregateId: "a1",
aggregateType: "TestAggregate",
version: 5,
state: { count: 10 },
takenAt: new Date(),
};
await store.saveSnapshot(snap);
const loaded = await store.loadSnapshot("a1");
expect(loaded).toMatchObject({ version: 5 });
});
it("loadSnapshot returns undefined when none", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
const snap = await store.loadSnapshot("none");
expect(snap).toBeUndefined();
});
it("getStream yields events", { timeout: 5000 }, async () => {
const store = createMemoryEventStore();
await store.append(
[makeEvent("a1", "E1", 1), makeEvent("a1", "E2", 2)],
0
);
const collected: DomainEvent[] = [];
for await (const event of store.getStream("a1")) {
collected.push(event);
}
expect(collected).toHaveLength(2);
});
it("getStream yields nothing for unknown aggregate", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
const collected: DomainEvent[] = [];
for await (const event of store.getStream("unknown")) {
collected.push(event);
}
expect(collected).toHaveLength(0);
});
it("respects maxStreamLength", { timeout: 5000 }, async () => {
const store = createMemoryEventStore({ maxStreamLength: 2 });
await store.append(
[makeEvent("a1", "E1", 1), makeEvent("a1", "E2", 2)],
0
);
await expect(
store.append([makeEvent("a1", "E3", 3)], 2)
).rejects.toThrow(EventSourcingError);
});
it("maxStreamLength error has correct code", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore({ maxStreamLength: 1 });
await store.append([makeEvent("a1", "E1", 1)], 0);
const err = await store
.append([makeEvent("a1", "E2", 2)], 1)
.catch((e: unknown) => e);
expect((err as EventSourcingError).code).toBe(ES_CODES.APPEND_FAILED);
});
it("publishes to bus after append", { timeout: 5000 }, async () => {
const published: Array<{ topic: string; payload: unknown }> = [];
const bus = {
publish(topic: string, payload: unknown) {
published.push({ topic, payload });
},
};
const store = createMemoryEventStore({ bus });
await store.append([makeEvent("a1", "E1", 1)], 0);
expect(published).toHaveLength(1);
expect(published[0]?.topic).toBe("domain-event:TestAggregate:E1");
});
it("appends empty event list without error", {
timeout: 5000,
}, async () => {
const store = createMemoryEventStore();
await expect(store.append([], 0)).resolves.toBeUndefined();
});
it("createMemoryEventStore returns EventStore interface", {
timeout: 5000,
}, () => {
const store = createMemoryEventStore();
expect(typeof store.append).toBe("function");
expect(typeof store.load).toBe("function");
});
it("MemoryEventStore class is directly instantiable", {
timeout: 5000,
}, () => {
const store = new MemoryEventStore();
expect(store).toBeDefined();
});
});

View File

@@ -0,0 +1,4 @@
export {
createMemoryEventStore,
MemoryEventStore,
} from "./memory-event-store.js";

View File

@@ -0,0 +1,82 @@
# memory-event-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory implementation of the `EventStore` interface for use in tests, prototyping, and single-process deployments. Stores event streams in plain `Map` structures, enforces optimistic concurrency via version checking, supports snapshot storage, provides an async generator stream reader, and optionally publishes committed events onto an `EventBusLike` after successful append.
## Features
- Optimistic concurrency: `append` compares the current stream tail version against `expectedVersion` and throws `EventSourcingError(ES.CONCURRENCY_CONFLICT)` on mismatch
- Stream length cap: `maxStreamLength` option prevents unbounded growth during tests
- Snapshot round-trip: `saveSnapshot` / `loadSnapshot` with per-aggregate keyed storage
- Async generator: `getStream(aggregateId)` yields events one by one for streaming replay
- Bus publish-after-commit: when `bus` is provided, each committed event is published as `domain-event:<aggregateType>:<eventType>`
- OTel instrumentation: spans on `append` and `load`, counters for appended events, concurrency conflicts, and snapshots taken, histogram for append and load duration
## Usage: Basic Example
```ts
import { createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
await store.append([{
id: "e1", aggregateId: "a1", aggregateType: "Order",
type: "OrderPlaced", version: 1,
payload: { total: 100 }, metadata: {}, occurredAt: new Date()
}], 0);
const events = await store.load("a1");
console.log(events.length); // 1
```
## Usage: Advanced Example
```ts
import { createMemoryEventStore } from "@suaveplan/event-sourcing";
const bus = {
async publish(topic: string, payload: unknown) {
console.log(`Event published: ${topic}`, payload);
}
};
const store = createMemoryEventStore({ bus, maxStreamLength: 10000 });
// Concurrent append detection
await store.append([event1], 0);
try {
await store.append([event2], 0); // version already at 1
} catch (err) {
// EventSourcingError ES.CONCURRENCY_CONFLICT
}
```
## API Reference
### `createMemoryEventStore(options?)`
Factory returning an `EventStore`. Options: `maxStreamLength`, `clock`, `idGenerator`, `bus`, `logger`.
### `append(events, expectedVersion)`
Atomically appends events to the stream. Throws `CONCURRENCY_CONFLICT` if stream version !== `expectedVersion`.
### `load(aggregateId, fromVersion?)`
Returns all events after `fromVersion` (default 0).
### `loadSnapshot(aggregateId)` / `saveSnapshot(snapshot)`
Stores/retrieves a single snapshot per aggregate.
### `getStream(aggregateId)`
Returns an async generator yielding all events for the aggregate.
## Implementation Notes
The in-memory store is not thread-safe — it is designed for single-threaded Node.js / Bun use. For distributed deployments a database-backed adapter should be used. The `append` operation is synchronous under the hood (no real I/O) but presents an async interface so that all `EventStore` implementations are interchangeable.

View File

@@ -0,0 +1,153 @@
import { type Counter, type Histogram, trace } from "@opentelemetry/api";
import {
ES_CODES,
EventSourcingError,
} from "../../errors/event-sourcing-error.js";
import type {
DomainEvent,
EventStore,
MemoryEventStoreOptions,
Snapshot,
} from "../../types/event-sourcing.types.js";
const tracer = trace.getTracer("@suaveplan/event-sourcing");
function getCounter(name: string): Counter {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { metrics } =
require("@opentelemetry/api") as typeof import("@opentelemetry/api");
return metrics.getMeter("@suaveplan/event-sourcing").createCounter(name);
}
function getHistogram(name: string): Histogram {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { metrics } =
require("@opentelemetry/api") as typeof import("@opentelemetry/api");
return metrics.getMeter("@suaveplan/event-sourcing").createHistogram(name);
}
/**
* In-memory implementation of `EventStore` for testing and single-process use.
*
* Supports optimistic concurrency via `expectedVersion`, optional snapshot storage,
* an async generator stream API, and optional bus publishing after commit.
*/
export class MemoryEventStore implements EventStore {
protected readonly streams = new Map<string, DomainEvent[]>();
protected readonly snapshots = new Map<string, Snapshot>();
protected readonly opts: MemoryEventStoreOptions;
public constructor(opts: MemoryEventStoreOptions = {}) {
this.opts = opts;
}
public async append(
events: DomainEvent[],
expectedVersion: number
): Promise<void> {
if (events.length === 0) return;
const aggregateId = events[0]!.aggregateId;
const span = tracer.startSpan("es.append");
const start = Date.now();
try {
const stream = this.streams.get(aggregateId) ?? [];
const currentVersion =
stream.length > 0 ? stream[stream.length - 1]!.version : 0;
if (currentVersion !== expectedVersion) {
getCounter("es.concurrency_conflicts_total").add(1);
throw new EventSourcingError(
ES_CODES.CONCURRENCY_CONFLICT,
`Expected version ${expectedVersion} but stream is at version ${currentVersion}`,
{
context: {
aggregateId,
expectedVersion,
currentVersion,
},
}
);
}
const maxLen = this.opts.maxStreamLength;
if (
maxLen !== undefined &&
stream.length + events.length > maxLen
) {
throw new EventSourcingError(
ES_CODES.APPEND_FAILED,
`Stream length would exceed maxStreamLength (${maxLen})`,
{ context: { aggregateId, maxStreamLength: maxLen } }
);
}
const newStream = [...stream, ...events];
this.streams.set(aggregateId, newStream);
getCounter("es.events_appended_total").add(events.length);
getHistogram("es.append_duration_ms").record(Date.now() - start);
span.setAttribute("es.aggregate_id", aggregateId);
span.setAttribute("es.event_count", events.length);
span.setAttribute("es.version", events[events.length - 1]!.version);
if (this.opts.bus) {
for (const event of events) {
const topic = `domain-event:${event.aggregateType}:${event.type}`;
await this.opts.bus.publish(topic, event);
}
}
} finally {
span.end();
}
}
public async load(
aggregateId: string,
fromVersion = 0
): Promise<DomainEvent[]> {
const span = tracer.startSpan("es.load");
const start = Date.now();
try {
const stream = this.streams.get(aggregateId) ?? [];
const filtered = stream.filter((e) => e.version > fromVersion);
getHistogram("es.load_duration_ms").record(Date.now() - start);
span.setAttribute("es.aggregate_id", aggregateId);
span.setAttribute("es.event_count", filtered.length);
return filtered;
} finally {
span.end();
}
}
public async loadSnapshot(
aggregateId: string
): Promise<Snapshot | undefined> {
return this.snapshots.get(aggregateId);
}
public async saveSnapshot(snapshot: Snapshot): Promise<void> {
this.snapshots.set(snapshot.aggregateId, snapshot);
getCounter("es.snapshots_taken_total").add(1);
}
public async *getStream(aggregateId: string): AsyncGenerator<DomainEvent> {
const stream = this.streams.get(aggregateId) ?? [];
for (const event of stream) {
yield event;
}
}
}
/**
* Factory function for creating a `MemoryEventStore`.
*/
export function createMemoryEventStore(
options?: MemoryEventStoreOptions
): EventStore {
return new MemoryEventStore(options);
}

View File

@@ -0,0 +1,79 @@
# event-sourcing-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error type and stable error-code registry for `@suaveplan/event-sourcing`. All runtime failures from the event store, aggregate repository, projections, and upcasters surface as `EventSourcingError` instances with a stable machine-readable `code` field, enabling callers to discriminate failures without fragile string matching on error messages.
## Features
- `ES_CODES` registry via `@suaveplan/error`'s `defineCodes` — codes are namespaced `ES.*`
- `EventSourcingError` extends `SuaveplanError` for full cause-chain traversal and wire serialisation
- Six stable codes covering concurrency conflicts, missing aggregates, snapshot corruption, missing upcasters, corrupt streams, and append failures
- `EventSourcingCode` union type for exhaustive switch statements
## Usage: Basic Example
```ts
import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
throw new EventSourcingError(
ES_CODES.CONCURRENCY_CONFLICT,
"Expected version 5 but stream is at version 6",
{ context: { aggregateId: "order-1", expected: 5, actual: 6 } }
);
```
## Usage: Advanced Example
```ts
import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
async function saveWithRetry(aggregate: OrderAggregate): Promise<void> {
const maxRetries = 3;
for (let i = 0; i < maxRetries; i++) {
try {
await repository.save(aggregate);
return;
} catch (err) {
if (
err instanceof EventSourcingError &&
err.code === ES_CODES.CONCURRENCY_CONFLICT &&
i < maxRetries - 1
) {
// Reload aggregate and retry
aggregate = await repository.load(aggregate.id);
continue;
}
throw err;
}
}
}
```
## API Reference
### `ES_CODES`
Object mapping error names to fully-qualified code strings:
- `ES_CODES.CONCURRENCY_CONFLICT` — optimistic concurrency version mismatch
- `ES_CODES.AGGREGATE_NOT_FOUND` — no events found for an aggregate ID
- `ES_CODES.SNAPSHOT_INVALID` — snapshot failed schema validation
- `ES_CODES.UPCASTER_MISSING` — no upcaster registered for event type + version pair
- `ES_CODES.STREAM_CORRUPT` — events in a stream have non-sequential versions
- `ES_CODES.APPEND_FAILED` — general append failure (wraps underlying cause)
### `EventSourcingError`
- `constructor(code, message, options?)` — `options.cause` wraps the originating error; `options.context` carries structured metadata
### `EventSourcingCode`
Union type of all valid code strings for exhaustive type checking.
## Implementation Notes
`defineCodes` prefixes all names with `"ES."`, producing strings like `"ES.CONCURRENCY_CONFLICT"`. This namespace avoids collisions with other `@suaveplan/*` error registries.

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ES_CODES,
type EventSourcingCode,
EventSourcingError,
} from "./event-sourcing-error.js";
describe("EventSourcingError", () => {
it("has correct name", { timeout: 5000 }, () => {
const err = new EventSourcingError(ES_CODES.APPEND_FAILED, "test");
expect(err.name).toBe("EventSourcingError");
});
it("carries the code", { timeout: 5000 }, () => {
const err = new EventSourcingError(
ES_CODES.CONCURRENCY_CONFLICT,
"version mismatch"
);
expect(err.code).toBe(ES_CODES.CONCURRENCY_CONFLICT);
});
it("carries the message", { timeout: 5000 }, () => {
const err = new EventSourcingError(
ES_CODES.AGGREGATE_NOT_FOUND,
"not found"
);
expect(err.message).toBe("not found");
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new EventSourcingError(ES_CODES.APPEND_FAILED, "wrap", {
cause,
});
expect(err.cause).toBe(cause);
});
it("carries context", { timeout: 5000 }, () => {
const err = new EventSourcingError(
ES_CODES.CONCURRENCY_CONFLICT,
"msg",
{
context: { aggregateId: "a1" },
}
);
expect(
(err as unknown as { context?: Record<string, unknown> }).context
).toMatchObject({ aggregateId: "a1" });
});
it("all codes defined", { timeout: 5000 }, () => {
const codes: EventSourcingCode[] = [
ES_CODES.CONCURRENCY_CONFLICT,
ES_CODES.AGGREGATE_NOT_FOUND,
ES_CODES.SNAPSHOT_INVALID,
ES_CODES.UPCASTER_MISSING,
ES_CODES.STREAM_CORRUPT,
ES_CODES.APPEND_FAILED,
];
for (const code of codes) {
expect(typeof code).toBe("string");
expect(code).toMatch(/^ES\./);
}
});
it("instanceof checks work", { timeout: 5000 }, () => {
const err = new EventSourcingError(ES_CODES.STREAM_CORRUPT, "corrupt");
expect(err instanceof EventSourcingError).toBe(true);
expect(err instanceof Error).toBe(true);
});
});

View File

@@ -0,0 +1,44 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/event-sourcing`.
*/
export const ES_CODES = defineCodes("ES", [
"CONCURRENCY_CONFLICT",
"AGGREGATE_NOT_FOUND",
"SNAPSHOT_INVALID",
"UPCASTER_MISSING",
"STREAM_CORRUPT",
"APPEND_FAILED",
] as const);
export type EventSourcingCode = (typeof ES_CODES)[keyof typeof ES_CODES];
/**
* Typed error for all `@suaveplan/event-sourcing` failures.
*
* Discriminate via `error instanceof EventSourcingError` or `error.code`.
*
* @example
* ```ts
* import { EventSourcingError, ES_CODES } from "@suaveplan/event-sourcing";
*
* try {
* await repository.save(aggregate);
* } catch (err) {
* if (err instanceof EventSourcingError && err.code === ES_CODES.CONCURRENCY_CONFLICT) {
* // retry the load-modify-save cycle
* }
* }
* ```
*/
export class EventSourcingError extends SuaveplanError<EventSourcingCode> {
public constructor(
code: EventSourcingCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "EventSourcingError";
}
}

View File

@@ -0,0 +1,5 @@
export type { EventSourcingCode } from "./event-sourcing-error.js";
export {
ES_CODES,
EventSourcingError,
} from "./event-sourcing-error.js";

View File

@@ -0,0 +1,52 @@
// Errors
// Constants
export {
DEFAULT_SNAPSHOT_THRESHOLD,
MAX_UPCAST_CHAIN_STEPS,
} from "./constants/index.js";
// Core — Aggregate
export { Aggregate } from "./core/aggregate/aggregate.js";
// Core — Repository
export { createAggregateRepository } from "./core/aggregate-repository/aggregate-repository.js";
// Core — Event Store
export {
createMemoryEventStore,
MemoryEventStore,
} from "./core/event-store/memory-event-store.js";
export type { EventSourcingCode } from "./errors/event-sourcing-error.js";
export {
ES_CODES,
EventSourcingError,
} from "./errors/event-sourcing-error.js";
export type { Projection } from "./projection/builder/builder.js";
// Projection
export { createProjection } from "./projection/builder/builder.js";
// Schemas
export {
DomainEventSchema,
EventMetadataSchema,
MemoryEventStoreOptionsSchema,
SnapshotSchema,
UpcasterOptionsSchema,
} from "./schemas/event-sourcing.schemas.js";
// Types
export type {
AggregateRepository,
AggregateRepositoryOptions,
DomainEvent,
EventMetadata,
EventStore,
MemoryEventStoreOptions,
ProjectionHandlers,
ProjectionOptions,
Snapshot,
UpcasterOptions,
} from "./types/event-sourcing.types.js";
// Upcasting
export {
clearUpcasters,
registerUpcaster,
upcastEvents,
} from "./upcasting/upcaster/upcaster.js";

View File

@@ -0,0 +1,72 @@
# projection-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `createProjection`, a factory for building declarative read-model projections over sequences of domain events. Projections are the CQRS query side — they accumulate state from an immutable event log without modifying aggregates. The builder pattern keeps projections pure, composable, and testable independently of the event store.
## Features
- `project(events)` — folds a synchronous array of events onto `initialState` using the registered handlers
- `projectStream(stream)` — async variant that consumes an `AsyncIterable<DomainEvent>` for streaming replay
- Unknown event types are silently ignored — projections are forward-compatible with new event types
- Fully typed: `TState` is inferred from `initialState`
- No side effects — projections always start fresh from `initialState`
## Usage: Basic Example
```ts
import { createProjection } from "@suaveplan/event-sourcing";
const orderSummary = createProjection({
initialState: { total: 0, count: 0 },
handlers: {
OrderPlaced: (state, event) => ({
total: state.total + (event.payload as { amount: number }).amount,
count: state.count + 1,
}),
},
});
const result = orderSummary.project(events);
console.log(result.count); // number of placed orders
```
## Usage: Advanced Example
```ts
import { createProjection, createMemoryEventStore } from "@suaveplan/event-sourcing";
const store = createMemoryEventStore();
// Build projection from async stream
const projection = createProjection({
initialState: [] as string[],
handlers: {
UserRegistered: (state, event) => [
...state,
(event.payload as { username: string }).username,
],
},
});
const result = await projection.projectStream(store.getStream("users"));
```
## API Reference
### `createProjection<TState>(options)`
- `options.initialState: TState` — starting state for each projection run
- `options.handlers: Record<string, (state: TState, event: DomainEvent) => TState>` — reducer per event type
### `Projection<TState>`
- `project(events: DomainEvent[]): TState`
- `projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>`
## Implementation Notes
Each call to `project` or `projectStream` starts from `initialState` — there is no incremental update. For live projections that need incremental updates, callers should maintain external state and call the handler functions directly, or use a stateful wrapper that holds the accumulated state.

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DomainEvent } from "../../types/event-sourcing.types.js";
import { createProjection } from "./builder.js";
function makeEvent(type: string, payload: unknown, version = 1): DomainEvent {
return {
id: `e-${version}`,
aggregateId: "a1",
aggregateType: "T",
type,
version,
payload,
metadata: {},
occurredAt: new Date(),
};
}
describe("createProjection", () => {
it("projects empty events to initial state", { timeout: 5000 }, () => {
const p = createProjection({ initialState: 0, handlers: {} });
expect(p.project([])).toBe(0);
});
it("applies handlers to events", { timeout: 5000 }, () => {
const p = createProjection({
initialState: { count: 0 },
handlers: {
Incremented: (s) => ({ count: s.count + 1 }),
},
});
const result = p.project([
makeEvent("Incremented", {}),
makeEvent("Incremented", {}),
]);
expect(result.count).toBe(2);
});
it("ignores unknown event types", { timeout: 5000 }, () => {
const p = createProjection({
initialState: 0,
handlers: { Known: (s) => s + 1 },
});
expect(p.project([makeEvent("Unknown", {})])).toBe(0);
});
it("passes event to handler", { timeout: 5000 }, () => {
const p = createProjection({
initialState: 0,
handlers: {
Added: (s, e) => s + (e.payload as { amount: number }).amount,
},
});
expect(p.project([makeEvent("Added", { amount: 5 })])).toBe(5);
});
it("projectStream processes async iterable", {
timeout: 5000,
}, async () => {
const p = createProjection({
initialState: 0,
handlers: { Tick: (s) => s + 1 },
});
async function* gen(): AsyncGenerator<DomainEvent> {
yield makeEvent("Tick", {}, 1);
yield makeEvent("Tick", {}, 2);
yield makeEvent("Tick", {}, 3);
}
const result = await p.projectStream(gen());
expect(result).toBe(3);
});
it("projectStream ignores unknown events", { timeout: 5000 }, async () => {
const p = createProjection({ initialState: 0, handlers: {} });
async function* gen(): AsyncGenerator<DomainEvent> {
yield makeEvent("Unknown", {});
}
const result = await p.projectStream(gen());
expect(result).toBe(0);
});
it("starts fresh from initialState each call", { timeout: 5000 }, () => {
const p = createProjection({
initialState: { count: 0 },
handlers: { Incremented: (s) => ({ count: s.count + 1 }) },
});
p.project([makeEvent("Incremented", {})]);
expect(p.project([])).toEqual({ count: 0 });
});
});

View File

@@ -0,0 +1,73 @@
import type {
DomainEvent,
Projection,
ProjectionOptions,
} from "../../types/event-sourcing.types.js";
/**
* Builds a read-model projection from a declarative handler map.
*
* Each key in `handlers` is an event type string; the value is a reducer
* function `(state, event) => newState`. Unknown event types are silently
* ignored, enabling forward-compatible projections.
*/
class ProjectionImpl<TState> implements Projection<TState> {
protected readonly initialState: TState;
protected readonly handlers: Record<
string,
(state: TState, event: DomainEvent) => TState
>;
public constructor(options: ProjectionOptions<TState>) {
this.initialState = options.initialState;
this.handlers = options.handlers;
}
public project(events: DomainEvent[]): TState {
let state = this.initialState;
for (const event of events) {
const handler = this.handlers[event.type];
if (handler) {
state = handler(state, event);
}
}
return state;
}
public async projectStream(
stream: AsyncIterable<DomainEvent>
): Promise<TState> {
let state = this.initialState;
for await (const event of stream) {
const handler = this.handlers[event.type];
if (handler) {
state = handler(state, event);
}
}
return state;
}
}
/**
* Creates a typed `Projection` from an initial state and handler map.
*
* @example
* ```ts
* const projection = createProjection({
* initialState: { total: 0 },
* handlers: {
* OrderPlaced: (state, event) => ({
* total: state.total + (event.payload as { amount: number }).amount,
* }),
* },
* });
* const result = projection.project(events);
* ```
*/
export function createProjection<TState>(
options: ProjectionOptions<TState>
): Projection<TState> {
return new ProjectionImpl(options);
}
export type { Projection } from "../../types/event-sourcing.types.js";

View File

@@ -0,0 +1,2 @@
export type { Projection } from "./builder.js";
export { createProjection } from "./builder.js";

View File

@@ -0,0 +1,66 @@
# event-sourcing.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod validation schemas for all public wire-format types in `@suaveplan/event-sourcing`. These schemas are used at integration boundaries — when deserialising events from persistence layers, validating snapshot data, and parsing upcaster registrations — to ensure structural integrity before domain logic runs.
## Features
- `DomainEventSchema` — validates the full event envelope including version, metadata, and typed occurredAt
- `SnapshotSchema` — validates aggregate snapshots before loading into aggregates
- `EventMetadataSchema` — validates event metadata with passthrough for unknown keys
- `MemoryEventStoreOptionsSchema` — validates store construction options
- `UpcasterOptionsSchema` — validates upcaster registration including the migration function
## Usage: Basic Example
```ts
import { DomainEventSchema } from "@suaveplan/event-sourcing";
const raw = JSON.parse(storedJson);
const event = DomainEventSchema.parse({ ...raw, occurredAt: new Date(raw.occurredAt) });
```
## Usage: Advanced Example
```ts
import { SnapshotSchema } from "@suaveplan/event-sourcing";
import z from "zod";
const MyStateSchema = z.object({ balance: z.number() });
const snapshot = SnapshotSchema.extend({
state: MyStateSchema,
}).parse(rawSnapshot);
// snapshot.state is typed as { balance: number }
```
## API Reference
### `DomainEventSchema`
Validates `{ id, aggregateId, aggregateType, type, version, payload, metadata, occurredAt }`. Version must be a positive integer. `occurredAt` must be a `Date`.
### `SnapshotSchema`
Validates `{ aggregateId, aggregateType, version, state, takenAt }`. Version is non-negative integer.
### `EventMetadataSchema`
Validates optional `causationId`, `correlationId`, `actor` and passes through unknown keys.
### `MemoryEventStoreOptionsSchema`
Optional schema; validates `maxStreamLength` as positive integer if present.
### `UpcasterOptionsSchema`
Validates `{ eventType, fromVersion, toVersion, upcast }`. `fromVersion` must be non-negative; `toVersion` must be positive.
## Implementation Notes
All schemas use `.passthrough()` on metadata to avoid stripping custom keys. The `payload` and `state` fields use `z.unknown()` since the concrete shape depends on the event type — callers may extend the schema for stricter validation.

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
DomainEventSchema,
EventMetadataSchema,
MemoryEventStoreOptionsSchema,
SnapshotSchema,
UpcasterOptionsSchema,
} from "./event-sourcing.schemas.js";
describe("DomainEventSchema", () => {
const valid = {
id: "e1",
aggregateId: "a1",
aggregateType: "Order",
type: "OrderPlaced",
version: 1,
payload: { amount: 100 },
metadata: { actor: "user-1" },
occurredAt: new Date(),
};
it("parses valid event", { timeout: 5000 }, () => {
expect(() => DomainEventSchema.parse(valid)).not.toThrow();
});
it("rejects zero version", { timeout: 5000 }, () => {
expect(() =>
DomainEventSchema.parse({ ...valid, version: 0 })
).toThrow();
});
it("rejects missing id", { timeout: 5000 }, () => {
const { id: _id, ...rest } = valid;
expect(() => DomainEventSchema.parse(rest)).toThrow();
});
it("rejects string occurredAt", { timeout: 5000 }, () => {
expect(() =>
DomainEventSchema.parse({ ...valid, occurredAt: "2024-01-01" })
).toThrow();
});
});
describe("SnapshotSchema", () => {
const valid = {
aggregateId: "a1",
aggregateType: "Order",
version: 5,
state: { balance: 100 },
takenAt: new Date(),
};
it("parses valid snapshot", { timeout: 5000 }, () => {
expect(() => SnapshotSchema.parse(valid)).not.toThrow();
});
it("accepts version 0", { timeout: 5000 }, () => {
expect(() =>
SnapshotSchema.parse({ ...valid, version: 0 })
).not.toThrow();
});
it("rejects negative version", { timeout: 5000 }, () => {
expect(() => SnapshotSchema.parse({ ...valid, version: -1 })).toThrow();
});
});
describe("EventMetadataSchema", () => {
it("parses empty metadata", { timeout: 5000 }, () => {
expect(() => EventMetadataSchema.parse({})).not.toThrow();
});
it("passes through unknown keys", { timeout: 5000 }, () => {
const result = EventMetadataSchema.parse({ custom: "value" });
expect(result).toMatchObject({ custom: "value" });
});
it("accepts all optional fields", { timeout: 5000 }, () => {
const result = EventMetadataSchema.parse({
causationId: "c1",
correlationId: "r1",
actor: "user-1",
});
expect(result.actor).toBe("user-1");
});
});
describe("MemoryEventStoreOptionsSchema", () => {
it("accepts undefined", { timeout: 5000 }, () => {
expect(() =>
MemoryEventStoreOptionsSchema.parse(undefined)
).not.toThrow();
});
it("accepts maxStreamLength", { timeout: 5000 }, () => {
expect(() =>
MemoryEventStoreOptionsSchema.parse({ maxStreamLength: 1000 })
).not.toThrow();
});
it("rejects zero maxStreamLength", { timeout: 5000 }, () => {
expect(() =>
MemoryEventStoreOptionsSchema.parse({ maxStreamLength: 0 })
).toThrow();
});
});
describe("UpcasterOptionsSchema", () => {
it("parses valid upcaster", { timeout: 5000 }, () => {
expect(() =>
UpcasterOptionsSchema.parse({
eventType: "OrderPlaced",
fromVersion: 1,
toVersion: 2,
upcast: (p: unknown) => p,
})
).not.toThrow();
});
it("rejects negative fromVersion", { timeout: 5000 }, () => {
expect(() =>
UpcasterOptionsSchema.parse({
eventType: "E",
fromVersion: -1,
toVersion: 1,
upcast: (p: unknown) => p,
})
).toThrow();
});
});

View File

@@ -0,0 +1,57 @@
import z from "zod";
/**
* Zod schema for `EventMetadata`.
*/
export const EventMetadataSchema = z
.object({
causationId: z.string().optional(),
correlationId: z.string().optional(),
actor: z.string().optional(),
})
.passthrough();
/**
* Zod schema for `DomainEvent`.
*/
export const DomainEventSchema = z.object({
id: z.string(),
aggregateId: z.string(),
aggregateType: z.string(),
type: z.string(),
version: z.number().int().positive(),
payload: z.unknown(),
metadata: EventMetadataSchema,
occurredAt: z.date(),
});
/**
* Zod schema for `Snapshot`.
*/
export const SnapshotSchema = z.object({
aggregateId: z.string(),
aggregateType: z.string(),
version: z.number().int().nonnegative(),
state: z.unknown(),
takenAt: z.date(),
});
/**
* Zod schema for `MemoryEventStoreOptions`.
*/
export const MemoryEventStoreOptionsSchema = z
.object({
maxStreamLength: z.number().int().positive().optional(),
})
.passthrough()
.optional();
/**
* Zod schema for `UpcasterOptions`.
*/
export const UpcasterOptionsSchema = z.object({
eventType: z.string(),
fromVersion: z.number().int().nonnegative(),
toVersion: z.number().int().positive(),
upcast: z.function(),
});

View File

@@ -0,0 +1,7 @@
export {
DomainEventSchema,
EventMetadataSchema,
MemoryEventStoreOptionsSchema,
SnapshotSchema,
UpcasterOptionsSchema,
} from "./event-sourcing.schemas.js";

View File

@@ -0,0 +1,79 @@
# event-sourcing.types
## Purpose
Shared TypeScript type definitions for the `@suaveplan/event-sourcing` package. These types form the contract between aggregates, the event store, projections, and the aggregate repository. Centralising them here prevents circular imports and makes the public API surface explicit.
## Features
- `DomainEvent<TPayload>` — the canonical envelope for all domain events, carrying identity, versioning, and metadata
- `Snapshot` — serialised aggregate state at a given stream version for efficient replay
- `EventStore` — the interface that every store implementation must satisfy
- `AggregateRepository<T>` — the typed repository facade
- `MemoryEventStoreOptions` — injectable clock, ID generator, bus, and logger for the in-memory implementation
- `ProjectionOptions<TState>` / `ProjectionHandlers<TState>` — declarative read-model configuration
- `UpcasterOptions` — event schema migration registration
- `EventMetadata` — free-form cross-cutting metadata with typed optional fields
## Usage: Basic Example
```ts
import type { DomainEvent, Snapshot } from "@suaveplan/event-sourcing";
const event: DomainEvent<{ amount: number }> = {
id: "01HXYZ",
aggregateId: "order-1",
aggregateType: "Order",
type: "OrderPlaced",
version: 1,
payload: { amount: 100 },
metadata: { actor: "user-42" },
occurredAt: new Date(),
};
```
## Usage: Advanced Example
```ts
import type {
DomainEvent,
EventStore,
AggregateRepositoryOptions,
} from "@suaveplan/event-sourcing";
// Custom aggregate repository options with snapshot support
const opts: AggregateRepositoryOptions<MyAggregate> = {
store: myEventStore,
factory: (id) => new MyAggregate(id),
snapshotThreshold: 50,
};
```
## API Reference
### `DomainEvent<TPayload>`
- `id: string` — unique event identifier
- `aggregateId: string` — owning aggregate
- `aggregateType: string` — aggregate class discriminator
- `type: string` — event type discriminator
- `version: number` — 1-based sequence within aggregate stream
- `payload: TPayload` — typed event payload
- `metadata: EventMetadata` — cross-cutting metadata
- `occurredAt: Date` — wall-clock timestamp
### `Snapshot`
Point-in-time capture of aggregate state used to skip full replay.
### `EventStore`
Interface with `append`, `load`, `loadSnapshot`, `saveSnapshot`, `getStream`.
### `MemoryEventStoreOptions`
Injectable options for in-memory store: `clock`, `idGenerator`, `bus`, `logger`, `maxStreamLength`.
## Implementation Notes
The types are purely declarative — no runtime code. Using a separate `types` module keeps the aggregate and store implementations free of cross-dependencies.

View File

@@ -0,0 +1,129 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
/**
* Metadata carried on every domain event.
*/
export interface EventMetadata {
causationId?: string;
correlationId?: string;
actor?: string;
[key: string]: unknown;
}
/**
* A domain event emitted by an aggregate.
*
* @typeParam TPayload - The strongly-typed payload for this event.
*/
export interface DomainEvent<TPayload = unknown> {
/** Unique event ID (UUID). */
id: string;
/** The aggregate this event belongs to. */
aggregateId: string;
/** Type tag of the aggregate (e.g. `"Order"`). */
aggregateType: string;
/** Discriminator string for the event (e.g. `"OrderPlaced"`). */
type: string;
/** Sequence position within the aggregate stream (1-based). */
version: number;
/** Event payload. */
payload: TPayload;
/** Cross-cutting metadata. */
metadata: EventMetadata;
/** Wall-clock time the event occurred. */
occurredAt: Date;
}
/**
* A point-in-time snapshot of aggregate state.
*/
export interface Snapshot {
aggregateId: string;
aggregateType: string;
/** The stream version at which this snapshot was taken. */
version: number;
/** Opaque serialized state. */
state: unknown;
takenAt: Date;
}
/**
* Options for the in-memory event store.
*/
export interface MemoryEventStoreOptions {
/** Cap each stream to this many events; excess raises an error. */
maxStreamLength?: number;
clock?: Clock;
idGenerator?: IdGenerator;
/** Publish committed events as `domain-event:<type>:<event>` topics. */
bus?: { publish(topic: string, payload: unknown): Promise<void> | void };
logger?: {
debug(msg: string, ctx?: Record<string, unknown>): void;
warn(msg: string, ctx?: Record<string, unknown>): void;
};
}
/**
* Options for the aggregate repository.
*/
export interface AggregateRepositoryOptions<T> {
store: EventStore;
/** Factory that creates a blank aggregate given an ID. */
factory: (id: string) => T;
/** Automatically snapshot every N events (default: disabled). */
snapshotThreshold?: number;
}
/**
* The core event store interface.
*/
export interface EventStore {
append(events: DomainEvent[], expectedVersion: number): Promise<void>;
load(aggregateId: string, fromVersion?: number): Promise<DomainEvent[]>;
loadSnapshot(aggregateId: string): Promise<Snapshot | undefined>;
saveSnapshot(snapshot: Snapshot): Promise<void>;
getStream(aggregateId: string): AsyncGenerator<DomainEvent>;
}
/**
* Projection handler map.
*/
export type ProjectionHandlers<TState> = Record<
string,
(state: TState, event: DomainEvent) => TState
>;
/**
* Options for building a projection.
*/
export interface ProjectionOptions<TState> {
initialState: TState;
handlers: ProjectionHandlers<TState>;
}
/**
* A read-model projection that folds domain events into state.
*/
export interface Projection<TState> {
project(events: DomainEvent[]): TState;
projectStream(stream: AsyncIterable<DomainEvent>): Promise<TState>;
}
/**
* Options for registering an upcaster.
*/
export interface UpcasterOptions {
eventType: string;
fromVersion: number;
toVersion: number;
upcast: (payload: unknown) => unknown;
}
/**
* Repository interface returned from `createAggregateRepository`.
*/
export interface AggregateRepository<T> {
load(aggregateId: string): Promise<T>;
save(aggregate: T): Promise<void>;
exists(aggregateId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,12 @@
export type {
AggregateRepository,
AggregateRepositoryOptions,
DomainEvent,
EventMetadata,
EventStore,
MemoryEventStoreOptions,
ProjectionHandlers,
ProjectionOptions,
Snapshot,
UpcasterOptions,
} from "./event-sourcing.types.js";

View File

@@ -0,0 +1 @@
export { clearUpcasters, registerUpcaster, upcastEvents } from "./upcaster.js";

View File

@@ -0,0 +1,69 @@
# upcaster
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides event schema migration via a registry-based upcaster pattern. When an aggregate's event payloads evolve over time, old events stored in the event store can be transparently migrated to newer schemas on load. Each upcaster migrates from one payload version to the next, forming a chain that `upcastEvents` walks automatically.
## Features
- `registerUpcaster(options)` — adds a migration function for `eventType` at `fromVersion → toVersion`
- `upcastEvents(events, options?)` — walks the upcaster chain for each event, applying all registered migrations
- `clearUpcasters()` — resets the registry (for test isolation)
- `requireComplete` option — throws `UPCASTER_MISSING` if the chain does not terminate for an event type
- Chain limit of 100 steps prevents infinite loops from misconfigured upcasters
## Usage: Basic Example
```ts
import { registerUpcaster, upcastEvents } from "@suaveplan/event-sourcing";
registerUpcaster({
eventType: "OrderPlaced",
fromVersion: 1,
toVersion: 2,
upcast: (payload) => ({
...(payload as object),
currency: "USD", // added in v2
}),
});
const migrated = upcastEvents(oldEvents);
```
## Usage: Advanced Example
```ts
import { registerUpcaster, upcastEvents, clearUpcasters } from "@suaveplan/event-sourcing";
// Chain: v1 -> v2 -> v3
registerUpcaster({ eventType: "OrderPlaced", fromVersion: 1, toVersion: 2, upcast: addCurrency });
registerUpcaster({ eventType: "OrderPlaced", fromVersion: 2, toVersion: 3, upcast: addTaxId });
const events = await store.load("order-1");
const migrated = upcastEvents(events);
// In tests: clean up
clearUpcasters();
```
## API Reference
### `registerUpcaster(options: UpcasterOptions)`
Registers `options.upcast` to migrate payloads from `options.fromVersion` to `options.toVersion` for `options.eventType`.
### `upcastEvents(events, options?)`
- `options.requireComplete` — if `true`, throws `UPCASTER_MISSING` when chain has gaps
- Returns a new array of events with migrated payloads (original events are not mutated)
### `clearUpcasters()`
Resets the global registry. Use in `afterEach` during tests.
## Implementation Notes
The upcaster registry is module-level (global singleton). This matches the typical pattern for event schema registries. The 100-step chain limit prevents runaway loops while supporting realistic migration depths. The `requireComplete` check is a best-effort safeguard and does not guarantee the upcaster graph has no cycles.

View File

@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import type { DomainEvent } from "../../types/event-sourcing.types.js";
import { clearUpcasters, registerUpcaster, upcastEvents } from "./upcaster.js";
function makeEvent(type: string, payload: unknown): DomainEvent {
return {
id: "e1",
aggregateId: "a1",
aggregateType: "T",
type,
version: 1,
payload,
metadata: {},
occurredAt: new Date(),
};
}
describe("upcastEvents", () => {
afterEach(() => {
clearUpcasters();
});
it("returns events unchanged when no upcasters", { timeout: 5000 }, () => {
const events = [makeEvent("OrderPlaced", { amount: 100 })];
const result = upcastEvents(events);
expect(result[0]?.payload).toEqual({ amount: 100 });
});
it("applies single upcaster", { timeout: 5000 }, () => {
registerUpcaster({
eventType: "OrderPlaced",
fromVersion: 1,
toVersion: 2,
upcast: (p) => ({ ...(p as object), currency: "USD" }),
});
const result = upcastEvents([
makeEvent("OrderPlaced", { amount: 100 }),
]);
expect((result[0]?.payload as { currency: string }).currency).toBe(
"USD"
);
});
it("applies chained upcasters", { timeout: 5000 }, () => {
registerUpcaster({
eventType: "E",
fromVersion: 1,
toVersion: 2,
upcast: (p) => ({ ...(p as object), v2: true }),
});
registerUpcaster({
eventType: "E",
fromVersion: 2,
toVersion: 3,
upcast: (p) => ({ ...(p as object), v3: true }),
});
const result = upcastEvents([makeEvent("E", {})]);
const payload = result[0]?.payload as { v2: boolean; v3: boolean };
expect(payload.v2).toBe(true);
expect(payload.v3).toBe(true);
});
it("does not mutate original events", { timeout: 5000 }, () => {
registerUpcaster({
eventType: "E",
fromVersion: 1,
toVersion: 2,
upcast: (p) => ({ ...(p as object), added: true }),
});
const original = makeEvent("E", { original: true });
upcastEvents([original]);
expect((original.payload as { added?: boolean }).added).toBeUndefined();
});
it("ignores events without registered upcasters", { timeout: 5000 }, () => {
const events = [makeEvent("UnknownEvent", { x: 1 })];
expect(() => upcastEvents(events)).not.toThrow();
});
it("clearUpcasters resets registry", { timeout: 5000 }, () => {
registerUpcaster({
eventType: "E",
fromVersion: 1,
toVersion: 2,
upcast: (p) => ({ ...(p as object), added: true }),
});
clearUpcasters();
const result = upcastEvents([makeEvent("E", {})]);
expect(
(result[0]?.payload as { added?: boolean }).added
).toBeUndefined();
});
it("handles empty event array", { timeout: 5000 }, () => {
expect(upcastEvents([])).toEqual([]);
});
it("requireComplete does not throw when chain is complete", {
timeout: 5000,
}, () => {
registerUpcaster({
eventType: "E",
fromVersion: 1,
toVersion: 2,
upcast: (p) => p,
});
// fromVersion 2 has no registered upcaster — chain terminates
expect(() =>
upcastEvents([makeEvent("E", {})], { requireComplete: true })
).not.toThrow();
});
it("requireComplete throws when iteration cap exceeded with next upcaster present", {
timeout: 5000,
}, () => {
// Register 101 upcasters (G:1→2, G:2→3, ..., G:101→102).
// The loop cap is 100 iterations so it stops at payloadVersion=101.
// G:101 is still registered, so requireComplete throws.
for (let v = 1; v <= 101; v++) {
registerUpcaster({
eventType: "G",
fromVersion: v,
toVersion: v + 1,
upcast: (p) => p,
});
}
expect(() =>
upcastEvents([makeEvent("G", {})], { requireComplete: true })
).toThrow();
});
});

View File

@@ -0,0 +1,82 @@
import {
ES_CODES,
EventSourcingError,
} from "../../errors/event-sourcing-error.js";
import type {
DomainEvent,
UpcasterOptions,
} from "../../types/event-sourcing.types.js";
/**
* Registry of upcasters keyed by `eventType:fromVersion`.
*/
const registry = new Map<string, UpcasterOptions>();
/**
* Registers an upcaster that migrates event payloads from `fromVersion` to
* `toVersion` for the given `eventType`.
*
* Upcasters are applied in sequence during `upcastEvents`.
*/
export function registerUpcaster(options: UpcasterOptions): void {
const key = `${options.eventType}:${options.fromVersion}`;
registry.set(key, options);
}
/**
* Clears all registered upcasters (useful in tests).
*/
export function clearUpcasters(): void {
registry.clear();
}
/**
* Applies registered upcasters to a sequence of events.
*
* For each event, if an upcaster is registered for `event.type` at the
* current payload version, it is applied and the process repeats until no
* more upcasters match.
*
* @throws `EventSourcingError(ES.UPCASTER_MISSING)` when `requireComplete` is
* `true` and an event's payload version has no registered upcaster path to
* the latest version.
*/
export function upcastEvents(
events: DomainEvent[],
options?: { requireComplete?: boolean }
): DomainEvent[] {
return events.map((event) =>
upcastEvent(event, options?.requireComplete ?? false)
);
}
function upcastEvent(
event: DomainEvent,
requireComplete: boolean
): DomainEvent {
let current = event;
let payloadVersion = 1; // default payload version if not tracked
// Walk the upcaster chain
for (let i = 0; i < 100; i++) {
const key = `${current.type}:${payloadVersion}`;
const upcaster = registry.get(key);
if (!upcaster) break;
const upcasted = upcaster.upcast(current.payload);
current = { ...current, payload: upcasted };
payloadVersion = upcaster.toVersion;
}
if (requireComplete) {
// Check that no pending upcaster exists at the current version
const nextKey = `${current.type}:${payloadVersion}`;
if (registry.has(nextKey)) {
throw new EventSourcingError(
ES_CODES.UPCASTER_MISSING,
`Upcaster chain incomplete for event type "${current.type}" at version ${payloadVersion}`
);
}
}
return current;
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("@suaveplan/event-sourcing dep-graph", () => {
it("imports without error", { timeout: 30000 }, async () => {
const mod = await import("../src/index.js");
expect(typeof mod.Aggregate).toBe("function");
expect(typeof mod.MemoryEventStore).toBe("function");
expect(typeof mod.createMemoryEventStore).toBe("function");
expect(typeof mod.createAggregateRepository).toBe("function");
expect(typeof mod.createProjection).toBe("function");
expect(typeof mod.registerUpcaster).toBe("function");
expect(typeof mod.upcastEvents).toBe("function");
expect(typeof mod.clearUpcasters).toBe("function");
expect(typeof mod.EventSourcingError).toBe("function");
expect(mod.ES_CODES).toBeDefined();
});
});

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Aggregate } from "../src/core/aggregate/aggregate.js";
import { createAggregateRepository } from "../src/core/aggregate-repository/aggregate-repository.js";
import { createMemoryEventStore } from "../src/core/event-store/memory-event-store.js";
import { createProjection } from "../src/projection/builder/builder.js";
import type { DomainEvent } from "../src/types/event-sourcing.types.js";
interface AccountState {
balance: number;
open: boolean;
}
class BankAccount extends Aggregate<AccountState> {
public constructor(id: string) {
super(id, { balance: 0, open: true });
}
public deposit(amount: number): void {
this.raise("Deposited", { amount });
}
public withdraw(amount: number): void {
if (amount > this.state.balance) throw new Error("Insufficient funds");
this.raise("Withdrawn", { amount });
}
public close(): void {
this.raise("AccountClosed", {});
}
protected applyEvent(event: DomainEvent): void {
const p = event.payload as { amount: number };
switch (event.type) {
case "Deposited":
this.state = {
...this.state,
balance: this.state.balance + p.amount,
};
break;
case "Withdrawn":
this.state = {
...this.state,
balance: this.state.balance - p.amount,
};
break;
case "AccountClosed":
this.state = { ...this.state, open: false };
break;
}
}
public get balance(): number {
return this.state.balance;
}
public get isOpen(): boolean {
return this.state.open;
}
}
describe("event-sourcing integration", () => {
it("full lifecycle: create, save, reload", { timeout: 30000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
});
const account = new BankAccount("acc-1");
account.deposit(1000);
account.withdraw(250);
await repo.save(account);
const loaded = await repo.load("acc-1");
expect(loaded.balance).toBe(750);
expect(loaded.getVersion()).toBe(2);
});
it("project events to read model", { timeout: 30000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
});
const account = new BankAccount("acc-2");
account.deposit(500);
account.deposit(200);
account.withdraw(100);
await repo.save(account);
const totalDeposits = createProjection({
initialState: 0,
handlers: {
Deposited: (s, e) =>
s + (e.payload as { amount: number }).amount,
},
});
const events = await store.load("acc-2");
expect(totalDeposits.project(events)).toBe(700);
});
it("snapshot reduces replay events", { timeout: 30000 }, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
snapshotThreshold: 3,
});
const account = new BankAccount("acc-3");
account.deposit(100);
account.deposit(200);
account.deposit(300);
await repo.save(account); // should snapshot at version 3
const snap = await store.loadSnapshot("acc-3");
expect(snap).toBeDefined();
expect(snap?.version).toBe(3);
const loaded = await repo.load("acc-3");
expect(loaded.balance).toBe(600);
});
it("concurrent save raises concurrency conflict", {
timeout: 30000,
}, async () => {
const store = createMemoryEventStore();
const repo = createAggregateRepository({
store,
factory: (id) => new BankAccount(id),
});
// First save
const a1 = new BankAccount("acc-4");
a1.deposit(100);
await repo.save(a1);
// Load same aggregate twice
const b1 = await repo.load("acc-4");
const b2 = await repo.load("acc-4");
b1.deposit(50);
b2.withdraw(50);
await repo.save(b1);
// b2 has stale version — should conflict
await expect(repo.save(b2)).rejects.toThrow();
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

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

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/event-sourcing",
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/error",
"@suaveplan/types",
"@suaveplan/events",
"@suaveplan/diff",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
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/**/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

@@ -0,0 +1,32 @@
# @suaveplan/events-metrics - OTel metrics companion for @suaveplan/events: dispatch counters, latency histograms, queue-depth gauges, and active-subscriber counts via interceptor API.
## Summary
OTel metrics companion for `@suaveplan/events`. Attaches via the public interceptor API to record dispatch totals, listener error counts, and latency histograms per event name.
<!-- begin:auto-docs -->
## Documentation tree
- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_- [attach-event-metrics](./src/core/attach-event-metrics/attach-event-metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metric-names](./src/metrics/metric-names/metric-names.md) — <!-- begin:auto-docs --> _(no submodules)_
## Quick Start
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
const bus = new EventBus();
const handle = attachEventMetrics(bus);
await bus.emitAsync("user.created", { id: "u-1" });
console.log(handle.snapshot().dispatchTotal["user.created"]); // 1
handle.detach();
```
## Modules
- [`src/core/attach-event-metrics/attach-event-metrics`](src/core/attach-event-metrics/attach-event-metrics.md) — `attachEventMetrics`, `EventMetricsHandle`, `MetricsSnapshot`
- [`src/metrics/metric-names/metric-names`](src/metrics/metric-names/metric-names.md) — `METRIC_NAMES`, `ATTRIBUTE_KEYS`

View File

@@ -0,0 +1,76 @@
{
"name": "@suaveplan/events-metrics",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "OTel metrics companion for @suaveplan/events: dispatch counters, latency histograms, queue-depth gauges, and active-subscriber counts via interceptor API.",
"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"
}
},
"./core/attach-event-metrics": {
"import": {
"types": "./dist/core/attach-event-metrics/attach-event-metrics.d.ts",
"default": "./dist/core/attach-event-metrics/attach-event-metrics.js"
},
"require": {
"types": "./dist/core/attach-event-metrics/attach-event-metrics.d.ts",
"default": "./dist/core/attach-event-metrics/attach-event-metrics.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": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,16 @@
/** Stable OTel metric names for events-metrics. */
export const METRIC_NAMES = {
DISPATCH_TOTAL: "suaveplan.events.dispatch.total",
LISTENER_ERROR_TOTAL: "suaveplan.events.listener.error.total",
DISPATCH_LATENCY_MS: "suaveplan.events.dispatch.latency_ms",
QUEUE_DEPTH: "suaveplan.events.queue.depth",
ACTIVE_SUBSCRIBERS: "suaveplan.events.subscribers.active",
} as const;
/** Stable OTel attribute keys for events-metrics. */
export const ATTRIBUTE_KEYS = {
EVENT_NAME: "event.name",
EVENT_CATEGORY: "event.category",
LISTENER_ID: "listener.id",
BUS_ID: "bus.id",
} as const;

View File

@@ -0,0 +1,98 @@
# attach-event-metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Attaches OTel metrics instrumentation to an `EventBus` via the public interceptor API from `@suaveplan/events`. It records event dispatch totals, listener error counts, and latency histograms per event name without modifying the bus or its listeners. The attachment is transparent to publishers and subscribers — they do not need to know that metrics are being collected.
The implementation prioritises zero per-dispatch allocation on the hot path for previously seen event names by pre-caching the OTel counter/histogram references and using object property access rather than Map lookups.
## Features
- `attachEventMetrics(bus, options?)` — installs a metrics interceptor and returns a `EventMetricsHandle`
- Dispatch total counter (`suaveplan.events.dispatch.total`) per event name
- Listener error counter (`suaveplan.events.listener.error.total`) per event name
- Dispatch latency histogram (`suaveplan.events.dispatch.latency_ms`) per event name
- `EventMetricsHandle.snapshot()` returns in-process totals without needing an OTel exporter
- `EventMetricsHandle.detach()` removes the interceptor
- Injectable `meter`, `clock`, `logger` for testability
- `eventFilter` option to limit recording to a subset of event names
## Usage: Basic Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
const bus = new EventBus<{ "user.created": { id: string } }>();
const handle = attachEventMetrics(bus);
bus.on("user.created", (payload) => console.log(payload));
await bus.emitAsync("user.created", { id: "u-1" });
const snap = handle.snapshot();
console.log(snap.dispatchTotal["user.created"]); // 1
handle.detach();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { attachEventMetrics } from "@suaveplan/events-metrics";
import { metrics } from "@opentelemetry/api";
const bus = new EventBus<{ click: { x: number }; submit: { formId: string } }>();
const handle = attachEventMetrics(bus, {
meter: metrics.getMeter("my-app"),
eventFilter: (name) => name.startsWith("click"),
clock: { now: () => performance.now() },
});
// Only "click" events will be instrumented; "submit" events pass through uninstrumented
await bus.emitAsync("click", { x: 42 });
await bus.emitAsync("submit", { formId: "f-1" });
const snap = handle.snapshot();
console.log(snap.dispatchTotal["click"]); // 1
console.log(snap.dispatchTotal["submit"]); // undefined (filtered out)
handle.detach();
```
## API Reference
### `attachEventMetrics<TMap>(bus, options?): EventMetricsHandle`
Installs a metrics interceptor on `bus`.
| Param | Type | Description |
|---|---|---|
| `bus` | `EventBus<TMap>` | The event bus to instrument |
| `options` | `EventMetricsOptions?` | Meter, filter, clock, logger |
**Returns**: `EventMetricsHandle`
### `EventMetricsHandle`
| Method | Returns | Description |
|---|---|---|
| `detach()` | `void` | Remove the interceptor |
| `snapshot()` | `MetricsSnapshot` | In-process metric values |
### `MetricsSnapshot`
| Field | Type | Description |
|---|---|---|
| `dispatchTotal` | `Record<string, number>` | Total dispatches per event name |
| `listenerErrorTotal` | `Record<string, number>` | Total listener errors per event name |
| `avgDispatchLatencyMs` | `Record<string, number>` | Average latency per event name |
| `queueDepth` | `number` | Current queue depth (0 for synchronous bus) |
| `activeSubscribers` | `number` | Active subscriber count |
## Implementation Notes
The metrics interceptor uses `bus.use()` which is the `@suaveplan/events` public interceptor API. It wraps `next()` with a timer, increments counters, and records the histogram. OTel instruments are lazily created on first use to avoid startup cost when the bus is never dispatched. The `queueDepth` and `activeSubscribers` fields in `snapshot()` are reported as 0 because the synchronous `EventBus` does not expose a queue depth API; applications using `BoundedQueue` would need a separate gauge observer.

View File

@@ -0,0 +1,169 @@
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { attachEventMetrics } from "./attach-event-metrics.js";
type TestMap = {
click: { x: number };
submit: { formId: string };
"user.created": { id: string };
};
describe("attachEventMetrics", () => {
it("counts dispatches per event name", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 1 });
await bus.emitAsync("click", { x: 2 });
await bus.emitAsync("submit", { formId: "f" });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBe(2);
expect(snap.dispatchTotal["submit"]).toBe(1);
handle.detach();
});
it("snapshot returns zeros for unknown events", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBeUndefined();
expect(snap.listenerErrorTotal["click"]).toBeUndefined();
handle.detach();
});
it("counts listener errors", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => {
throw new Error("listener error");
});
await bus.emitAsync("click", { x: 0 });
const snap = handle.snapshot();
expect(snap.listenerErrorTotal["click"]).toBe(1);
handle.detach();
});
it("measures latency (avgDispatchLatencyMs >= 0)", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 5 });
const snap = handle.snapshot();
expect(snap.avgDispatchLatencyMs["click"]).toBeGreaterThanOrEqual(0);
handle.detach();
});
it("respects eventFilter option", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus, {
eventFilter: (name) => name === "click",
});
bus.on("click", () => void 0);
bus.on("submit", () => void 0);
await bus.emitAsync("click", { x: 1 });
await bus.emitAsync("submit", { formId: "f" });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBe(1);
expect(snap.dispatchTotal["submit"]).toBeUndefined();
handle.detach();
});
it("detach removes the interceptor", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
bus.on("click", () => void 0);
handle.detach();
await bus.emitAsync("click", { x: 1 });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBeUndefined();
});
it("uses injectable clock", { timeout: 5000 }, async () => {
const bus = new EventBus<TestMap>();
let t = 0;
const clock = { now: () => (t += 10) };
const handle = attachEventMetrics(bus, { clock });
bus.on("click", () => void 0);
await bus.emitAsync("click", { x: 1 });
const snap = handle.snapshot();
expect(snap.avgDispatchLatencyMs["click"]).toBeGreaterThan(0);
handle.detach();
});
it("snapshot returns queueDepth and activeSubscribers as numbers", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
const snap = handle.snapshot();
expect(typeof snap.queueDepth).toBe("number");
expect(typeof snap.activeSubscribers).toBe("number");
handle.detach();
});
it("handles multiple listeners and counts correctly", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
let count = 0;
bus.on("click", () => {
count++;
});
bus.on("click", () => {
count++;
});
await bus.emitAsync("click", { x: 0 });
const snap = handle.snapshot();
expect(snap.dispatchTotal["click"]).toBe(1); // one dispatch, two listeners
expect(count).toBe(2);
handle.detach();
});
it("counts errors thrown in interceptor chain", {
timeout: 5000,
}, async () => {
const bus = new EventBus<TestMap>();
const handle = attachEventMetrics(bus);
// Install a second interceptor that throws
bus.use(async (_envelope, _next) => {
throw new Error("interceptor error");
});
// The event bus wraps non-EventsError throws; verify via cause chain
let caught: unknown;
try {
await bus.emitAsync("click", { x: 0 });
} catch (e) {
caught = e;
}
const hasMsg = (err: unknown, msg: string): boolean =>
err instanceof Error
? err.message.includes(msg) || hasMsg(err.cause, msg)
: false;
expect(hasMsg(caught, "interceptor error")).toBe(true);
const snap = handle.snapshot();
expect(snap.listenerErrorTotal["click"]).toBe(1);
handle.detach();
});
});

Some files were not shown because too many files have changed in this diff Show More