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