Files
2026-05-27 23:29:53 +01:00

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. (See openspec/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() 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 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.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"