5.1 KiB
channel/channel
(no submodules)
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>/handleIncomingsurface preserving payload generics. - Lowest-tab-ID leader election with
leader:electedlifecycle event on transition. - Heartbeat + cleanup intervals via the
HeartbeatRunnerhelper. - Zod boundary validation on every inbound frame; malformed frames are dropped, warned, and counted.
- Payload-size guard at
config.maxMessageBytesraisingBroadcastChannelPayloadTooLargeError. BroadcastChannelClosedErroronsendbeforeopen/ afterclose.- suaveplan-shape
EventBus<BroadcastChannelEventMap>lifecycle bus. - Injectable
Clock/Rng/LoggerLikefrom@suaveplan/types.
Usage: Basic
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
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 TypedBroadcastChannelconstructor(options: TypedBroadcastChannelOptions)— acceptsconfig,factory, optionalclock,rng,logger.open(): void— idempotent; subsequentopen()is a no-op.close(): void— safe beforeopen(); clears tabs, handlers, and timers.send<T>(type: string, payload: T): void— throwsBroadcastChannelClosedErrorif the channel is not open; throwsBroadcastChannelPayloadTooLargeErrorif the JSON-encoded byte length of the message exceedsconfig.maxMessageBytes.on<T>(type: string, handler: ChannelHandler<T>): () => void— returns an unsubscribe closure.getTabId(): string— local 16-hex tab ID derived from the injectedRng.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(): booleanhandleIncoming(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.