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

3.5 KiB

AskResponderRegistry

Purpose

AskResponderRegistry is the response-side counterpart to AskController. It maps an inbound handlerKey to the single responder function registered for that key via TypedBroadcastChannel.onAsk(handlerKey, handler) and enforces the package's "at most one responder per key" invariant. The single-responder rule is deliberate: multi-responder fan-out (askMany() / askAll()) is its own future capability with its own design surface, and forcing a second registration to throw BroadcastChannelAskHandlerDuplicateError makes collisions impossible to ignore at integration time.

Features

  • Single-handler-per-key invariant enforced synchronously on the second register() call.
  • Returned disposer is identity-safe: it only removes the slot if the current occupant is still the handler the caller registered, so an out-of-order dispose after a re-register does nothing.
  • resolve(key) returns undefined for unregistered keys so the channel can drop the ask frame silently without any error path on the receiver.
  • clear() drops every responder, invoked once per channel.close().

Usage: Basic

import { AskResponderRegistry } from "@suaveplan/broadcast-channel/ask/responder";

const registry = new AskResponderRegistry();

const dispose = registry.register<{ n: number }, { n: number }>(
    "increment",
    async ({ n }) => ({ n: n + 1 })
);

const handler = registry.resolve("increment");
if (handler !== undefined) {
    const out = await handler({ n: 41 }, {
        correlationId: "c1",
        senderId: "tab-a",
        handlerKey: "increment",
    });
    // out = { n: 42 }
}

dispose();

Usage: Advanced

// Re-registration after disposal is fine; double-register without disposal
// throws synchronously.
const r = new AskResponderRegistry();
const d1 = r.register("k", () => 1);
expect(() => r.register("k", () => 2)).toThrowError(
    /ASK_HANDLER_DUPLICATE/
);
d1();
const d2 = r.register("k", () => 3); // ok
d2();

API Reference

class AskResponderRegistry

  • register<TReq, TResp>(key: string, handler: AskHandler<TReq, TResp>): () => void — registers handler for key; throws BroadcastChannelAskHandlerDuplicateError (code BROADCAST_CHANNEL.ASK_HANDLER_DUPLICATE) if key is already taken; returns an identity-safe disposer.
  • resolve(key: string): AskHandler | undefined — returns the registered handler or undefined. Receiver path drops unregistered ask frames.
  • clear(): void — drop every handler; called from channel.close().

type AskHandler<TReq, TResp>

type AskHandler<TReq, TResp> = (
    payload: TReq,
    ctx: AskHandlerContext
) => TResp | Promise<TResp>;

The receiver may return a synchronous value or a Promise. The channel awaits the return value and posts the result as the ask-response frame's payload.

Implementation Notes

The registry deliberately exposes only register / resolve / clear; the "call the handler and post the response" logic lives on TypedBroadcastChannel because that is where the wire transport, the OTel counters, and the error projection live. Keeping the registry pure makes it trivial to unit-test in isolation: every test in responder.test.ts constructs a fresh registry, so state cannot leak between cases.

Identity-safe disposal matters when a handler is re-registered after disposal in a long-running session — without the identity check, a delayed disposer from the previous registration would silently drop the new handler and the asker would see ASK_TIMEOUT errors with no obvious cause.