Initial Commit
This commit is contained in:
114
packages/networking/events/broadcast-channel/README.md
Normal file
114
packages/networking/events/broadcast-channel/README.md
Normal 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
|
||||
|
||||
134
packages/networking/events/broadcast-channel/package.json
Normal file
134
packages/networking/events/broadcast-channel/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
130
packages/networking/events/broadcast-channel/src/ask/ask.md
Normal file
130
packages/networking/events/broadcast-channel/src/ask/ask.md
Normal 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.
|
||||
529
packages/networking/events/broadcast-channel/src/ask/ask.test.ts
Normal file
529
packages/networking/events/broadcast-channel/src/ask/ask.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
176
packages/networking/events/broadcast-channel/src/ask/ask.ts
Normal file
176
packages/networking/events/broadcast-channel/src/ask/ask.ts
Normal 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) };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
AskController,
|
||||
type AskControllerOptions,
|
||||
buildAskErrorPayload,
|
||||
DEFAULT_ASK_TIMEOUT_MS,
|
||||
} from "./ask.js";
|
||||
export { type AskHandler, AskResponderRegistry } from "./responder.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ChannelHandler,
|
||||
TypedBroadcastChannel,
|
||||
type TypedBroadcastChannelOptions,
|
||||
} from "./channel.js";
|
||||
export { HeartbeatRunner, type HeartbeatRunnerOptions } from "./heartbeat.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
130
packages/networking/events/broadcast-channel/src/errors/codes.ts
Normal file
130
packages/networking/events/broadcast-channel/src/errors/codes.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
BROADCAST_CHANNEL_CODES,
|
||||
BroadcastChannelClosedError,
|
||||
BroadcastChannelError,
|
||||
type BroadcastChannelErrorCode,
|
||||
BroadcastChannelMessageMalformedError,
|
||||
BroadcastChannelPayloadTooLargeError,
|
||||
BroadcastChannelStorageFailedError,
|
||||
} from "./codes.js";
|
||||
79
packages/networking/events/broadcast-channel/src/id/id.md
Normal file
79
packages/networking/events/broadcast-channel/src/id/id.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
55
packages/networking/events/broadcast-channel/src/id/id.ts
Normal file
55
packages/networking/events/broadcast-channel/src/id/id.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
defaultRng,
|
||||
generateId,
|
||||
} from "./id.js";
|
||||
97
packages/networking/events/broadcast-channel/src/index.ts
Normal file
97
packages/networking/events/broadcast-channel/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { noopLogger } from "./noop.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
recordLeaderElected,
|
||||
recordMessageDropped,
|
||||
recordMessageReceived,
|
||||
recordMessageSent,
|
||||
recordTabJoined,
|
||||
recordTabLeft,
|
||||
refreshBroadcastChannelMeters,
|
||||
} from "./metrics.js";
|
||||
104
packages/networking/events/broadcast-channel/src/otel/metrics.md
Normal file
104
packages/networking/events/broadcast-channel/src/otel/metrics.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
119
packages/networking/events/broadcast-channel/src/otel/metrics.ts
Normal file
119
packages/networking/events/broadcast-channel/src/otel/metrics.ts
Normal 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 });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ChannelConfigInput,
|
||||
ChannelConfigSchema,
|
||||
type ChannelMessageInput,
|
||||
ChannelMessageSchema,
|
||||
} from "./messages.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/networking/events/broadcast-channel/tsconfig.json
Normal file
12
packages/networking/events/broadcast-channel/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
38
packages/networking/events/broadcast-channel/vite.config.ts
Normal file
38
packages/networking/events/broadcast-channel/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
Reference in New Issue
Block a user