9.2 KiB
Capability Spec Delta: broadcast-channel (add-broadcast-channel)
Package: @suaveplan/broadcast-channel
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. (Seeopenspec/AGENTS.md§13.)
ADDED 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 reasons CHANNEL_CLOSED, MESSAGE_MALFORMED, PAYLOAD_TOO_LARGE, STORAGE_FAILED. Every code SHALL be thrown from at least one production code path and the registry SHALL be frozen.
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"