# `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 - `correlationId` per call drawn from the injected `Rng`; concurrent asks stay isolated. - Injectable `TimersLike` (`setTimer` / `clearTimer`) — production wraps `setTimeout`, tests pass a fake pair backed by a `FakeClock`. - Three terminal states for each pending ask: resolve on `outcome: "ok"`, reject on `outcome: "error"` (handler-thrown error projected as `{ message, code? }`), reject on timeout (with `BroadcastChannelAskTimeoutError` or, when `requireResponder: true` and no peer claimed the key, `BroadcastChannelAskNoResponderError`). - `cancelAll()` for `channel.close()` integration — clears every timer and rejects every pending promise with `BroadcastChannelClosedError`. - OTel counters wired on every hot path: `channel.ask.sent` on issue, `channel.ask.responded` on settle, `channel.ask.timeout` on deadline miss. ## Usage: Basic ```ts 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("ping", { who: "tab-a" }); ``` ## Usage: Advanced ```ts // 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)` — wires `clock`, `rng`, `timers`, `channelName`, `senderId`, and the outbound `post` callback. - `issue(handlerKey, payload, opts?): Promise` — synchronously posts the `ask` frame and registers the pending entry; the promise resolves when an `ask-response` arrives with a matching `responseTo` and 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 given `handlerKey` as having seen a peer claim so the `requireResponder` branch downgrades to a normal timeout. - `cancelAll(): void` — rejects every pending ask with `BroadcastChannelClosedError` and 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.