npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nice-code/action

v0.77.0

Published

Downloads

6,222

Readme

@nice-code/action

Docs: nicecode.io — guides, integrations, and the full API surface. Stability: nicecode.io/production/stability — the pre-1.0 posture, lockstep versioning, and the wire-format skew promise. Working with an AI assistant? Point it at nicecode.io/llms-action.txt (just this package) or nicecode.io/llms.txt (the whole stack) — the complete, current docs flattened into plain text.

Typed, transport-agnostic action system for calling functions across runtime boundaries (client/server, worker/worker, peer/peer) with full TypeScript inference — including bi-directional calls where the acceptor pushes actions back over the same connection.

Install

bun add @nice-code/action

Peer deps: valibot (or any Standard Schema library), @tanstack/react-query (for @nice-code/action/react-query).

React Native / Expo

nice-action is platform-agnostic and uses only standard web interfaces — no browser-only APIs (e.g. base64 goes through @nice-code/util's runtime-agnostic bytesToBase64/base64ToBytes, not btoa/atob). On React Native (Hermes) two standard interfaces are missing and must be polyfilled by the app:

  • WebCrypto (crypto.subtle) — the secure handshake/encryption needs Ed25519 / X25519 / HKDF / AES-GCM.
  • URL — Hermes' built-in URL is incomplete; carriers use it to build WS/HTTP urls.

Install the two RN polyfill packages and run them as the very first side-effect of your app entry, before any ActionRuntime / secure session is constructed:

bun add react-native-quick-crypto react-native-url-polyfill
// polyfills.ts — import this first in your entry (e.g. before "expo-router/entry")
import "react-native-url-polyfill/auto";
import { install } from "react-native-quick-crypto";
install(); // patches global.crypto with OpenSSL-backed WebCrypto (SubtleCrypto)

react-native-quick-crypto needs the new architecture + a recent deployment target (it compiles OpenSSL via Nitro/JSI). That's the whole requirement — nothing nice-action-specific to install. A complete, working reference lives in packages/demo-expo-frontend (src/polyfills.ts + index.js).

If you hit a runtime failure that "can't happen" per the source (a provably-set value reads as undefined) only on Hermes, see docs/hermes-await-codegen.md — a Hermes codegen gotcha, with reusable on-device crypto/transport diagnostics.


Mental model

One sentence: a runtime links to a peer over a carrier, and the routing between them is declared once as a channel. Identity, auth, and encryption work the same regardless of carrier — the only distinctions that survive every carrier are:

  • role — who dials (connector) vs who accepts and can push back (acceptor).
  • shapeduplex (a WebSocket / WebRTC channel: push-capable, the return path for results and server pushes) vs exchange (HTTP: one request, one reply).

The pieces:

| Concept | What it is | | --- | --- | | ActionDomain | A named group of typed actions (your API surface) | | ActionSchema | Input/output schema + declared error types for one action | | ActionRuntime | One per runtime; identifies it and dispatches actions to handlers | | Channel | The transport-agnostic routing contract + binary wire identity between two runtimes, declared by role (toAcceptor / toConnector) with defineChannel. Security is a per-transport choice, not a channel one | | Carrier | How bytes actually move: wsCarrier / httpCarrier / inMemoryCarrier / rtcCarrier (connector side), wsAcceptorCarrier / httpAcceptorCarrier (acceptor side) | | Transport | A carrier wrapped with a security policy (handshake + optional encryption, or plain). You don't build these directly — connectChannel / serveChannel apply the policy to each carrier for you | | RuntimeCoordinate | Identifies an environment (frontend, backend, worker…) and is how actions are routed |

One runtime per client. A client (a frontend, a backend, a worker) has a single ActionRuntime identifying it across every peer it talks to — not one per feature or per backend. Register your local handlers on it, then connectChannel(...) once per peer (or serveChannel(...) to accept). This keeps one identity (and one crypto identity, for secure channels) per client and avoids routing ambiguity.

The high-level entry points — connectChannel (dial out) and serveChannel (accept) — are what you reach for 95% of the time. The lower-level handler/carrier/transport objects they desugar to are documented at the end under Lower-level building blocks.

Code blocks below are labelled by where they run: // shared.ts (imported by both ends), // server.ts (the acceptor), // client.ts (the connector). A typical app is exactly these three files.


Defining actions

1. Create a root domain (shared between both ends)

// shared.ts
import { createActionRootDomain, actionSchema } from "@nice-code/action";
import * as v from "valibot";

// Root domain — no actions, just a namespace anchor
export const appRoot = createActionRootDomain({ domain: "app_root" });

// Child domain with actions
export const userDomain = appRoot.createChildDomain({
  domain: "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"]),  // from @nice-code/error

    updateName: actionSchema()
      .input({ schema: v.object({ userId: v.string(), name: v.string() }) })
      .output({ schema: v.object({ success: v.boolean() }) }),
  },
});

2. Serialization for non-JSON-native types

createAt: actionSchema()
  .output(
    { schema: v.object({ createdAt: v.date() }) },
    ({ createdAt }) => ({ createdAt: createdAt.toISOString() }),  // serialize
    ({ createdAt }) => ({ createdAt: new Date(createdAt) }),       // deserialize
  ),

3. Declare thrown errors

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 },
    }),
  },
});

// Attach to an action schema
actionSchema()
  .throws(err_user)                        // any id from err_user
  .throws(err_user, ["not_found"])          // only specific ids

Channels — the routing contract

A channel declares what flows in each direction between two runtimes, by role, so both ends derive their routing from one shared definition instead of restating domain lists:

  • toAcceptor — domains the connector sends to the acceptor (the classic "request").
  • toConnector — domains the acceptor pushes back to the connector (the classic "push"). A domain can appear in both lists if it's bidirectional.

Define it once in code shared by both ends:

// shared.ts — both ends import this
import { defineChannel, RuntimeCoordinate } from "@nice-code/action";

export const appChannel = defineChannel({
  toAcceptor: [userDomain, lobbyDomain],  // client → server requests
  toConnector: [lobbyDomain],             // server → client pushes (lobbyDomain is bidirectional)
});

// Runtime coordinates name each side. Define them here, in shared code, so BOTH
// ends reference the same values
export const serverCoord = RuntimeCoordinate.env("backend");
export const frontendCoord = RuntimeCoordinate.env("frontend");

A channel carries both its routing (toAcceptor / toConnector domains) and its wire identity — the positional binary wire dictionary and a version derived from the domains, so the per-connection codec and version can never drift between the two ends. Whether a given transport actually runs encrypted is a per-transport choice (secure on connectChannel's transports and the acceptor's securityLevel), not a property of the channel — so this one definition serves both plain and secure transports.

The lists are positional for the binary wire dictionary — add new domains to the end of their list. Reordering shifts the version, and a stale peer is then cleanly rejected by the handshake instead of silently misrouting a frame.


Runtimes & handlers

Local execution (the acceptor's actual work)

A local handler runs actions in the current process. Build one and register it on the runtime; this is what answers incoming requests.

// server.ts
import { ActionRuntime, createLocalHandler } from "@nice-code/action";
import { serverCoord } from "./shared"; // coordinates come from shared code, never defined here

// Map syntax (preferred)
const userHandler = createLocalHandler().forDomainActionCases(userDomain, {
  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 };
  },
});

// Or one action at a time
const userHandler2 = createLocalHandler()
  .forAction(userDomain.action.getUser, async ({ input }) => db.users.find(input.userId));

// Or wrap an object directly off the domain
const userHandler3 = userDomain.wrapAsLocalHandler({
  getUser: async ({ userId }) => { /* ... */ },
  updateName: async ({ userId, name }) => { /* ... */ },
});

wrapAsPartialLocalHandler is the same but lets you implement only some of a domain's actions (useful for local-first clients that resolve a few actions themselves and forward the rest).

Accepting connections — serveChannel

serveChannel is the one call that stands up an acceptor: it builds the crypto identity once, fans it across every carrier, registers your handlers, wires hibernation, and returns a server object whose fetch / receive / drop / pushToClient you forward straight to the host.

// server.ts
import {
  ActionRuntime,
  serveChannel,
  wsAcceptorCarrier,
  httpAcceptorCarrier,
} from "@nice-code/action";
import { appChannel, serverCoord } from "./shared";

const runtime = new ActionRuntime(serverCoord);

const server = serveChannel(runtime, appChannel, {
  storage: storageAdapter,                       // backs identity + TOFU key pins (persistent)
  handlers: [userHandler],                        // your local execution
  carriers: [
    wsAcceptorCarrier({ send: (ws, frame) => ws.send(frame), /* upgrade, attachmentStore */ }),
    httpAcceptorCarrier(),                         // HTTP fallback (same channel)
  ],
});

// Wire the host's events to the server — `receive`/`drop` are the universal forwarding seam:
//   fetch(req)                     => server.fetch(req)
//   webSocketMessage(ws, msg)      => server.receive(ws, msg)
//   webSocketClose/Error(ws)       => server.drop(ws)

serveChannel(runtime, channel, options) returns { acceptors, fetch, receive, drop, pushToClient, broadcast }:

  • fetch — a web-standard handler that does the WS upgrade, the (secure or plain) HTTP action POST, CORS preflight, and a 404 fallback. Forward the host's fetch straight to it.
  • receive(conn, frame) / drop(conn) — the universal connection lifecycle; forward your host's message and close/error events here. Routes to the sole duplex carrier (the common single-WebSocket case); with several duplex carriers, feed each carrier handle directly.
  • pushToClient(target, request, opts?) / broadcast(makeRequest, opts?) — push a server-initiated action to one / every connected client (see Bi-directional).
  • handlers — the acceptor handlers it built, one per duplex carrier (reach for these for per-handler broadcast).

Key options: storage (required only when a carrier is secure — the default), carriers, handlers, channelCases (connection-aware cases — see below), plus securityLevel / link / verifyKeyResolver / defaultTimeout. There's also an optional clientEnv you can almost always skip — see the footnote below.

Footnote — clientEnv (optional, rarely needed). A result or push to a connected client always routes back over the carrier it actually connected on (the acceptor knows each client's exact coordinate from its handshake), so a normal request/response server needs nothing here. The one case it helps is a scoring fallback for returning to a client that is no longer connected (the offline-push case). Omitting it is also what lets a single serveChannel accept clients of different envs at once — e.g. a wallet role and a partner role on one bridge — instead of standing up a second serveChannel per role.

On Cloudflare, @nice-code/action/platform/cloudflare collapses the Durable Object carrier + storage + lifecycle boilerplate into a single serveDurableObject call.

Serving in another environment (Bun, Node, …)? serveChannel's carriers are environment-neutral, and serveHost(runtime, channel, hostAdapter, options) folds a reusable { carriers, storage, onServed } host adapter into it — the same seam serveDurableObject is built on.

Connecting — connectChannel

The connector has one runtime and connectChannels to each acceptor it talks to. One call binds the shared facts — the channel (its codec + routing), the runtime, and one crypto identity over storage — into every transport, so you state them once. It routes the channel's toAcceptor domains out over the transports (first = preferred, rest = fallback) and registers local handlers for the toConnector pushes from onPush — all derived from the channel, no restated lists. It's the exact dial-out dual of serveChannel.

// client.ts
import {
  ActionRuntime,
  ESecurityLevel,
  connectChannel,
  wsCarrier,
  httpCarrier,
} from "@nice-code/action";
import { appChannel, serverCoord, frontendCoord } from "./shared";

export const clientRuntime = new ActionRuntime(frontendCoord);

connectChannel(clientRuntime, appChannel, {
  peer: serverCoord,
  storage,                            // one crypto identity, fanned across every secure transport
  securityLevel: ESecurityLevel.encrypted,
  transports: [
    { carrier: wsCarrier(() => ({ url: "wss://api.example.com/resolve_action/ws" })) }, // secure WS, preferred
    { carrier: httpCarrier(() => ({ url: "https://api.example.com/resolve_action" })), secure: false }, // plain HTTP fallback
  ],
  // onPush: { ... }  // handlers for the channel's toConnector pushes (see below)
});

connectChannel(runtime, channel, options) returns the ChannelConnector so you can later handler.clearTransportCache() (which also closes any live sockets) on teardown — plus the reliable-tier surface: handler.closeReliableStream(action, streamKey?) and handler.reliablePending(...) (see Reliable delivery). Options:

  • peer — the acceptor's RuntimeCoordinate this connection dials.
  • transports — declared by carrier { carrier, secure? }, in preference order; all carry the channel's toAcceptor domains and the manager falls through on failure. secure defaults to true.
  • storage — one backing store for the connection's crypto identity, fanned across every secure transport. Required when any transport is secure; omit for a fully-plain connection. (link shares an existing identity instead.) Must be durable, not just present: the server pins this identity's verify key on first contact (TOFU, keyed envId::perId), so a store that forgets across reloads — a memory adapter — regenerates the key and every load after the first is rejected (identity_pin_mismatch). Browsers: createWebLocalStorageAdapter. Memory adapters are for tests.
  • securityLevel — default level for secure transports (authenticated if omitted; override per transport with securityLevel on the descriptor).
  • onPush — handlers for the channel's toConnector pushes (optional; omit for send-only).
  • defaultTimeout — default per-action timeout.
  • reliableActionTimeout — delivery deadline for .reliable() sends (default 60s); set it to your longest deadline class and timed-abort shorter classes down (see Reliable delivery).
  • onReliableEvent — handle-less observer for reliable-stream events (abandoned with the skipped seq range, overflow at the unacked cap).

Several channels on one connection — connectChannels / serveChannels

When one backend exposes a few independent feature contracts (a core request/reply channel, a lobby push channel, …) you don't need a connection — or a handshake, or a crypto identity — per feature. connectChannels carries several channels over one connection, and serveChannels is its exact acceptor dual. The wire dictionary is the union of the channels' domains, but the runtime still routes each action by its own domain, so the channels stay independent contracts:

// shared.ts — two independent feature channels
export const coreChannel  = defineChannel({ toAcceptor: [userDomain],  toConnector: [] });
export const lobbyChannel = defineChannel({ toAcceptor: [lobbyDomain], toConnector: [lobbyDomain] });

// client.ts — one handshake + one identity carry both
connectChannels(clientRuntime, [coreChannel, lobbyChannel], {
  peer: serverCoord,
  storage,
  transports: [{ carrier: wsCarrier(() => ({ url: wsUrl })) }],
  onPush: { position_update: async (p) => { render(p); return { acknowledged: true }; } },
});

// server.ts — the exact dual (or `serveDurableObject(ctx, [coreChannel, lobbyChannel], …)` on a DO)
serveChannels(serverRuntime, [coreChannel, lobbyChannel], { storage, handlers, carriers });

Because the acceptor registers the set, a client connecting only a subset still works: each connection advertises the channel tags it carries in the handshake and the acceptor composes the matching codec + version for that connection. So an HTTP-only client can connect just coreChannel while a WebSocket client next door connects both — against the one endpoint. (Keep separate connectChannel calls only when you genuinely want a separate identity/transport per backend, e.g. an ephemeral per-resource connection.) combineChannels([...]) is the underlying primitive if you need the merged channel value directly.


Calling actions

Once a runtime is wired, calling an action looks the same on any side — client or server — and regardless of where it resolves (locally or over a carrier):

// any runtime with the domain wired
// Run and get the output directly (throws on a declared/transport error)
const output = await userDomain.action.getUser
  .request({ userId: "u_123" })
  .runToOutput();
console.log(output); // { id: "u_123", name: "Alice" }

// Or keep the RunningAction handle for progress/abort, then await the full result payload
const running = await userDomain.runAction(userDomain.action.getUser.request({ userId: "u_123" }));
const result = await running.waitForResultPayload();
console.log(result.output);

Reliable delivery — .reliable()

By default an action is best-effort: it rides one transport attempt, and a dropped frame or a mid-flight disconnect surfaces as a transport error for you to handle. That's the right default for a request/response call you'll just retry. For a stream of updates that must all arrive, in order, exactly once — game moves, a chat feed, incremental sync — opt the action into the reliable tier:

export const sessionDomain = appRoot.createChildDomain({
  domain: "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* so it also survives a server eviction/restart.
    host_event: actionSchema().input({ schema: v.object({ n: v.number() }) }).reliable({ persist: true }),
  },
});

reliable is the only delivery knob, and persist its only parameter — there is no per-message policy. Both ends read the tier from the shared domain:id, so nothing extra rides the wire beyond a small per-frame sequence number (and a cumulative ack coming back). Best-effort actions are completely unaffected — their frames are byte-identical to before.

What you get, and the one obligation

  • Ordered — the handler never sees frames out of order; an out-of-order frame is buffered until the gap before it fills.
  • At-least-once — an unacked frame is resent when the transport reconnects, so a drop or a reconnect never loses an update.
  • Deduped within a session — a resent frame the server already has is acknowledged but not re-delivered to your handler (for a reply-less fireAndForget().reliable() stream), or is re-run and its reply regenerated (for a reply-carrying one — see the timing note below).

The obligation is the flip side of at-least-once: reliable handlers must be idempotent. On a socket drop + reconnect to a still-running server, the client resends its unacked frames and the server — its dedup state intact — delivers each exactly once (a reply-carrying duplicate may re-run its handler to regenerate its reply; a reply-less one is suppressed). Make handling an update twice a no-op (upsert by an id, ignore an already-applied move) and that path is safe.

The session tier across a server restart (auto re-sync): if the server itself restarts or is evicted mid-stream (a fresh process, or a hibernated Durable Object woken with its in-memory state gone), it has no high-water for the stream. When a client that had already progressed resends a frame past seq 0, the fresh receiver detects the reset and asks the client to re-sync: the client renumbers its still-unacked frames from 0 and resends, so the in-flight tail continues in order rather than stalling on an unfillable gap. What the session tier can't recover is frames the old server already acked (they were pruned from the outbox — they were delivered before the restart). To also survive eviction with the full history intact, use the persisted tier (it persists the high-water; see Persisted dedup across eviction below). A plain socket drop + reconnect to a still-running server is unaffected either way. Use .reliable() for a stream over a resumable connection; add { persist: true } when the server must recover acked history across an eviction.

Contract in one line: ordered + deduped over a live-or-reconnecting session; the in-flight tail re-syncs across a server restart, and the persisted tier also recovers acked history across eviction.

Transport: reliable delivery is a duplex-carrier feature (WebSocket / WebRTC / in-memory — a persistent connection with a binary codec). Over an HTTP exchange (one request, one reply) there's no standing connection to resend on and no reliability slot on the wire, 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 delivered best-effort — no outbox, no seq — with a one-time dev warning per route. For a push stream that must all arrive, build the persisted-log recipe (below); for one confirmed push, await pushToClient(...).waitForResultPayload() and retry. Two backends that each dial the other get reliability both ways — it follows the dialer.

Lifetime: reliability spans drops and reconnects, not reloads — the outbox is memory, and a fresh boot is a fresh stream space (the runtime auto-generates a per-boot insId; that's a load-bearing contract, only set insId yourself if it's unique per boot). A send that must survive a tab close wants the persist-the-input-and-replay pattern: idempotent handlers make the replay safe.

Resolution timing

Opting into reliable changes when a reliable action's promise settles, because the point is to keep retrying rather than fail fast:

  • A reply-carrying reliable action (.output(...)) resolves when its reply arrives — the same as best-effort, except a transport drop no longer rejects it; it's resent and resolves after the reconnect.
  • A reply-less reliable action (.fireAndForget().reliable()) resolves on send (there's no reply to await) — it's handed to the outbox and its promise settles immediately; delivery continues in the background across reconnects.
  • Either way, every reliable send carries a delivery deadline (reliableActionTimeout, default 60s, settable on connectChannel): if the peer hasn't acknowledged the frame by then — e.g. it's simply unreachable — the frame is abandoned. A pending (reply-carrying) action aborts with a reliable_delivery_abandoned transport error; an already-settled reply-less send surfaces through a one-time console warning (its caller was told "success on send" — that's the reply-less contract). So runToOutput() on a reliable action always terminates, and background delivery never retries forever.

Abandoned frames — the stream continues

An abandoned frame (delivery deadline expired, or the action was explicitly aborted while unacked) is not allowed to wedge its stream: the outbox drops it and every older still-unacked frame with it (they could no longer be delivered in order; each fails loudly the same way), then tells the receiver via a small control frame to skip past the abandoned sequence numbers. The receiver advances, delivers anything it had buffered 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 reply-less 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:
const running = sessionDomain.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)

// Per connection — no handles (app-level monitoring):
connectChannel(runtime, channel, {
  ...,
  onReliableEvent: (event) => {
    if (event.type === "abandoned") resendFromRecorder(event.fromSeq, event.toSeq); // "skipped seqs N..M"
    if (event.type === "overflow") shedCosmeticTraffic();
  },
});

For a best-effort action waitForAck() mirrors the action itself, so generic code composes without branching on the tier. Observers can also listen for the reliability update on running.addUpdateListeners (fires when acked flips — the devtools chip's ).

The failure surface of a fire-and-forget reliable send, in one line each: reliable_outbox_overflow rejects at the call; a connect failure does not reject (background retry until the deadline); deadline abandonment happens after the promise resolved — surfaced via waitForAck / the abandoned event (+ one console warning per route). A rejected settlement means the sender stopped trying, not that the frame provably never arrived (the ack itself may have been lost) — idempotent handlers cover that.

Handler-side facts — action.context.reliability

The executing handler of a reliable action can read the receiver-side facts for the frame it's handling — the free idempotency key for exactly-once effects:

host_move: (action, conn) => {
  const rel = action.context.reliability; // { seq, streamKey?, redelivered } | undefined (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),
  );
},

Local-only (never serialized; a reply is byte-identical with or without it), also mirrored as conn.reliability in channelCases. Tier nuance: redelivered: true appears only on reply-carrying re-runs — a fire-and-forget duplicate is suppressed before dispatch and never reaches the handler at all.

Backpressure — see it coming, and the overflow cliff

The client's outbox holds unacked frames until they're acknowledged. If a peer stays dead, that can't grow without bound: each stream has a cap (default 1024 unacked). When a stream first exceeds it, the offending reliable action fails with a reliable_outbox_overflow transport error instead of the outbox growing forever — 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:

connector.reliablePending();                                        // total unacked, all streams
const p = connector.reliablePending(sessionDomain.action.host_move, runId);
// p.unackedCount / p.oldestUnackedAgeMs / p.maxUnackedPerStream  →  headroom = max - count

Persisted dedup across eviction — persist

For a backend that can be evicted between messages (a Cloudflare Durable Object hibernating, a redeploy), the session-tier dedup state resets on wake — the session tier then re-syncs the in-flight tail (see the boundary note above), but frames the old server already acked are gone. The persisted tier persists the per-stream high-water so dedup + ordering survive the eviction outright: the stream continues exactly where it left off, and a replayed frame (even one already acked) is deduped rather than redelivered. On Cloudflare, back it with the DO's SQLite in one line:

import { serveDurableObject, cloudflareReliableLog } from "@nice-code/action/platform/cloudflare";

const server = serveDurableObject(this.ctx, sessionChannel, {
  runtime,
  clientEnv,
  reliableStore: cloudflareReliableLog(this.ctx), // persisted-tier streams dedup across eviction
  channelCases: { /* … */ },
});

cloudflareReliableLog needs a SQLite-backed Durable Object class (enable it in wrangler). Omit reliableStore and persisted actions degrade gracefully to the in-memory (session) behavior. The store is a generic ReliableLog over a small synchronous port, so a non-Cloudflare backend can supply its own — see @nice-code/action/advanced.

Independent streams of one action — streamKey

By default a reliable action is one ordered stream per (peer, action): every frame shares one sequence. That's right when the action is the stream. When one connection multiplexes several independent logical streams through the same action — one per game room, per entity, per chat channel — pass a streamKey so each is its own ordered, deduped, independently-resending stream. Per-key ordering is preserved; a gap in one key never head-of-line-blocks another, and one key's backpressure can't overflow the others.

// One backend connection, many rooms, one action the client sends — each room id is its own stream.
function sendRoomEvent(roomId: string, event: TRoomEvent) {
  return room.action.host_event.request(event).runToOutput({ streamKey: roomId });
}
sendRoomEvent("alpha", a); // stream (peer, room:host_event#alpha)
sendRoomEvent("beta", b);  // stream (peer, room:host_event#beta) — separate seq space, isolated

streamKey is a per-request run option (.run({ streamKey }) / .runToOutput({ streamKey })). Omitting it is byte-for-byte identical to today's single stream. A keyed frame is a distinct wire length, so a peer that predates keyed streams rejects it (a one-time dev warning surfaces the version skew) — keyed streams need both ends on a version that supports them.

Because keys are client-chosen strings, the server caps distinct keyed streams per client (maxKeyedStreamsPerClient, default 256) — past the cap, a new key's frames are served best-effort with a one-time warning rather than minting more receiver state. A closed key (below) stops counting; an idle-but-unclosed one keeps counting.

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:

const connector = connectChannel(runtime, channel, { ... });
// at run teardown:
connector.closeReliableStream(sessionDomain.action.host_move, runId);

One call: every still-unacked 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 maxKeyedStreamsPerClient slot. It's synchronous on the sender's state (nothing can resend after it returns), closing a never-used stream is a no-op, and a closed key can be reused safely.

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 DO instances behind one peer coordinate, selected by a mutable dial URL (/session/ws?runId=XidFromName(X)). If the dial state changes while unacked frames sit in the outbox, retries follow the connection — into a different physical instance.

Two lines close the window: (1) closeReliableStream(action, runId) at teardown, before changing the dial state; (2) defense-in-depth, stamp the entity id in reliable payloads and have the instance drop foreign ones. The drop is terminal by contract: acks track inbox acceptance, not handler outcome — a handler that discards a frame still settles it, so it never retries.

What reliable delivery deliberately does not do

To keep the guarantee knob-free and the implementation maintainable, the following are out of scope by design — don't file them as bugs. Each has a straightforward composition on the levers above (the full worked recipes live in the reliable-delivery docs page):

  • No message prioritiesstreamKey lanes ("critical" / "bulk") are independently ordered, independently backpressured; read pressure per lane with reliablePending.
  • No per-message TTLs / deadlines — set reliableActionTimeout to your longest class, then timed-abort the short class down (setTimeout(() => running.abort(), ttl)); an abort sweeps older unacked frames on the same stream with it, so give differing TTL classes their own streamKey lane. waitForAck stands the timer down early.
  • No exactly-once effect — the wire is exactly-once delivery within a session; the durable effect is your idempotent handler's job, and action.context.reliability hands it the free (seq, streamKey) key (see Handler-side facts).
  • No causal ordering across different actions — send one envelope action with a discriminated-union input (v.variant("kind", [vStart, vEvents, vEnd])) + streamKey per entity: one totally-ordered stream, the handler is a switch (input.kind). If you're carrying arrival-order tolerance code across several actions, collapse to this instead.
  • No reliable fan-out / broadcastbroadcast(...) stays best-effort; reliability is point-to-point on the originating connection. For a server→client stream that must all arrive, assemble it from the persisted log (recipe below) rather than expecting broadcast to buffer for absent clients.

Recipe: reliable server→client push (built on the persisted log, no new API)

Reliable delivery is point-to-point over a live connection; to hold updates for a client that's currently offline (or that must never miss one) and flush them on reconnect, compose the persisted ReliableLog yourself — append each push to a per-client log (it assigns the seq; carry it in the payload so the client can dedup), and on reconnect replay the gap-free prefix the client hasn't acked:

import { cloudflareReliableLog } from "@nice-code/action/platform/cloudflare";

// One log per recipient (namespaced by client id), living in the DO's SQLite.
const outboxFor = (clientId: string) => cloudflareReliableLog(this.ctx, `push_${clientId}`);

// Enqueue a push while the client may be offline — persisted, not lost. `append` assigns hw+1.
function enqueuePush(clientId: string, update: unknown) {
  const { seq } = outboxFor(clientId).append(streamIdFor(clientId), update);
  server.pushToClient(runtime, clientCoord, pushDomain.action.update.request({ seq, update })); // live path, best-effort
}

// On reconnect, replay everything the client hasn't confirmed, in order…
function flushOnReconnect(clientId: string) {
  for (const update of outboxFor(clientId).contiguousPrefix(streamIdFor(clientId))) {
    server.pushToClient(runtime, clientCoord, pushDomain.action.update.request(update));
  }
}

Client-side, dedup/order with your own ReliableInbox (exported from @nice-code/action/advanced) keyed by the payload's seq, and confirm via a normal action; the server then prunes with the store's deleteThrough(streamId, upTo) — or set keepDelivered retention on the log and skip explicit pruning. contiguousPrefix() is the safe-to-replay run; when to ack and prune stays your app's policy, which is exactly why this is a recipe rather than a core feature.


Bi-directional communication (acceptor ⇆ connector)

Over a duplex carrier (a WebSocket) the acceptor can call the connector back on the same open connection — no second channel, no polling. The shape:

  1. Declare the push domain in the channel's toConnector (shared by both ends).
  2. On the connector, handle those pushes with connectChannel's onPush — keyed by action id, typed from the channel. The reply routes straight back over the same socket.
  3. On the acceptor, use server.pushToClient(...) (one client) or a handler's broadcast(...) (everyone). The originating client is available on any inbound action as action.context.originClient.

Shared channel

// shared.ts — bidirectional: client sends `start_feed`; server pushes `position_update` back.
export const lobbyDomain = appRoot.createChildDomain({
  domain: "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: [userDomain, lobbyDomain],  // start_feed flows here
  toConnector: [lobbyDomain],             // position_update pushes back here
});

Connector side — handle pushes with onPush

// client.ts
connectChannel(clientRuntime, appChannel, {
  peer: serverCoord,
  storage,
  transports: [{ carrier: wsCarrier(() => ({ url: wsUrl })) }, { carrier: httpCarrier(() => ({ url: httpUrl })), secure: false }],
  onPush: {
    // Keyed by the toConnector action id; input + output typed from the channel.
    position_update: async ({ player, x, y }) => {
      renderPlayer(player, x, y);
      return { acknowledged: true };
    },
  },
});

Acceptor side — push back

The local handler reads action.context.originClient to know who asked, then pushes to them:

// server.ts
const lobbyHandler = createLocalHandler().forDomainActionCases(lobbyDomain, {
  start_feed: async (action) => {
    let delivered = 0;
    for (let seq = 0; seq < action.input.count; seq++) {
      const running = server.pushToClient(
        action.context.originClient,                // the requesting client's coordinate
        lobbyDomain.action.position_update.request({ player: "alice", x: 1, y: 2 }),
      );
      await running.waitForResultPayload(); // await the client's ack like any action
      delivered++;
    }
    return { delivered };
  },
});

Fan one out to everyone on the sole duplex carrier with server.broadcast (fire-and-forget; skip the origin or filter by connection):

server.broadcast(
  () => lobbyDomain.action.position_update.request({ player: "system", x: 0, y: 0 }),
  { except: originWs, where: (ws) => server.connections.get(ws)?.role === "player" },
);

When a handler needs the originating connection itself (to register it in a room, track per-socket state, etc.) rather than just the client coordinate, pass channelCases to serveChannel / serveDurableObject — each case receives the request and an IConnectionContext (conn.state / conn.setState / conn.broadcast({ exceptSelf }) / conn.pushBack / conn.connection). With several duplex carriers (no sole acceptor), reach for a specific server.acceptors[i].broadcast(...) and acceptChannelConnections(handler, channel, { ... }) directly (see Lower-level building blocks).


Cloudflare Durable Objects

@nice-code/action/platform/cloudflare collapses the entire DO transport stack — the hibernatable secure WebSocket, an HTTP fallback, the DO-storage crypto identity, and the ping/pong keepalive — into a single serveDurableObject call, leaving the DO to forward its four socket lifecycle methods. The core library stays platform-agnostic — nothing here is reachable from the main entry.

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 runtime = new ActionRuntime(serverCoord);

    // Hibernatable secure WS + plain HTTP fallback + DO-storage crypto identity + keepalive, all folded in.
    this._server = serveDurableObject(this.ctx, appChannel, {
      runtime,
      keyPrefix: "ws:",
      handlers: [userHandler],
    });
    return this._server;
  }

  async fetch(request: Request): Promise<Response> {
    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); }
}

serveDurableObject(ctx, channel, options) takes the same serveChannel surface (handlers, channelCases, connectionState, the optional clientEnv, …) plus the host knobs:

  • runtime — this DO's runtime.
  • keyPrefix — namespace for the DO-storage crypto-identity keys.
  • httpFallback"plain" (default), "secure" (handshake-protected exchange sharing the WS identity), or false (WebSocket only).
  • secure — whether the WebSocket itself runs the handshake (default true). Trusted-client verify-key pins (TOFU) default to DO-storage-backed — they survive eviction and restarts, no configuration needed; pass verifyKeyResolver only to override the trust policy itself.

For finer control the lower-level pieces are still exported: cloudflareDurableObjectHost(ctx, opts) builds just the { carriers, storage, onServed } host adapter (hand it to serveHost), and durableObjectWsCarrier / durableObjectStorage build the individual carrier / storage adapter.

The Worker front door — serveWorker, actionRouter, forwardToDurableObject

A real app's edge is a Worker that serves some channels itself and routes the rest on to the right Durable Object. Three helpers cover the whole front door — and the request body stays opaque to the edge the whole way (security terminates at the final runtime, never at the Worker).

Serve a channel from the Worker itself — serveWorker

serveWorker is the stateless dual of serveDurableObject. The secure exchange's handshake + session ride sealed tokens, so any isolate serves it with no Durable Object. One call folds in everything a hand-rolled stateless endpoint repeats: the crypto identity (with one-time provisioning for KV's eventual consistency), the in-memory TOFU default, the HTTP-exchange carrier, and the lazy build the Workers global scope forces — over a StorageAdapter you supply (e.g. kvStorageAdapter over a KV namespace). runtime and handlers are factories — the Workers runtime forbids generating random ids / doing I/O at module scope, so serveWorker builds and memoizes them on the first request.

This is the whole "bridge create" use-case: a verified action that provisions a fresh Durable Object server-side and hands back its stable id, served statelessly with no creator DO of its own:

// worker.ts
import { ActionRuntime } from "@nice-code/action";
import { serveWorker, kvStorageAdapter } from "@nice-code/action/platform/cloudflare";

// create_bridge → mint a fresh bridge DO, seed it, return its id (a factory; constructing a handler
// generates a random id, which the Workers runtime forbids at module scope).
const createHandler = () =>
  bridgeCreateDomain.wrapAsLocalHandler({
    create_bridge: async ({ label }) => {
      const id = env.BRIDGE.newUniqueId();               // a fresh, unique bridge DO…
      await env.BRIDGE.get(id).initBridge({ label });    // …seeded before the client ever connects…
      return { bridgeId: id.toString() };                // …and addressed by this stable id.
    },
  });

const serveCreate = serveWorker(bridgeCreateChannel, {
  runtime: () => new ActionRuntime(creatorCoord),        // a factory — built lazily on first request
  storage: kvStorageAdapter({ kvNamespace: env.KV, keyPrefix: "bridge-create:" }),
  handlers: () => [createHandler()],
});
// it returns an { fetch }, so route it from any framework — or an actionRouter (below):
//   honoApi.on(["POST", "OPTIONS"], "/bridge/create/secure", (c) => serveCreate.fetch(c.req.raw));

Serve several channels on one stateless endpoint with serveWorkers([a, b], …) — the stateless dual of serveChannels (and the multi-channel form of serveWorker). It composes the matching dictionary version per connection from each client's advertised subset, so a client connecting just one of the channels via connectChannel is accepted; the connect-side dual is connectChannels. (Don't reach for serveWorker(combineChannels([a, b]), …) — that serves one fixed version over the whole union and rejects every single-channel client with a dictionary-version mismatch.) For a plain (no-handshake) endpoint pass secure: false and no storage.

Route the front door — actionRouter + forwardToDurableObject

Once a bridge has an id, the client talks to its specific Durable Object. The Worker routes by the URL — a secure exchange body is opaque (handshake / encrypted frames), so the destination can't come from inspecting the body — and forwardToDurableObject passes the request straight through, keeping security end-to-end between the client and the DO. It answers the CORS OPTIONS preflight at the edge, so a per-id DO is never woken (or billed) just to reply to a preflight.

actionRouter is the optional, framework-free multiplexer for a raw export default { fetch }: every entry — a serveWorker, a forwardToDurableObject, even a nested router — is just an { fetch }, so they compose by mounting:

import { actionRouter } from "@nice-code/action";
import { forwardToDurableObject } from "@nice-code/action/platform/cloudflare";

const router = actionRouter()
  .route("/bridge/create/*", serveCreate)                          // stateless, served on the Worker
  .route("/bridge/:id/*", forwardToDurableObject(({ params }) =>   // per-id, E2E client ↔ DO
     env.BRIDGE.get(env.BRIDGE.idFromString(params.id))))
  .otherwise(restOfApp);                                           // anything unmatched → your existing app

export default { fetch: (request: Request) => router.fetch(request) };

Inside the bridge DO, serve the exchange (and the WS upgrade) exactly as the DO above — make the HTTP fallback secure so the whole path is handshake-protected:

this._server = serveDurableObject(this.ctx, bridgeChannel, { runtime, httpFallback: "secure" });
// fetch(request) => this._server.fetch(request)

The matching connector points its httpCarrier (or wsCarrier) at /bridge/:id/secure (or /ws); pickStub may be async (e.g. to resolve an id first) and receives { request, url, params } (the matched path params when routed through actionRouter). forwardToDurableObject is CF sugar over the generic, platform-neutral forwardTo(pickTarget), which forwards to any { fetch } — a DO stub, a service binding (() => env.OTHER_WORKER), or an external server (() => ({ fetch: (req) => fetch(UPSTREAM, req) }), with an optional rewrite to strip a path prefix).

This routes to a per-id durable instance (one keeping a live WebSocket or per-instance state). If a secure endpoint needs neither, it's a serveWorker (above) — or, framework-free, a bare serveChannel over [httpAcceptorCarrier()] (see Serving without a Durable Object).

Need to handle the exchange yourself instead of handing the endpoint to serveChannel? The secure exchange acceptor and its plain {k:"act",w} envelope codec are exported from the main entry: ExchangeAcceptor (drive the handshake + token sessions + decrypt over your own fetch), and encodeExchange / decodeExchangeRequest / decodeExchangeReply (read/write the plain envelope when you must inspect or rewrite the wire before running it). Secure bodies are only decodable through an ExchangeAcceptor session — route-before-decode by URL, as above.

Per-connection state + broadcast (stateful DOs)

A presence/room DO that tracks who's on each socket and fans messages out adds two knobs — connectionState (typed per-socket app state, co-stored with the routing binding so both survive a wake) and channelCases. Each case gets an IConnectionContext as its second argument — the originating connection plus everything a case reaches for, so it never threads ws through server.connections / server.broadcast by hand:

const server = serveDurableObject(this.ctx, lobbyChannel, {
  runtime,
  keyPrefix: "lobby-ws:",
  connectionState: { schema: vs_player },
  channelCases: {
    join: (action, conn) => {
      conn.setState(action.input); // typed by the schema; co-stored with the binding
      conn.broadcast(() => lobbyPush.action.player_joined.request(action.input), { exceptSelf: true });
      return { players: this.roster() };
    },
    move: (action, conn) => {
      const player = conn.state;          // this socket's typed app state (or null)
      if (!player) return;                // a move before join is dropped
      conn.broadcast(() => lobbyPush.action.player_moved.request({ id: player.id, ...action.input }), {
        exceptSelf: true,
      });
    },
  },
});

// Rebuild in-memory state from surviving sockets after a wake (binding replay is automatic):
for (const [, player] of server.connections.entries()) this.players.set(player.id, player);

The IConnectionContext (conn) gives a case: conn.state / conn.setState(x) / conn.clearState() (the typed app state), conn.broadcast(makeRequest, { exceptSelf }), conn.pushBack(request) (push down this same socket), conn.connection (the raw socket, null on the HTTP-exchange path), and conn.reliability (the frame's receiver-side reliable facts — same value as action.context.reliability). Passing connectionState narrows the return so server.connections is non-optional. Because the WS carrier persists each connection's binding on bind and replays it on construction, results and pushes still route to the right socket after the object wakes from eviction.


Security levels

ESecurityLevel (used by connectChannel and serveChannel):

  • none — identity self-asserted, no handshake. Fastest; fine for dev/trusted networks.
  • authenticated — the handshake verifies identity (sign/verify + trust-on-first-use key pin); frames are unencrypted.
  • encrypted — authenticated plus every frame AES-GCM encrypted with the handshake-derived key.

The connector picks its level; an acceptor by default negotiates any of the three per connection, so one endpoint serves all three. The client identifies itself with its runtime coordinate + a persisted crypto identity; the server pins client keys trust-on-first-use. Persisting the server's binding (via the hibernatable carrier) lets an authenticated/encrypted connection resume after eviction without re-handshaking.

The whole thing rides one channel: the same secure { carrier: wsCarrier(...) } transport works at any level, and httpCarrier runs the same secure session over HTTP (handshake → token → encrypted frames), with the request/reply correlation provided for free by the HTTP transaction. Pair a secure WS with a plain HTTP fallback by giving the acceptor a httpAcceptorCarrier({ secure: false }).


Serving without a Durable Object — stateless secure HTTP exchange

The secure HTTP exchange is stateless: its handshake and session ride sealed tokens (sealed under the server's own crypto identity), so any request can be served by any isolate. You don't need a Durable Object — or any sticky instance — just to keep a handshake's two POSTs together. A single secure-exchange acceptor is therefore just serveChannel over an HTTP carrier, on any backend (a plain Worker route, a Node handler):

// server.ts — a stateless secure-exchange endpoint (no Durable Object)
const server = serveChannel(runtime, appChannel, {
  storage,                          // backs the (read-mostly) crypto identity; sessions never touch it
  carriers: [httpAcceptorCarrier()],
  handlers: [appHandler],
});
// route any action POST straight to it:
//   app.post("/action", (req) => server.fetch(req))

The only thing the exchange reads from storage is the server's crypto identity. On a strongly consistent store (Node memory, a DO's storage, D1) the default lazy identity is fork-safe. On an eventually consistent store (Cloudflare KV) provision the identity once, up front, so a transient read-miss can never mint a second identity (which trust-on-first-use-pinned clients would then reject):

import { ClientCryptoKeyLink } from "@nice-code/util";

// `required` never mints on the request path — an unprovisioned link throws IdentityNotProvisionedError
// instead of forking a fresh identity on a storage hiccup.
const link = new ClientCryptoKeyLink({ storageAdapter: storage, identityMode: "required" });
await link.provisionIdentity();     // once, out-of-band (a deploy step / first boot)

const server = serveChannel(runtime, appChannel, {
  storage, link,                    // pass the provisioned, required-mode identity
  carriers: [httpAcceptorCarrier()],
  handlers: [appHandler],
});

On Cloudflare Workers, build the server lazily on the first request (memoized), not at module scope — the runtime forbids generating random ids and doing I/O in the global scope. A live WebSocket still wants a Durable Object (a hibernatable socket needs a stable instance); this statelessness is for the request/reply HTTP exchange.

serveWorker is exactly this, folded into one call: it does the lazy build, the in-memory TOFU default, and the one-time KV identity provisioning for you. Reach for the hand-rolled serveChannel form above only when you're not on the Cloudflare adapter.


Multiple carriers on one runtime

serveChannel accepts any number of duplex carriers (e.g. WebSocket + WebRTC) plus at most one exchange carrier. They all share one crypto identity and one runtime, and each result/push routes back over the carrier its client actually connected on (connection-aware return routing). With several duplex carriers, server.receive/server.drop throw (they can't pick a carrier) — feed each carrier handle's own receive/drop directly.

const ws  = wsAcceptorCarrier({ send: wsSend, upgrade, attachmentStore });
const rtc = rtcCarrier(/* ... */);
const server = serveChannel(runtime, appChannel, {
  storage,
  carriers: [ws, rtc, httpAcceptorCarrier()],
  handlers: [appHandler],
});
// route each host's events to the matching handle:
//   onWsMessage(c, m)  => ws.receive(c, m)
//   onRtcMessage(c, m) => rtc.receive(c, m)

On the connector side, list several transports in connectChannel's transports (preference order) to get automatic fallback across carriers (e.g. secure WS, then plain HTTP).

Gating a transport until a precondition holds — available

A transport can declare an available predicate to opt out of selection until a runtime precondition is met. While it returns false, the manager treats that transport as unsupported and falls through to the next one in preference order — without opening its carrier or even computing its cache key. The predicate is re-evaluated per action dispatch, so the transport switches on the moment the precondition holds, with no reconnect.

// client.ts — prefer the secure socket, but only once a session id exists; fall back to HTTP meanwhile.
connectChannel(runtime, appChannel, {
  peer: serverCoord,
  storage,
  transports: [
    {
      carrier: wsCarrier(() => ({ url: `${url}/ws` }), {
        // Only ever called once `available` passes, so no need for a placeholder cache key.
        getTransportCacheKey: () => [sessionId],
      }),
      available: () => sessionId != null, // ws preferred; HTTP serves while ws is gated off
    },
    { carrier: httpCarrier(() => ({ url: httpUrl })), secure: false },
  ],
});

Flipping available back to false only skips selection — it does not tear down a live carrier (socket closure stays owned by the carrier's own disconnect handling), and a still-cached connection is reused with no reconnect once it becomes available again. Omit available for an always-available transport (the default).


React Query integration

import { useActionQuery, useActionMutation } from "@nice-code/action/react-query";

// Query
function UserProfile({ userId }: { userId: string }) {
  const { data } = useActionQuery(
    userDomain.action.getUser,
    { userId },
    { queryKey: ["user", userId] },
  );
  return <div>{data?.name}</div>;
}

// Mutation
function RenameUser() {
  const { mutate } = useActionMutation(userDomain.action.updateName);
  return <button onClick={() => mutate({ userId: "u_1", name: "Bob" })}>Rename</button>;
}

Devtools

Browser panel — @nice-code/action/devtools/browser

A panel showing every action run: status, timing, input/output, routing, errors, and call stacks. It renders in the standalone devtools window, not inside your app — your app builds the core and streams it.

import { ActionDevtoolsCore, actionBridgeScope } from "@nice-code/action/devtools/browser";

const devtoolsCore = new ActionDevtoolsCore();
devtoolsCore.attachToDomain(appRoot);

host.contribute(actionBridgeScope(devtoolsCore));

Server devtools — @nice-code/action/devtools/server

A backend plugs its action domain into the shared server devtools host as a neutral scope. The default console sink logs action lifecycle (started / success / error / aborted) with timings — pretty lines or newline-delimited JSON.

import { createServerDevtoolsHost } from "@nice-code/devtools-core";
import { actionDomainScope } from "@nice-code/action/devtools/server";

createServerDevtoolsHost({ name: "api", format: "json" })
  .contribute(actionDomainScope(appRoot, { logPayloads: false }))
  .start();

Add sinks: ["console", "relay"] + a relayUrl and the same host streams live to a standalone devtools window, alongside any realm engines contributed with realmEngineScope. Inert when NODE_ENV === "production".


RuntimeCoordinate

Identifies a runtime environment and is used to route actions to the right handler.

RuntimeCoordinate.env("backend")                                // named env
RuntimeCoordinate.env("backend").specify({ perId: "worker-1" }) // env + instance
RuntimeCoordinate.env("backend").withPersistentId(id.toString()) // env + persistent instance id
RuntimeCoordinate.unknown                                        // unspecified

withPersistentId is a trust decision, not just a name. On a secure connection the server's trust-on-first-use pin is keyed by envId::perId — the first verify key seen for that id is pinned, a different key is rejected forever after (identity_pin_mismatch). So the connection's storage (which holds the verify key) must persist at least as long as the persistent id does: an id kept in localStorage + an identity in a memory adapter works exactly once, then bricks on reload. connectChannel warns when it sees this pairing.


Error handling in actions

Every action resolves to a deterministic outcome — it never rejects with a raw throw. Use runToResult() and branch on expected (was this error one the action declared via .throws()?):

const result = await userDomain.action.getUser.request({ userId }).runToResult();

if (result.ok) {
  use(result.output);
} else if (result.expected) {
  // result.error is the declared union — fully typed
  matchFirst(result.error, {
    not_found: ({ userId }) => show404(userId),
    forbidden: () => showForbidden(),
  });
} else {
  // Not declared by this action. Refine with the error's own flag if you care:
  if (result.error.isUnhandled) alertOncall(result.error); // foreign throw / bug / infra
  else report(result.error);                                // a real NiceError you didn't .throws()
}

Two cleanly-separated axes:

| Axis | Member | Question | |---|---|---| | Relational (on the result) | result.expected | Did the action declare this error via .throws()? | | Intrinsic (on the error) | error.isUnhandled | Was it a wrapped foreign throw we never accounted for? |

Throw-style with a typed guard

If you prefer runToOutput() (which rethrows on failure), narrow caught errors with isExpectedError:

import { castNiceError, matchFirst } from "@nice-code/error";

try {
  const output = await userDomain.action.getUser.request({ userId }).runToOutput();
} catch (e) {
  if (userDomain.action.getUser.isExpectedError(e)) {
    matchFirst(e, { not_found: ({ userId }) => show404(userId), forbidden: () => showForbidden() });
  } else {
    report(castNiceError(e).toStructuredLog());
  }
}

Lower-level building blocks

connectChannel and serveChannel are the supported entry points — they bind the channel, runtime, and crypto identity for you. The pieces below are what they're built on; reach for them only for the rare routing that isn't a single channel.

Most of these live under the @nice-code/action/advanced subpath, not the main entry — the default @nice-code/action export stays focused on the high-level API (acceptChannel / acceptChannelConnections and the carriers remain on the main entry). Import the raw handler classes (ChannelAcceptor, ChannelConnector, …), the transport classes/codec, and the handshake primitives from @nice-code/action/advanced.

  • Carriers vs transports. A carrier (wsCarrier, httpCarrier, inMemoryCarrier, rtcCarrier) is raw byte movement; a transport wraps one with a security policy. You name carriers in connectChannel's transports (the secure flag picks the policy) and in serveChannel's carriers; the transport wrapping happens internally, so there's no separate transport-builder to call.

  • acceptChannel(runtime, channel, { storage, send, clientEnv?, ... }) — build the se