@nice-code/action 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,10 +14,13 @@ Peer deps: `valibot` (or any [Standard Schema](https://github.com/standard-schem
14
14
 
15
15
  - **ActionDomain** — a named group of typed actions (like an API surface)
16
16
  - **ActionSchema** — input/output schema + declared error types for one action
17
- - **ActionRuntime** — processes incoming requests and dispatches them to handlers
17
+ - **ActionRuntime** — one per client; processes incoming requests and dispatches them to handlers. Wire each backend it talks to with `runtime.connectTo(...)`
18
18
  - **ActionLocalHandler** — executes actions in the current process
19
- - **ActionExternalClientHandler** — forwards/receives actions over HTTP or WebSocket
20
- - **RuntimeCoordinate** — identifies an environment (frontend, backend, worker…)
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.
21
24
 
22
25
  ---
23
26
 
@@ -140,33 +143,80 @@ app.post("/resolve_action", async (c) => {
140
143
  });
141
144
  ```
142
145
 
143
- ### Client (external handler)
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`:
144
148
 
145
149
  ```ts
146
- import {
147
- ActionRuntime,
148
- ActionExternalClientHandler,
149
- RuntimeCoordinate,
150
- HttpTransport,
151
- } from "@nice-code/action";
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";
152
176
 
153
177
  export const clientCoord = RuntimeCoordinate.env("frontend");
154
178
  export const serverCoord = RuntimeCoordinate.env("backend");
155
179
 
180
+ // The single runtime that identifies this client everywhere.
181
+ export const clientRuntime = new ActionRuntime(clientCoord);
182
+
156
183
  // Transport definitions are plain, reusable objects you construct once.
157
184
  const serverHttp = HttpTransport.create({
158
185
  createRequest: () => ({ url: "https://api.example.com/resolve_action" }),
159
186
  });
160
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
161
212
  const serverHandler = new ActionExternalClientHandler({
162
213
  runtimeCoordinate: serverCoord,
163
214
  transports: [serverHttp],
164
215
  }).forDomain(userDomain);
165
216
 
166
- export const clientRuntime = new ActionRuntime(clientCoord)
167
- .addHandlers([serverHandler])
168
- .apply();
217
+ clientRuntime.addHandlers([serverHandler]).apply();
169
218
  ```
219
+ </details>
170
220
 
171
221
  ---
172
222
 
@@ -191,6 +241,162 @@ const output = await userDomain.action.getUser
191
241
 
192
242
  ---
193
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. createBinaryWsAdapter([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
+
194
400
  ## React Query integration
195
401
 
196
402
  ```ts
@@ -253,17 +459,18 @@ devtools.attachToDomain(appRoot);
253
459
  ## WebSocket transport
254
460
 
255
461
  ```ts
256
- import { WebSocketTransport } from "@nice-code/action";
462
+ import { ActionRuntime, WebSocketTransport, HttpTransport } from "@nice-code/action";
257
463
 
258
464
  const serverWs = WebSocketTransport.create({
259
465
  createWebSocket: () => new WebSocket("wss://api.example.com/resolve_action/ws"),
260
466
  getTransportCacheKey: () => ["wss://api.example.com/resolve_action/ws"],
261
467
  });
262
468
 
263
- const serverHandler = new ActionExternalClientHandler({
264
- runtimeCoordinate: serverCoord,
265
- transports: [serverWs, serverHttp], // WebSocket preferred, HTTP as fallback
266
- }).forDomain(userDomain);
469
+ // One channel, WebSocket preferred with HTTP as fallback.
470
+ clientRuntime.connectTo(serverCoord, {
471
+ transports: [serverWs, serverHttp],
472
+ domains: [userDomain],
473
+ });
267
474
  ```
268
475
 
269
476
  The socket is opened lazily and reused across actions sharing a `getTransportCacheKey`. Multiple
@@ -276,6 +483,73 @@ derive the request per action straight from the params (e.g.
276
483
  control over readiness (support detection, async init), `WebSocketTransport` / `CustomTransport` also
277
484
  offer `.createAdvanced({ getTransport })`.
278
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
+
279
553
  ---
280
554
 
281
555
  ## RuntimeCoordinate