first commit
This commit is contained in:
130
docs/networking/events/broadcast-channel/src/ask/ask.md
Normal file
130
docs/networking/events/broadcast-channel/src/ask/ask.md
Normal 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.
|
||||
Reference in New Issue
Block a user