@nice-code/action 0.17.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 +637 -582
- package/package.json +4 -4
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
});
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
//
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
```
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
```
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nice-code/action",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"main": "./build/index.cjs",
|
|
5
5
|
"module": "./build/index.mjs",
|
|
6
6
|
"types": "./build/index.d.cts",
|
|
@@ -86,9 +86,9 @@
|
|
|
86
86
|
},
|
|
87
87
|
"type": "module",
|
|
88
88
|
"dependencies": {
|
|
89
|
-
"@nice-code/common-errors": "0.
|
|
90
|
-
"@nice-code/error": "0.
|
|
91
|
-
"@nice-code/util": "0.
|
|
89
|
+
"@nice-code/common-errors": "0.18.0",
|
|
90
|
+
"@nice-code/error": "0.18.0",
|
|
91
|
+
"@nice-code/util": "0.18.0",
|
|
92
92
|
"@standard-schema/spec": "^1.1.0",
|
|
93
93
|
"@tanstack/react-virtual": "^3.13.26",
|
|
94
94
|
"http-status-codes": "^2.3.0",
|