Files
skills/docs/networking/events/broadcast-channel/src/channel/channel.md
2026-05-29 08:20:56 +01:00

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> / 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

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 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.