Files
skills/docs/networking/events/broadcast-channel/src/ask/ask.md
2026-05-29 08:20:56 +01:00

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

  • 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

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) — wires clock, rng, timers, channelName, senderId, and the outbound post callback.
  • issue<TResp>(handlerKey, payload, opts?): Promise<TResp> — 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.