Serving & Connecting
Stand up an acceptor with serveChannel and dial it with connectChannel.
serveChannel (listen) and connectChannel (dial) are the two functions you’ll reach for. Each one takes the shared facts — your channel and your runtime — and applies them to every connection, so you only state them once.
Listening — serveChannel
Section titled “Listening — serveChannel”serveChannel registers your handlers, builds the crypto identity, sets up sleep/wake (hibernation), and hands back a server object. You export its web fetch — and, once you add a socket, forward your host’s connection events — straight to your host.
The simplest server — HTTP only
Section titled “The simplest server — HTTP only”The smallest possible 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:
import { ActionRuntime, 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 serverRuntime = new ActionRuntime(serverCoord);
const userHandler = act_user.wrapAsLocalHandler({ getUser: async ({ userId }) => ({ id: userId, name: "Ada Lovelace" }),});
const server = serveChannel(serverRuntime, appChannel, { 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. Back it with persistent storage so the server’s crypto identity and its trusted-client pins survive a restart — see Storage.
The server object
Section titled “The server object”Whatever carriers you pass, serveChannel returns { 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’sfetchto it.receive(conn, frame)/drop(conn)— the live-connection lifecycle, needed once you add a WebSocket carrier (below). Forward your host’s “message” event toreceive, and its “close”/“error” events todrop.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/broadcast over a specificacceptors[i].
WebSockets — adding server push
Section titled “WebSockets — adding server push”HTTP alone is request/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 just one thing from your host — how to send a frame to a socket (send: (ws, frame) => ws.send(frame)) — and you forward the host’s socket events to server.receive / server.drop. The host performs the upgrade itself, so the carrier’s upgrade is omitted and the socket is fed in “out of band”. The secure handshake rides the same socket as ordinary messages, so the connection is authenticated by default with no extra steps.
On Cloudflare Durable Objects all of this — the upgrade, the hibernation-safe attachment, and the keepalive — is folded into one
serveDurableObjectcall. See Cloudflare.
Bun.serve owns the upgrade, so let it perform the upgrade and feed its websocket events straight to the server:
import type { ServerWebSocket } from "bun";import { ActionRuntime, serveChannel, wsAcceptorCarrier, httpAcceptorCarrier } from "@nice-code/action";import { createMemoryStorageAdapter_json } from "@nice-code/util";import { appChannel, serverCoord } from "./shared";
const serverRuntime = new ActionRuntime(serverCoord);
const server = serveChannel(serverRuntime, appChannel, { 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’s what you hand to server.receive / server.drop, and what the carrier’s send writes back to:
import { Hono } from "hono";import { createBunWebSocket } from "hono/bun";import type { ServerWebSocket } from "bun";import { ActionRuntime, 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 serverRuntime = new ActionRuntime(serverCoord);
const server = serveChannel(serverRuntime, appChannel, { 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 };
createBunWebSocketis the Bun adapter; on Node swap in@hono/node-ws’screateNodeWebSocket, which exposes the sameupgradeWebSocket. The send/receive wiring is identical — only the import changes.
Dialing — connectChannel
Section titled “Dialing — connectChannel”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 transports (the first is preferred, the rest are fallbacks), and uses onPush to answer any toConnector pushes the server sends back.
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 transports: [ { carrier: wsCarrier(() => ({ url: "wss://api.example.com/ws" })) }, // preferred { carrier: httpCarrier(() => ({ url: "https://api.example.com/action" })) }, // fallback ], // onPush: { ... } // your handlers for the server's pushes});The main options: peer (which server you’re calling), transports (your carriers in preference order — it falls back automatically), 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: connector.clearTransportCache() on teardown, or realmConnection(connector) to ride a realm on the same socket.
The client’s
storagemust be durable too. It holds this client’s crypto identity, and the server pins that identity’s verify key the first time they meet — so a store that forgets across page loads (a memory adapter) gets the client rejected from its second load on. In a browser,createWebLocalStorageAdapter({ localStorage }); the full story is on Identity & Trust.
Your connections are already protected. By default
serveChannelandconnectChannelset up an authenticated connection for you — there’s nothing extra to wire up here. When you want to choose between authenticated and encrypted, or turn it off for local dev, see Security Levels.
Logging served requests
Section titled “Logging served requests”For a “host and forget” backend you often just want 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, one when its result goes back (with the elapsed time). createDefaultServeLogger() is a ready-made console logger you can drop straight in:
import { serveChannel, createDefaultServeLogger } from "@nice-code/action";
const server = serveChannel(serverRuntime, appChannel, { storage: storageAdapter, carriers: [httpAcceptorCarrier()], handlers: [userHandler], logger: createDefaultServeLogger(),});
// ▶ act_user/getUser via http from envId[frontend]… [encrypted]// ✓ act_user/getUser ok via http 12msEvery line is tagged with the carrier that handled the request (http, ws, …), so the logger spans all your carriers at once. 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 — opt in only when you want it.showSecurityLevel— show the negotiated level ([encrypted], …) on the request line (defaulttrue).tag— the prefix on every line, for grepping (default"[nice-action]").sink— where lines are written (anything withlog/error; defaults toconsole).
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 the server calls with the outcome, so one logger call spans the whole request → response and you can pair the two lines (and time the gap) however you like. Returning undefined from onRequest skips the result line (handy for sampling or muting a noisy action).
This lightweight per-request logger is distinct from the richer server devtools host (
createNiceServerDevtools/actionDomainScope), which attaches to a domain and reports each action’s full lifecycle. Use theloggeroption for a quick request/response trace on the serving side; reach for the devtools host when you want lifecycle stages, payload inspection, or a live stream into a devtools window. The sameloggerflows throughserveDurableObject/serveWorkeron Cloudflare.
Traffic instrumentation — wireTap
Section titled “Traffic instrumentation — wireTap”Beside the per-request logger, both serveChannel and connectChannel take a wireTap — a
per-frame hook that sees every frame in and out of every carrier, tagged by its lane (action,
a protocol id like realm, handshake, keepalive, http) and its byte size. Off by default (omit
it), zero overhead when absent.
It is the seam the devtools Traffic view rides: feed it a TrafficMetricsCore and contribute
that to a devtools host (see React Query & Devtools), or pass your own
function to route byte counts straight into your telemetry. One hook spans every carrier at once, so
it is the place to measure real egress — the Cloudflare per-message billing lens included.
// doc-check: skip — illustrative wiring; the devtools host lives in @nice-code/devtools-coreconst traffic = new TrafficMetricsCore();serveChannel(runtime, channel, { 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 ride a realm — or any 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 — deliberately, 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 simply rides the socket when one is up.
A client that dials before its server is up does not fail permanently — the first failed dial arms the same keep-alive backoff ladder a mid-session drop would. See the first connect for what that looks like and how to bound it.