Skip to content

Serving & Connecting

Set up the listening side with serveChannel and dial it with connectChannel.

serveChannel and connectChannel are the two functions you’ll use most. One listens (the acceptor), the other dials it (the connector). The server builds an instance runtime from its identity; the client uses its dialing runtime. Each applies the shared facts to every connection, so you state them once.

serveChannel registers your handlers, builds the crypto identity, sets up sleep/wake (hibernation), and hands back a server object. You pass its web fetch straight to your host. Once you add a socket, you also forward your host’s connection events to it.

serveChannel(channel, { identity, … }) creates a private, instance-scoped runtime and exposes it as server.runtime. This keeps server handlers out of global dispatch when several hosts share one isolate. Pass runtime instead only when another integration must share a caller-owned instance runtime. A supplied runtime remains yours: server.dispose() releases that server’s handlers and connections without disposing it.

The smallest backend serves over a single HTTP carrier. server.fetch is a standard web fetch handler, so Bun, Deno, and Cloudflare Workers run a default { fetch } export with nothing else to wire:

server.ts
import { serveChannel, httpAcceptorCarrier, createDefaultServeLogger } from "@nice-code/action";
import { createMemoryStorageAdapter_json } from "@nice-code/util";
import { appChannel, act_user, serverCoord } from "./shared"; // coordinates live in shared code
const userHandler = act_user.wrapAsLocalHandler({
getUser: async ({ userId }) => ({ id: userId, name: "Ada Lovelace" }),
});
const server = serveChannel(appChannel, {
identity: serverCoord,
storage: createMemoryStorageAdapter_json(), // swap for persistent storage in production
carriers: [httpAcceptorCarrier()], // HTTP only — no socket wiring
handlers: [userHandler],
logger: createDefaultServeLogger(), // optional: one log line per request + outcome
});
// Bun / Deno / Cloudflare Workers serve a default `{ fetch }` export directly:
export default { fetch: server.fetch };

Run it with bun run server.ts (or deno serve server.ts, or deploy the module to a Worker). On Node, wrap server.fetch with a fetch-style adapter such as @hono/node-server. That’s a complete, secure backend. Clients dial it over the matching httpCarrier (see Dialing).

The in-memory store gets you running. In production, use persistent storage so the server’s crypto identity and its trusted-client pins survive a restart. See Storage.

Whatever carriers you pass, serveChannel returns { runtime, acceptors, fetch, receive, drop, pushToClient, broadcast }:

  • fetch — the standard web handler above. It does the WebSocket upgrade, the action POST, the CORS preflight, and a 404 for anything else. Export it, or forward your host’s fetch to it.
  • receive(conn, frame) / drop(conn) — the live-connection lifecycle, needed once you add a WebSocket carrier (below). Forward your host’s “message” event to receive, and its “close”/“error” events to drop.
  • pushToClient / broadcast — for the server calling clients (see Bi-directional).
  • acceptors — the accept-side connection objects, one per duplex carrier. You rarely touch these. With several socket carriers, push or broadcast over a specific acceptors[i].

HTTP alone is request and reply. To hold a live socket open and let the server push to a connected client, add a wsAcceptorCarrier alongside the HTTP one. The carrier needs one thing from your host: how to send a frame to a socket (send: (ws, frame) => ws.send(frame)). You forward the host’s socket events to server.receive / server.drop. The host performs the upgrade itself, so you omit the carrier’s upgrade and feed the socket in “out of band”. The secure handshake runs over the same socket as ordinary messages, so the connection is authenticated by default with no extra steps.

On Cloudflare Durable Objects, one serveDurableObject call covers all of this: the upgrade, the hibernation-safe attachment, and the keepalive. See Cloudflare.

Bun.serve owns the upgrade, so let it do that and feed its websocket events straight to the server:

server.ts
import type { ServerWebSocket } from "bun";
import { serveChannel, wsAcceptorCarrier, httpAcceptorCarrier } from "@nice-code/action";
import { createMemoryStorageAdapter_json } from "@nice-code/util";
import { appChannel, serverCoord } from "./shared";
const server = serveChannel(appChannel, {
identity: serverCoord,
storage: createMemoryStorageAdapter_json(),
handlers: [userHandler],
carriers: [
wsAcceptorCarrier<ServerWebSocket>({ send: (ws, frame) => ws.send(frame) }),
httpAcceptorCarrier(), // HTTP fallback on the same channel
],
});
Bun.serve({
port: 3000,
fetch(request, bun) {
// Hand the socket to Bun on an upgrade; everything else is an action POST / CORS / 404.
if (request.headers.get("Upgrade") === "websocket") {
return bun.upgrade(request) ? undefined : new Response("upgrade failed", { status: 400 });
}
return server.fetch(request);
},
websocket: {
message: (ws, msg) => server.receive(ws, msg),
close: (ws) => server.drop(ws),
},
});

Hono’s WebSocket helper gives you the same hooks. The underlying socket is on ws.raw. That is what you hand to server.receive / server.drop, and what the carrier’s send writes back to:

server.ts
import { Hono } from "hono";
import { createBunWebSocket } from "hono/bun";
import type { ServerWebSocket } from "bun";
import { serveChannel, wsAcceptorCarrier, httpAcceptorCarrier } from "@nice-code/action";
import { createMemoryStorageAdapter_json } from "@nice-code/util";
import { appChannel, serverCoord } from "./shared";
const { upgradeWebSocket, websocket } = createBunWebSocket<ServerWebSocket>();
const server = serveChannel(appChannel, {
identity: serverCoord,
storage: createMemoryStorageAdapter_json(),
handlers: [userHandler],
carriers: [
wsAcceptorCarrier<ServerWebSocket>({ send: (ws, frame) => ws.send(frame) }),
httpAcceptorCarrier(),
],
});
const app = new Hono();
app.get("/ws", upgradeWebSocket(() => ({
onMessage: (event, ws) => { if (ws.raw) server.receive(ws.raw, event.data); },
onClose: (_event, ws) => { if (ws.raw) server.drop(ws.raw); },
})));
// Action POST + CORS + 404 for every other route:
app.all("*", (c) => server.fetch(c.req.raw));
export default { fetch: app.fetch, websocket };

createBunWebSocket is the Bun adapter. On Node, swap in @hono/node-ws’s createNodeWebSocket, which exposes the same upgradeWebSocket. The send/receive wiring is identical; only the import changes.

The dialing side has one runtime and calls connectChannel once per server it wants to reach. It sends the channel’s toAcceptor actions out over the carriers: the first is preferred and the rest are fallbacks. It uses onPush to answer any toConnector pushes the server sends back.

client.ts
import { ActionRuntime, connectChannel, wsCarrier, httpCarrier } from "@nice-code/action";
import { appChannel, serverCoord, frontendCoord } from "./shared"; // same coordinates the server uses
export const clientRuntime = new ActionRuntime(frontendCoord);
connectChannel(clientRuntime, appChannel, {
peer: serverCoord, // the server's coordinate, straight from shared
storage, // persistent storage for this runtime
carriers: [
wsCarrier("wss://api.example.com/ws"), // preferred
httpCarrier("https://api.example.com/action"), // fallback
],
// onPush: { ... } // your handlers for the server's pushes
});

The main options are peer (which server you’re calling), carriers (in preference order, with automatic fallback), storage, onPush, and defaultTimeout, plus reliableActionTimeout for reliable actions. It returns the connection itself, a ChannelConnector. Keep it as connector when you need it later: for connector.clearTransportCache() on teardown, or realmConnection(connector) to run a realm on the same socket.

The client’s storage must be durable too. It holds this client’s crypto identity, and the server pins that identity’s verify key the first time they meet. A store that forgets across page loads, such as a memory adapter, gets the client rejected from its second load on. In a browser, use createWebLocalStorageAdapter({ localStorage }). The full story is on Identity & Trust.

Your connections are already protected. By default serveChannel and connectChannel set up an authenticated connection for you, with nothing extra to wire up here. To choose between authenticated and encrypted, or to turn it off for local dev, see Security Levels.

Several channels on one connection — connectChannels

Section titled “Several channels on one connection — connectChannels”

When one server serves two channels on the same endpoint, dial both with connectChannels. It takes a list of channels and the same options as connectChannel. You get one connection and one handshake, and onPush covers the pushes of every channel in the list:

client.ts
connectChannels(clientRuntime, [appChannel, notificationsChannel], {
peer: serverCoord,
storage,
carriers: [wsCarrier("wss://api.example.com/ws")],
});

The server lists the same channels with serveChannels([a, b], { identity, ...options }), or by passing an array to serveDurableObject.

Several connections to one peer — connectionId

Section titled “Several connections to one peer — connectionId”

Most apps dial a backend once. Some can’t. Take a store socket scoped per resource and a jobs socket that parks when no screen is watching. They hit different endpoints, so they need two connectChannel calls even though they talk to the same server.

The server binds each connection by the identity that connection presents, and by default every connection of a runtime presents the runtime’s coordinate. With two links and one identity, the server can’t tell them apart. Replies and pushes for both go back over whichever connected first, and the other socket’s traffic hangs until it times out. It looks exactly like a stalled backend.

Give each connection a name and they separate:

client.ts
const storeConnector = connectChannel(runtime, storeChannel, {
peer: backendCoord,
connectionId: "store",
carriers: [wsCarrier(() => `wss://api.example.com/store/${scope}/ws`)],
storage,
});
const jobsConnector = connectChannel(runtime, jobsChannel, {
peer: backendCoord,
connectionId: "jobs",
carriers: [wsCarrier("wss://api.example.com/jobs")],
storage,
onPush: { progress: ({ pct }) => setProgress(pct) },
});

You still have one runtime and one identity. connectionId is folded into the runtime’s per-boot instance id, and leaves the environment and the persistent id untouched. The server sees one client, one trust-on-first-use pin and one set of permissions. It can now tell your two sockets apart. This is not a way to run several identities from one app. If that’s what you want, use the persistent id (withPersistentId) instead.

Because a stable label is folded into the per-boot id rather than replacing it, "jobs" stays safe for reliable delivery. Each boot is still a fresh stream epoch, and each named connection now gets its own streams instead of sharing one sequence counter.

connectChannel warns when a second connection to the same peer reuses an identity already in use, so you see the problem at startup rather than as a mystery timeout.

Two channels that can share one socket don’t need this. Use connectChannels([a, b]) instead: one connection, one handshake, both channels’ actions. connectionId is for when the transports genuinely differ. Two connections to different peers already differ by peer, so they need nothing here.

For a “host and forget” backend, often all you want is to see what’s coming in. Pass a logger to serveChannel and it logs every accepted request and its outcome: one line when the request arrives, and one when its result goes back, with the elapsed time. createDefaultServeLogger() is a ready-made console logger:

server.ts
import { serveChannel, createDefaultServeLogger } from "@nice-code/action";
const server = serveChannel(appChannel, {
identity: serverCoord,
storage: storageAdapter,
carriers: [httpAcceptorCarrier()],
handlers: [userHandler],
logger: createDefaultServeLogger(),
});
// ▶ act_user/getUser via http from envId[frontend]… [encrypted]
// ✓ act_user/getUser ok via http 12ms

Every line is tagged with the carrier that handled the request (http, ws, …), so one logger covers all your carriers. A failed request logs the error’s id and message and goes to console.error.

The default logger takes a few options:

  • logInputs — also print each action’s input. Off by default, since inputs can carry sensitive data.
  • showSecurityLevel — show the negotiated level ([encrypted], …) on the request line (default true).
  • tag — the prefix on every line, for grepping (default "[nice-action]").
  • sink — where lines are written (anything with log / error; defaults to console).
logger: createDefaultServeLogger({ logInputs: true, tag: "[api]" }),

To forward into your own stack (pino, a metrics sink, …) instead of the console, implement IActionServeLogger directly. Its onRequest(info) fires when a request is accepted and returns a reporter, which the server calls with the outcome. So one logger call covers the whole request and response, and you can pair the two lines and time the gap however you like. Returning undefined from onRequest skips the result line, which is handy for sampling or muting a noisy action.

This lightweight per-request logger is separate from the richer server devtools host (createNiceServerDevtools / actionDomainScope), which attaches to a domain and reports each action’s full lifecycle. Use the logger option for a quick request/response trace on the serving side. Use the devtools host when you want lifecycle stages, payload inspection, or a live stream into a devtools window. The same logger works through serveDurableObject / serveWorker on Cloudflare.

Beside the per-request logger, both serveChannel and connectChannel take a wireTap. It is a per-frame hook that sees every frame in and out of every carrier. Each frame is tagged with its byte size and its lane: action, a protocol id like realm, handshake, keepalive, or http. It is off by default, and costs nothing when you omit it.

The devtools Traffic view is built on it: feed it a TrafficMetricsCore and contribute that to a devtools host (see React Query & Devtools). Or pass your own function to send byte counts straight into your telemetry. One hook covers every carrier at once, so it is the place to measure real egress, including Cloudflare’s per-message billing.

// doc-check: skip — illustrative wiring; the devtools host lives in @nice-code/devtools-core
const traffic = new TrafficMetricsCore();
serveChannel(channel, { identity: serverCoord, storage, carriers, wireTap: traffic.wireTap });

A realm (or any protocol) needs a duplex carrier

Section titled “A realm (or any protocol) needs a duplex carrier”

You run a realm, or any other prefixed mux protocol, on a connection with realmConnection(connector). That needs a duplex carrier (a WebSocket). A mux protocol pushes unsolicited frames, which an exchange-only (request/reply HTTP) connection cannot carry. Register one on an exchange-only connection and it throws protocol_on_exchange_only at bring-up: when the connection is constructed, not later at connect time. That is deliberate, so a missing socket is a loud construction error rather than a silent mid-session surprise. Keep an httpCarrier in the chain as a fallback for the action lane. The realm uses the socket when one is up.

A client that dials before its server is up does not fail permanently. The first failed dial starts the same keep-alive backoff ladder that a mid-session drop would. See the first connect for what that looks like and how to bound it.