first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
# `AskController`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<string>("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<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.