@nice-code/action 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,582 +1,637 @@
1
- # @nice-code/action
2
-
3
- Typed, transport-agnostic action system for calling functions across client/server boundaries with full TypeScript inference.
4
-
5
- ## Install
6
-
7
- ```bash
8
- bun add @nice-code/action
9
- ```
10
-
11
- Peer deps: `valibot` (or any [Standard Schema](https://github.com/standard-schema/standard-schema) library), `@tanstack/react-query` (for `@nice-code/action/react-query`).
12
-
13
- ## Core concepts
14
-
15
- - **ActionDomain** — a named group of typed actions (like an API surface)
16
- - **ActionSchema** — input/output schema + declared error types for one action
17
- - **ActionRuntime** — one per client; processes incoming requests and dispatches them to handlers. Wire each backend it talks to with `runtime.connectTo(...)`
18
- - **ActionLocalHandler** — executes actions in the current process
19
- - **ActionExternalClientHandler** forwards/receives actions over HTTP or WebSocket to another client
20
- - **ActionServerHandler** server-side handler for backends accepting many client connections over one open channel; ferries results back and can push/broadcast server-initiated actions to clients
21
- - **RuntimeCoordinate** identifies an environment (frontend, backend, worker…) and is how actions are routed
22
-
23
- > **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 call `runtime.connectTo(peerCoordinate, { transports, domains })` once per peer to wire the outbound channels. This keeps one identity (and one crypto identity, for secure channels) per client and avoids routing ambiguity.
24
-
25
- ---
26
-
27
- ## Defining actions
28
-
29
- ### 1. Create a root domain (shared between client and server)
30
-
31
- ```ts
32
- import { createActionRootDomain, actionSchema } from "@nice-code/action";
33
- import * as v from "valibot";
34
-
35
- // Root domain no actions, just a namespace anchor
36
- export const appRoot = createActionRootDomain({ domain: "app_root" });
37
-
38
- // Child domain with actions
39
- export const userDomain = appRoot.createChildDomain({
40
- domain: "user",
41
- actions: {
42
- getUser: actionSchema()
43
- .input({ schema: v.object({ userId: v.string() }) })
44
- .output({ schema: v.object({ id: v.string(), name: v.string() }) })
45
- .throws(err_user, ["not_found"]), // from @nice-code/error
46
-
47
- updateName: actionSchema()
48
- .input({ schema: v.object({ userId: v.string(), name: v.string() }) })
49
- .output({ schema: v.object({ success: v.boolean() }) }),
50
- },
51
- });
52
- ```
53
-
54
- ### 2. Serialization for non-JSON-native types
55
-
56
- ```ts
57
- createAt: actionSchema()
58
- .output(
59
- { schema: v.object({ createdAt: v.date() }) },
60
- ({ createdAt }) => ({ createdAt: createdAt.toISOString() }), // serialize
61
- ({ createdAt }) => ({ createdAt: new Date(createdAt) }), // deserialize
62
- ),
63
- ```
64
-
65
- ### 3. Declare thrown errors
66
-
67
- ```ts
68
- import { defineNiceError, err } from "@nice-code/error";
69
-
70
- const err_user = defineNiceError({
71
- domain: "err_user",
72
- schema: {
73
- not_found: err<{ userId: string }>({
74
- message: ({ userId }) => `User not found: ${userId}`,
75
- httpStatusCode: 404,
76
- context: { required: true },
77
- }),
78
- },
79
- } as const);
80
-
81
- // Attach to an action schema
82
- actionSchema()
83
- .throws(err_user) // any id from err_user
84
- .throws(err_user, ["not_found"]) // only specific ids
85
- ```
86
-
87
- ---
88
-
89
- ## Setting up runtimes
90
-
91
- ### Server (local handler)
92
-
93
- ```ts
94
- import { ActionRuntime, createLocalHandler, RuntimeCoordinate } from "@nice-code/action";
95
-
96
- // Identify this environment
97
- export const serverCoord = RuntimeCoordinate.env("backend");
98
-
99
- // Implement the actions
100
- const userHandler = createLocalHandler()
101
- .forDomain(userDomain, async (request) => {
102
- if (request.id === "getUser") {
103
- const user = await db.users.find(request.input.userId);
104
- if (!user) throw err_user.fromId("not_found", { userId: request.input.userId });
105
- return user;
106
- }
107
- });
108
-
109
- // Or use the map syntax (preferred)
110
- const userHandler = createLocalHandler().forDomainActionCases(userDomain, {
111
- getUser: async (req) => {
112
- const user = await db.users.find(req.input.userId);
113
- if (!user) throw err_user.fromId("not_found", { userId: req.input.userId });
114
- return user;
115
- },
116
- updateName: async (req) => {
117
- await db.users.update(req.input.userId, { name: req.input.name });
118
- return { success: true };
119
- },
120
- });
121
-
122
- // Or wrap an object directly
123
- const userHandler = userDomain.wrapAsLocalHandler({
124
- getUser: async ({ userId }) => { /* ... */ },
125
- updateName: async ({ userId, name }) => { /* ... */ },
126
- });
127
-
128
- // Wire up the runtime
129
- export const serverRuntime = new ActionRuntime(serverCoord)
130
- .addHandlers([userHandler])
131
- .apply();
132
- ```
133
-
134
- ### Handling incoming requests (HTTP endpoint)
135
-
136
- ```ts
137
- // Hono example
138
- app.post("/resolve_action", async (c) => {
139
- const wire = await c.req.json();
140
- const runningAction = await serverRuntime.handleActionPayloadWire(wire);
141
- const result = await runningAction.waitForResultPayload();
142
- return c.json(result.toJsonObject());
143
- });
144
- ```
145
-
146
- Or fold the whole endpoint (CORS + preflight, the `/action` POST, an optional WebSocket upgrade, and a
147
- 404 fallback) into one web-standard `fetch` handler with `createActionFetchHandler`:
148
-
149
- ```ts
150
- import { createActionFetchHandler } from "@nice-code/action";
151
-
152
- // Works anywhere Request/Response exist (Workers, Durable Objects, Bun, …).
153
- const fetchHandler = createActionFetchHandler(serverRuntime, {
154
- // Omit for an HTTP-only endpoint. For a Durable Object:
155
- onWebSocketUpgrade: () => {
156
- const pair = new WebSocketPair();
157
- ctx.acceptWebSocket(pair[1]);
158
- return new Response(null, { status: 101, webSocket: pair[0] });
159
- },
160
- });
161
-
162
- // async fetch(request) { return fetchHandler(request); }
163
- ```
164
-
165
- By default a `POST` whose path ends in `/action` carries an action wire, and an `Upgrade: websocket`
166
- request whose path ends in `/ws` is upgraded; override with `isActionPath` / `isWebSocketPath`.
167
-
168
- ### Client (connecting to a backend)
169
-
170
- A client has one runtime. Call `connectTo` once per backend it talks to: it builds the
171
- `ActionExternalClientHandler`, routes the listed `domains`/`actions` to it, registers it, and applies —
172
- all in one call.
173
-
174
- ```ts
175
- import { ActionRuntime, RuntimeCoordinate, HttpTransport } from "@nice-code/action";
176
-
177
- export const clientCoord = RuntimeCoordinate.env("frontend");
178
- export const serverCoord = RuntimeCoordinate.env("backend");
179
-
180
- // The single runtime that identifies this client everywhere.
181
- export const clientRuntime = new ActionRuntime(clientCoord);
182
-
183
- // Transport definitions are plain, reusable objects you construct once.
184
- const serverHttp = HttpTransport.create({
185
- createRequest: () => ({ url: "https://api.example.com/resolve_action" }),
186
- });
187
-
188
- // Wire the backend — route the userDomain's actions over this transport.
189
- clientRuntime.connectTo(serverCoord, {
190
- transports: [serverHttp],
191
- domains: [userDomain],
192
- });
193
-
194
- // Connect to more backends on the same runtime as needed:
195
- // clientRuntime.connectTo(authCoord, { transports: [authWs], domains: [authDomain] });
196
- ```
197
-
198
- `connectTo(peerCoordinate, options)` accepts:
199
-
200
- - `transports` — one or more transports, tried in declared order (first = preferred, e.g. a WebSocket; later = fallback, e.g. HTTP)
201
- - `domains` / `actions` — which domains (or individual actions) route to this peer
202
- - `localHandlers` — handlers that run *locally* on this same channel, e.g. to answer **server→client pushes** (see [Bi-directional communication](#bi-directional-communication-server--client))
203
- - `defaultTimeout` — default per-action timeout
204
-
205
- It returns the `ActionExternalClientHandler` so you can later `handler.clearTransportCache()` (which also
206
- closes any live sockets) on teardown.
207
-
208
- <details>
209
- <summary>What <code>connectTo</code> desugars to</summary>
210
-
211
- ```ts
212
- const serverHandler = new ActionExternalClientHandler({
213
- runtimeCoordinate: serverCoord,
214
- transports: [serverHttp],
215
- }).forDomain(userDomain);
216
-
217
- clientRuntime.addHandlers([serverHandler]).apply();
218
- ```
219
- </details>
220
-
221
- ---
222
-
223
- ## Calling actions
224
-
225
- ```ts
226
- // Create a request payload
227
- const request = userDomain.action.getUser.request({ userId: "u_123" });
228
-
229
- // Run it returns a RunningAction
230
- const runningAction = await userDomain.runAction(request);
231
-
232
- // Wait for the result
233
- const result = await runningAction.waitForResultPayload();
234
- console.log(result.output); // { id: "u_123", name: "Alice" }
235
-
236
- // Or run and get the output directly (throws on error)
237
- const output = await userDomain.action.getUser
238
- .request({ userId: "u_123" })
239
- .runToOutput();
240
- ```
241
-
242
- ---
243
-
244
- ## Bi-directional communication (server ⇆ client)
245
-
246
- Actions flow both ways. The frontend calls the backend the way shown above; the **backend can call the
247
- frontend** over the *same* open connection — no second channel, no polling. This needs a duplex transport
248
- (a WebSocket), so both ends share one channel.
249
-
250
- The shape:
251
-
252
- - **Define the client-facing actions in their own domain**, shared by both ends (same as any other domain).
253
- - **On the client**, register a *local handler* for those actions and route them as `localHandlers` on the
254
- `connectTo` channel — so when the server pushes a request, the client executes it locally and the result
255
- is sent straight back over the same socket.
256
- - **On the server**, use an `ActionServerHandler` (one per connection-accepting channel). It ferries
257
- results back to the originating socket automatically, and gives you `pushToClient` / `broadcast` to
258
- initiate actions toward connected clients.
259
-
260
- ### Shared domains
261
-
262
- ```ts
263
- // Client implements these — the server pushes them.
264
- export const clientPushDomain = appRoot.createChildDomain({
265
- domain: "client_push",
266
- actions: {
267
- notify: actionSchema()
268
- .input({ schema: v.object({ text: v.string() }) })
269
- .output({ schema: v.object({ seen: v.boolean() }) }),
270
- },
271
- });
272
-
273
- // Server implements these the client calls them (normal direction).
274
- export const gameDomain = appRoot.createChildDomain({
275
- domain: "game",
276
- actions: {
277
- join: actionSchema()
278
- .input({ schema: v.object({ roomId: v.string() }) })
279
- .output({ schema: v.object({ ok: v.boolean() }) }),
280
- },
281
- });
282
- ```
283
-
284
- ### Server side
285
-
286
- ```ts
287
- import {
288
- ActionRuntime,
289
- RuntimeCoordinate,
290
- createServerHandler,
291
- } from "@nice-code/action";
292
-
293
- const backendCoord = RuntimeCoordinate.env("backend");
294
- const clientEnv = RuntimeCoordinate.env("frontend"); // the env of connecting clients
295
-
296
- const serverRuntime = new ActionRuntime(backendCoord);
297
-
298
- // Handles inbound client→server actions locally.
299
- const gameHandler = gameDomain.wrapAsLocalHandler({
300
- join: async ({ roomId }) => ({ ok: true }),
301
- });
302
-
303
- // Accepts many client connections over one channel; you tell it how to send a frame.
304
- const serverHandler = createServerHandler<WebSocket>({
305
- clientEnv,
306
- formatMessage: adapter, // e.g. createBinaryWireAdapter([gameDomain, clientPushDomain])
307
- send: (ws, frame) => ws.send(frame),
308
- runtime: serverRuntime, // lets broadcast() find the runtime without threading it through
309
- });
310
-
311
- serverRuntime.addHandlers([gameHandler, serverHandler]).apply();
312
-
313
- // Per inbound socket message (e.g. a Durable Object's webSocketMessage):
314
- // serverHandler.receive(ws, message);
315
- // On close/error:
316
- // serverHandler.dropConnection(ws);
317
- ```
318
-
319
- Push a server-initiated action to a specific client (await its reply just like any action), or fan one
320
- out to everyone:
321
-
322
- ```ts
323
- // One client pass the connection token or the client's RuntimeCoordinate.
324
- const running = serverHandler.pushToClient(
325
- serverRuntime,
326
- ws,
327
- clientPushDomain.action.notify.request({ text: "hi" }),
328
- );
329
- const result = await running.waitForResultPayload();
330
- console.log(result.output); // { seen: true }
331
-
332
- // Everyone currently connected (fire-and-forget); skip the origin, or filter with `where`.
333
- serverHandler.broadcast(() => clientPushDomain.action.notify.request({ text: "room update" }), {
334
- except: originWs,
335
- where: (ws) => ws.deserializeAttachment()?.role === "player",
336
- });
337
- ```
338
-
339
- When an inbound client action needs the originating socket (to reply on it, register it in a room, etc.),
340
- use `forConnectionDomainCases` — each case receives the request *and* that client's live connection,
341
- saving the manual `getConnectionForClient(action.context.originClient)` lookup:
342
-
343
- ```ts
344
- const gameCases = serverHandler.forConnectionDomainCases(gameDomain, {
345
- join: ({ input }, conn) => {
346
- if (conn != null) rooms.add(input.roomId, conn);
347
- return { ok: conn != null };
348
- },
349
- });
350
-
351
- serverRuntime.addHandlers([gameCases, serverHandler]).apply();
352
- ```
353
-
354
- ### Client side
355
-
356
- The client registers a local handler for the pushed actions and hands it to `connectTo` as a
357
- `localHandler` on the WebSocket channel. Results route back over the same socket automatically.
358
-
359
- ```ts
360
- const clientRuntime = new ActionRuntime(RuntimeCoordinate.env("frontend"));
361
-
362
- // Execute server→client pushes locally.
363
- const pushHandler = clientPushDomain.wrapAsLocalHandler({
364
- notify: async ({ text }) => {
365
- showToast(text);
366
- return { seen: true };
367
- },
368
- });
369
-
370
- clientRuntime.connectTo(backendCoord, {
371
- transports: [gameWs], // a WebSocketTransport (duplex)
372
- domains: [gameDomain], // client→server actions routed out over this socket
373
- localHandlers: [pushHandler], // server→client pushes handled locally on the same socket
374
- });
375
- ```
376
-
377
- ### Durable Objects / hibernation
378
-
379
- For transports whose sockets outlive process eviction (e.g. a Durable Object's hibernatable
380
- WebSockets), pair the server handler with `createHibernatableWsServerAdapter`. It owns persistence:
381
- it stores each connection's binding on bind and replays them on construction, so results and pushes
382
- still route to the right socket after the object wakes.
383
-
384
- ```ts
385
- import { createHibernatableWsServerAdapter } from "@nice-code/action";
386
-
387
- const wsServer = createHibernatableWsServerAdapter({
388
- handler: serverHandler,
389
- getWebSockets: () => ctx.getWebSockets(),
390
- getAttachment: (ws) => ws.deserializeAttachment(),
391
- setAttachment: (ws, binding) => ws.serializeAttachment(binding),
392
- });
393
-
394
- // webSocketMessage(ws, msg) => wsServer.receive(ws, msg);
395
- // webSocketClose/Error(ws) => wsServer.drop(ws);
396
- ```
397
-
398
- ---
399
-
400
- ## React Query integration
401
-
402
- ```ts
403
- import { useActionQuery, useActionMutation } from "@nice-code/action/react-query";
404
-
405
- // Query
406
- function UserProfile({ userId }: { userId: string }) {
407
- const { data } = useActionQuery(
408
- userDomain.action.getUser,
409
- { userId },
410
- { queryKey: ["user", userId] },
411
- );
412
- return <div>{data?.name}</div>;
413
- }
414
-
415
- // Mutation
416
- function RenameUser() {
417
- const { mutate } = useActionMutation(userDomain.action.updateName);
418
- return <button onClick={() => mutate({ userId: "u_1", name: "Bob" })}>Rename</button>;
419
- }
420
- ```
421
-
422
- ---
423
-
424
- ## Devtools
425
-
426
- ### Browser panel `@nice-code/action/devtools/browser`
427
-
428
- A dockable in-app panel showing every action run: status, timing, input/output, routing, errors, and call stacks. Renders only when `NODE_ENV === "development"` (or with `forceEnable`).
429
-
430
- ```tsx
431
- import { ActionDevtoolsCore, NiceActionDevtools } from "@nice-code/action/devtools/browser";
432
-
433
- const devtoolsCore = new ActionDevtoolsCore();
434
- devtoolsCore.attachToDomain(appRoot);
435
-
436
- function App() {
437
- return (
438
- <>
439
- <MyApp />
440
- <NiceActionDevtools core={devtoolsCore} position="dock-bottom" />
441
- </>
442
- );
443
- }
444
- ```
445
-
446
- ### Server logger`@nice-code/action/devtools/server`
447
-
448
- Logs action lifecycle (started / progress / success / error) with timings pretty lines or newline-delimited JSON.
449
-
450
- ```ts
451
- import { ActionServerDevtools } from "@nice-code/action/devtools/server";
452
-
453
- const devtools = new ActionServerDevtools({ format: "json", logPayloads: false });
454
- devtools.attachToDomain(appRoot);
455
- ```
456
-
457
- ---
458
-
459
- ## WebSocket transport
460
-
461
- ```ts
462
- import { ActionRuntime, WebSocketTransport, HttpTransport } from "@nice-code/action";
463
-
464
- const serverWs = WebSocketTransport.create({
465
- createWebSocket: () => new WebSocket("wss://api.example.com/resolve_action/ws"),
466
- getTransportCacheKey: () => ["wss://api.example.com/resolve_action/ws"],
467
- });
468
-
469
- // One channel, WebSocket preferred with HTTP as fallback.
470
- clientRuntime.connectTo(serverCoord, {
471
- transports: [serverWs, serverHttp],
472
- domains: [userDomain],
473
- });
474
- ```
475
-
476
- The socket is opened lazily and reused across actions sharing a `getTransportCacheKey`. Multiple
477
- transports can be registered; the runtime picks the best available one (WebSocket preferred for lower
478
- latency, HTTP as fallback). For channels nice-action doesn't model natively, use `CustomTransport`.
479
-
480
- Each transport takes a single creation function so you decide how simple or complex it should be —
481
- derive the request per action straight from the params (e.g.
482
- `HttpTransport.create({ createRequest: (input) => ({ url: \`/api/${input.action.id}\` }) })`). For full
483
- control over readiness (support detection, async init), `WebSocketTransport` / `CustomTransport` also
484
- offer `.createAdvanced({ getTransport })`.
485
-
486
- A WebSocket is duplex, so it's also the channel the server pushes back over — see
487
- [Bi-directional communication](#bi-directional-communication-server--client).
488
-
489
- ### Secure WebSocket channel
490
-
491
- For an authenticated (and optionally end-to-end encrypted) socket, define the channel once in shared
492
- code and hand it to both ends — the binary codec and wire-dictionary version can never drift apart. The
493
- client identifies itself with its runtime coordinate + a persisted crypto identity; the server pins
494
- client keys trust-on-first-use.
495
-
496
- ```ts
497
- // shared.ts — both ends import this
498
- import { defineSecureWsChannel } from "@nice-code/action";
499
-
500
- // Domains in a stable order (the binary wire dictionary is positional — add new ones to the end).
501
- export const channel = defineSecureWsChannel({ domains: [userDomain, clientPushDomain] });
502
- ```
503
-
504
- ```ts
505
- // client.ts
506
- import { createSecureWebSocketTransport, ESecurityLevel } from "@nice-code/action";
507
-
508
- const secureWs = createSecureWebSocketTransport({
509
- channel,
510
- runtime: clientRuntime, // its coordinate is the authenticated identity in the handshake
511
- storageAdapter, // persists this client's verify key across reloads (@nice-code/util)
512
- securityLevel: ESecurityLevel.encrypted, // none | authenticated | encrypted
513
- url: "wss://api.example.com/meta/ws",
514
- });
515
-
516
- clientRuntime.connectTo(serverCoord, {
517
- transports: [secureWs, serverHttp], // secure WS preferred, HTTP fallback
518
- domains: [userDomain],
519
- localHandlers: [pushHandler], // server pushes ride the same secure socket
520
- });
521
- ```
522
-
523
- ```ts
524
- // server.ts (e.g. inside a Durable Object)
525
- import {
526
- createSecureActionServerHandler,
527
- createHibernatableWsServerAdapter,
528
- } from "@nice-code/action";
529
-
530
- const serverHandler = createSecureActionServerHandler<WebSocket>({
531
- channel, // same shared channel
532
- clientEnv, // env of the connecting clients
533
- runtime: serverRuntime, // its coordinate is the server identity in the handshake
534
- storageAdapter, // backs BOTH the server's identity and its TOFU key pins — use persistent storage
535
- send: (ws, frame) => ws.send(frame),
536
- // securityLevel defaults to negotiating any of none / authenticated / encrypted per connection
537
- });
538
-
539
- const wsServer = createHibernatableWsServerAdapter({
540
- handler: serverHandler,
541
- getWebSockets: () => ctx.getWebSockets(),
542
- getAttachment: (ws) => ws.deserializeAttachment(),
543
- setAttachment: (ws, binding) => ws.serializeAttachment(binding),
544
- });
545
- ```
546
-
547
- Security levels: `none` (self-asserted identity, fine for dev/trusted networks), `authenticated`
548
- (handshake-verified identity), `encrypted` (authenticated + per-connection AES-GCM payload encryption).
549
- A server can accept a negotiable set so a single endpoint serves all three. Persisting the channel's
550
- binding (via the hibernatable adapter) lets an `authenticated`/`encrypted` connection resume after the
551
- server is evicted without re-handshaking.
552
-
553
- ---
554
-
555
- ## RuntimeCoordinate
556
-
557
- Identifies a runtime environment and is used to route actions to the right handler.
558
-
559
- ```ts
560
- RuntimeCoordinate.env("backend") // named env
561
- RuntimeCoordinate.env("backend").specify({ perId: "worker-1" }) // env + instance
562
- RuntimeCoordinate.unknown // unspecified
563
- ```
564
-
565
- ---
566
-
567
- ## Error handling in actions
568
-
569
- Actions declared with `.throws(domain, ids?)` surface typed errors at the call site:
570
-
571
- ```ts
572
- import { castNiceError } from "@nice-code/error";
573
-
574
- try {
575
- const output = await userDomain.action.getUser.request({ userId }).runToOutput();
576
- } catch (e) {
577
- const error = castNiceError(e);
578
- if (err_user.isExact(error) && error.hasId("not_found")) {
579
- console.log("User not found:", error.getContext("not_found").userId);
580
- }
581
- }
582
- ```
1
+ # @nice-code/action
2
+
3
+ Typed, transport-agnostic action system for calling functions across runtime boundaries (client/server,
4
+ worker/worker, peer/peer) with full TypeScript inference — including **bi-directional** calls where the
5
+ acceptor pushes actions back over the same connection.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @nice-code/action
11
+ ```
12
+
13
+ Peer deps: `valibot` (or any [Standard Schema](https://github.com/standard-schema/standard-schema) library), `@tanstack/react-query` (for `@nice-code/action/react-query`).
14
+
15
+ ---
16
+
17
+ ## Mental model
18
+
19
+ One sentence: **a `runtime` links to a `peer` over a `carrier`, and the routing between them is declared
20
+ once as a `channel`.** Identity, auth, and encryption work the same regardless of carrier the only
21
+ distinctions that survive every carrier are:
22
+
23
+ - **role**who dials (**connector**) vs who accepts and can push back (**acceptor**).
24
+ - **shape** — **duplex** (a WebSocket / WebRTC channel: push-capable, the return path for results and
25
+ server pushes) vs **exchange** (HTTP: one request, one reply).
26
+
27
+ The pieces:
28
+
29
+ | Concept | What it is |
30
+ | --- | --- |
31
+ | **ActionDomain** | A named group of typed actions (your API surface) |
32
+ | **ActionSchema** | Input/output schema + declared error types for one action |
33
+ | **ActionRuntime** | One per runtime; identifies it and dispatches actions to handlers |
34
+ | **Channel** | The transport-agnostic routing contract between two runtimes, declared *by role* (`toAcceptor` / `toConnector`) — `defineChannel` (plain) or `defineSecureChannel` (binary + encryption) |
35
+ | **Carrier** | How bytes actually move: `wsCarrier` / `httpCarrier` / `inMemoryCarrier` / `rtcCarrier` (connector side), `wsAcceptorCarrier` / `httpAcceptorCarrier` (acceptor side) |
36
+ | **Transport** | A carrier wrapped with a security policy: `secureTransport` (handshake + optional encryption) or `plainTransport` |
37
+ | **RuntimeCoordinate** | Identifies an environment (frontend, backend, worker…) and is how actions are routed |
38
+
39
+ > **One runtime per client.** A client (a frontend, a backend, a worker) has a *single* `ActionRuntime`
40
+ > identifying it across every peer it talks to — not one per feature or per backend. Register your local
41
+ > handlers on it, then `connectChannel(...)` once per peer (or `serveChannel(...)` to accept). This keeps
42
+ > one identity (and one crypto identity, for secure channels) per client and avoids routing ambiguity.
43
+
44
+ The high-level entry points — `connectChannel` (dial out) and `serveChannel` (accept) are what you
45
+ reach for 95% of the time. The lower-level handler/carrier/transport objects they desugar to are
46
+ documented at the end under [Lower-level building blocks](#lower-level-building-blocks).
47
+
48
+ ---
49
+
50
+ ## Defining actions
51
+
52
+ ### 1. Create a root domain (shared between both ends)
53
+
54
+ ```ts
55
+ import { createActionRootDomain, actionSchema } from "@nice-code/action";
56
+ import * as v from "valibot";
57
+
58
+ // Root domain — no actions, just a namespace anchor
59
+ export const appRoot = createActionRootDomain({ domain: "app_root" });
60
+
61
+ // Child domain with actions
62
+ export const userDomain = appRoot.createChildDomain({
63
+ domain: "user",
64
+ actions: {
65
+ getUser: actionSchema()
66
+ .input({ schema: v.object({ userId: v.string() }) })
67
+ .output({ schema: v.object({ id: v.string(), name: v.string() }) })
68
+ .throws(err_user, ["not_found"]), // from @nice-code/error
69
+
70
+ updateName: actionSchema()
71
+ .input({ schema: v.object({ userId: v.string(), name: v.string() }) })
72
+ .output({ schema: v.object({ success: v.boolean() }) }),
73
+ },
74
+ });
75
+ ```
76
+
77
+ ### 2. Serialization for non-JSON-native types
78
+
79
+ ```ts
80
+ createAt: actionSchema()
81
+ .output(
82
+ { schema: v.object({ createdAt: v.date() }) },
83
+ ({ createdAt }) => ({ createdAt: createdAt.toISOString() }), // serialize
84
+ ({ createdAt }) => ({ createdAt: new Date(createdAt) }), // deserialize
85
+ ),
86
+ ```
87
+
88
+ ### 3. Declare thrown errors
89
+
90
+ ```ts
91
+ import { defineNiceError, err } from "@nice-code/error";
92
+
93
+ const err_user = defineNiceError({
94
+ domain: "err_user",
95
+ schema: {
96
+ not_found: err<{ userId: string }>({
97
+ message: ({ userId }) => `User not found: ${userId}`,
98
+ httpStatusCode: 404,
99
+ context: { required: true },
100
+ }),
101
+ },
102
+ } as const);
103
+
104
+ // Attach to an action schema
105
+ actionSchema()
106
+ .throws(err_user) // any id from err_user
107
+ .throws(err_user, ["not_found"]) // only specific ids
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Channels the routing contract
113
+
114
+ A **channel** declares what flows in each direction between two runtimes, *by role*, so both ends derive
115
+ their routing from one shared definition instead of restating domain lists:
116
+
117
+ - **`toAcceptor`** domains the connector *sends to* the acceptor (the classic "request").
118
+ - **`toConnector`** domains the acceptor *pushes back to* the connector (the classic "push"). A domain
119
+ can appear in both lists if it's bidirectional.
120
+
121
+ Define it once in code shared by both ends:
122
+
123
+ ```ts
124
+ // shared.ts both ends import this
125
+ import { defineChannel } from "@nice-code/action";
126
+
127
+ export const appChannel = defineChannel({
128
+ toAcceptor: [userDomain], // client server requests
129
+ toConnector: [], // server client pushes (none here)
130
+ });
131
+ ```
132
+
133
+ For an authenticated/encrypted binary WebSocket, use **`defineSecureChannel`** instead — same role-based
134
+ shape, plus it bakes in the positional binary wire dictionary and a version derived from the domains, so
135
+ the codec and version can never drift between the two ends:
136
+
137
+ ```ts
138
+ import { defineSecureChannel } from "@nice-code/action";
139
+
140
+ export const appChannel = defineSecureChannel({
141
+ toAcceptor: [userDomain, lobbyDomain], // requests
142
+ toConnector: [lobbyDomain], // pushes (lobbyDomain is bidirectional)
143
+ });
144
+ ```
145
+
146
+ > The lists are **positional** for the binary wire dictionary **add new domains to the end** of their
147
+ > list. Reordering shifts the version, and a stale peer is then cleanly rejected by the handshake instead
148
+ > of silently misrouting a frame.
149
+
150
+ A secure channel is still an ordinary channel, so it works anywhere a channel is expected.
151
+
152
+ ---
153
+
154
+ ## Runtimes & handlers
155
+
156
+ ### Local execution (the acceptor's actual work)
157
+
158
+ A **local handler** runs actions in the current process. Build one and register it on the runtime; this
159
+ is what answers incoming requests.
160
+
161
+ ```ts
162
+ import { ActionRuntime, createLocalHandler, RuntimeCoordinate } from "@nice-code/action";
163
+
164
+ export const serverCoord = RuntimeCoordinate.env("backend");
165
+
166
+ // Map syntax (preferred)
167
+ const userHandler = createLocalHandler().forDomainActionCases(userDomain, {
168
+ getUser: async (action) => {
169
+ const user = await db.users.find(action.input.userId);
170
+ if (!user) throw err_user.fromId("not_found", { userId: action.input.userId });
171
+ return user;
172
+ },
173
+ updateName: async (action) => {
174
+ await db.users.update(action.input.userId, { name: action.input.name });
175
+ return { success: true };
176
+ },
177
+ });
178
+
179
+ // Or one action at a time
180
+ const userHandler2 = createLocalHandler()
181
+ .forAction(userDomain.action.getUser, async ({ input }) => db.users.find(input.userId));
182
+
183
+ // Or wrap an object directly off the domain
184
+ const userHandler3 = userDomain.wrapAsLocalHandler({
185
+ getUser: async ({ userId }) => { /* ... */ },
186
+ updateName: async ({ userId, name }) => { /* ... */ },
187
+ });
188
+ ```
189
+
190
+ `wrapAsPartialLocalHandler` is the same but lets you implement only *some* of a domain's actions (useful
191
+ for local-first clients that resolve a few actions themselves and forward the rest).
192
+
193
+ ### Accepting connections — `serveChannel`
194
+
195
+ `serveChannel` is the one call that stands up an acceptor: it builds the crypto identity **once**, fans it
196
+ across every carrier, registers your handlers, wires hibernation, and returns a server object whose
197
+ `fetch` / `duplex` / `pushToClient` you forward straight to the host.
198
+
199
+ ```ts
200
+ import {
201
+ ActionRuntime,
202
+ RuntimeCoordinate,
203
+ serveChannel,
204
+ wsAcceptorCarrier,
205
+ httpAcceptorCarrier,
206
+ } from "@nice-code/action";
207
+
208
+ const runtime = new ActionRuntime(serverCoord);
209
+
210
+ const server = serveChannel(runtime, appChannel, {
211
+ clientEnv: RuntimeCoordinate.env("frontend"), // env of the connecting clients
212
+ storage: storageAdapter, // backs identity + TOFU key pins (persistent)
213
+ handlers: [userHandler], // your local execution
214
+ carriers: [
215
+ wsAcceptorCarrier({ send: (ws, frame) => ws.send(frame), /* upgrade, hibernation */ }),
216
+ httpAcceptorCarrier(), // HTTP fallback (same channel)
217
+ ],
218
+ });
219
+
220
+ // Wire the host's events to the server:
221
+ // fetch(req) => server.fetch(req)
222
+ // webSocketMessage(ws, msg) => server.duplex?.receive(ws, msg)
223
+ // webSocketClose/Error(ws) => server.duplex?.drop(ws)
224
+ ```
225
+
226
+ `serveChannel(runtime, channel, options)` returns `{ handlers, fetch, duplex?, pushToClient }`:
227
+
228
+ - **`fetch`** — a web-standard handler that does the WS upgrade, the (secure or plain) HTTP action POST,
229
+ CORS preflight, and a `404` fallback. Forward the host's `fetch` straight to it.
230
+ - **`duplex`** the `{ receive, drop }` lifecycle for the *sole* duplex carrier (a shortcut for the
231
+ common single-WebSocket case; `undefined` when there are zero or several — then feed each carrier
232
+ handle directly).
233
+ - **`pushToClient(target, request, opts?)`** — push a server-initiated action to one connected client
234
+ (see [Bi-directional](#bi-directional-communication-acceptor--connector)).
235
+ - **`handlers`** — the acceptor handlers it built, one per duplex carrier (reach for these for per-handler
236
+ `broadcast`).
237
+
238
+ Key options: `clientEnv` (required), `storage` (required only when a carrier is secure — the default),
239
+ `carriers`, `handlers`, plus `securityLevel` / `link` / `verifyKeyResolver` / `defaultTimeout`.
240
+
241
+ > On Cloudflare, [`@nice-code/action/platform/cloudflare`](#cloudflare-durable-objects) collapses the
242
+ > Durable Object carrier + storage boilerplate to one-liners.
243
+
244
+ ### Connecting `connectChannel`
245
+
246
+ The connector has one runtime and `connectChannel`s to each acceptor it talks to. It routes the channel's
247
+ `toAcceptor` domains out over the transports (first = preferred, rest = fallback) and registers local
248
+ handlers for the `toConnector` pushes from `onPush` — all derived from the channel, no restated lists.
249
+
250
+ ```ts
251
+ import {
252
+ ActionRuntime,
253
+ RuntimeCoordinate,
254
+ ESecurityLevel,
255
+ connectChannel,
256
+ secureTransport,
257
+ plainTransport,
258
+ wsCarrier,
259
+ httpCarrier,
260
+ } from "@nice-code/action";
261
+
262
+ export const clientRuntime = new ActionRuntime(RuntimeCoordinate.env("frontend"));
263
+
264
+ // A carrier wrapped in a security policy = a transport.
265
+ const wsTransport = secureTransport({
266
+ channel: appChannel,
267
+ runtime: clientRuntime, // its coordinate is the authenticated identity in the handshake
268
+ storageAdapter, // persists this client's crypto identity across reloads
269
+ securityLevel: ESecurityLevel.encrypted,
270
+ carrier: wsCarrier("wss://api.example.com/resolve_action/ws"),
271
+ });
272
+ const httpTransport = plainTransport({
273
+ carrier: httpCarrier(() => ({ url: "https://api.example.com/resolve_action" })),
274
+ });
275
+
276
+ connectChannel(clientRuntime, serverCoord, {
277
+ channel: appChannel,
278
+ transports: [wsTransport, httpTransport], // secure WS preferred, HTTP fallback
279
+ // onPush: { ... } // handlers for the channel's toConnector pushes (see below)
280
+ });
281
+ ```
282
+
283
+ `connectChannel(runtime, acceptorCoordinate, options)` returns the `ConnectorHandler` so you can later
284
+ `handler.clearTransportCache()` (which also closes any live sockets) on teardown. Options:
285
+
286
+ - **`channel`** — the shared channel; its `toAcceptor`/`toConnector` drive all routing.
287
+ - **`transports`** — to the acceptor, in preference order; all carry the same `toAcceptor` domains and the
288
+ manager falls through on failure.
289
+ - **`onPush`** — handlers for the channel's `toConnector` pushes (optional; omit for send-only).
290
+ - **`defaultTimeout`** — default per-action timeout.
291
+
292
+ ---
293
+
294
+ ## Calling actions
295
+
296
+ Once a runtime is wired, calling an action looks the same regardless of where it resolves (locally or
297
+ over a carrier):
298
+
299
+ ```ts
300
+ // Run and get the output directly (throws on a declared/transport error)
301
+ const output = await userDomain.action.getUser
302
+ .request({ userId: "u_123" })
303
+ .runToOutput();
304
+ console.log(output); // { id: "u_123", name: "Alice" }
305
+
306
+ // Or keep the RunningAction handle for progress/abort, then await the full result payload
307
+ const running = await userDomain.runAction(userDomain.action.getUser.request({ userId: "u_123" }));
308
+ const result = await running.waitForResultPayload();
309
+ console.log(result.output);
310
+ ```
311
+
312
+ ---
313
+
314
+ ## Bi-directional communication (acceptor ⇆ connector)
315
+
316
+ Over a **duplex** carrier (a WebSocket) the acceptor can call the connector back on the *same* open
317
+ connection — no second channel, no polling. The shape:
318
+
319
+ 1. **Declare the push domain in the channel's `toConnector`** (shared by both ends).
320
+ 2. **On the connector**, handle those pushes with `connectChannel`'s `onPush` — keyed by action id,
321
+ typed from the channel. The reply routes straight back over the same socket.
322
+ 3. **On the acceptor**, use `server.pushToClient(...)` (one client) or a handler's `broadcast(...)`
323
+ (everyone). The originating client is available on any inbound action as
324
+ `action.context.originClient`.
325
+
326
+ ### Shared channel
327
+
328
+ ```ts
329
+ // Bidirectional: client sends `start_feed`; server pushes `position_update` back.
330
+ export const lobbyDomain = appRoot.createChildDomain({
331
+ domain: "lobby",
332
+ actions: {
333
+ start_feed: actionSchema()
334
+ .input({ schema: v.object({ count: v.number() }) })
335
+ .output({ schema: v.object({ delivered: v.number() }) }),
336
+ position_update: actionSchema()
337
+ .input({ schema: v.object({ player: v.string(), x: v.number(), y: v.number() }) })
338
+ .output({ schema: v.object({ acknowledged: v.boolean() }) }),
339
+ },
340
+ });
341
+
342
+ export const appChannel = defineSecureChannel({
343
+ toAcceptor: [userDomain, lobbyDomain], // start_feed flows here
344
+ toConnector: [lobbyDomain], // position_update pushes back here
345
+ });
346
+ ```
347
+
348
+ ### Connector side — handle pushes with `onPush`
349
+
350
+ ```ts
351
+ connectChannel(clientRuntime, serverCoord, {
352
+ channel: appChannel,
353
+ transports: [wsTransport, httpTransport],
354
+ onPush: {
355
+ // Keyed by the toConnector action id; input + output typed from the channel.
356
+ position_update: async ({ player, x, y }) => {
357
+ renderPlayer(player, x, y);
358
+ return { acknowledged: true };
359
+ },
360
+ },
361
+ });
362
+ ```
363
+
364
+ ### Acceptor side push back
365
+
366
+ The local handler reads `action.context.originClient` to know who asked, then pushes to them:
367
+
368
+ ```ts
369
+ const lobbyHandler = createLocalHandler().forDomainActionCases(lobbyDomain, {
370
+ start_feed: async (action) => {
371
+ let delivered = 0;
372
+ for (let seq = 0; seq < action.input.count; seq++) {
373
+ const running = server.pushToClient(
374
+ action.context.originClient, // the requesting client's coordinate
375
+ lobbyDomain.action.position_update.request({ player: "alice", x: 1, y: 2 }),
376
+ );
377
+ await running.waitForResultPayload(); // await the client's ack like any action
378
+ delivered++;
379
+ }
380
+ return { delivered };
381
+ },
382
+ });
383
+ ```
384
+
385
+ Fan one out to everyone on a carrier with a handler's `broadcast` (fire-and-forget; skip the origin or
386
+ filter by connection):
387
+
388
+ ```ts
389
+ server.handlers[0].broadcast(
390
+ () => lobbyDomain.action.position_update.request({ player: "system", x: 0, y: 0 }),
391
+ { except: originWs, where: (ws) => ws.deserializeAttachment()?.role === "player" },
392
+ );
393
+ ```
394
+
395
+ > **When a handler needs the originating connection itself** (to register it in a room, etc.) rather than
396
+ > just the client coordinate, build connection-aware cases with `acceptChannelConnections(handler,
397
+ > channel, { ... })` — each case receives the request *and* that client's live connection. See
398
+ > [Lower-level building blocks](#lower-level-building-blocks).
399
+
400
+ ---
401
+
402
+ ## Cloudflare Durable Objects
403
+
404
+ `@nice-code/action/platform/cloudflare` collapses the DO boilerplate (the `WebSocketPair` upgrade, the
405
+ hibernation attachment wiring, and the DO-storage adapter) into one-liners you hand to `serveChannel`.
406
+ The core library stays platform-agnostic nothing here is reachable from the main entry.
407
+
408
+ ```ts
409
+ import { DurableObject } from "cloudflare:workers";
410
+ import { ActionRuntime, serveChannel, httpAcceptorCarrier } from "@nice-code/action";
411
+ import {
412
+ durableObjectWsCarrier,
413
+ durableObjectStorage,
414
+ type TDurableObjectChannelServer,
415
+ } from "@nice-code/action/platform/cloudflare";
416
+
417
+ export class MyDurableObject extends DurableObject {
418
+ private _server: TDurableObjectChannelServer | null = null;
419
+
420
+ private getServer(): TDurableObjectChannelServer {
421
+ if (this._server != null) return this._server;
422
+ const runtime = new ActionRuntime(serverCoord);
423
+
424
+ // One WS carrier (duplex — pushes + hibernation persistence) + a secure HTTP fallback (exchange),
425
+ // both sharing one crypto identity built from this DO's storage, surviving eviction.
426
+ this._server = serveChannel(runtime, appChannel, {
427
+ clientEnv: RuntimeCoordinate.env("frontend"),
428
+ storage: durableObjectStorage(this.ctx, { keyPrefix: "ws:" }),
429
+ handlers: [userHandler],
430
+ carriers: [durableObjectWsCarrier(this.ctx), httpAcceptorCarrier()],
431
+ });
432
+ return this._server;
433
+ }
434
+
435
+ async fetch(request: Request): Promise<Response> {
436
+ return this.getServer().fetch(request);
437
+ }
438
+ async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
439
+ this.getServer().duplex?.receive(ws, msg);
440
+ }
441
+ async webSocketClose(ws: WebSocket) { this.getServer().duplex?.drop(ws); }
442
+ async webSocketError(ws: WebSocket) { this.getServer().duplex?.drop(ws); }
443
+ }
444
+ ```
445
+
446
+ - **`durableObjectWsCarrier(ctx, { secure? })`** a hibernatable-WebSocket acceptor carrier with `send`,
447
+ the `WebSocketPair` upgrade, and the hibernation hooks all derived from the DO's `ctx`. Pass
448
+ `secure: false` for a plain WS endpoint (then no `storage` is needed for it).
449
+ - **`durableObjectStorage(ctx, { keyPrefix? })`** — wraps the DO's storage as a `StorageAdapter` for
450
+ `serveChannel`'s `storage`.
451
+
452
+ Because the WS carrier persists each connection's binding on bind and replays it on construction, results
453
+ and pushes still route to the right socket after the object wakes from eviction.
454
+
455
+ ---
456
+
457
+ ## Security levels
458
+
459
+ `ESecurityLevel` (used by `secureTransport` and `serveChannel`):
460
+
461
+ - **`none`** — identity self-asserted, no handshake. Fastest; fine for dev/trusted networks.
462
+ - **`authenticated`** the handshake verifies identity (sign/verify + trust-on-first-use key pin);
463
+ frames are unencrypted.
464
+ - **`encrypted`** authenticated *plus* every frame AES-GCM encrypted with the handshake-derived key.
465
+
466
+ The connector picks its level; an acceptor by default **negotiates any of the three per connection**, so
467
+ one endpoint serves all three. The client identifies itself with its runtime coordinate + a persisted
468
+ crypto identity; the server pins client keys trust-on-first-use. Persisting the server's binding (via the
469
+ hibernatable carrier) lets an `authenticated`/`encrypted` connection resume after eviction without
470
+ re-handshaking.
471
+
472
+ The whole thing rides one channel: the same `secureTransport({ carrier: wsCarrier(...) })` works at any
473
+ level, and `httpCarrier` runs the *same* secure session over HTTP (handshake → token → encrypted frames),
474
+ with the request/reply correlation provided for free by the HTTP transaction. Pair a secure WS with a
475
+ plain HTTP fallback by giving the acceptor a `httpAcceptorCarrier({ secure: false })`.
476
+
477
+ ---
478
+
479
+ ## Multiple carriers on one runtime
480
+
481
+ `serveChannel` accepts **any number of duplex carriers** (e.g. WebSocket + WebRTC) plus at most one
482
+ exchange carrier. They all share one crypto identity and one runtime, and each result/push routes back
483
+ over the carrier its client actually connected on (connection-aware return routing). With several duplex
484
+ carriers, `server.duplex` is `undefined` — feed each carrier handle's own `receive`/`drop` directly.
485
+
486
+ ```ts
487
+ const ws = wsAcceptorCarrier({ send: wsSend, upgrade, hibernation });
488
+ const rtc = rtcCarrier(/* ... */);
489
+ const server = serveChannel(runtime, appChannel, {
490
+ clientEnv, storage,
491
+ carriers: [ws, rtc, httpAcceptorCarrier()],
492
+ handlers: [appHandler],
493
+ });
494
+ // route each host's events to the matching handle:
495
+ // onWsMessage(c, m) => ws.receive(c, m)
496
+ // onRtcMessage(c, m) => rtc.receive(c, m)
497
+ ```
498
+
499
+ On the connector side, list several transports in `connectChannel`'s `transports` (preference order) to
500
+ get automatic fallback across carriers (e.g. secure WS, then plain HTTP).
501
+
502
+ ---
503
+
504
+ ## React Query integration
505
+
506
+ ```ts
507
+ import { useActionQuery, useActionMutation } from "@nice-code/action/react-query";
508
+
509
+ // Query
510
+ function UserProfile({ userId }: { userId: string }) {
511
+ const { data } = useActionQuery(
512
+ userDomain.action.getUser,
513
+ { userId },
514
+ { queryKey: ["user", userId] },
515
+ );
516
+ return <div>{data?.name}</div>;
517
+ }
518
+
519
+ // Mutation
520
+ function RenameUser() {
521
+ const { mutate } = useActionMutation(userDomain.action.updateName);
522
+ return <button onClick={() => mutate({ userId: "u_1", name: "Bob" })}>Rename</button>;
523
+ }
524
+ ```
525
+
526
+ ---
527
+
528
+ ## Devtools
529
+
530
+ ### Browser panel — `@nice-code/action/devtools/browser`
531
+
532
+ A dockable in-app panel showing every action run: status, timing, input/output, routing, errors, and call stacks. Renders only when `NODE_ENV === "development"` (or with `forceEnable`).
533
+
534
+ ```tsx
535
+ import { ActionDevtoolsCore, NiceActionDevtools } from "@nice-code/action/devtools/browser";
536
+
537
+ const devtoolsCore = new ActionDevtoolsCore();
538
+ devtoolsCore.attachToDomain(appRoot);
539
+
540
+ function App() {
541
+ return (
542
+ <>
543
+ <MyApp />
544
+ <NiceActionDevtools core={devtoolsCore} position="dock-bottom" />
545
+ </>
546
+ );
547
+ }
548
+ ```
549
+
550
+ ### Server logger `@nice-code/action/devtools/server`
551
+
552
+ Logs action lifecycle (started / progress / success / error) with timings — pretty lines or newline-delimited JSON.
553
+
554
+ ```ts
555
+ import { ActionServerDevtools } from "@nice-code/action/devtools/server";
556
+
557
+ const devtools = new ActionServerDevtools({ format: "json", logPayloads: false });
558
+ devtools.attachToDomain(appRoot);
559
+ ```
560
+
561
+ ---
562
+
563
+ ## RuntimeCoordinate
564
+
565
+ Identifies a runtime environment and is used to route actions to the right handler.
566
+
567
+ ```ts
568
+ RuntimeCoordinate.env("backend") // named env
569
+ RuntimeCoordinate.env("backend").specify({ perId: "worker-1" }) // env + instance
570
+ RuntimeCoordinate.env("backend").withPersistentId(id.toString()) // env + persistent instance id
571
+ RuntimeCoordinate.unknown // unspecified
572
+ ```
573
+
574
+ ---
575
+
576
+ ## Error handling in actions
577
+
578
+ Actions declared with `.throws(domain, ids?)` surface typed errors at the call site:
579
+
580
+ ```ts
581
+ import { castNiceError } from "@nice-code/error";
582
+
583
+ try {
584
+ const output = await userDomain.action.getUser.request({ userId }).runToOutput();
585
+ } catch (e) {
586
+ const error = castNiceError(e);
587
+ if (err_user.isExact(error) && error.hasId("not_found")) {
588
+ console.log("User not found:", error.getContext("not_found").userId);
589
+ }
590
+ }
591
+ ```
592
+
593
+ ---
594
+
595
+ ## Lower-level building blocks
596
+
597
+ `connectChannel` and `serveChannel` are sugar; reach for these when you need finer control.
598
+
599
+ - **`ActionRuntime.connectTo(coordinate, { transports, domains, actions, localHandlers, defaultTimeout })`**
600
+ — what `connectChannel` desugars to: build a `ConnectorHandler` for a peer, route domains/actions to
601
+ it, register it (plus any local push handlers), and apply — in one call. Use it directly when your
602
+ routing isn't expressed as a single channel.
603
+
604
+ - **Carriers vs transports.** A *carrier* (`wsCarrier`, `httpCarrier`, `inMemoryCarrier`, `rtcCarrier`) is
605
+ raw byte movement; a *transport* (`secureTransport`, `plainTransport`) wraps one with a security policy.
606
+ `connectTo` takes transports; `serveChannel` takes acceptor carriers (`wsAcceptorCarrier`,
607
+ `httpAcceptorCarrier`) and applies the security policy itself.
608
+
609
+ - **`acceptChannel(runtime, channel, { clientEnv, storageAdapter, send, ... })`** — build the secure
610
+ `AcceptorHandler` for a channel by hand (the accept-in counterpart to a single transport), when you're
611
+ not using `serveChannel`. Pair it with **`acceptChannelConnections(handler, channel, cases)`** to
612
+ register connection-aware execution — each case receives the request *and* the originating connection:
613
+
614
+ ```ts
615
+ const acceptor = acceptChannel(runtime, appChannel, { clientEnv, storageAdapter, send });
616
+ const cases = acceptChannelConnections(acceptor, appChannel, {
617
+ join: ({ input }, conn) => { if (conn != null) rooms.add(input.roomId, conn); return { ok: true }; },
618
+ });
619
+ runtime.addHandlers([cases, acceptor]).apply();
620
+ ```
621
+
622
+ - **`createActionFetchHandler(runtime, options)`** — the web-standard `fetch` handler (CORS, action POST,
623
+ optional WS upgrade, 404) on its own, when you want the HTTP entry without `serveChannel`.
624
+
625
+ - **`createHibernatableWsServerAdapter({ handler, getConnections, getAttachment, setAttachment })`** —
626
+ the hibernation persistence layer `serveChannel` wires automatically; use it directly with a hand-built
627
+ `AcceptorHandler`.
628
+
629
+ - **`createBinaryWireAdapter(domains)`** / **`createBinaryWireSessionFactory(domains)`** — the positional
630
+ binary codecs `defineSecureChannel` builds for you; useful for custom carriers.
631
+
632
+ - **`createInMemoryChannelPair()` / `inMemoryCarrier`** — wire two runtimes together in-process (tests,
633
+ same-process peers) with no network.
634
+
635
+ - **Custom carriers** — for any channel nice-action doesn't model natively, implement an
636
+ `IDuplexCarrierSource` / `IExchangeCarrierSource` and hand it to `secureTransport` / `plainTransport`
637
+ (connector) or build an acceptor carrier for `serveChannel`.