first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.