Initial Commit
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user