@nice-code/action 0.14.0 → 0.16.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 +582 -582
- package/build/{ActionDevtoolsCore-CCRLYASa.d.cts → ActionDevtoolsCore-B3JwSaRH.d.cts} +3 -3
- package/build/{ActionDevtoolsCore-9PsnscvK.mjs → ActionDevtoolsCore-BcItqP-C.mjs} +7 -7
- package/build/ActionDevtoolsCore-BcItqP-C.mjs.map +1 -0
- package/build/{ActionDevtoolsCore-CYGD2o6C.d.mts → ActionDevtoolsCore-C4TDUY7-.d.mts} +3 -3
- package/build/{ActionDevtoolsCore-DtgXwPBZ.cjs → ActionDevtoolsCore-Cb_QR44N.cjs} +7 -7
- package/build/ActionDevtoolsCore-Cb_QR44N.cjs.map +1 -0
- package/build/{ActionPayload.types-BN-rXFBK.d.cts → ActionPayload.types-CO_hXlBc.d.cts} +1452 -941
- package/build/{ActionPayload.types-D28ELKXC.d.mts → ActionPayload.types-fieMKAgt.d.mts} +1452 -941
- package/build/devtools/browser/index.cjs +5 -5
- package/build/devtools/browser/index.cjs.map +1 -1
- package/build/devtools/browser/index.d.cts +1 -1
- package/build/devtools/browser/index.d.mts +1 -1
- package/build/devtools/browser/index.mjs +5 -5
- package/build/devtools/browser/index.mjs.map +1 -1
- package/build/devtools/server/index.cjs +1 -1
- package/build/devtools/server/index.cjs.map +1 -1
- package/build/devtools/server/index.d.cts +1 -1
- package/build/devtools/server/index.d.mts +1 -1
- package/build/devtools/server/index.mjs +1 -1
- package/build/devtools/server/index.mjs.map +1 -1
- package/build/index.cjs +2733 -1963
- package/build/index.cjs.map +1 -1
- package/build/index.d.cts +2 -2
- package/build/index.d.mts +2 -2
- package/build/index.mjs +2706 -1950
- package/build/index.mjs.map +1 -1
- package/build/react-query/index.cjs.map +1 -1
- package/build/react-query/index.d.cts +1 -1
- package/build/react-query/index.d.mts +1 -1
- package/build/react-query/index.mjs.map +1 -1
- package/package.json +4 -4
- package/build/ActionDevtoolsCore-9PsnscvK.mjs.map +0 -1
- package/build/ActionDevtoolsCore-DtgXwPBZ.cjs.map +0 -1
package/README.md
CHANGED
|
@@ -1,582 +1,582 @@
|
|
|
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.
|
|
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 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
|
+
```
|