Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,338 @@
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->