first commit
This commit is contained in:
114
docs/networking/events/broadcast-channel/README.md
Normal file
114
docs/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
|
||||
|
||||
130
docs/networking/events/broadcast-channel/src/ask/ask.md
Normal file
130
docs/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.
|
||||
102
docs/networking/events/broadcast-channel/src/ask/responder.md
Normal file
102
docs/networking/events/broadcast-channel/src/ask/responder.md
Normal 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.
|
||||
122
docs/networking/events/broadcast-channel/src/channel/channel.md
Normal file
122
docs/networking/events/broadcast-channel/src/channel/channel.md
Normal 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.
|
||||
@@ -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.
|
||||
99
docs/networking/events/broadcast-channel/src/errors/codes.md
Normal file
99
docs/networking/events/broadcast-channel/src/errors/codes.md
Normal 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.
|
||||
79
docs/networking/events/broadcast-channel/src/id/id.md
Normal file
79
docs/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.
|
||||
66
docs/networking/events/broadcast-channel/src/logger/noop.md
Normal file
66
docs/networking/events/broadcast-channel/src/logger/noop.md
Normal 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.
|
||||
104
docs/networking/events/broadcast-channel/src/otel/metrics.md
Normal file
104
docs/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,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,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,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.
|
||||
Reference in New Issue
Block a user