@digitalocean/channel gives you a nice, typed interface for requests and responses over message channels.
Define each event once with Zod schemas. Then, connect each endpoint through a Port. Each endpoint can send a request or respond to a request.
Install the package and Zod:
pnpm add @digitalocean/channel zodnpm install @digitalocean/channel zodyarn add @digitalocean/channel zodZod is a peer dependency. Install Zod 4.
For Chrome extension handshakes, use the @digitalocean/channel/chrome import path.
Put the shared ChannelDefinition in a module. Both endpoints must import this module.
import { z } from "zod";
import { ChannelDefinition } from "@digitalocean/channel";
export const definition = new ChannelDefinition({
ping: {
requestSchema: z.object({ message: z.string() }),
responseSchema: z.object({ reply: z.string() }),
},
});Each event can have a requestSchema, a responseSchema, or both schemas.
If you do not give a requestSchema, the request has no payload. If you do not give a responseSchema, the event has no response: send returns void instead of a promise, and resolvers for the event do not get a respond function.
The example that follows connects a page to a web worker through a MessageChannel.
- Create a
MessageChannel. - Use
Port.fromMessagePortto convert eachMessagePortto aPort. - Use
postMessageto transfer one port to the worker. - Call
definition.connectat each endpoint. - Give the resolvers to the worker.
- Call
channel.sendon the page.
Page:
import { Port } from "@digitalocean/channel";
import { definition } from "./events.js";
const worker = new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
});
const { port1, port2 } = new MessageChannel();
const channel = definition.connect({
port: Port.fromMessagePort(port1),
});
worker.postMessage(undefined, [port2]);
const result = await channel.send("ping", { message: "hello" });
// result = { reply: "saw hello" }Worker (worker.js):
import { Port } from "@digitalocean/channel";
import { definition } from "./events.js";
addEventListener("message", (event: MessageEvent) => {
definition.connect({
port: Port.fromMessagePort(event.ports[0]!),
resolvers: {
ping: async (payload, respond) => {
await doSomeWork();
respond({ reply: `saw ${payload.message}` });
},
},
});
});connect sets the channel state to connected.
To stop the channel, do one of these steps:
- Call
channel.disconnect(). - Abort the
AbortSignalthat you gave toconnect.
Do not use the channel after you disconnect it. Call connect again with a new port.
A resolver responds to a request. To add resolvers, give the resolvers option to connect. Map each event name to a function.
Each resolver gets two arguments:
payload: the request payload. The channel parses the payload withrequestSchemabefore it calls the resolver.respond: a function. Callrespondone time to send the response. The channel parses the response withresponseSchema.
If the event has no responseSchema, the resolver only gets payload. There is no response to send.
The example that follows uses Port.memory() to connect two endpoints in one context:
import { z } from "zod";
import { ChannelDefinition, Port } from "@digitalocean/channel";
const definition = new ChannelDefinition({
getUser: {
requestSchema: z.object({ id: z.string() }),
responseSchema: z.object({ name: z.string() }),
},
ping: {
responseSchema: z.object({ reply: z.string() }),
},
});
const { port1, port2 } = Port.memory();
definition.connect({
port: port1,
resolvers: {
// `payload` has the type `{ id: string }`.
getUser: async (payload, respond) => {
const user = await loadUser(payload.id);
// The argument must obey `responseSchema`.
respond({ name: user.name });
},
// There is no resolver for "ping". This endpoint does not respond
// to "ping" requests.
},
});
const channel = definition.connect({ port: port2 });
const user = await channel.send("getUser", { id: "17" });
// user = { name: "Ada" }
// "ping" has no `requestSchema`, so `send` takes no payload.
// No resolver responds, so this promise rejects when the response
// timeout elapses.
void channel.send("ping");A resolver can be an asynchronous function. Call respond when the result is ready.
You do not have to give a resolver for each event. If an endpoint has no resolver for an event, the endpoint does not send a response. The send promise at the other endpoint rejects when the response timeout elapses.
Each endpoint can have resolvers. Thus each endpoint can send requests and can respond to requests.
Each send promise rejects if no response arrives in time, so an await does not hang forever. The timeout comes from the first of these values:
- The
responseTimeoutMsoption ofchannel.send. - The
responseTimeoutMsoption ofconnect. - The default: 60 seconds (
ChannelDefinition.DEFAULT_RESPONSE_TIMEOUT_MS).
Give the send options after the payload. If the event has no requestSchema, give the options as the only argument.
If the event has no responseSchema, send returns void and takes no options. There is no response, so there is no timeout.
const channel = definition.connect({
port: port2,
responseTimeoutMs: 10_000,
});
// This request uses the 10 second timeout from `connect`.
const user = await channel.send("getUser", { id: "17" });
// This request overrides the timeout for one message.
const report = await channel.send("getUser", { id: "18" }, { responseTimeoutMs: 30_000 });
// "ping" has no `requestSchema`, so the options come first.
await channel.send("ping", { responseTimeoutMs: 1_000 });A Port transmits messages between two endpoints. A Port does not contain information about events or schemas.
Use these factory methods:
- Use
Port.fromMessagePort(messagePort)for aMessagePort. Workers, iframes, andMessageChannelare examples. - Use
Port.fromBroadcastChannel(broadcastChannel)for aBroadcastChannel. - Use
Port.fromWebSocket(websocket)for aWebSocketon the client or the server. This method uses a JSON codec by default. - Use
Port.fromChromeRuntimePort(chromePort)for achrome.runtime.Port. - Use
Port.memory()to create two connected ports. Use these ports to test code in one context.
You can also create a Port with custom functions. Supply a postMessage function, a listener function, and lifecycle functions.
definition.relay(port1, port2) forwards messages in both directions.
The relay makes sure that each message obeys the definition schema. The relay does not change message IDs. The two endpoints can match each response to its request.
You can connect more than one relay. The path that follows is not usual, but it is permitted:
server ↔ WebSocket ↔ page ↔ iframe ↔ worker
The page and the iframe are only relays. The server and the worker are the channel endpoints.
Server (convert the WebSocket that the server accepts):
definition.connect({
port: Port.fromWebSocket(socket),
resolvers: {
ping: (payload, respond) => {
respond({ reply: `saw ${payload.message}` });
},
},
});Page (relay the socket into an iframe):
const socket = new WebSocket("wss://example.com/channel");
const { port1, port2 } = new MessageChannel();
socket.addEventListener("open", () => {
definition.relay(Port.fromWebSocket(socket), Port.fromMessagePort(port1));
});
iframe.contentWindow!.postMessage({ type: "channel-port" }, "*", [port2]);Iframe (relay the parent port into a worker):
addEventListener("message", (event: MessageEvent) => {
if (event.data?.type !== "channel-port") {
return;
}
const fromPage = event.ports[0]!;
const worker = new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
});
const { port1, port2 } = new MessageChannel();
definition.relay(Port.fromMessagePort(fromPage), Port.fromMessagePort(port1));
worker.postMessage(undefined, [port2]);
});Worker (connect as the far endpoint):
addEventListener("message", (event: MessageEvent) => {
const channel = definition.connect({
port: Port.fromMessagePort(event.ports[0]!),
});
void channel.send("ping", { message: "hello from the worker" });
});ChromeHandshake prepares the transports for a Chrome extension channel:
- A
MessagePortconnects the host page to the content script throughwindow.postMessage. - A
chrome.runtime.Portconnects the content script or an extension page to the background.
Do these steps:
- Create one
ChromeHandshakewith a sharednamespace. - On the host, wait for
handshake.hostPort(). Then, callconnect. - In the content script, wait for
handshake.contentBackgroundBridge(). Then, callrelay. - In the background, use
handshake.onConnect(...)orhandshake.backgroundAccept(...). - In an extension page, wait for
handshake.extensionPagePort({ page }). Then, callconnect.
For more information, refer to the test extension in src/test-utils/chrome-extension/.
ISC