Files
suaveplan/openspec/specs/broadcast-channel/spec.md
2026-05-27 23:29:53 +01:00

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() then close()
  • THEN isOpen() returns false, getTabs() returns [], and the underlying ChannelLike.close() has been invoked exactly once

Scenario: open is idempotent

  • WHEN open() is called twice without a close() in between
  • THEN only one ChannelLike is 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 TypedBroadcastChannel opens with no peers
  • THEN getIsLeader() returns true

Scenario: lower-ID peer wins

  • WHEN a peer with lexicographically lower ID joins
  • THEN getIsLeader() returns false and getTabs() reports the peer as isLeader: 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 heartbeatInterval ticks
  • THEN at least one heartbeat frame has been posted to the underlying channel

Scenario: stale tab is evicted

  • WHEN a peer's lastSeen exceeds tabTimeout
  • 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 type are 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, and channel.message.dropped increments

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 data is the original payload and whose static type at the call site is MessageEvent

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 message listener fires with event.data deep-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.sent has been incremented by 1 with attribute {type: "greet"}

Scenario: malformed frame emits dropped counter

  • WHEN the channel receives a malformed frame
  • THEN channel.message.dropped has been incremented by 1 with attribute {reason: "malformed"}

Scenario: leadership transition emits leader counter

  • WHEN the local tab becomes leader
  • THEN channel.leader.elected has 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 exceeds maxMessageBytes
  • THEN the call throws BroadcastChannelPayloadTooLargeError and 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.code field

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 registered onAsk("ping", async ({ n }) => ({ n: n + 1 }))
  • THEN channel A's promise resolves with { n: 2 } exactly once and channel B has posted exactly one ask-response frame whose responseTo matches the original correlationId

Scenario: ask defaults to a 5000 ms timeout

  • WHEN ask("nobody-home", null) is invoked with no opts.timeoutMs and no peer answers within 5000 simulated ms
  • THEN the promise rejects with code BROADCAST_CHANNEL.ASK_TIMEOUT and the underlying transport has emitted exactly one ask frame

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 correlationId and 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 then onAsk("dup", h2) without disposing the first
  • THEN the second call synchronously throws an error whose code equals BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE

Scenario: disposer frees the key

  • WHEN a consumer registers onAsk("k", h), invokes the returned disposer, and then re-registers onAsk("k", h2)
  • THEN the second registration succeeds and h2 receives the next ask("k", ...) payload

Scenario: unregistered key drops the ask silently

  • WHEN an inbound ask frame with handlerKey: "unknown" arrives at a channel that has no responder for "unknown"
  • THEN no ask-response frame 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 (not ASK_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 throws new Error("nope")
  • THEN channel A's ask("crash", null) promise rejects with an Error whose message contains "nope" and an ask-response frame with outcome: "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 and channel.close() is invoked
  • THEN the promise rejects with code BROADCAST_CHANNEL.CHANNEL_CLOSED and no leaked timer remains scheduled in the injected TimersLike

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 safeParse succeeds with the legacy interpretation

Scenario: ask frame validates only with correlationId

  • WHEN an inbound frame has type: "ask" but is missing correlationId
  • THEN safeParse fails and the channel drops the frame, incrementing channel.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.sent has been incremented by 1 with attribute { key: "ping" }

Scenario: ask-response emits responded counter

  • WHEN the asker receives an ask-response with outcome: "ok" for a pending ask of key "ping"
  • THEN channel.ask.responded has 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.timeout has 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 TimersLike whose setTimer records the callback without firing, the test fires the callback manually, and ask("k", null, { timeoutMs: 5000 }) is awaited
  • THEN the promise rejects with BROADCAST_CHANNEL.ASK_TIMEOUT and no real setTimeout was 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: true ask with no peer, and a duplicate onAsk registration
  • THEN each of the three new codes is observed on a thrown error or a Report.code field, and Object.isFrozen(BROADCAST_CHANNEL_CODES) is true

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"