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.
In one sentence
Section titled “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
Section titled “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
Section titled “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:
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
Section titled “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
Section titled “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
Section titled “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
RuntimeCoordinatelikeRuntimeCoordinate.env("backend")always belongs in shared code — its ownruntimeCoordinates.tsis the natural home, since it’s orthogonal to how you channel your actions — never inserver.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.