# @nice-code/action — documentation Call a function on another machine as if it were local — fully typed, transport-agnostic. This file concatenates only the @nice-code/action pages of https://nicecode.io — point a local AI coding assistant at it when you're working with just this package. For the full set (every nice-code package and how they fit together) use /llms.txt. Generated from src/content/docs — do not edit by hand. --- # Bi-directional Source: /nice-action/bidirectional Description: Push actions from the acceptor back to the connector over the same socket. Over a **duplex** connection (like a WebSocket), the listening side can call the dialing side back on the _same_ open connection — no second connection, and no polling for updates. ## How it works 1. **Put the push domain in the channel's `toConnector`** (in shared code, so both sides agree). 2. **On the dialing side**, answer those pushes with `connectChannel`'s `onPush` — one entry per action, fully typed from the channel. Your reply goes straight back over the same connection. 3. **On the listening side**, call `server.pushToClient(...)` to reach one client, or a handler's `broadcast(...)` to reach everyone. To know who originally called you, read `action.context.originClient`. ## Shared channel ```ts title="shared.ts" export const act_lobby = act_app.createChildDomain({ domain: "act_lobby", actions: { start_feed: actionSchema() .input({ schema: v.object({ count: v.number() }) }) .output({ schema: v.object({ delivered: v.number() }) }), position_update: actionSchema() .input({ schema: v.object({ player: v.string(), x: v.number(), y: v.number() }) }) .output({ schema: v.object({ acknowledged: v.boolean() }) }), }, }); export const appChannel = defineChannel({ toAcceptor: [act_user, act_lobby], // start_feed goes this way (client → server) toConnector: [act_lobby], // position_update pushes back this way (server → client) }); ``` ## Dialing side — answer the pushes ```ts title="client.ts" connectChannel(clientRuntime, appChannel, { peer: serverCoord, storage, transports: [{ carrier: wsCarrier(() => ({ url: wsUrl })) }], onPush: { position_update: async ({ player, x, y }) => { renderPlayer(player, x, y); return { acknowledged: true }; }, }, }); ``` ## Listening side — push back A handler reads `action.context.originClient` to find out who called, then pushes to them: ```ts title="server.ts" const lobbyHandler = createLocalHandler().forDomainActionCases(act_lobby, { start_feed: async (action) => { let delivered = 0; for (let seq = 0; seq < action.input.count; seq++) { const running = server.pushToClient( action.context.originClient, act_lobby.action.position_update.request({ player: "alice", x: 1, y: 2 }), ); await running.waitForResultPayload(); // wait for the client's reply, just like any action delivered++; } return { delivered }; }, }); ``` ## Sending to everyone at once Use `server.broadcast` to push to every connected client (fire-and-forget). You can skip the original sender, or only target some connections: ```ts server.broadcast( () => act_lobby.action.position_update.request({ player: "system", x: 0, y: 0 }), { except: originWs, where: (ws) => server.connections.get(ws)?.role === "player" }, ); ``` > **Pushes are best-effort — even for `.reliable()` actions.** [Reliable delivery](/nice-action/reliable-delivery/) > engages on the *dialing* side's sends, not on server pushes: `pushToClient` and `broadcast` make one > attempt over the live socket, and a client that's offline simply isn't reached. For a single push that > needs confirmation, await `waitForResultPayload()` and retry; for a push stream that must all arrive, see > [the direction note](/nice-action/reliable-delivery/#one-direction--pushes-are-best-effort) for the > persisted-log pattern. ## When a handler needs the connection itself Sometimes a handler needs the actual **connection** — to add it to a room, or to remember some per-connection state. For that, pass `channelCases` to `serveChannel` / `serveDurableObject`. Each case gets the request **and** an `IConnectionContext`: ```ts channelCases: { join: (action, conn) => { conn.setState(action.input); // remember typed state for this connection conn.broadcast(() => act_lobby.action.player_joined.request(action.input), { exceptSelf: true }); return { players: roster() }; }, } ``` `conn` gives you `state` / `setState` / `clearState`, `broadcast({ exceptSelf })`, `pushBack(request)`, and `connection` (the raw socket — it's `null` on the HTTP path, which has no live connection). ## Advanced — `clientEnv` (most apps never set this) `serveChannel` takes an optional `clientEnv`. It's easy to misread it, so here's exactly what it is and isn't. **It is not a filter.** Setting `clientEnv` does **not** restrict who may connect, and it does **not** make the acceptor "belong to" one kind of client. One `serveChannel` always accepts every kind of client, whether you set `clientEnv` or not. **It does not reach disconnected clients.** Pushing to a client that has no live connection simply fails — there's no queue and no store-and-forward. So `clientEnv` can't "deliver to an offline client"; nothing can. Concretely, the failure surfaces on the push's running action, not as a silent no-op. `await running.waitForResultPayload()` **rejects**: the promise throws a `NiceError`. Because the failure happens below the action layer (the socket is gone, there's no declared error for it), it comes back as an *unhandled* error — `error.isUnhandled === true` — rather than one of the action's `.throws()` errors. Guard it like any other transport failure: ```ts title="server.ts" import { castNiceError } from "@nice-code/error"; const running = server.pushToClient(originClient, act_lobby.action.position_update.request(pos)); try { await running.waitForResultPayload(); } catch (e) { const err = castNiceError(e); if (err.isUnhandled) { // client was offline / the connection dropped — nothing was delivered } } ``` **What it actually does:** every reply and every push to a *connected* client travels back over the very connection that client is on — automatically, with nothing to configure. `clientEnv` only comes into play in one narrow setup: when a **single runtime runs more than one acceptor** (say a WebSocket acceptor *and* a WebRTC acceptor side by side) and the runtime has to decide *which* of those sibling acceptors a return should go through. `clientEnv` labels each acceptor with the kind of client it's meant for, so that choice is deterministic instead of arbitrary. **So, the rule of thumb:** - One acceptor on the runtime → leave `clientEnv` unset. It changes nothing. - Multiple acceptors on one runtime, serving different kinds of client → set `clientEnv` on each, matching the client env it serves, to pin return routing. ```ts // Only meaningful when several acceptors share one runtime: const server = serveChannel(runtime, appChannel, { storage: storageAdapter, handlers: [userHandler], clientEnv: RuntimeCoordinate.env("frontend"), // this acceptor serves "frontend" clients carriers: [wsAcceptorCarrier({ send: (ws, frame) => ws.send(frame) })], }); ``` --- # Calling Actions Source: /nice-action/calling Description: Run actions and read their output — the same on any side of the connection. Once your runtime is set up, calling an action looks the same on **any** side — client or server — and it doesn't matter whether the action runs right here or over on a peer. You call it the same way either way. ## Just get the result The everyday case: run the action and get its output back. It throws if the action fails — whether that's a declared error or a connection problem. ```ts const output = await act_user.action.getUser .request({ userId: "u_123" }) .runToOutput(); console.log(output); // { id: "u_123", name: "Alice" } ``` ## Keep a handle on the running call If you want to track progress or be able to cancel, hold onto the `RunningAction` and wait for its full result: ```ts const running = await act_user.runAction( act_user.action.getUser.request({ userId: "u_123" }), ); const result = await running.waitForResultPayload(); console.log(result.output); ``` ## Handling errors where you call Any error you listed with `.throws(domain, ids?)` comes back fully typed. Use `castNiceError` and the domain's checks to narrow down which one it is: ```ts import { castNiceError } from "@nice-code/error"; try { const output = await act_user.action.getUser.request({ userId }).runToOutput(); } catch (e) { const error = castNiceError(e); if (err_user.isExact(error) && error.hasId("not_found")) { console.log("User not found:", error.getContext("not_found").userId); } } ``` This `request(...).runToOutput()` shape is the same whether the action runs locally (a handler in this runtime) or remotely (over a connection to a peer) — the runtime figures out where it needs to go. ## Getting a result back instead of a throw `runToResult()` hands you the outcome as a value instead of throwing. You check `expected` (did this action say it could throw this error?) rather than wrapping every call in `try`/`catch`: ```ts const result = await act_user.action.getUser.request({ userId }).runToResult(); if (result.ok) use(result.output); else if (result.expected) handleDeclared(result.error); else report(result.error); // an error this action didn't declare, or an unexpected crash ``` This is the recommended path — see [Error Handling →](/nice-action/error-handling/) for the full model (`expected` vs `isUnhandled`, the typed `isExpectedError` guard, and how it surfaces in devtools). > Actions defined with [`.reliable()`](/nice-action/reliable-delivery/) call exactly the same way — the only > differences are *when* the promise settles (it retries across reconnects instead of failing fast) and an > optional per-call `streamKey` on `.run()` / `.runToOutput()` to keep independent streams (one per room, > per entity) separately ordered. --- # Channels Source: /nice-action/channels Description: Declare what flows in each direction between two runtimes — once. A **channel** lists which actions flow in each direction between two apps. You write it once in shared code, and both sides use that same list — so neither side has to repeat itself or risk getting out of sync. - **`toAcceptor`** — actions the connector **sends to** the acceptor (a normal request: client asks, server answers). - **`toConnector`** — actions the acceptor **pushes to** the connector (the server calling the client). A domain can be in both lists if calls go both ways. ```ts title="shared.ts" import { defineChannel } from "@nice-code/action"; export const appChannel = defineChannel({ toAcceptor: [act_user, act_lobby], // client → server requests toConnector: [act_lobby], // server → client pushes (act_lobby goes both ways) }); ``` ## The channel keeps both sides in sync A channel holds two things: **the routing** (the `toAcceptor` / `toConnector` lists) and a compact **binary format** it uses to send actions over the wire, plus a version number worked out from your domains. Because both sides build all of this from the exact same definition, they can never disagree about the format or the version. A channel only describes the actions and their format — never how a connection is secured. The same channel definition works whether the connection is plain or locked down, so you never have to think about that here. (When you're ready, [Security Levels](/nice-wire/security/) shows how the same channel gains authenticated or encrypted connections.) Both lists are optional. `defineChannel({})` is a **connection-only channel**: no actions in either direction, but the connection it establishes is real — identity, security, and any frame protocols riding it. (A purely realm-only app doesn't need even that: it opens the same connection with [`createWireClient`](/nice-realm/connecting/#realm-only-apps--createwireclient), no channel at all — the two handshakes are interchangeable.) ## Order matters in the lists The order of domains in each list is what builds that compact binary format, so the position of each one matters. > **Always add new domains to the end** of a list. If you reorder them, the version changes — and an out-of-date peer is cleanly rejected during the handshake instead of quietly sending things to the wrong place. That's the one rule to remember: add to the end, never reorder. --- # Cloudflare Durable Objects Source: /nice-action/cloudflare Description: Collapse the entire DO transport stack into one serveDurableObject call. `@nice-code/action/platform/cloudflare` wraps up _everything_ a Durable Object needs into one `serveDurableObject` call — the secure WebSocket (that survives the DO sleeping and waking), an HTTP fallback, the crypto identity stored in DO storage, and the `ping`/`pong` keepalive. Your DO just forwards its four socket events. The core library itself stays platform-neutral. ## A complete Durable Object ```ts import { DurableObject } from "cloudflare:workers"; import { ActionRuntime } from "@nice-code/action"; import { serveDurableObject, type TDurableObjectChannelServer } from "@nice-code/action/platform/cloudflare"; import { appChannel, serverCoord } from "./shared"; // coordinates live in shared code export class MyDurableObject extends DurableObject { private _server: TDurableObjectChannelServer | null = null; private getServer(): TDurableObjectChannelServer { if (this._server != null) return this._server; const serverRuntime = new ActionRuntime(serverCoord); this._server = serveDurableObject(this.ctx, appChannel, { runtime: serverRuntime, keyPrefix: "ws:", handlers: [userHandler], }); return this._server; } async fetch(request: Request): Promise { return this.getServer().fetch(request); } async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { this.getServer().receive(ws, msg); } async webSocketClose(ws: WebSocket) { this.getServer().drop(ws); } async webSocketError(ws: WebSocket) { this.getServer().drop(ws); } } ``` > Build the server **on the first request** (and remember it), not at the top level of the file. The Workers runtime won't let you generate random ids or do I/O in the global scope. ## Options `serveDurableObject(ctx, channel, options)` accepts everything `serveChannel` does (`handlers`, `channelCases`, `connectionState`, `logger`, …) plus a few host-specific knobs: - **`runtime`** — this DO's runtime. - **`keyPrefix`** — a prefix for the crypto-identity keys stored in DO storage, so they don't clash with your other keys. - **`httpFallback`** — `"plain"` (default), `"secure"` (a handshake-protected fallback that shares the WebSocket's identity), or `false` (WebSocket only). - **`secure`** — whether the WebSocket itself runs the handshake (default `true`). Trusted-client verify-key pins ([trust-on-first-use](/nice-wire/identity/#trust-on-first-use-tofu)) default to **DO-storage-backed**, so they survive eviction and restarts with no configuration; pass `verifyKeyResolver` only to replace the trust policy itself. - **`reliableStore`** — pass `cloudflareReliableLog(this.ctx)` so [`.reliable({ persist: true })`](/nice-action/reliable-delivery/#surviving-a-server-restart) streams keep their dedup state in the DO's SQLite and survive eviction. (On the lower-level acceptor surface this same store is the `persistedReceiver` option — one store, two entry points.) - **`protocols`** — frame protocols to ride the same sockets. This is how a [realm](/nice-realm/serving/) shares the DO's connections with the channel's actions: `protocols: [realms.protocol]`. (A DO that serves *only* a realm doesn't need a channel or this package at all — it hosts on `serveWireDurableObject` from `@nice-code/wire/platform/cloudflare` instead; see [Serving a Realm](/nice-realm/serving/).) ## A plain Worker endpoint (no Durable Object) If a channel can be served by a plain Worker — no Durable Object, because secure HTTP needs no shared memory between requests — use `serveWorker`, the Worker version of `serveDurableObject`. It bundles in the crypto-identity link (created once up front to handle KV's eventual consistency), the default trust-on-first-use store, the HTTP carrier, and the build-on-first-request pattern the Workers global scope forces on you — all over a `StorageAdapter` you pass in: ```ts import { serveWorker, kvStorageAdapter } from "@nice-code/action/platform/cloudflare"; const serveCreate = serveWorker(bridgeCreateChannel, { runtime: () => new ActionRuntime(bridgeCreatorCoord), // a factory — built lazily on first request storage: kvStorageAdapter({ kvNamespace: env.KV, keyPrefix: "bridge-create-identity:" }), handlers: () => [bridgeCreateHandler()], // a factory — built lazily, never at module scope }); // route it from any framework: honoApi.on(["POST", "OPTIONS"], "/create/*", (c) => serveCreate.fetch(c.req.raw)); ``` To serve several channels from one Worker endpoint, use `serveWorkers([a, b], …)` (the Worker version of `serveChannels`). It works out the right version for each connection from whichever channels that client actually uses — so a client that connects to just one of them (via `connectChannel`) is still accepted; on the connecting side, the matching helper is `connectChannels`. (Don't do `serveWorker(combineChannels([a, b]), …)` instead — that locks in a single version for the whole set and rejects every client that only uses one channel, because the versions won't match.) ## Sending HTTP to the right DO instance Often each DO instance _is_ one thing — a single bridge, room, or game — and the Worker has to pick the right one for each request. Route by the **URL**: the body of a secure request is unreadable to the Worker anyway, so security stays end-to-end between the client and the DO. `forwardToDurableObject` picks the right DO, hands off the request, and answers the CORS `OPTIONS` preflight _right at the edge_ — so a specific DO never has to wake up just to reply to a preflight. It returns a `{ fetch }`, so it fits into the optional `actionRouter` or any framework: ```ts import { actionRouter } from "@nice-code/action"; import { forwardToDurableObject } from "@nice-code/action/platform/cloudflare"; const router = actionRouter() .route("/bridge/:id/*", forwardToDurableObject(({ params }) => env.BRIDGE.get(env.BRIDGE.idFromString(params.id)))) // per-id, E2E client ↔ DO .route("/app/*", forwardToDurableObject(() => env.APP.get(env.APP.idFromName("main")))); // singleton export default { fetch: (request: Request) => router.fetch(request) }; ``` `forwardToDurableObject` is just a Cloudflare-flavoured shortcut for the platform-neutral `forwardTo(pickTarget)`, which forwards to **any** `{ fetch }` — a DO, a service binding, or `(req) => fetch(upstream, req)` for an outside server. Inside the DO, make the HTTP fallback secure so the whole path is handshake-protected: ```ts this._server = serveDurableObject(this.ctx, bridgeChannel, { runtime, httpFallback: "secure" }); ``` ## Remembering who's on each connection + broadcasting A presence or room DO that tracks who's on each socket adds two options: `connectionState` (typed state for each connection, saved alongside the routing info so both survive the DO sleeping and waking) and `channelCases`: ```ts const server = serveDurableObject(this.ctx, lobbyChannel, { runtime, keyPrefix: "lobby-ws:", connectionState: { schema: vs_player }, channelCases: { join: (action, conn) => { conn.setState(action.input); conn.broadcast(() => act_lobby.action.player_joined.request(action.input), { exceptSelf: true }); return { players: roster() }; }, move: (action, conn) => { const player = conn.state; // this connection's typed state (or null) if (!player) return; conn.broadcast(() => act_lobby.action.player_moved.request({ id: player.id, ...action.input }), { exceptSelf: true }); }, }, }); // After waking, rebuild your in-memory state from the sockets that are still open // (the routing info is replayed for you automatically): for (const [, player] of server.connections.entries()) players.set(player.id, player); ``` The WebSocket carrier saves each connection's routing info when it's set, and replays it when the DO is rebuilt — so even after the object wakes from sleeping, replies and pushes still reach the right socket. --- # Defining Actions Source: /nice-action/defining-actions Description: Root domains, child domains, action schemas, serialization, and thrown errors. ## Root and child domains First make a **root domain** — an empty top-level namespace with no actions of its own. Then hang **child domains** off it, and put your actions in those. Both sides import all of this from shared code. We prefix action domains with `act_` (so `act_user`, `act_lobby`) the same way error domains use `err_`. It keeps names predictable and makes it obvious at a glance what kind of thing you're looking at. ```ts title="shared.ts" import { createActionRootDomain, actionSchema } from "@nice-code/action"; import * as v from "valibot"; export const act_app = createActionRootDomain({ domain: "act_app" }); export const act_user = act_app.createChildDomain({ domain: "act_user", actions: { getUser: actionSchema() .input({ schema: v.object({ userId: v.string() }) }) .output({ schema: v.object({ id: v.string(), name: v.string() }) }) .throws(err_user, ["not_found"]), updateName: actionSchema() .input({ schema: v.object({ userId: v.string(), name: v.string() }) }) .output({ schema: v.object({ success: v.boolean() }) }), }, }); ``` You can write the schemas with any [Standard Schema](https://github.com/standard-schema/standard-schema) library — Valibot, Zod, and so on. ## Sending values that aren't plain JSON Some values don't survive a trip over the network on their own — a `Date` or a `Map`, for example. For those, pass a pair of functions (one to pack the value for sending, one to unpack it on arrival) as the 2nd and 3rd arguments: ```ts createdAt: actionSchema() .output( { schema: v.object({ createdAt: v.date() }) }, ({ createdAt }) => ({ createdAt: createdAt.toISOString() }), // pack for sending ({ createdAt }) => ({ createdAt: new Date(createdAt) }), // unpack on arrival ), ``` Your handler and your caller always see the real value (a `Date` here) — never the packed string that actually travels over the wire. > **Note:** The schema always describes your **local, real value** (the `Date` object), not the packed wire form. The library runs standard schema validation **after** unpacking on the way in, and **before** packing on the way out — so validation only ever sees the real value, and your pack/unpack functions only ever deal with the wire form. ## Saying which errors an action can throw Use `.throws()` to attach `@nice-code/error` domains. Whoever calls the action then gets those errors fully typed, so they can check for them by name. ```ts import { defineNiceError, err } from "@nice-code/error"; const err_user = defineNiceError({ domain: "err_user", schema: { not_found: err<{ userId: string }>({ message: ({ userId }) => `User not found: ${userId}`, httpStatusCode: 404, context: { required: true }, }), }, }); actionSchema() .throws(err_user) // can throw any error from err_user .throws(err_user, ["not_found"]); // can throw only "not_found" ``` ## Streams that must not drop updates — `.reliable()` By default an action is best-effort — perfect for request/response calls you'd just retry. For a **stream of updates that must all arrive, in order, without duplicates** (game moves, a chat feed, incremental sync), chain `.reliable()` onto the schema: ```ts host_move: actionSchema() .input({ schema: v.object({ n: v.number() }) }) .reliable(), // ordered + deduped + resent across reconnects ``` That one call is the whole opt-in — see [Reliable Delivery](/nice-action/reliable-delivery/) for what it guarantees, the `persist` option for surviving server restarts, and independent streams per room/entity. --- # Error Handling Source: /nice-action/error-handling Description: Every action resolves to a deterministic outcome — branch on expected vs unhandled. Every action always finishes with a **clear, predictable outcome** — it never just rejects with a raw throw. Your handler can throw anything it likes (a declared `NiceError`, an undeclared one, or a plain `Error`); the runtime turns all of them into one typed result, so the place you call from always has the same shape to check against. ## Two questions: `expected` and `isUnhandled` When an action fails, there are two separate things you might want to know: | Question | Where to look | Meaning | |---|---|---| | Did **this action** say it could throw this? | `result.expected` (on the result) | You listed it with `.throws()`, so it's a known, planned-for error. | | Was this an **unexpected crash**? | `error.isUnhandled` (on the error) | Something threw that wasn't a `NiceError` at all — a bug or infrastructure failure. | `expected` depends on *which action* you called: the very same `NiceError` can be expected for one action (which declared it) and unexpected for another (which didn't). `isUnhandled` is a property of the error itself — it's `true` only for the generic wrapper `castNiceError` puts around a non-`NiceError` throw, and it stays `true` even after the error travels over the network. ## The recommended way — `runToResult()` Get the outcome as a value and check it. No `try`/`catch`. ```ts import { matchFirst } from "@nice-code/error"; const result = await act_user.action.getUser.request({ userId }).runToResult(); if (result.ok) { use(result.output); } else if (result.expected) { // result.error is fully typed — it can only be one of this action's declared errors. matchFirst(result.error, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden(), }); } else { // This action didn't declare this one. Check the error's own flag if you care which kind it is: if (result.error.isUnhandled) alertOncall(result.error); // an unexpected crash / bug / infra issue else report(result.error); // a real NiceError you just didn't .throws() } ``` The outcome is one of three shapes: ```ts type TActionResultOutcome = | { ok: true; output: OUT } | { ok: false; expected: true; error: DECLARED } // one of the errors this action declared | { ok: false; expected: false; error: NiceError }; // anything else ``` `expected` is always worked out fresh against the **receiver's own** definition — it's never trusted from the wire. So an error that arrived over the network is sorted exactly the same way as one thrown locally. ## Throw style, with a typed check If you prefer `runToOutput()` (which throws on failure), use the action's `isExpectedError` check to narrow a caught error: ```ts import { castNiceError, matchFirst } from "@nice-code/error"; try { const output = await act_user.action.getUser.request({ userId }).runToOutput(); } catch (e) { if (act_user.action.getUser.isExpectedError(e)) { // e is now narrowed to this action's declared errors matchFirst(e, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden() }); } else { report(castNiceError(e).toStructuredLog()); } } ``` ## Devtools The [browser devtools panel](/nice-action/integrations/) sorts each failed run by these same two questions. Based on `result.expected`, an error is labelled either **Expected Error (declared)** or **Unexpected Error (undeclared / unhandled)**; and based on `error.isUnhandled`, an unexpected crash gets an extra **`unhandled`** badge. So the same distinction you check in code is the one you see in the timeline. --- # Handlers Source: /nice-action/handlers Description: Build local handlers that run actions in the current process. A **handler** is the actual code that runs when an action is called. You write one, register it on the runtime, and it answers incoming calls. There are three ways to write one — use whichever reads best for you. ## Map style (recommended) ```ts title="server.ts" import { createLocalHandler } from "@nice-code/action"; const userHandler = createLocalHandler().forDomainActionCases(act_user, { getUser: async (action) => { const user = await db.users.find(action.input.userId); if (!user) throw err_user.fromId("not_found", { userId: action.input.userId }); return user; }, updateName: async (action) => { await db.users.update(action.input.userId, { name: action.input.name }); return { success: true }; }, }); ``` Each handler gets the whole `action`: `action.input` is typed, and `action.context` carries routing details like `originClient` — who made the call (see [Bi-directional](/nice-action/bidirectional/)). ## One action at a time ```ts const userHandler = createLocalHandler() .forAction(act_user.action.getUser, async ({ input }) => db.users.find(input.userId)); ``` ## Straight from the domain ```ts const userHandler = act_user.wrapAsLocalHandler({ getUser: async ({ userId }) => { /* ... */ }, updateName: async ({ userId, name }) => { /* ... */ }, }); ``` Here each handler gets the **input** directly (already destructured) instead of the full `action` object — the shortest form, for when you don't need `action.context`. ## Handling only some actions `wrapAsPartialLocalHandler` works like `wrapAsLocalHandler`, but you only implement **some** of a domain's actions. This is handy for a client that answers a few calls itself and lets the rest travel on to the server. ```ts const partial = act_user.wrapAsPartialLocalHandler({ getUser: async ({ userId }) => cache.get(userId), // the rest are forwarded over the connection }); ``` Once you've written a handler, pass it to [`serveChannel`](/nice-action/serving-connecting/) on the listening side, or — to answer `toConnector` pushes on the dialing side — to `connectChannel`'s `onPush`. --- # React Query & Building Blocks Source: /nice-action/integrations Description: TanStack Query hooks for your actions, a one-line devtools pointer, and the lower-level pieces connectChannel is built from — for the rare routing a single channel can't describe. ## React Query integration Import from `@nice-code/action/react-query` (peer dep: `@tanstack/react-query`). ```tsx import { useActionQuery, useActionMutation } from "@nice-code/action/react-query"; function UserProfile({ userId }: { userId: string }) { const { data } = useActionQuery( act_user.action.getUser, { userId }, { queryKey: ["user", userId] }, ); return
{data?.name}
; } function RenameUser() { const { mutate } = useActionMutation(act_user.action.updateName); return ; } ``` The input, output, and errors all come straight from the action schema — you don't write a query function or any types by hand. ## Devtools Actions surface in the [devtools window](/devtools/window/) with one call — `createNiceDevtools({ runtime, domains: [act_app] })` names the **root** domains to observe (an action whose root isn't listed never surfaces), and on the server `createNiceServerDevtools({ runtime, domains: [act_app] })` does the same for a backend. Failed runs are sorted by the same question you check in code (see [Error Handling](/nice-action/error-handling/)): an error is labelled **Expected (declared)** or **Unexpected (undeclared / unhandled)** based on `result.expected`. The whole window, the server host, and its options are documented in the [Devtools section](/devtools/window/); [Observing Backends](/devtools/backends/) covers attaching a server or a Durable Object. ## Lower-level building blocks `connectChannel` / `serveChannel` are the entry points you should normally use. For the rare case where your routing isn't a single channel, the pieces they're built from are also exported — most under the `@nice-code/action/advanced` subpath: - **`acceptChannel`** / **`acceptChannelConnections`** — build a secure listening side by hand, with access to each connection. - **`createActionFetchHandler`** — the standard `fetch` handler on its own. - **`createInMemoryChannelPair` / `inMemoryCarrier`** — connect two runtimes in the same process (for tests or same-process peers) with no network involved. `inMemoryCarrier()` hands back two cross-wired ends: a `carrier` for the connector side and a `serverEndpoint` for the acceptor side. Frames cross on a microtask, so each side observes the other asynchronously — exactly like a real socket, but with no socket to spin up. That lets a unit test exercise your full channel, handlers, routing, and types end to end: ```ts import { describe, it, expect } from "vitest"; import { ActionRuntime, connectChannel, acceptChannelConnections, inMemoryCarrier } from "@nice-code/action"; import { createSecureChannelAcceptor } from "@nice-code/action/advanced"; import { StorageAdapter, createMemoryStorageMethods_json } from "@nice-code/util"; import { appChannel, act_user, serverCoord, frontendCoord } from "./shared"; // A throwaway in-memory crypto-identity store, fresh per test. const memStorage = () => new StorageAdapter({ methods: createMemoryStorageMethods_json(new Map()) }); describe("getUser", () => { it("routes a call through the channel with full types, no sockets", async () => { const { carrier, serverEndpoint } = inMemoryCarrier(); // Acceptor: wire the in-memory end into an acceptor, register your cases. const serverRuntime = new ActionRuntime(serverCoord); const conn = { id: "test-conn" }; const acceptor = createSecureChannelAcceptor({ channel: appChannel, runtime: serverRuntime, storage: memStorage(), send: (_c, frame) => serverEndpoint.send(frame), }); serverRuntime.addHandlers([ acceptChannelConnections(acceptor, appChannel, { getUser: ({ input }) => ({ id: input.userId, name: "Test User" }), }), acceptor, ]); serverEndpoint.onMessage((frame) => acceptor.receive(conn, frame)); // Connector: dial the in-memory carrier instead of a real WebSocket. const clientRuntime = new ActionRuntime(frontendCoord); connectChannel(clientRuntime, appChannel, { peer: serverCoord, storage: memStorage(), transports: [{ carrier }], }); const user = await act_user.action.getUser.request({ userId: "u_1" }).runToOutput(); expect(user).toEqual({ id: "u_1", name: "Test User" }); // typed both sides }); }); ``` You're mocking the edge network topology locally with full type safety — flip `inMemoryCarrier` out for `wsCarrier` and the exact same test shape runs over a real socket. - **`createBinaryWireAdapter`** — the binary format `defineChannel` builds for you, exposed for custom carriers. Only reach for these when a single channel can't describe the routing you need. --- # Mental Model Source: /nice-action/mental-model Description: Runtimes, peers, carriers, and channels — the four ideas behind nice-action. `@nice-code/action` lets you call a function that lives somewhere else — on a server, in a worker, on another peer — as if it were a normal local function, with all the types intact. It doesn't care *how* the two sides are connected. It even works **both ways**: over a live connection, the side that's listening can call back to the side that connected. ## In one sentence > A **runtime** connects to a **peer** over a **carrier**, and a **channel** describes — once — which calls flow in each direction. The interesting part: it all behaves the same no matter which carrier you use. Only two things ever really differ: - **Who started the connection** — the **connector** dials in; the **acceptor** listens, and can call back. - **The shape of the connection** — **duplex** (a two-way line like a WebSocket, so either side can speak at any time) vs **exchange** (one request, one reply, like a normal HTTP call). ## The pieces | Concept | What it is | |---|---| | **ActionDomain** | A named group of related actions — think of it as one section of your API. | | **ActionSchema** | One action's input type, output type, and the errors it can throw. | | **ActionRuntime** | One per app. It's your app's identity, and it routes incoming calls to your code. | | **Channel** | Lists which actions go from connector to acceptor (`toAcceptor`) and back (`toConnector`). Both sides build it from the same definition, so they always agree. | | **Carrier** | The actual way bytes travel. On the dialing side: `wsCarrier`, `httpCarrier`, `inMemoryCarrier`, `rtcCarrier`. On the listening side: `wsAcceptorCarrier`, `httpAcceptorCarrier`. | | **Transport** | A carrier with its connection settings. You never build one by hand — `connectChannel` / `serveChannel` do it for you. | | **RuntimeCoordinate** | A label for an environment (frontend, backend, worker…). It's how calls find the right destination. | ## The Network Flow A call leaves your code as a typed request and arrives on the other side as a typed handler argument. In between, everything is plumbing the channel set up for you: ```text CONNECTOR (client.ts) ACCEPTOR (server.ts) ┌────────────────────┐ ┌────────────────────┐ │ ActionRuntime │ │ ActionRuntime │ │ act_user.getUser │ │ getUser handler │ │ .request() │ │ (your code) │ └─────────┬──────────┘ └─────────▲──────────┘ │ │ ▼ │ ┌────────────────────┐ ── same definition, both sides ──┌──────────┐ │ Channel (Schema) │ ········· the type boundary ······│ Channel │ │ validate + encode │ │ decode + │ └─────────┬──────────┘ │ validate │ │ └────▲─────┘ ▼ │ ┌────────────────────┐ ┌─────────┴──────────┐ │ Handshake/Security │ identity + (optional) crypto │ Handshake/Security │ │ none/auth/encrypt │ ◄───────────────────────────► │ none/auth/encrypt │ └─────────┬──────────┘ └─────────▲──────────┘ │ │ ▼ │ ┌────────────────────┐ bytes on the wire ┌──────┴───────────┐ │ Carrier (WS/HTTP) │ ─────────────────────────────► │ Carrier (WS/HTTP)│ │ wsCarrier / http │ ◄───────────────────────────── │ wsAcceptorCarrier│ └────────────────────┘ └──────────────────┘ ``` The **channel is the boundary**. It's the one definition both sides import, so the shape that gets encoded on the connector is exactly the shape that's decoded and validated on the acceptor — there's no second copy of the types to drift out of sync. Everything below the channel (handshake, security level, which carrier) can change without touching a single action call: swap a WebSocket for HTTP, or turn on encryption, and the request/handler signatures stay identical. ## One runtime per app Each app — a frontend, a backend, a worker — has **one** `ActionRuntime` that represents it to everyone it talks to. Not one per feature, not one per server you connect to. Register your handlers on it, then call `connectChannel(...)` once for each peer you dial (or `serveChannel(...)` to listen). One runtime means one identity, and no confusion about where a call should go. ## The two functions you'll use most `connectChannel` (dial out) and `serveChannel` (listen) cover almost everything you'll do. They take your channel and runtime and wire up all the lower-level carrier and transport plumbing for you. ## The three-file shape A typical app is exactly three files: - **`shared.ts`** — imported by both sides: the domains, the channel, and the runtime coordinates that name each side. - **`server.ts`** — the listening side: your handlers + `serveChannel`. - **`client.ts`** — the dialing side: `connectChannel` + your action calls. The pages that follow label each code block with the file it belongs in. > **Coordinates are shared facts.** A `RuntimeCoordinate` like `RuntimeCoordinate.env("backend")` always belongs in shared code — its own `runtimeCoordinates.ts` is the natural home, since it's orthogonal to how you channel your actions — never in `server.ts`: the client needs the *same* coordinate to name the server it's dialing, and it can't import that from server-only code. Define each side's coordinate once, in shared, and import it on both sides. --- # Reliable Delivery Source: /nice-action/reliable-delivery Description: Opt an action into ordered, deduplicated, resend-on-reconnect delivery with .reliable(). By default an action is **best-effort**: it rides one transport attempt, and a dropped frame or a mid-flight disconnect surfaces as an error for you to handle. That's the right default for a request/response call you'll just retry. Some actions aren't like that. A **stream of updates that must all arrive, in order, without duplicates** — game moves, a chat feed, incremental sync — shouldn't lose an update to a flaky network or deliver it twice after a reconnect. For those, opt the action into the reliable tier: ```ts title="shared.ts" export const act_session = act_app.createChildDomain({ domain: "act_session", actions: { // Ordered, at-least-once, deduped for the life of a (resumable) connection. host_move: actionSchema().input({ schema: v.object({ n: v.number() }) }).reliable(), // Same, but the dedup state is *persisted* server-side, so it also survives // a server eviction/restart (a Durable Object waking, a redeploy). host_event: actionSchema().input({ schema: v.object({ n: v.number() }) }).reliable({ persist: true }), }, }); ``` That's the entire configuration surface: `.reliable()` is the only delivery knob, and `persist` its only parameter. There is no per-message policy to tune. Both sides read the tier from the shared action definition, so nothing extra travels on the wire beyond a small per-frame sequence number (and a cumulative acknowledgement coming back). Actions that don't opt in are completely unaffected. > **Transport:** reliable delivery works over **duplex** carriers (WebSocket / WebRTC / in-memory — a > persistent connection). Over an HTTP exchange there's no standing connection to resend on, so > `.reliable()` there is simply best-effort. Point a reliable stream at a WebSocket carrier. > **Direction:** reliable delivery engages when the **dialing side sends to the listening side** > (connector → acceptor). A server push (`pushToClient` / `broadcast`) of a reliable-declared action is > still **best-effort** — see [the direction note](#one-direction-pushes-are-best-effort) below. ## What you get — and the one thing you owe - **Ordered** — your handler never sees frames out of order; an early arrival waits until the gap before it fills. - **At-least-once** — an unacknowledged frame is resent when the transport reconnects, so a drop never loses an update. - **Deduped** — a resent frame the server already received is acknowledged but not re-delivered to your handler (for a `fireAndForget().reliable()` stream), or is re-run so its reply regenerates (for an action with an `.output()` — the caller may still be waiting on that reply). The one thing you owe in return is the flip side of *at-least-once*: **reliable handlers must be idempotent**. In rare windows (a reply lost right at a disconnect, a delivery deadline that fired just as the frame landed) the same update can reach your handler twice. Make handling it twice a no-op — upsert by an id, ignore an already-applied move — and every path is safe. ## When the promise settles Opting in changes *when* a reliable action's promise settles, because the whole point is to keep retrying instead of failing fast: - An action **with an output** resolves when its reply arrives — same as best-effort, except a transport drop no longer rejects it; the frame is resent and the promise resolves after the reconnect. - A **fire-and-forget** action (`.fireAndForget().reliable()`) resolves **on send** — delivery continues in the background across reconnects. - Either way, every reliable send has a **delivery deadline** (`reliableActionTimeout` on `connectChannel`, default 60s). If the peer hasn't acknowledged the frame by then — say it's simply unreachable — the frame is **abandoned**: a still-pending action rejects with a `reliable_delivery_abandoned` error, and an already-settled fire-and-forget send surfaces through a one-time console warning. So a reliable call always terminates, and background delivery never retries forever. ```ts title="client.ts" connectChannel(clientRuntime, appChannel, { peer: serverCoord, storage, transports: [{ carrier: wsCarrier(() => ({ url: wsUrl })) }], reliableActionTimeout: 30_000, // optional — how long an unacknowledged frame retries (default 60s) }); ``` ### Abandoned frames don't wedge the stream An abandoned frame (deadline expired, or you called `running.abort()` while it was unacknowledged) can't block everything behind it. The sender drops it — along with any *older* frames still unacknowledged, each failing loudly the same way — and tells the receiver to skip past those sequence numbers. The receiver advances, delivers anything it was holding behind the gap, and the stream **continues**: later sends deliver normally. Ordering among *delivered* frames is always preserved; an abandoned frame is a surfaced failure, never a silent hole. ## Observing delivery — `waitForAck` and stream events A fire-and-forget reliable send resolves **on send**, so its promise can't tell you what became of the frame. Two surfaces do: **Per send** — hold the `RunningAction` and await its delivery settlement: ```ts const running = act_session.action.host_move.request(move).run({ streamKey: runId }); await running.waitForAck(); // resolves when the peer's cumulative ack covers this frame; // rejects with the abandon reason (deadline / abort / sweep / stream close) ``` For a best-effort action `waitForAck()` simply mirrors the action itself (resolves on success, rejects on failure), so generic code composes without branching on the tier. Observers can also watch `runningAction.addUpdateListeners` for the `reliability` update, which fires when `reliability.acked` flips — the devtools chip's `✓` rides this. **Per connection** — no handles, for app-level monitoring: ```ts connectChannel(runtime, channel, { ..., onReliableEvent: (event) => { if (event.type === "abandoned") { // frames event.fromSeq..event.toSeq of (event.domain/event.actionId [#event.streamKey]) // were dropped undelivered — re-push from your own records, or show "receiver may be behind". } if (event.type === "overflow") { /* a send hit the unacked-window cap — coalesce/shed */ } }, }); ``` ### Which failures surface where For a **fire-and-forget** reliable send, the failure classes are deliberately split: | What happened | When it surfaces | How | |---|---|---| | Stream at its unacked cap | **at the call** | the send's promise rejects with `reliable_outbox_overflow` (+ the `overflow` event) | | Peer unreachable right now | not an error | background retry with backoff, until the deadline | | Delivery deadline expired | **after** the promise resolved | `waitForAck()` rejects, the `abandoned` event fires, one console warning per route | | Explicit `abort()` / stream close | after the promise resolved | same as deadline, with the abort/close reason | A reply-carrying reliable send is simpler: everything above that would be "silent" instead rejects its still-pending promise loudly. Note the at-least-once caveat both ways: a *rejected* settlement means the sender stopped trying — the ack itself may have been lost after delivery, so an "abandoned" frame may still have arrived (idempotent handlers make that harmless). ### Backpressure Unacknowledged frames are held until the peer confirms them, and that can't grow without bound: each stream caps at 1024 unacknowledged sends. Past the cap, new sends on that stream fail with a `reliable_outbox_overflow` error — surface it as "connection lost, please retry" rather than silently dropping updates. You don't have to wait for the cliff: the connector exposes read-only pressure stats, so a high-frequency sender can coalesce batches or shed cosmetic traffic *before* overflowing: ```ts const connector = connectChannel(runtime, channel, { ... }); connector.reliablePending(); // total unacked sends, all streams const p = connector.reliablePending(act_session.action.host_events, runId); // p.unackedCount / p.oldestUnackedAgeMs / p.maxUnackedPerStream — headroom = max - count ``` ## Surviving a server restart **A socket drop + reconnect to a still-running server** is the everyday case and needs nothing from you: the client resends what wasn't acknowledged, the server's dedup state is intact, and each update is delivered exactly once. **A server restart mid-stream** (a redeploy, a Durable Object evicted with its memory gone) is the harder case, and the two tiers answer it differently: - **`.reliable()` (the session tier)** self-heals the *in-flight* part: the fresh server notices it has no state for a mid-stream frame and asks the client to re-sync; the client renumbers its unacknowledged frames and resends, and the stream continues in order instead of stalling. What this can't recover is frames the old server had already confirmed — those were delivered before the restart, and the fresh server doesn't remember them, so a handler may see them again on replay (idempotency covers it). - **`.reliable({ persist: true })` (the persisted tier)** persists the dedup state itself, so the stream survives the restart *outright* — a replayed frame is recognised and skipped, even across an eviction. The persisted tier needs somewhere to keep that state. On Cloudflare it's one line — back it with the Durable Object's SQLite: ```ts title="server.ts (Durable Object)" import { serveDurableObject, cloudflareReliableLog } from "@nice-code/action/platform/cloudflare"; const server = serveDurableObject(this.ctx, appChannel, { runtime, reliableStore: cloudflareReliableLog(this.ctx), // persisted-tier streams survive eviction channelCases: { /* … */ }, }); ``` `cloudflareReliableLog` needs a SQLite-backed DO class (enable it in `wrangler`). If you omit `reliableStore`, `persist: true` actions gracefully behave like the session tier. On other backends, implement the small `IReliableLogStore` port and pass `new ReliableLog(store)` — both exported from `@nice-code/action/advanced`. Storage stays bounded: out-of-order frames are only persisted within a window (`maxGapWindow`, default 1024), and you can opt into compaction of delivered history with `cloudflareReliableLog(ctx, name, { keepDelivered: 256 })`. ## Independent streams of one action — `streamKey` By default a reliable action is **one** ordered stream per peer: every frame shares one sequence. That's right when the action *is* the stream. But when one connection multiplexes several *independent* logical streams through the same action — one per game room, per entity, per chat channel — you don't want a gap in one room holding back the others. Pass a **`streamKey`** and each key gets its own ordered, deduped, independently-resending stream: ```ts title="client.ts" // One connection, many rooms, one action — each room id is its own stream. function sendRoomEvent(roomId: string, event: TRoomEvent) { return act_room.action.host_event.request(event).runToOutput({ streamKey: roomId }); } sendRoomEvent("alpha", a); // stream #alpha — its own sequence sendRoomEvent("beta", b); // stream #beta — fully isolated from alpha ``` `streamKey` is a per-call option on `.run()` / `.runToOutput()` — the key is usually only known at runtime (a room id), which is why it isn't part of the action's schema. Omitting it gives you exactly the single default stream. A gap, an abandonment, or backpressure in one key never affects another. Because keys are chosen by the client, the server caps how many distinct keyed streams one client can open (`maxKeyedStreamsPerClient` on `serveChannel`, default 256). What counts against the cap: distinct keys per bound client per action route, on the accepting side. A key stops counting when its stream is **closed** (below); an idle-but-unclosed key keeps counting. Client-side, each used key holds a small seq-counter entry for the life of the tab — `closeReliableStream` reclaims it. > Keyed frames use a slightly extended wire format, so **both ends** need a library version that supports > `streamKey`. Against an older peer you get a one-time dev warning and the keyed frames are not delivered. ### Closing a stream — `closeReliableStream` Keyed streams are created implicitly on first use; when a stream's real-world subject is *over* (the game run ended, the room was left), close it explicitly: ```ts const connector = connectChannel(runtime, channel, { ... }); // at run teardown: connector.closeReliableStream(act_session.action.host_events, runId); ``` One call: every still-unacknowledged send on the stream is abandoned (pending actions reject with `reliable_stream_closed`; settled fire-and-forget sends stop resending; the `abandoned` event fires with the swept range), the receiver is told to skip past them, and **both sides release the stream's state** — including the key's slot in the `maxKeyedStreamsPerClient` quota. It is **synchronous on the sender's state**: after it returns, nothing from that stream can resend. That ordering is the point — see [multiplexed peers](#multiplexed-peers-many-server-instances-behind-one-coordinate) below. Closing a stream you never sent on is a no-op, and a closed key can be reused safely (the library self-heals the seq bookkeeping in every crash/race interleaving). ## Multiplexed peers — many server instances behind one coordinate > **The one sharp edge to know about.** A reliable stream is identified by > `(peer coordinate + action route [+ streamKey])`, but frames are *delivered* over whatever transport > currently dials that coordinate. On Cloudflare the natural shape is many Durable Object instances behind > **one** peer coordinate, selected by a mutable dial URL (`/session/ws?runId=X` → `idFromName(X)`). If > your dial state changes while unacknowledged frames are still in the outbox, **the retries follow the > connection — into a different physical instance**: run A's unacked tail can be redelivered into run B's > DO after you switch runs. Two lines close the window entirely: 1. **Close the stream at teardown, *before* changing the dial state** — `connector.closeReliableStream(action, runId)` is synchronous on the sender's state, so once it returns nothing from the old run can resend. 2. **Defense-in-depth: stamp the payload and guard server-side** — carry the entity id (`runId`) in every reliable payload and have the instance drop payloads that aren't its own (`input.runId !== ctx.id.name` for an `idFromName` DO). Dropping is safe *and terminal*, because of the contract below. **Acknowledgement is a receipt contract, not an outcome contract.** The receiver acknowledges a frame when its inbox *accepts* it — ordering/dedup bookkeeping — regardless of what your handler then does with it. A handler that inspects a frame and discards it still settles that frame: it will not retry. This is guaranteed behavior (the server-side-guard pattern above depends on it), and it's also why "acked" never means "the handler liked it" — only "it arrived, in order, once". ## What reliability spans — and what it doesn't Reliable delivery spans **drops and reconnects, not reloads**. The outbox holding unacknowledged sends is memory: a page reload or tab close discards it (nothing is ever redelivered *cross-run* by a reload — the outbox dies with the tab — but nothing is delivered later either). For a send that **must** survive a reload (an end-of-run submission racing a tab close), persist the input yourself and replay it through a normal reliable send on next launch — replayed sends get fresh seqs, and your idempotent handler (the contract you already signed up for) makes the replay safe end-to-end. Relatedly, reliable streams are scoped **per runtime boot**: the `ActionRuntime` generates a fresh per-instance id (`insId`) at construction when you don't set one, so a reloaded app is a *new* stream identity rather than a collision with the server's retained bookkeeping for the old one. Only set `insId` yourself if it is genuinely unique per process boot. ## One direction — pushes are best-effort Reliable delivery engages on the **dialing side's sends** (connector → acceptor): the dialing side keeps the resend state, and its process lifetime is the natural bound for it. A server push ([`pushToClient` / `broadcast`](/nice-action/bidirectional/)) of a reliable-declared action does **not** get an outbox or a sequence — it's delivered best-effort, exactly like any other push (with a one-time dev warning per route, so the direction downgrade is never silent). That's deliberate. A push's receiver is a browser tab or a phone that routinely disappears for longer than any resend window could cover — so a push stream that *truly* must all arrive needs durable catch-up on the server, not a live-socket resend. Build it from the persisted log: append each push to a per-client `ReliableLog`, replay `contiguousPrefix()` when the client reconnects, and prune once the client confirms. (The building blocks — `ReliableLog`, `ReliableInbox`, and the stores — are exported from `@nice-code/action/advanced`.) For a single push that needs confirmation, awaiting `pushToClient(...).waitForResultPayload()` and retrying already gives you at-least-once. Two backends that each dial the other (worker ↔ worker) get reliable delivery in both directions today — reliability follows the dialer. ## Seeing it work The [devtools panel](/nice-action/integrations/) tags every reliable action's row with a chip — `reliable · 3` (the frame's sequence number), gaining a `✓` once the peer confirms delivery, or `persisted · 3` for the persisted tier. On the serving side, the [request logger](/nice-action/serving-connecting/#logging-served-requests) tags each reliable request with its sequence, the cumulative acknowledgement, and whether it was a deduplicated redelivery: ``` ▶ act_session/host_move via ws from envId[frontend]… reliable seq=3 ack=3 ▶ act_session/host_move via ws from envId[frontend]… reliable seq=3 ack=3 (redelivered) ``` ## What reliable delivery deliberately does *not* do To keep the guarantee knob-free and the library maintainable, these are **out of scope by design**. That list reads as *scoped*, not as dead-ends — every item has a worked recipe in the next section: - **No message priorities** → [priority lanes](#1-priority-lanes). - **No per-message TTLs or deadlines** → [TTL classes](#2-per-message-ttl-classes). - **No exactly-once *effects*** → [the free idempotency key](#3-exactly-once-effects). - **No ordering across different actions** → [the envelope action](#4-ordering-across-event-types--the-envelope). - **No reliable broadcast / reliable push** → [the persisted-log recipe](#5-reliable-serverclient-push). ## Building on top — the recipes ### 1. Priority lanes Ordering is per stream, so make a stream per priority class: `streamKey: "critical"` and `streamKey: "bulk"` are independently ordered, independently backpressured lanes of one action — a slow bulk lane never head-of-line-blocks the critical one (cross-lane ordering is intentionally absent; that's what a priority scheme wants). A scheduler reads pressure per lane with `connector.reliablePending(action, "bulk")` and sheds or coalesces the cheap lane first. ### 2. Per-message TTL classes There is no per-call deadline knob — compose one from the two levers that exist. Set the **connection** deadline to your *longest* class, then abort the short-lived class down with a timer: ```ts // host_end is precious (let it retry ~10 minutes); host_events batches are stale after ~15s. connectChannel(runtime, channel, { ..., reliableActionTimeout: 10 * 60_000 }); let lastBatch: RunningAction | undefined; function sendBatch(events: TEvent[]) { lastBatch = act_session.action.host_events.request({ runId, events }).run({ streamKey: runId }); const running = lastBatch; const ttl = setTimeout(() => running.abort(), 15_000); // the per-class deadline running.waitForAck().then(() => clearTimeout(ttl), () => clearTimeout(ttl)); } ``` Two facts make this correct: an abort **sweeps older unacknowledged frames on the same stream with it** (they could no longer deliver in order anyway) — so give differing TTL classes their own `streamKey` lane — and different *actions* are different streams, so `host_events` aborts never touch `host_end`. Note the `lastBatch` variable is also your teardown handle: aborting only the most recent send abandons the whole unacked tail (or just call `closeReliableStream`). For the precious class, remember [the lifetime boundary](#what-reliability-spans--and-what-it-doesnt): a 10-minute retry still dies with the tab. A must-arrive payload wants the persist-and-replay pattern too. ### 3. Exactly-once effects The wire dedups *delivery*; your handler owns the *effect*. The library hands the handler a free idempotency key for it — the receiver-side facts of the frame being handled: ```ts // In a Durable Object case: effect + dedup in ONE transaction, keyed by the library's own (stream, seq). host_move: (action, conn) => { const rel = action.context.reliability; // { seq, streamKey?, redelivered } — undefined for best-effort this.ctx.storage.sql.exec( `INSERT OR IGNORE INTO effects (stream_key, seq, payload) VALUES (?, ?, ?)`, rel?.streamKey ?? "", rel?.seq, JSON.stringify(action.input), ); }, ``` Tier nuance: for a **fire-and-forget** reliable stream, a duplicate never reaches your handler at all (the receiver suppresses it before dispatch) — `redelivered: true` appears only on **reply-carrying** re-runs, where the re-run regenerates the reply; use it to return a memoized reply instead of re-running an expensive body. The one duplicate window no flag can cover is a session-tier server reset replay — that's exactly what the `(stream, seq)`-keyed transaction above absorbs. ### 4. Ordering across event types — the envelope If several kinds of update must stay in **one** order — `start`, then `events`×N, then `end`, all for one entity — do not send them as three actions and re-derive tolerance server-side. Send **one** action whose input is a discriminated union, keyed per entity: ```ts host_record: actionSchema() .input({ schema: v.variant("kind", [vStart, vEvents, vEnd]) }) .fireAndForget() .reliable(), // every send: .run({ streamKey: runId }) — one run = one totally-ordered stream ``` One action, one stream per run: `start` → `events` → `end` arrive in exactly that order with **zero** app-level reordering tolerance, and the handler is a `switch (input.kind)`. (This is the shape a migration from a legacy multi-action protocol should collapse to — if you're carrying "arrival-order tolerance" code, you're holding the shape wrong.) ### 5. Reliable server→client push Reliability engages connector→acceptor ([direction](#one-direction-pushes-are-best-effort)); a push stream that must all arrive needs durable catch-up, which subsumes what a live-socket resend could give: ```ts // Server: append each push to a per-client log — `append` assigns the seq, carry it in the payload. const log = cloudflareReliableLog(this.ctx, "push"); const { seq } = log.append(`push::${clientId}`, update); server.pushToClient(client, act_feed.action.entry.request({ seq, update })); // live path, best-effort // Client: dedup/order with its own inbox (from `@nice-code/action/advanced`), ack via a normal action. const inbox = new ReliableInbox(); onPush: { entry: ({ seq, update }) => { for (const u of inbox.receive("feed", seq, update).deliver) apply(u); act_feed.action.ack.request({ upTo: inbox.contiguousSeq("feed") }).runToOutput(); }} // Server, on reconnect: replay the gap-free prefix; prune what the client confirmed. for (const update of log.contiguousPrefix(`push::${clientId}`)) resend(update); // on ack: store.deleteThrough(`push::${clientId}`, upTo) — or set `keepDelivered` retention instead. ``` For a *single* push that needs confirmation, skip all of this: await `pushToClient(...).waitForResultPayload()` and retry. ### 6. Stream teardown with multiplexed peers The worked shape of the [multiplexed-peers warning](#multiplexed-peers-many-server-instances-behind-one-coordinate): ```ts async function endRun(runId: string) { // 1. Close the run's streams FIRST — synchronous; nothing from run A can resend after this. connector.closeReliableStream(act_session.action.host_events, runId); // 2. Only then flip the dial state the WS carrier derives its URL from. activeRun.set(nextRunId); } ``` Keep the payload stamp + server-side guard as defense-in-depth — the guard is terminal because [acks are receipt, not outcome](#multiplexed-peers-many-server-instances-behind-one-coordinate). --- # Serving & Connecting Source: /nice-action/serving-connecting Description: 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` `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 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: ```ts title="server.ts" 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](#dialing--connectchannel)). > 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](/nice-util/storage/). ### 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'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](/nice-action/bidirectional/)). - **`acceptors`** — the accept-side connection objects, one per duplex carrier. You rarely touch these; with several socket carriers, push/broadcast over a specific `acceptors[i]`. ## 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 `serveDurableObject` call. See [Cloudflare](/nice-action/cloudflare/). ### Bun `Bun.serve` owns the upgrade, so let it perform the upgrade and feed its `websocket` events straight to the server: ```ts title="server.ts" 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({ 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 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: ```ts title="server.ts" 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(); const serverRuntime = new ActionRuntime(serverCoord); const server = serveChannel(serverRuntime, appChannel, { storage: createMemoryStorageAdapter_json(), handlers: [userHandler], carriers: [ wsAcceptorCarrier({ 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. ## 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. ```ts title="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 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](/nice-action/reliable-delivery/)). 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](/nice-realm/connecting/) 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 — 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](/nice-wire/identity/#your-storage-must-be-durable--the-mistake-that-bites). > **Your connections are already protected.** By default `serveChannel` and `connectChannel` set 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](/nice-wire/security/). ## 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: ```ts title="server.ts" 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 12ms ``` Every 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 (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`). ```ts 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](/devtools/backends/) (`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; reach for the devtools host when you want lifecycle stages, payload inspection, or a live stream into a devtools window. The same `logger` flows through `serveDurableObject` / `serveWorker` on Cloudflare. ## 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](/nice-action/integrations/)), 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. ```ts // doc-check: skip — illustrative wiring; the devtools host lives in @nice-code/devtools-core const traffic = new TrafficMetricsCore(); serveChannel(runtime, channel, { storage, carriers, wireTap: traffic.wireTap }); ``` ## A realm (or any protocol) needs a duplex carrier You ride a [realm](/nice-realm/connecting/) — 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](/nice-realm/resilient-client/#the-first-connect--when-the-server-isnt-up-yet) for what that looks like and how to bound it.