4.8 KiB
AskController
(no submodules)
Purpose
AskController is the request-side engine for the cross-tab request/response
primitive layered on top of TypedBroadcastChannel. It owns the registry of
in-flight ask() calls, keyed by a freshly drawn correlationId, and is
responsible for posting the outbound ask wire frame, scheduling the timeout
via the injected TimersLike, settling the promise when a matching
ask-response frame arrives, and rejecting pending promises during a channel
close. Every behaviour the controller drives — clock reads, ID draws, timer
scheduling, OTel counter emission — flows through injected seams so a single
deterministic test can exercise the entire ask lifecycle in zero wall-clock
time.
Features
correlationIdper call drawn from the injectedRng; concurrent asks stay isolated.- Injectable
TimersLike(setTimer/clearTimer) — production wrapssetTimeout, tests pass a fake pair backed by aFakeClock. - Three terminal states for each pending ask: resolve on
outcome: "ok", reject onoutcome: "error"(handler-thrown error projected as{ message, code? }), reject on timeout (withBroadcastChannelAskTimeoutErroror, whenrequireResponder: trueand no peer claimed the key,BroadcastChannelAskNoResponderError). cancelAll()forchannel.close()integration — clears every timer and rejects every pending promise withBroadcastChannelClosedError.- OTel counters wired on every hot path:
channel.ask.senton issue,channel.ask.respondedon settle,channel.ask.timeouton deadline miss.
Usage: Basic
import { AskController } from "@suaveplan/broadcast-channel/ask/ask";
const controller = new AskController({
clock: () => Date.now(),
rng,
timers: {
setTimer: (cb, ms) => setTimeout(cb, ms) as unknown as number,
clearTimer: (h) => clearTimeout(h),
},
channelName: "my-app",
senderId: tabId,
post: (frame) => channel.postMessage(frame),
});
const reply = await controller.issue<string>("ping", { who: "tab-a" });
Usage: Advanced
// Deterministic timeout test driving a fake TimersLike.
const fired: Array<() => void> = [];
const fakeTimers = {
setTimer: (cb: () => void) => {
fired.push(cb);
return fired.length;
},
clearTimer: (handle: number) => {
fired[handle - 1] = () => {};
},
};
const controller = new AskController({
clock: () => 0,
rng,
timers: fakeTimers,
channelName: "det",
senderId: "asker",
post: () => {},
});
const promise = controller.issue("k", null, {
timeoutMs: 1000,
requireResponder: true,
});
// Drive the timer manually — no real setTimeout was scheduled.
fired[0]?.();
await expect(promise).rejects.toMatchObject({
code: "BROADCAST_CHANNEL.ASK_NO_RESPONDER",
});
API Reference
class AskController
constructor(opts: AskControllerOptions)— wiresclock,rng,timers,channelName,senderId, and the outboundpostcallback.issue<TResp>(handlerKey, payload, opts?): Promise<TResp>— synchronously posts theaskframe and registers the pending entry; the promise resolves when anask-responsearrives with a matchingresponseToand rejects on timeout or close.settle(frame: AskResponseFrame): void— resolves or rejects the matching pending entry; late arrivals (no matching entry) are ignored.noteResponderClaim(key: string): void— marks every pending ask with the givenhandlerKeyas having seen a peer claim so therequireResponderbranch downgrades to a normal timeout.cancelAll(): void— rejects every pending ask withBroadcastChannelClosedErrorand clears every timer.
buildAskErrorPayload(err: unknown): AskErrorPayload
Projects an arbitrary thrown value into the wire-safe { message, code? }
shape carried by an outcome: "error" ask-response frame.
Implementation Notes
The correlationId is drawn from the same injected Rng as the existing tab
and message IDs, so seeding the Rng in tests gives deterministic
correlationId values without any extra wiring. The pending map is keyed by
correlationId rather than handlerKey so a single asker can have multiple
in-flight asks against the same handler key. Timer handles are stored on each
entry so cancelAll() can clear every scheduled callback in O(n) without
walking the underlying timer pool.
Error projection is intentionally lossy — only message and code cross the
wire — so handlers may throw arbitrary objects locally without leaking
implementation details across tabs. Cross-tab transport is structured-clone,
so passing a real Error instance through payload would only deliver a
plain object on the receiver anyway; the explicit projection makes that
contract typed and observable rather than implicit.