Skip to content

Mental Model

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.

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 connectionduplex (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).
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. In between, everything is plumbing the channel set up for you:

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.

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.

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.

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.