11 KiB
Capability Spec Delta: broadcast-channel — add ask() request/response
Package: @suaveplan/broadcast-channel
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions.
ADDED Requirements
Requirement: ask() correlated request/response
TypedBroadcastChannel SHALL expose ask<TReq, TResp>(handlerKey: string, payload: TReq, opts?: AskOptions): Promise<TResp>. Every call SHALL post an ask frame to the underlying transport 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 registeredonAsk("ping", async ({ n }) => ({ n: n + 1 })) - THEN channel A's promise resolves with
{ n: 2 }exactly once and channel B has posted exactly oneask-responseframe whoseresponseTomatches the originalcorrelationId
Scenario: ask defaults to a 5000 ms timeout
- WHEN
ask("nobody-home", null)is invoked with noopts.timeoutMsand no peer answers within 5000 simulated ms - THEN the promise rejects with code
BROADCAST_CHANNEL.ASK_TIMEOUTand the underlying transport has emitted exactly oneaskframe
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
correlationIdand 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 thenonAsk("dup", h2)without disposing the first - THEN the second call synchronously throws an error whose
codeequalsBROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE
Scenario: disposer frees the key
- WHEN a consumer registers
onAsk("k", h), invokes the returned disposer, and then re-registersonAsk("k", h2) - THEN the second registration succeeds and
h2receives the nextask("k", ...)payload
Scenario: unregistered key drops the ask silently
- WHEN an inbound
askframe withhandlerKey: "unknown"arrives at a channel that has no responder for"unknown" - THEN no
ask-responseframe 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 track whether at least one peer has claimed the handlerKey (signalled by a responder-claim echo frame OR the eventual receipt of an ask-response from any peer). If the timeout fires AND no peer has claimed the key, the promise SHALL reject with BroadcastChannelAskNoResponderError (code BROADCAST_CHANNEL.ASK_NO_RESPONDER) instead of ASK_TIMEOUT. If at least one peer claimed the key but did not respond in time, the promise SHALL reject with ASK_TIMEOUT as normal.
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(notASK_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 throwsnew Error("nope") - THEN channel A's
ask("crash", null)promise rejects with an Error whose message contains"nope"and anask-responseframe withoutcome: "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 throw BroadcastChannelClosedError synchronously like the existing send() path.
Scenario: pending ask rejects on close
- WHEN
ask("slow", null, { timeoutMs: 10_000 })is in flight andchannel.close()is invoked - THEN the promise rejects with code
BROADCAST_CHANNEL.CHANNEL_CLOSEDand no leaked timer remains scheduled in the injectedTimersLike
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 existing frame shape AND two new variants: AskFrameSchema with required fields { type: "ask", correlationId: string, handlerKey: string, payload: unknown, expiresAt: number, id: string, senderId: string, timestamp: number }, and AskResponseFrameSchema with required fields { type: "ask-response", responseTo: string, handlerKey: string, outcome: "ok" | "error", payload: unknown, id: string, senderId: string, timestamp: number }. Frames that match the existing variant continue to parse with no behavioral change. 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
safeParsesucceeds with the legacy interpretation
Scenario: ask frame validates only with correlationId
- WHEN an inbound frame has
type: "ask"but is missingcorrelationId - THEN
safeParsefails and the channel drops the frame, incrementingchannel.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.senthas been incremented by 1 with attribute{ key: "ping" }
Scenario: ask-response emits responded counter
- WHEN the asker receives an
ask-responsewithoutcome: "ok"for a pending ask of key"ping" - THEN
channel.ask.respondedhas 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.timeouthas 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. Tests MUST be able to drive the entire ask lifecycle in zero wall-clock time by passing a fake TimersLike.
Scenario: fake TimersLike drives timeout deterministically
- WHEN the channel is constructed with a fake
TimersLikewhosesetTimerrecords the callback without firing, the test fires the callback manually, andask("k", null, { timeoutMs: 5000 })is awaited - THEN the promise rejects with
BROADCAST_CHANNEL.ASK_TIMEOUTand no realsetTimeoutwas 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 three additional entries — ASK_TIMEOUT, ASK_NO_RESPONDER, ASK_HANDLER_DUPLICATE — bringing the total registry size to seven. Every new code SHALL be thrown by at least one production path (timeout in AskController, no-responder branch in AskController, second onAsk registration in AskResponderRegistry) and the registry SHALL remain frozen.
Scenario: every new code reachable
- WHEN the test suite exercises a timed-out ask, a
requireResponder: trueask with no peer, and a duplicateonAskregistration - THEN each of the three new codes is observed on a thrown error or a
Report.codefield, andObject.isFrozen(BROADCAST_CHANNEL_CODES)istrue
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"