Skip to main content

Requests

A message is one-way: you send it and nobody answers. A request is a message the sender waits on. Exactly one response, or a failure, comes back for it.

Requests live in a separate factory, createTypedRpcChannel. It gives you request and handle next to the emit and on you already know, so one channel carries both kinds of traffic.

Setup

A request map describes requests as functions: the params are the function’s single parameter, the response is its return type.

import { createTypedRpcChannel, requests } from "typed-channel";
import { createPostMessageTransport } from "typed-channel/transports/postMessage";

// Requests the worker answers
type WorkerRequests = {
  compute: (params: { steps: number }) => number;
  ping: () => string;
};

// Requests this page answers
type ClientRequests = {
  confirm: (question: string) => boolean;
};

// Messages still travel through the same channel
type Messages = {
  progress: { done: number };
};

const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
const transport = createPostMessageTransport<Messages>(worker);

// First map: what this side handles. Second map: what it calls on the peer.
const channel = createTypedRpcChannel(transport, requests<ClientRequests, WorkerRequests>());

requests<Handled, Called>() is a type witness. It returns nothing at runtime: it exists so both request maps come from a single argument. Called defaults to Handled, so a channel that answers and calls the same requests writes requests<AppRequests>().

The worker side mirrors the two maps:

const channel = createTypedRpcChannel(transport, requests<WorkerRequests, ClientRequests>());

A request may declare at most one parameter, and it must be a params object or a single value. The parameter must be required, and its type must not include undefined: the handler side reads the payload to decide how it calls the handler, so an undefined payload hands the handler the context alone. requests<Handled, Called>() rejects a map that breaks these rules.

Calling

request(type, params, options) returns a promise. It resolves with what the peer’s handler returned, and rejects when the handler throws or when the request is aborted.

const total = await channel.request("compute", { steps: 6 });

The params slot is always there, so a request without params passes undefined when it needs the options:

const controller = new AbortController();

await channel.request("ping");
await channel.request("ping", undefined, { signal: controller.signal });

Handling

handle(type, handler) registers the function that answers one request name. It returns a cleanup function that removes the handler.

Here is the worker side, whose channel is requests<WorkerRequests, ClientRequests>():

const stopHandling = channel.handle("compute", async ({ steps }, { signal }) => {
  let total = 0;

  for (let step = 1; step <= steps; step++) {
    await runStep(step, signal);
    total += step;
  }

  return total;
});

stopHandling();

The handler always receives a context object as its last argument, and the request params before it when the request declares them. A request without params hands the context alone:

channel.handle("ping", ({ signal }) => (signal.aborted ? "" : "pong"));

The context holds a signal today. It is an object so that a later version can add a field without changing the shape of every handler you wrote.

Only one handler per request name is allowed. A second handle call with the same name throws.

Timeout and abort

Pass a signal to stop a request. The platform gives you both a timeout and a manual abort, and AbortSignal.any combines them:

const controller = new AbortController();
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]);

try {
  const total = await channel.request("compute", { steps: 6 }, { signal });

  console.log(`total: ${total}`);
} catch (error) {
  // Both cases reject with signal.reason: a TimeoutError from the timeout signal,
  // or whatever controller.abort() was given
  console.log(`compute failed: ${String(error)}`);
}

// Somewhere else, for example on a "Stop" button click
controller.abort();

The promise rejects with signal.reason. The abort also travels to the peer, so the handler’s own signal aborts and that side sends nothing back.

An abort goes out whenever the request stops waiting, not only when your signal fires. The first response also ends it, so every peer that is still working on that request stops. This costs one small extra message per request, even when only one peer answers.

Rules

  • A request with no handler on the other side, or a handler registered after the request arrived, gets no response. It ends only through its signal, so always pass a timeout signal when the peer may be missing.
  • With several peers the first response wins. Later responses for the same request, and responses for a request that already ended, are dropped. The winning response also sends an abort, so a peer that is still running a handler for that request stops.
  • A request channel takes one transport, not a list. A request goes to one place and its answer comes back from there. A transport that reaches several peers, such as a BroadcastChannel, still works: the rule above applies to it.
  • A handler throw becomes a rejection with a plain Error rebuilt from name and message. Subclasses and extra fields are lost, so read error.name instead of instanceof.
  • unlisten() rejects every pending request with an AbortError DOMException and aborts every running handler. listen() after that takes requests again, and the handlers you registered are still in place.
  • request() between an unlisten() and the next listen() rejects at once with that same AbortError DOMException, and nothing goes out on the transport.
  • A message and a request may share a name. They travel apart and never reach each other’s handlers.

Wire format

Request traffic travels as a flat object with an rpc field:

type RpcMessageOf<Kind extends string> = { rpc: Kind; id: string; type: string; payload?: unknown };

type RpcMessage =
  | RpcMessageOf<"request">
  | RpcMessageOf<"response">
  | RpcMessageOf<"error">
  | RpcMessageOf<"abort">;

Every kind carries the same four fields, so one check tells real request traffic from anything else a transport may hand over. id is a random string made by the caller. type is the request name. The rpc field is what tells request traffic from a plain message, which is why a message and a request may share a name. You need this shape when you write a custom transport that inspects messages, or when you read the traffic in devtools.

An abort carries no payload. An error carries { name, message }, but the type does not promise it: the payload comes from the peer, so the caller rebuilds the Error from whatever arrives. A reply whose payload is missing those two fields still ends the request, with the built-in "Error" name and an empty message.

Mixing factories

Both factories speak the same transport contract, so you can put a createTypedChannel peer on one end and a createTypedRpcChannel peer on the other. Messages then work in both directions.

A plain channel ignores everything with an rpc field, so it never answers a request. Requests to such a peer end only through their signal.