Initial Commit
This commit is contained in:
103
openspec/archive/2026-05-19-add-broadcast-channel/design.md
Normal file
103
openspec/archive/2026-05-19-add-broadcast-channel/design.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Design — add-broadcast-channel
|
||||
|
||||
## Module layout
|
||||
|
||||
```text
|
||||
packages/events/broadcast-channel/
|
||||
src/
|
||||
channel/
|
||||
channel.ts # TypedBroadcastChannel core (295 lines, under 300 budget)
|
||||
heartbeat.ts # HeartbeatRunner helper extracted to fit budget
|
||||
channel.test.ts
|
||||
heartbeat.test.ts
|
||||
channel.md
|
||||
heartbeat.md
|
||||
index.ts
|
||||
errors/
|
||||
codes.ts # defineCodes("BROADCAST_CHANNEL", [...]) + 4 subclasses
|
||||
codes.test.ts
|
||||
codes.md
|
||||
index.ts
|
||||
id/
|
||||
id.ts # generateId(rng) + defaultRng
|
||||
id.test.ts
|
||||
id.md
|
||||
index.ts
|
||||
otel/
|
||||
metrics.ts # 6 counters + refreshBroadcastChannelMeters
|
||||
metrics.test.ts
|
||||
metrics.md
|
||||
index.ts
|
||||
schemas/
|
||||
messages.ts # ChannelMessageSchema + ChannelConfigSchema (Zod)
|
||||
messages.test.ts
|
||||
messages.md
|
||||
index.ts
|
||||
transport/
|
||||
native.ts # nativeChannelFactory + isNativeBroadcastChannelAvailable
|
||||
local-storage.ts # StorageLike-backed fallback
|
||||
transport.types.ts # ChannelLike (typed MessageEvent) + ChannelFactory
|
||||
native.test.ts
|
||||
local-storage.test.ts
|
||||
native.md
|
||||
local-storage.md
|
||||
index.ts
|
||||
types/
|
||||
broadcast-channel.types.ts
|
||||
index.ts
|
||||
index.ts # named re-exports only
|
||||
tests/setup/polyfills.ts # in-memory MeterProvider for OTel tests
|
||||
README.md
|
||||
package.json
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
tsconfig.json
|
||||
tsconfig.lib.json
|
||||
```
|
||||
|
||||
## Key design choices
|
||||
|
||||
- **Single-payload event map**: genesis `EventBus<TMap>` uses
|
||||
`Record<string, unknown>` (one payload per emit). Upstream used a tuple-args
|
||||
shape (`EventMap = Record<string, unknown[]>`). The migrated event map
|
||||
carries structured records (`ChannelOpenedEvent`, `TabJoinedEvent`, …) so
|
||||
every callback sees one object with named fields. Net public-surface impact:
|
||||
consumers register `on("tab:joined", e => e.tabId)` instead of
|
||||
`on("tab:joined", tabId => ...)`. This is documented in `proposal.md` →
|
||||
`BroadcastChannelEventMap`.
|
||||
- **`ChannelLike` listener fixed to `MessageEvent`**: upstream had
|
||||
`(event: any) => void` — the migration narrows to `(event: MessageEvent) => void`
|
||||
on the `ChannelLike` interface and propagates the change through both
|
||||
transports. This is the genesis-mandated gap fix.
|
||||
- **`StorageLike` rather than raw `window.localStorage`**: the fallback
|
||||
transport takes any `StorageLike` from `@suaveplan/types`. This generalises
|
||||
the polyfill beyond Safari — the same factory works on top of OPFS,
|
||||
IndexedDB, Garage S3, or an in-memory test double — and removes the
|
||||
package's dependency on a specific browser global.
|
||||
- **Storage failures logged, not thrown**: the polling / write paths fire on
|
||||
intervals; an unhandled rejection would crash the host. Failures wrap into
|
||||
`BroadcastChannelStorageFailedError`, surface through the injected
|
||||
`LoggerLike.warn`, and the loop continues. The code is still reachable from
|
||||
tests because the wrapping happens synchronously after the rejection.
|
||||
- **Six counters, no histograms**: every lifecycle path emits a counter. No
|
||||
meaningful latency axis exists (dispatch is in-process, `send` is sync) so a
|
||||
histogram would be empty noise.
|
||||
- **Payload guard via JSON byte length**: the canonical structured-clone size
|
||||
of a frame is unknowable without serialising. JSON is the only encoding the
|
||||
underlying transports universally support, so the guard runs the JSON
|
||||
encoder once, measures via `TextEncoder.encode`, and rejects above
|
||||
`maxMessageBytes`. The same encoded buffer is not reused for the native
|
||||
transport (which clones internally) but the cost is one `JSON.stringify`
|
||||
per `send` — well under any meaningful budget.
|
||||
|
||||
## Out-of-scope features and why
|
||||
|
||||
The upstream §4 list ("Feature-rich enhancements") catalogues ten further
|
||||
ideas. The migration explicitly defers nine of them — see
|
||||
`### Excluded from this migration` in `proposal.md`. The genesis rule "No
|
||||
deferrals when migrating/creating packages" applies to **legitimate parity**
|
||||
features that were in upstream's shipping surface; §4 enhancements are
|
||||
documented as future work in the audit, not as upstream-shipped surface, and
|
||||
each carries its own design surface and test contract that does not fit a
|
||||
4-file migration. Future changes (`add-broadcast-channel-ask`,
|
||||
`add-broadcast-channel-leader-strategies`, etc.) will land them individually.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Add @suaveplan/broadcast-channel
|
||||
|
||||
**Package**: `@suaveplan/broadcast-channel`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Design**: ./design.md
|
||||
**Spec delta**: ./specs/broadcast-channel/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Browser apps need a typed cross-tab messaging primitive built on the `BroadcastChannel` Web API:
|
||||
multi-tab sync, leader election, tab presence and heartbeats. Upstream `../suaveplan/packages/browser/broadcast-channel/`
|
||||
shipped at v1.0.5 with a clean public surface but four documented enterprise gaps (`enterprise/broadcast-channel.md`
|
||||
§2 smells / §3 required improvements): no Zod boundary on inbound frames, no OTel observability, an untyped
|
||||
`(event: any)` listener, and no fallback for browsers without `BroadcastChannel` (Safari < 15.4 and any
|
||||
extension/test sandbox without the global). Genesis ships the migration at full parity plus those four fixes.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `TypedBroadcastChannel` — typed cross-tab channel with leader election, tab presence, heartbeats, message-type handler registry, and `EventBus<BroadcastChannelEventMap>` lifecycle events.
|
||||
- ADDED: `ChannelFactory` seam — caller injects native or polyfill `ChannelLike`.
|
||||
- ADDED: `nativeChannelFactory` — wraps the global `BroadcastChannel`.
|
||||
- ADDED: `createLocalStorageChannelFactory` — `StorageLike`-backed fallback for environments where `BroadcastChannel` is unavailable.
|
||||
- ADDED: `ChannelMessageSchema` — Zod boundary validator; malformed inbound frames are dropped and surface `BROADCAST_CHANNEL.MESSAGE_MALFORMED`.
|
||||
- ADDED: OTel instruments under meter `@suaveplan/broadcast-channel` — `channel.message.sent`, `channel.message.received`, `channel.message.dropped`, `channel.tab.joined`, `channel.tab.left`, `channel.leader.elected`.
|
||||
- ADDED: Typed listener — `ChannelLike.addEventListener(type, listener: (event: MessageEvent) => void)`. Upstream `any` removed.
|
||||
- ADDED: `BROADCAST_CHANNEL_CODES` registry — `CHANNEL_CLOSED`, `MESSAGE_MALFORMED`, `PAYLOAD_TOO_LARGE`, `STORAGE_FAILED`.
|
||||
- ADDED: Payload-size guard — `maxMessageBytes` (default 1 MiB); over-budget `send()` throws `BroadcastChannelPayloadTooLargeError`.
|
||||
- ADDED: Injectable `Clock` / `Rng` / `LoggerLike` for deterministic tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `ask()` request/response helper — not in the four mandated gap fixes; consumers can layer correlation-ID on top of `send`/`on`. Will land in a follow-up change with `BROADCAST_CHANNEL.ASK_TIMEOUT` code.
|
||||
- Leader-election strategies (round-robin, active-tab-wins) — upstream parity is lowest-tab-ID; alternative strategies are §4 feature-rich extensions not required by the four gaps.
|
||||
- Message replay buffer, CRDT adapter, dev overlay, encryption hook, namespaced channels, presence hooks (`onTabFocus` / `onTabBlur`), compression — all §4 enhancements deferred to dedicated changes; each is a meaningful feature with its own design and test surface.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/broadcast-channel.md` and
|
||||
`../suaveplan/packages/browser/broadcast-channel/src/`.
|
||||
|
||||
| Requirement | Source lines / symbol | Notes |
|
||||
|---|---|---|
|
||||
| Typed channel with leader election | enterprise.md L9-L11; `channel/channel.ts::TypedBroadcastChannel` | verbatim parity |
|
||||
| Tab presence + heartbeats | enterprise.md L9-L11; `channel/channel.ts::startHeartbeat` | verbatim parity |
|
||||
| Per-type handler registry | enterprise.md L9-L11; `channel/channel.ts::on` | verbatim parity |
|
||||
| `ChannelFactory` injection seam | enterprise.md L11; `channel/channel.ts::ChannelFactory` | verbatim parity |
|
||||
| `BroadcastChannelEventMap` lifecycle events | enterprise.md L11; `types/broadcast-channel.types.ts` | parity, adapted to genesis `EventBus<TMap>` (single-payload map) |
|
||||
| `DEFAULT_CHANNEL_CONFIG` | `types/broadcast-channel.types.ts::DEFAULT_CHANNEL_CONFIG` | verbatim parity |
|
||||
| Zod boundary on inbound frames | enterprise.md L31, L38, L56, L79 | gap fix |
|
||||
| `localStorage` fallback transport | enterprise.md L29, L38, L51, L64-L65, L80, L96 | gap fix |
|
||||
| Typed `MessageEvent` listener | enterprise.md L27, L39, L78 | gap fix |
|
||||
| OTel counters for sent / received / tabs / leader | enterprise.md L43, L83 | gap fix |
|
||||
| Payload-size guard | enterprise.md L33, L42, L57, L99 | gap fix, paired with `PAYLOAD_TOO_LARGE` code |
|
||||
| `defineCodes("BROADCAST_CHANNEL", [...])` registry | enterprise.md L57 | extension into genesis error system |
|
||||
| Injectable `Clock` / `Rng` / `LoggerLike` | enterprise.md L46-L58 (cross-cutting); CLAUDE.md rule 2 | genesis cross-cutting |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100% Istanbul coverage threshold | Genesis-wide policy (`AGENTS.md` §7) |
|
||||
| `@suaveplan/testing/runner` test imports | Genesis-wide rule 11 / CLAUDE.md |
|
||||
| Bun catalog refs for external deps | Genesis-wide rule (`AGENTS.md` §1) |
|
||||
|
||||
### Excluded from this migration
|
||||
|
||||
| Upstream §4 enhancement | Reason |
|
||||
|---|---|
|
||||
| `ask()` request/response | Not in the four mandated gap fixes; deferred to a dedicated change with its own correlation-ID + timeout tests |
|
||||
| Round-robin / active-tab-wins leader strategies | Upstream parity is lowest-tab-ID; alternative strategies are §4 enhancements |
|
||||
| Message replay buffer (ring) | §4 enhancement; needs its own bounded-buffer design |
|
||||
| CRDT adapter, encryption hook, compression, presence hooks, namespacing, dev overlay | §4 enhancements; each is a meaningful feature with its own design |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/channel/channel.test.ts` — `TypedBroadcastChannel` parity scenarios, leader election, heartbeat, stale cleanup, payload-size guard.
|
||||
- `src/transport/native.test.ts` — `nativeChannelFactory` round-trip via real `BroadcastChannel` in happy-dom.
|
||||
- `src/transport/local-storage.test.ts` — `createLocalStorageChannelFactory` round-trip via real `StorageLike` fallback.
|
||||
- `src/schemas/messages.test.ts` — Zod boundary acceptance / rejection.
|
||||
- `src/otel/metrics.test.ts` — every instrument asserted against real in-memory `MeterProvider`.
|
||||
- `src/id/id.test.ts` — `generateId(rng)` determinism + format.
|
||||
- `src/errors/codes.test.ts` — registry frozen-shape + every code thrown by ≥1 production path.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
No dependents on genesis yet (verified by `bunx turbo ls --filter=...@suaveplan/broadcast-channel` — package is new).
|
||||
This is a leaf browser-tier package.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive, new package.
|
||||
@@ -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 -->
|
||||
65
openspec/archive/2026-05-19-add-broadcast-channel/tasks.md
Normal file
65
openspec/archive/2026-05-19-add-broadcast-channel/tasks.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Tasks — Add @suaveplan/broadcast-channel
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Retroactive ticking is the failure mode this template guards against.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/broadcast-channel.md` end-to-end
|
||||
- [x] 1.2 Read upstream src `../suaveplan/packages/browser/broadcast-channel/src/`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` §§2a/6a/7/12/13/14, CLAUDE.md, templates
|
||||
- [x] 1.4 Draft `proposal.md` with `## Source mapping` and `### Excluded from this migration`
|
||||
- [x] 1.5 Draft `specs/broadcast-channel/spec.md` with one `## ADDED Requirements` block; every requirement has a `**Verified by:**` line authored at draft time
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/errors/codes.ts` — `BROADCAST_CHANNEL_CODES` via `defineCodes` + four typed subclasses extending `SuaveplanError`
|
||||
- [x] 2.2 `src/id/id.ts` — `generateId(rng)` using injectable `Rng` from `@suaveplan/types`
|
||||
- [x] 2.3 `src/schemas/messages.ts` — `ChannelMessageSchema`, `ChannelConfigSchema` (Zod default import)
|
||||
- [x] 2.4 `src/types/broadcast-channel.types.ts` — `ChannelConfig`, `ChannelMessage<T>`, `TabInfo`, `BroadcastChannelEventMap`, `DEFAULT_CHANNEL_CONFIG`, `MAX_MESSAGE_BYTES_DEFAULT`
|
||||
- [x] 2.5 `src/transport/transport.types.ts` — `ChannelLike` (typed `MessageEvent`), `ChannelFactory`
|
||||
- [x] 2.6 `src/transport/native.ts` — `nativeChannelFactory` wrapping global `BroadcastChannel`
|
||||
- [x] 2.7 `src/transport/local-storage.ts` — `StorageLike`-backed polyfill factory
|
||||
- [x] 2.8 `src/otel/metrics.ts` — six counters + `refreshBroadcastChannelMeters`
|
||||
- [x] 2.9 `src/channel/channel.ts` — `TypedBroadcastChannel` (Zod boundary, OTel emission, payload guard, typed listener, injectables)
|
||||
- [x] 2.10 `src/index.ts` — explicit named re-exports only
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/channel/channel.test.ts` — parity + payload guard + Zod boundary + logger injection
|
||||
- [x] 3.2 `src/transport/native.test.ts` — happy-dom `BroadcastChannel` round-trip
|
||||
- [x] 3.3 `src/transport/local-storage.test.ts` — fallback round-trip via in-memory `StorageLike`
|
||||
- [x] 3.4 `src/schemas/messages.test.ts` — Zod accept/reject
|
||||
- [x] 3.5 `src/otel/metrics.test.ts` — every counter asserted via in-memory `MeterProvider`
|
||||
- [x] 3.6 `src/id/id.test.ts` — deterministic ID via seeded `Rng`
|
||||
- [x] 3.7 `src/errors/codes.test.ts` — every code reachable from production path
|
||||
- [x] 3.8 All tests import from `@suaveplan/testing/runner`; every `it()` has `{ timeout: 5000 }` (or 30000 for async heartbeat)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, Purpose / Features / Basic / Advanced / API Reference / Implementation Notes)
|
||||
- [x] 4.2 `README.md` linking every co-located `.md` under `## Modules`
|
||||
|
||||
## 5. Pre-archive verification (5-gate triple)
|
||||
|
||||
- [x] 5.1 `bunx biome check src/` (package-scoped) — 0 errors
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/broadcast-channel` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/broadcast-channel` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/broadcast-channel` — exit 0
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/broadcast-channel` — green
|
||||
|
||||
## 6. Pre-archive 9-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded (`TypedBroadcastChannel`, `ChannelMessage<T>`, `send<T>`, `on<T>`)
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary (inbound `ChannelMessage` frames)
|
||||
- [x] 6.3 Every code in `BROADCAST_CHANNEL_CODES` thrown by ≥1 production path
|
||||
- [x] 6.4 Strict-mode behaviors tested (closed-channel send, malformed message drop)
|
||||
- [x] 6.5 OTel instruments emitted from production paths and asserted via real in-memory `MeterProvider`
|
||||
- [x] 6.6 Lifecycle dedup tested (re-open is idempotent; close-before-open is no-op)
|
||||
- [x] 6.7 No retroactive tick
|
||||
- [x] 6.8 No `export *` in any feature-module barrel — explicit named exports only
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports — all tests via `@suaveplan/testing/runner`
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/broadcast-channel/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-broadcast-channel/` → `openspec/archive/2026-05-19-add-broadcast-channel/`
|
||||
Reference in New Issue
Block a user