20 KiB
Capability Spec: broadcast-channel
Package: @suaveplan/broadcast-channel
Status: SHIPPED (2026-05-19)
Source change: openspec/archive/2026-05-19-add-broadcast-channel/
Every requirement below carries a
**Verified by:**line.
Requirements
Requirement: Typed cross-tab channel lifecycle
The package SHALL expose TypedBroadcastChannel providing open(), close(), send<T>(type, payload), on<T>(type, handler), getTabId(), getTabs(), getTabCount(), getIsLeader(), and isOpen(). open() MUST be idempotent (re-opening an open channel is a no-op); close() SHALL be safe to call before open() and SHALL clear all tracked tabs and handlers.
Scenario: open then close is reversible
- WHEN a consumer calls
open()thenclose() - THEN
isOpen()returnsfalse,getTabs()returns[], and the underlyingChannelLike.close()has been invoked exactly once
Scenario: open is idempotent
- WHEN
open()is called twice without aclose()in between - THEN only one
ChannelLikeis created via the injected factory
Verified by: src/channel/channel.test.ts::"opens and closes the channel" AND src/channel/channel.test.ts::"open is idempotent" AND src/channel/channel.test.ts::"close is safe before open"
Requirement: Leader election by lowest tab ID
The package SHALL elect a leader by sorted lowest tab ID across all known tabs. When a tab joins or leaves, the leader SHALL be re-elected; the local tab SHALL emit leader:elected once when it transitions from non-leader to leader.
Scenario: solo tab is leader
- WHEN a
TypedBroadcastChannelopens with no peers - THEN
getIsLeader()returnstrue
Scenario: lower-ID peer wins
- WHEN a peer with lexicographically lower ID joins
- THEN
getIsLeader()returnsfalseandgetTabs()reports the peer asisLeader: true
Verified by: src/channel/channel.test.ts::"becomes leader when alone" AND src/channel/channel.test.ts::"yields leadership to lower tab id"
Requirement: Tab presence with heartbeats and stale cleanup
The channel SHALL emit periodic heartbeat frames at the configured heartbeatInterval and SHALL evict tabs whose lastSeen exceeds tabTimeout. tab:joined and tab:left SHALL fire on the corresponding inbound frames.
Scenario: heartbeat fires on interval
- WHEN the channel has been open longer than two
heartbeatIntervalticks - THEN at least one
heartbeatframe has been posted to the underlying channel
Scenario: stale tab is evicted
- WHEN a peer's
lastSeenexceedstabTimeout - THEN the peer is removed from
getTabs()on the next cleanup tick
Verified by: src/channel/channel.test.ts::"emits heartbeat on interval" AND src/channel/channel.test.ts::"evicts stale peers"
Requirement: Zod boundary on inbound frames
Every inbound frame SHALL be validated against ChannelMessageSchema before dispatch. Malformed frames SHALL be dropped silently (no handlers invoked, no tab state mutated) and SHALL increment the channel.message.dropped counter; the optional injected LoggerLike.warn SHALL be called with the validation issue.
Scenario: well-formed frame is dispatched
- WHEN an inbound frame matches
ChannelMessageSchema - THEN registered handlers for its
typeare invoked
Scenario: malformed frame is dropped
- WHEN an inbound frame is missing required fields (e.g. no
id) - THEN no handler runs,
getTabCount()is unchanged for the malformed senderId, andchannel.message.droppedincrements
Verified by: src/channel/channel.test.ts::"dispatches valid frames to handlers" AND src/channel/channel.test.ts::"drops malformed frames and warns" AND src/schemas/messages.test.ts::"accepts valid frames" AND src/schemas/messages.test.ts::"rejects frames missing required fields"
Requirement: Typed MessageEvent listener
ChannelLike.addEventListener SHALL accept (type: "message", listener: (event: MessageEvent) => void). The any-typed listener parameter present upstream MUST NOT appear in the genesis surface.
Scenario: native transport hands back MessageEvent
- WHEN a frame is posted through the native factory's underlying
BroadcastChannel - THEN the listener receives an object whose
datais the original payload and whose static type at the call site isMessageEvent
Verified by: src/transport/native.test.ts::"round-trips a frame through native BroadcastChannel"
Requirement: BroadcastChannel-less fallback via StorageLike
When the host environment lacks BroadcastChannel, callers SHALL be able to use createLocalStorageChannelFactory(storage, options). The factory MUST produce ChannelLike instances that round-trip postMessage payloads to every other open channel of the same name through the injected StorageLike. close() MUST stop the polling loop and remove all listeners.
Scenario: two channels exchange a frame
- WHEN channel A posts a frame on name
"x"and channel B is also open on"x" - THEN channel B's
messagelistener fires withevent.datadeep-equal to the posted frame
Scenario: closed channel stops polling
- WHEN channel A is closed
- THEN subsequent posts on
"x"MUST NOT invoke A's listener
Verified by: src/transport/local-storage.test.ts::"round-trips a frame between two channels" AND src/transport/local-storage.test.ts::"stops receiving after close"
Requirement: OpenTelemetry observability
The package SHALL emit six instruments under meter @suaveplan/broadcast-channel: counter channel.message.sent, counter channel.message.received, counter channel.message.dropped, counter channel.tab.joined, counter channel.tab.left, counter channel.leader.elected. refreshBroadcastChannelMeters() SHALL re-resolve every instrument against the current global MeterProvider so test setups can rebind to an in-memory exporter.
Scenario: send emits sent counter
- WHEN a consumer calls
send("greet", payload) - THEN
channel.message.senthas been incremented by 1 with attribute{type: "greet"}
Scenario: malformed frame emits dropped counter
- WHEN the channel receives a malformed frame
- THEN
channel.message.droppedhas been incremented by 1 with attribute{reason: "malformed"}
Scenario: leadership transition emits leader counter
- WHEN the local tab becomes leader
- THEN
channel.leader.electedhas been incremented by 1
Verified by: src/otel/metrics.test.ts::"send increments channel.message.sent" AND src/otel/metrics.test.ts::"malformed frame increments channel.message.dropped" AND src/otel/metrics.test.ts::"tab join increments channel.tab.joined" AND src/otel/metrics.test.ts::"tab leave increments channel.tab.left" AND src/otel/metrics.test.ts::"leadership transition increments channel.leader.elected"
Requirement: Payload-size guard
send<T>(type, payload) SHALL serialize the message frame and reject any payload whose serialized JSON byte length exceeds config.maxMessageBytes (default 1 MiB) by throwing BroadcastChannelPayloadTooLargeError with code BROADCAST_CHANNEL.PAYLOAD_TOO_LARGE.
Scenario: over-budget payload is rejected
- WHEN
send("big", payload)is called with a payload whose JSON encoding exceedsmaxMessageBytes - THEN the call throws
BroadcastChannelPayloadTooLargeErrorand no frame is posted to the underlying channel
Verified by: src/channel/channel.test.ts::"rejects payloads above maxMessageBytes"
Requirement: Stable BROADCAST_CHANNEL_* error registry
The package SHALL export BROADCAST_CHANNEL_CODES built via defineCodes("BROADCAST_CHANNEL", [...]) with the original four reasons CHANNEL_CLOSED, MESSAGE_MALFORMED, PAYLOAD_TOO_LARGE, STORAGE_FAILED. The registry SHALL be frozen and every original code SHALL be thrown from at least one production code path. The registry MAY be extended by follow-up changes; see "Extended BROADCAST_CHANNEL_* error registry" below for the post-ask extension.
Scenario: registry is frozen
- WHEN a caller attempts to mutate
BROADCAST_CHANNEL_CODES - THEN the mutation throws in strict mode
Scenario: every code reachable
- WHEN the test suite exercises closed-channel send, malformed frame, oversized payload, and a failing storage backend
- THEN each of the four codes is observed on a thrown error or a
Report.codefield
Verified by: src/errors/codes.test.ts::"registry is frozen and shape matches" AND src/errors/codes.test.ts::"every code is thrown by a production path"
Requirement: Injectable Clock / Rng / LoggerLike
TypedBroadcastChannel SHALL accept optional clock, rng, and logger injections matching Clock, Rng, and LoggerLike from @suaveplan/types. Defaults SHALL use Date.now, crypto.getRandomValues, and a no-op logger. All timestamps and IDs in produced frames SHALL derive from the injected sources.
Scenario: deterministic IDs from seeded Rng
- WHEN the channel is constructed with a seeded
Rng - THEN the produced tab ID and outbound message IDs match a fixed expected hex string
Verified by: src/channel/channel.test.ts::"derives tab id from injected rng" AND src/id/id.test.ts::"derives a 16-hex id from rng bytes"
Requirement: ask() correlated request/response
The TypedBroadcastChannel.ask<TReq, TResp>(handlerKey: string, payload: TReq, opts?: AskOptions): Promise<TResp> API SHALL post an ask frame carrying a fresh correlationId drawn from the injected Rng, the supplied handlerKey, the supplied payload, and an expiresAt timestamp computed as clock() + opts.timeoutMs. The returned promise SHALL resolve with the matching peer's response payload when an ask-response frame whose responseTo equals the issued correlationId is received before the timeout elapses, and SHALL reject with BroadcastChannelAskTimeoutError (code BROADCAST_CHANNEL.ASK_TIMEOUT) when the injected TimersLike.setTimer callback fires first.
Scenario: ask round-trips through a responder
- WHEN channel A calls
ask("ping", { n: 1 })and channel B has registeredonAsk("ping", async ({ n }) => ({ n: n + 1 })) - THEN channel A's promise resolves with
{ n: 2 }exactly once and channel B has posted exactly oneask-responseframe whoseresponseTomatches the originalcorrelationId
Scenario: ask defaults to a 5000 ms timeout
- WHEN
ask("nobody-home", null)is invoked with noopts.timeoutMsand no peer answers within 5000 simulated ms - THEN the promise rejects with code
BROADCAST_CHANNEL.ASK_TIMEOUTand the underlying transport has emitted exactly oneaskframe
Scenario: concurrent asks remain isolated by correlationId
- WHEN the asker fires two concurrent
ask("k", ...)calls and the responder answers them in reverse order - THEN each promise resolves with the response posted against its own
correlationIdand neither promise sees the other's payload
Verified by: src/ask/ask.test.ts::"ask round-trips through a responder" AND src/ask/ask.test.ts::"defaults to a 5000 ms timeout when none supplied" AND src/ask/ask.test.ts::"keeps concurrent asks isolated by correlationId"
Requirement: onAsk() single-handler registration
TypedBroadcastChannel SHALL expose onAsk<TReq, TResp>(handlerKey: string, handler: (payload: TReq, ctx: AskHandlerContext) => TResp | Promise<TResp>): () => void. Only one handler MAY be registered per handlerKey; a second registration SHALL throw BroadcastChannelAskHandlerDuplicateError (code BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE) synchronously. The returned disposer SHALL remove the handler so the same key MAY be re-registered. Inbound ask frames whose handlerKey has no registered handler SHALL be dropped silently (no outbound ask-response, no listener error, no tab state mutation).
Scenario: duplicate registration throws
- WHEN a consumer calls
onAsk("dup", h1)and thenonAsk("dup", h2)without disposing the first - THEN the second call synchronously throws an error whose
codeequalsBROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE
Scenario: disposer frees the key
- WHEN a consumer registers
onAsk("k", h), invokes the returned disposer, and then re-registersonAsk("k", h2) - THEN the second registration succeeds and
h2receives the nextask("k", ...)payload
Scenario: unregistered key drops the ask silently
- WHEN an inbound
askframe withhandlerKey: "unknown"arrives at a channel that has no responder for"unknown" - THEN no
ask-responseframe is posted and no error is surfaced on the receiver
Verified by: src/ask/responder.test.ts::"throws ASK_HANDLER_DUPLICATE on second registration" AND src/ask/responder.test.ts::"disposer allows re-registration" AND src/ask/responder.test.ts::"unregistered handlerKey drops ask silently"
Requirement: requireResponder guard surfaces ASK_NO_RESPONDER
AskOptions.requireResponder SHALL default to false (timeout-only behavior — first response wins). When set to true, the controller SHALL reject a timed-out ask with BroadcastChannelAskNoResponderError (code BROADCAST_CHANNEL.ASK_NO_RESPONDER) instead of ASK_TIMEOUT. Because responses settle the promise before the timer fires, the requireResponder: true branch fires only when no peer answered in time.
Scenario: no peers, requireResponder rejects with ASK_NO_RESPONDER
- WHEN channel A calls
ask("orphan", null, { timeoutMs: 50, requireResponder: true })with no peer registered - THEN the promise rejects with code
BROADCAST_CHANNEL.ASK_NO_RESPONDER
Scenario: requireResponder defaults to off
- WHEN
ask("orphan", null, { timeoutMs: 50 })is called with no peer registered - THEN the promise rejects with code
BROADCAST_CHANNEL.ASK_TIMEOUT(notASK_NO_RESPONDER)
Verified by: src/ask/ask.test.ts::"requireResponder rejects with ASK_NO_RESPONDER when no peer claims the key" AND src/ask/ask.test.ts::"requireResponder defaults to off"
Requirement: Handler errors propagate to the asker
When the responder's onAsk handler throws or returns a rejected promise, the channel SHALL post an ask-response frame whose outcome is "error" and whose payload is a structured error projection ({ message: string, code?: string }). The asker's ask() promise SHALL reject with an Error whose message mirrors that projection.
Scenario: thrown handler error reaches the asker
- WHEN channel B's
onAsk("crash", ...)handler throwsnew Error("nope") - THEN channel A's
ask("crash", null)promise rejects with an Error whose message contains"nope"and anask-responseframe withoutcome: "error"was posted
Verified by: src/ask/ask.test.ts::"handler-thrown error rejects the asker"
Requirement: close() cancels pending asks
When TypedBroadcastChannel.close() is called while one or more ask() promises are pending, every pending promise SHALL reject with BroadcastChannelClosedError (code BROADCAST_CHANNEL.CHANNEL_CLOSED) and every scheduled timeout SHALL be cleared via TimersLike.clearTimer. Subsequent ask() calls SHALL reject with BroadcastChannelClosedError like the existing send() path.
Scenario: pending ask rejects on close
- WHEN
ask("slow", null, { timeoutMs: 10_000 })is in flight andchannel.close()is invoked - THEN the promise rejects with code
BROADCAST_CHANNEL.CHANNEL_CLOSEDand no leaked timer remains scheduled in the injectedTimersLike
Verified by: src/ask/ask.test.ts::"close rejects pending asks with CHANNEL_CLOSED"
Requirement: Frame schema extended via discriminated union
ChannelMessageSchema SHALL accept the legacy frame shape AND two new variants: AskFrameSchema with required fields { type: "ask", correlationId, handlerKey, payload, expiresAt, id, senderId, timestamp }, and AskResponseFrameSchema with required fields { type: "ask-response", responseTo, handlerKey, outcome: "ok" | "error", payload, id, senderId, timestamp }. Frames that match neither variant continue to be dropped silently and increment channel.message.dropped.
Scenario: existing frames still validate
- WHEN the schema is fed the legacy
{ type: "greet", payload, id, senderId, timestamp }shape - THEN
safeParsesucceeds with the legacy interpretation
Scenario: ask frame validates only with correlationId
- WHEN an inbound frame has
type: "ask"but is missingcorrelationId - THEN
safeParsefails and the channel drops the frame, incrementingchannel.message.dropped
Verified by: src/schemas/messages.test.ts::"accepts ask and ask-response variants" AND src/schemas/messages.test.ts::"rejects ask frames missing correlationId"
Requirement: OTel counters for ask hot paths
The package SHALL emit three additional instruments under meter @suaveplan/broadcast-channel: counter channel.ask.sent (attribute key), counter channel.ask.responded (attributes key, outcome), counter channel.ask.timeout (attribute key). refreshBroadcastChannelMeters() SHALL re-resolve all three alongside the existing six instruments.
Scenario: ask emits sent counter
- WHEN
ask("ping", payload)is invoked on an open channel - THEN
channel.ask.senthas been incremented by 1 with attribute{ key: "ping" }
Scenario: ask-response emits responded counter
- WHEN the asker receives an
ask-responsewithoutcome: "ok"for a pending ask of key"ping" - THEN
channel.ask.respondedhas been incremented by 1 with attributes{ key: "ping", outcome: "ok" }
Scenario: timeout emits timeout counter
- WHEN an ask of key
"slow"times out - THEN
channel.ask.timeouthas been incremented by 1 with attribute{ key: "slow" }
Verified by: src/otel/metrics.test.ts::"ask increments channel.ask.sent" AND src/otel/metrics.test.ts::"ask-response increments channel.ask.responded" AND src/otel/metrics.test.ts::"ask timeout increments channel.ask.timeout"
Requirement: Injectable TimersLike for deterministic timeouts
TypedBroadcastChannelOptions SHALL accept an optional timers?: TimersLike field matching @suaveplan/types::TimersLike. The default SHALL wrap the platform setTimeout / clearTimeout to satisfy the (callback, delayMs) => number / (handle) => void shape. All ask() timeouts SHALL be scheduled through the injected timers.setTimer and cleared through timers.clearTimer.
Scenario: fake TimersLike drives timeout deterministically
- WHEN the channel is constructed with a fake
TimersLikewhosesetTimerrecords the callback without firing, the test fires the callback manually, andask("k", null, { timeoutMs: 5000 })is awaited - THEN the promise rejects with
BROADCAST_CHANNEL.ASK_TIMEOUTand no realsetTimeoutwas ever scheduled
Verified by: src/ask/ask.test.ts::"fake TimersLike drives timeout deterministically"
Requirement: Extended BROADCAST_CHANNEL_* error registry
BROADCAST_CHANNEL_CODES SHALL contain seven entries: CHANNEL_CLOSED, MESSAGE_MALFORMED, PAYLOAD_TOO_LARGE, STORAGE_FAILED, ASK_TIMEOUT, ASK_NO_RESPONDER, ASK_HANDLER_DUPLICATE. Every code SHALL be thrown by at least one production path and the registry SHALL remain frozen.
Scenario: every new code reachable
- WHEN the test suite exercises a timed-out ask, a
requireResponder: trueask with no peer, and a duplicateonAskregistration - THEN each of the three new codes is observed on a thrown error or a
Report.codefield, andObject.isFrozen(BROADCAST_CHANNEL_CODES)istrue
Verified by: src/errors/codes.test.ts::"every code is thrown by a production path" AND src/errors/codes.test.ts::"registry is frozen and shape matches"