Skip to content

Mental Model

The four ideas behind nice-action: runtimes, peers, carriers, and channels.

@nice-code/action lets you call a function that lives somewhere else, such as on a server or in a worker, as if it were a normal local function, with all the types intact. It doesn’t care how the two sides are connected. It also works in both directions: over a live connection, the side that’s listening can call back to the side that connected.

A runtime connects to a peer over a carrier, and a channel describes, once, which calls flow in each direction.

All of it behaves the same whichever carrier you use. Only two things ever differ:

  • Who started the connection. The connector dials in. The acceptor listens, and can call back.
  • The shape of the connection. A duplex connection is a two-way line like a WebSocket, where either side can speak at any time. An exchange is one request and one reply, like a normal HTTP call.
ConceptWhat it is
ActionDomainA named group of related actions. Think of it as one section of your API.
ActionSchemaOne action’s input type, output type, and the errors it can throw.
ActionRuntimeOne per app. It’s your app’s identity, and it routes incoming calls to your code.
ChannelLists which actions go from connector to acceptor (toAcceptor) and back (toConnector). Both sides build it from the same definition, so they always agree.
CarrierThe actual way bytes travel. On the dialing side: wsCarrier, httpCarrier, inMemoryCarrier, rtcCarrier. On the listening side: wsAcceptorCarrier, httpAcceptorCarrier.
TransportA carrier with its connection settings. You never build one by hand; connectChannel / serveChannel do it for you.
RuntimeCoordinateA label for an environment (frontend, backend, worker…). It’s how calls find the right destination.

A call leaves your code as a typed request and arrives on the other side as a typed handler argument. Everything in between is set up for you by the channel:

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. Both sides import the same definition, so the shape the connector encodes is exactly the shape the acceptor decodes and validates. There’s no second copy of the types to drift out of sync. Everything below the channel can change without touching an action call. Swap a WebSocket for HTTP, change the handshake, or turn on encryption, and your request and handler signatures stay identical.

Each dialing app has one global ActionRuntime that represents it to everyone it talks to. Not one per feature or per server you connect to. Adding handlers or calling connectChannel(...) registers that runtime for untargeted action dispatch automatically.

A listening host normally passes identity to serveChannel, which creates and exposes an instance-scoped server.runtime. Instance runtimes never enter global dispatch, even if they also dial another service. Run their local work explicitly with server.runtime.run(request). This distinction prevents two server instances in one isolate from competing for an untargeted call. If more than one global runtime can handle a call, dispatch fails with ambiguous_action_runtime; target one explicitly rather than relying on construction order.

Several sockets to the same peer need names. You still have one runtime. But sometimes one app opens two duplex connections to one backend, say a store socket for the resource on screen, and a jobs socket that stays closed while no screen is watching it. The server binds each connection by the identity it presents, so it cannot tell two identical ones apart. Give each connection its own connectionId and they become separately addressable:

connectChannel(runtime, storeChannel, { peer: backend, connectionId: "store", … });
connectChannel(runtime, jobsChannel, { peer: backend, connectionId: "jobs", … });

The runtime and its identity stay shared, and so does the key the server pinned for that identity on first contact (trust on first use). connectionId only tells the links apart. See naming a second connection to the same peer. If the two channels can share one socket, connectChannels is the simpler answer: one connection carries both.

connectChannel dials out and serveChannel listens. Each has a plural form that takes a list, connectChannels(runtime, [a, b], options) and serveChannels, for putting several channels on one connection. They cover almost everything you’ll do: they take your channel and runtime, and set up the lower-level carrier and transport pieces for you.

A typical app is three files:

  • shared.ts is imported by both sides. It holds the domains, the channel, and the runtime coordinates that name each side.
  • server.ts is the listening side: your handlers and serveChannel.
  • client.ts is the dialing side: connectChannel and your action calls.

The pages that follow label each code block with the file it belongs in.

Coordinates belong in shared code. A RuntimeCoordinate like RuntimeCoordinate.env("backend") should never live 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. Its own runtimeCoordinates.ts is the natural home, since coordinates are independent of how you channel your actions. Define each side’s coordinate once, in shared, and import it on both sides.