@nice-code/action 0.1.0 → 0.1.1

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 ADDED
@@ -0,0 +1,410 @@
1
+ # @nice-code/action
2
+
3
+ A fully-typed action-based RPC framework for TypeScript. Define actions once, execute them locally or over HTTP/WebSocket — same code, same types, everywhere.
4
+
5
+ ## Why?
6
+
7
+ Modern apps split logic across server, client, and workers. Typed RPC frameworks help, but most still require separate client/server type definitions, custom serialization glue, and ad-hoc error handling. `@nice-code/action` solves this with:
8
+
9
+ - **One definition, every environment** — same action works locally, over HTTP, or WebSocket
10
+ - **Custom serialization baked in** — `Date`, `Map`, `Buffer` — define it once, it works across the wire automatically
11
+ - **Typed error unions** — declare which errors an action throws; TypeScript enforces handling them
12
+ - **Observable** — attach listeners for logging, analytics, or tracing with zero boilerplate
13
+
14
+ ---
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ bun add @nice-code/action @nice-code/error valibot
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Core Concepts
25
+
26
+ Actions flow through three states:
27
+
28
+ ```
29
+ NiceAction (definition) → NiceActionPrimed (input attached) → NiceActionResponse (result)
30
+ ```
31
+
32
+ You mostly work with `NiceAction` and let the framework handle the rest.
33
+
34
+ ---
35
+
36
+ ## Quick Start
37
+
38
+ ### 1. Define a domain and its actions
39
+
40
+ ```typescript
41
+ import { createActionRootDomain, action } from "@nice-code/action";
42
+ import * as v from "valibot";
43
+
44
+ const root = createActionRootDomain({ domain: "app" });
45
+
46
+ const orderDomain = root.createChildDomain({
47
+ domain: "order",
48
+ actions: {
49
+ placeOrder: action()
50
+ .input({ schema: v.object({ items: v.array(v.string()), total: v.number() }) })
51
+ .output({ schema: v.object({ orderId: v.string(), estimatedDelivery: v.string() }) }),
52
+
53
+ cancelOrder: action()
54
+ .input({ schema: v.object({ orderId: v.string(), reason: v.string() }) })
55
+ .output({ schema: v.object({ refundAmount: v.number() }) }),
56
+ },
57
+ });
58
+ ```
59
+
60
+ ### 2. Register handlers
61
+
62
+ ```typescript
63
+ import { ActionHandler, createActionRuntime } from "@nice-code/action";
64
+
65
+ const handler = new ActionHandler()
66
+ .forAction(orderDomain, "placeOrder", {
67
+ execution: async (primed) => {
68
+ const { items, total } = primed.input; // fully typed
69
+ const order = await db.orders.create({ items, total });
70
+ return primed.setResponse({
71
+ orderId: order.id,
72
+ estimatedDelivery: "2-3 business days",
73
+ });
74
+ },
75
+ })
76
+ .forAction(orderDomain, "cancelOrder", {
77
+ execution: async (primed) => {
78
+ const refund = await payments.refund(primed.input.orderId);
79
+ return primed.setResponse({ refundAmount: refund.amount });
80
+ },
81
+ });
82
+
83
+ root.setRuntimeEnvironment(
84
+ createActionRuntime({ envId: "server" }).addHandlers([handler])
85
+ );
86
+ ```
87
+
88
+ ### 3. Execute
89
+
90
+ ```typescript
91
+ const result = await orderDomain.action("placeOrder").execute({
92
+ items: ["SKU-001", "SKU-002"],
93
+ total: 49.99,
94
+ });
95
+ // result: { orderId: "ord_abc123", estimatedDelivery: "2-3 business days" }
96
+ ```
97
+
98
+ ---
99
+
100
+ ## Typed Error Handling
101
+
102
+ Declare errors your action can throw — TypeScript tracks them through `executeSafe()`.
103
+
104
+ ```typescript
105
+ import { err_auth, err_payment } from "@nice-code/common-errors";
106
+
107
+ const checkoutDomain = root.createChildDomain({
108
+ domain: "checkout",
109
+ actions: {
110
+ pay: action()
111
+ .input({ schema: v.object({ cartId: v.string(), cardToken: v.string() }) })
112
+ .output({ schema: v.object({ receiptId: v.string() }) })
113
+ .throws(err_auth, ["unauthenticated"]) // only this error from auth domain
114
+ .throws(err_payment), // all payment errors
115
+ },
116
+ });
117
+ ```
118
+
119
+ ```typescript
120
+ const result = await checkoutDomain.action("pay").executeSafe({
121
+ cartId: "cart_xyz",
122
+ cardToken: "tok_...",
123
+ });
124
+
125
+ if (!result.ok) {
126
+ // result.error is a fully typed NiceError union
127
+ result.error.handleWithSync([
128
+ forId(err_auth, "unauthenticated", () => res.status(401).json({ error: "Login required" })),
129
+ forId(err_payment, "card_declined", (err) => {
130
+ const { last4 } = err.getContext();
131
+ res.status(402).json({ error: `Card ending in ${last4} was declined` });
132
+ }),
133
+ forDomain(err_payment, () => res.status(402).json({ error: "Payment failed" })),
134
+ ]);
135
+ return;
136
+ }
137
+
138
+ // result.output: { receiptId: string }
139
+ res.json({ receiptId: result.output.receiptId });
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Custom Serialization
145
+
146
+ Non-JSON types — `Date`, `Map`, binary — need serialization for transport. Define it once on the schema; the framework handles it on both ends.
147
+
148
+ ```typescript
149
+ const eventDomain = root.createChildDomain({
150
+ domain: "event",
151
+ actions: {
152
+ schedule: action()
153
+ .input(
154
+ { schema: v.object({ name: v.string(), scheduledAt: v.date() }) },
155
+ // serialize: Date → ISO string for the wire
156
+ ({ name, scheduledAt }) => ({ name, iso: scheduledAt.toISOString() }),
157
+ // deserialize: ISO string → Date on the other end
158
+ ({ name, iso }) => ({ name, scheduledAt: new Date(iso) })
159
+ )
160
+ .output({ schema: v.object({ eventId: v.string() }) }),
161
+ },
162
+ });
163
+ ```
164
+
165
+ ```typescript
166
+ // Client sends this:
167
+ await eventDomain.action("schedule").execute({
168
+ name: "Team meeting",
169
+ scheduledAt: new Date("2025-03-15T14:00:00Z"), // ← native Date
170
+ });
171
+
172
+ // Wire format (what actually travels over HTTP):
173
+ // { name: "Team meeting", iso: "2025-03-15T14:00:00.000Z" }
174
+
175
+ // Server receives native Date automatically — no manual parsing
176
+ handler.forAction(eventDomain, "schedule", {
177
+ execution: async (primed) => {
178
+ console.log(primed.input.scheduledAt instanceof Date); // true
179
+ await calendar.create(primed.input.scheduledAt);
180
+ return primed.setResponse({ eventId: "evt_..." });
181
+ },
182
+ });
183
+ ```
184
+
185
+ ---
186
+
187
+ ## Same Actions, Different Environments
188
+
189
+ The same domain definition works on both server (with handlers) and client (with remote transport). No code duplication.
190
+
191
+ ### Server
192
+
193
+ ```typescript
194
+ // server.ts
195
+ const handler = new ActionHandler().forDomain(orderDomain, {
196
+ execution: async (primed) => {
197
+ /* ... real DB logic ... */
198
+ },
199
+ });
200
+
201
+ root.setRuntimeEnvironment(
202
+ createActionRuntime({ envId: "server" }).addHandlers([handler])
203
+ );
204
+
205
+ // One endpoint handles all actions
206
+ app.post("/actions", async (req, res) => {
207
+ const result = await handler.handleWire(req.body);
208
+ if (!result.handled) return res.status(404).end();
209
+ res.json(result.response.toJsonObject());
210
+ });
211
+ ```
212
+
213
+ ### Client
214
+
215
+ ```typescript
216
+ // client.ts — exact same domain, different runtime
217
+ import { ActionConnect, ConnectionConfig, createActionRuntime } from "@nice-code/action";
218
+
219
+ const connect = new ActionConnect(
220
+ [new ConnectionConfig({ transports: [{ type: "http", url: "/actions" }] })],
221
+ { requestTimeout: 30_000 }
222
+ ).routeDomain(orderDomain);
223
+
224
+ root.setRuntimeEnvironment(
225
+ createActionRuntime({ envId: "browser" }).addHandlers([connect])
226
+ );
227
+
228
+ // Works exactly like local execution — goes over HTTP transparently
229
+ const result = await orderDomain.action("placeOrder").execute({
230
+ items: ["SKU-001"],
231
+ total: 29.99,
232
+ });
233
+ ```
234
+
235
+ ### WebSocket for real-time
236
+
237
+ ```typescript
238
+ new ConnectionConfig({
239
+ transports: [
240
+ { type: "ws", url: "wss://api.example.com/ws" }, // persistent connection, no reconnect overhead
241
+ { type: "http", url: "/actions" }, // fallback
242
+ ],
243
+ })
244
+ ```
245
+
246
+ ---
247
+
248
+ ## Observability
249
+
250
+ Attach listeners to any domain — fire for every action without modifying handlers.
251
+
252
+ ```typescript
253
+ const unsubscribe = orderDomain.addActionListener({
254
+ execution: (primed, { runtime }) => {
255
+ logger.info(`[${runtime.name}] → ${primed.domain}::${primed.id}`, primed.input);
256
+ },
257
+ response: (response, { runtime }) => {
258
+ const { result } = response;
259
+ if (result.ok) {
260
+ metrics.increment(`action.success`, { action: response.id });
261
+ } else {
262
+ metrics.increment(`action.error`, { action: response.id, error: result.error.id });
263
+ }
264
+ },
265
+ });
266
+
267
+ // Clean up when done
268
+ unsubscribe();
269
+ ```
270
+
271
+ ---
272
+
273
+ ## Pattern Matching
274
+
275
+ When you receive an action and need to branch on its identity:
276
+
277
+ ```typescript
278
+ import { matchAction } from "@nice-code/action";
279
+
280
+ await matchAction(incomingAction)
281
+ .with({
282
+ domain: orderDomain,
283
+ id: "placeOrder",
284
+ handler: async (action) => {
285
+ // action narrowed to NiceAction<OrderDomain, "placeOrder">
286
+ await notifyWarehouse(action.input);
287
+ },
288
+ })
289
+ .with({
290
+ domain: orderDomain,
291
+ id: "cancelOrder",
292
+ handler: async (action) => {
293
+ await notifyCustomer(action.input.orderId);
294
+ },
295
+ })
296
+ .otherwise(async (action) => {
297
+ logger.warn(`Unhandled action: ${action.domain}::${action.id}`);
298
+ })
299
+ .runAsync();
300
+ ```
301
+
302
+ ---
303
+
304
+ ## Wire Format
305
+
306
+ Every action state serializes to JSON — useful for logging, queuing, or replay:
307
+
308
+ ```typescript
309
+ const primed = orderDomain.action("placeOrder").prime({ items: ["SKU-001"], total: 29.99 });
310
+
311
+ const wire = primed.toJsonObject();
312
+ // {
313
+ // type: "primed",
314
+ // domain: "order",
315
+ // allDomains: ["order", "app"],
316
+ // id: "placeOrder",
317
+ // cuid: "abc123...",
318
+ // timeCreated: 1704067200000,
319
+ // timePrimed: 1704067201000,
320
+ // input: { items: ["SKU-001"], total: 29.99 }
321
+ // }
322
+
323
+ // Reconstruct anywhere
324
+ const rehydrated = orderDomain.hydratePrimed(wire);
325
+ await rehydrated.execute(); // executes with original input
326
+ ```
327
+
328
+ Store actions in a queue, ship them to a worker, execute on the other side — full round-trip with validation.
329
+
330
+ ---
331
+
332
+ ## Named Handlers (Tags)
333
+
334
+ Route different action types to different handlers — useful for auth tiers, feature flags, or test overrides.
335
+
336
+ ```typescript
337
+ const adminHandler = new ActionHandler({ tag: "admin" })
338
+ .forAction(userDomain, "deleteUser", {
339
+ execution: async (primed) => { /* admin-only logic */ },
340
+ });
341
+
342
+ const publicHandler = new ActionHandler()
343
+ .forDomain(userDomain, { execution: handlePublicActions });
344
+
345
+ root.setRuntimeEnvironment(
346
+ createActionRuntime({ envId: "server" })
347
+ .addHandlers([adminHandler, publicHandler])
348
+ );
349
+
350
+ // Use the tagged handler explicitly
351
+ await userDomain.action("deleteUser").execute(input, { tag: "admin" });
352
+ ```
353
+
354
+ ---
355
+
356
+ ## API Reference
357
+
358
+ ### Domain
359
+
360
+ | Method | Description |
361
+ |---|---|
362
+ | `createActionRootDomain({ domain })` | Create root domain |
363
+ | `root.createChildDomain({ domain, actions })` | Create child domain |
364
+ | `domain.action(id)` | Get action definition |
365
+ | `domain.addActionListener({ execution?, response? })` | Observe all actions; returns unsubscribe fn |
366
+ | `domain.hydratePrimed(wire)` | Reconstruct primed action from JSON |
367
+ | `domain.hydrateResponse(wire)` | Reconstruct response from JSON |
368
+
369
+ ### Action
370
+
371
+ | Method | Description |
372
+ |---|---|
373
+ | `action.execute(input, meta?)` | Execute; throws on error |
374
+ | `action.executeSafe(input, meta?)` | Execute; returns `{ ok, output }` or `{ ok, error }` |
375
+ | `action.prime(input)` | Attach and validate input |
376
+ | `action.is(other)` | Type guard — check if action matches this definition |
377
+
378
+ ### Schema Builder (`action()`)
379
+
380
+ | Method | Description |
381
+ |---|---|
382
+ | `.input({ schema }, serialize?, deserialize?)` | Declare input schema + optional serde |
383
+ | `.output({ schema }, serialize?, deserialize?)` | Declare output schema + optional serde |
384
+ | `.throws(errorDomain, [ids]?)` | Declare throwable errors (all or subset) |
385
+
386
+ ### Handlers
387
+
388
+ | Class | Use case |
389
+ |---|---|
390
+ | `ActionHandler` | Local/same-process execution |
391
+ | `ActionConnect` | Remote execution over HTTP or WebSocket |
392
+
393
+ ### ActionHandler
394
+
395
+ | Method | Description |
396
+ |---|---|
397
+ | `.forAction(domain, id, { execution, response? })` | Handle one action |
398
+ | `.forActionIds(domain, ids, { execution, response? })` | Handle a subset |
399
+ | `.forDomain(domain, { execution, response? })` | Handle all actions in domain |
400
+ | `.forDomainActionCases(domain, cases)` | Handle actions via case map |
401
+ | `.handleWire(body)` | Parse and dispatch raw JSON (for HTTP endpoints) |
402
+
403
+ ### ActionConnect
404
+
405
+ | Method | Description |
406
+ |---|---|
407
+ | `.routeDomain(domain, route?)` | Route all domain actions remotely |
408
+ | `.routeAction(domain, id, route?)` | Route one action remotely |
409
+ | `.routeActionIds(domain, ids, route?)` | Route a subset remotely |
410
+ | `.disconnect()` | Close connections |
package/build/index.js CHANGED
@@ -3528,6 +3528,20 @@ class Transport {
3528
3528
  }
3529
3529
  }
3530
3530
 
3531
+ // src/ActionRuntimeEnvironment/ActionConnect/Transport/Transport.types.ts
3532
+ var ETransportType;
3533
+ ((ETransportType2) => {
3534
+ ETransportType2["ws"] = "ws";
3535
+ ETransportType2["http"] = "http";
3536
+ })(ETransportType ||= {});
3537
+ var ETransportStatus;
3538
+ ((ETransportStatus2) => {
3539
+ ETransportStatus2["uninitialized"] = "uninitialized";
3540
+ ETransportStatus2["initializing"] = "initializing";
3541
+ ETransportStatus2["ready"] = "ready";
3542
+ ETransportStatus2["failed"] = "failed";
3543
+ })(ETransportStatus ||= {});
3544
+
3531
3545
  // src/ActionRuntimeEnvironment/ActionConnect/Transport/TransportHttp.ts
3532
3546
  class TransportHttp extends Transport {
3533
3547
  abortControllers = new Map;
@@ -4200,16 +4214,22 @@ export {
4200
4214
  createActionRuntime,
4201
4215
  createActionRootDomain,
4202
4216
  action,
4203
- ConnectionConfig as Transport,
4217
+ TransportWebSocket,
4218
+ TransportHttp,
4219
+ Transport,
4204
4220
  NiceActionSchema,
4221
+ NiceActionRootDomain,
4205
4222
  NiceActionResponse,
4206
4223
  NiceActionPrimed,
4207
4224
  NiceActionDomain,
4208
4225
  NiceAction,
4226
+ ETransportType,
4227
+ ETransportStatus,
4209
4228
  EErrId_NiceTransport_WebSocket,
4210
4229
  EErrId_NiceTransport,
4211
4230
  EErrId_NiceAction,
4212
4231
  EActionState,
4232
+ ConnectionConfig,
4213
4233
  ActionRuntimeEnvironment,
4214
4234
  ActionHandler,
4215
4235
  ActionConnect
@@ -15,7 +15,7 @@ export declare class ActionHandler implements IActionHandler {
15
15
  private getHandlersForAction;
16
16
  /**
17
17
  * Register a handler for all actions in a domain.
18
- * Receives the full primed action — use `domain.matchAction()` to narrow by id.
18
+ * Receives the full primed action — use `matchAction()` to narrow to a specific action id.
19
19
  * Useful for forwarding all domain actions to a remote endpoint.
20
20
  * Lower priority than `forAction`.
21
21
  */
@@ -39,7 +39,7 @@ export declare class NiceAction<DOM extends INiceActionDomain, ID extends keyof
39
39
  * ```ts
40
40
  * const result = await domain.action("getUser").executeSafe({ userId: "123" });
41
41
  * if (!result.ok) {
42
- * result.error.handleWith([
42
+ * result.error.handleWithSync([
43
43
  * forDomain(err_auth, (h) => res.status(401).end()),
44
44
  * ]);
45
45
  * return;
@@ -39,7 +39,7 @@ export type INiceActionPrimed_JsonObject<DOM extends INiceActionDomain = INiceAc
39
39
  * ```ts
40
40
  * const result = await domain.action("getUser").executeSafe({ userId: "123" });
41
41
  * if (!result.ok) {
42
- * result.error.handleWith([
42
+ * result.error.handleWithSync([
43
43
  * forDomain(err_auth, (h) => res.status(401).end()),
44
44
  * ]);
45
45
  * return;
@@ -1,12 +1,18 @@
1
1
  export { createActionRootDomain } from "./ActionDomain/helpers/createRootActionDomain";
2
2
  export { NiceActionDomain } from "./ActionDomain/NiceActionDomain";
3
- export type { INiceActionDomain, INiceActionDomainChildOptions, MaybePromise, TDomainActionId, TInferInputFromSchema, TInferOutputFromSchema, TNiceActionDomainChildDef, TNiceActionDomainSchema, TPossibleDomainId, TPossibleDomainIdList, } from "./ActionDomain/NiceActionDomain.types";
3
+ export type { INiceActionDomain, INiceActionDomainChildOptions, INiceActionRootDomain, MaybePromise, TDomainActionId, TInferInputFromSchema, TInferOutputFromSchema, TNiceActionDomainChildDef, TNiceActionDomainSchema, TPossibleDomainId, TPossibleDomainIdList, } from "./ActionDomain/NiceActionDomain.types";
4
+ export { NiceActionRootDomain } from "./ActionDomain/RootDomain/NiceActionRootDomain";
4
5
  export { ActionConnect } from "./ActionRuntimeEnvironment/ActionConnect/ActionConnect";
5
6
  export * from "./ActionRuntimeEnvironment/ActionConnect/ActionConnect.types";
6
- export { ConnectionConfig as Transport } from "./ActionRuntimeEnvironment/ActionConnect/ConnectionConfig/ConnectionConfig";
7
+ export { ConnectionConfig } from "./ActionRuntimeEnvironment/ActionConnect/ConnectionConfig/ConnectionConfig";
8
+ export * from "./ActionRuntimeEnvironment/ActionConnect/ConnectionConfig/ConnectionConfig.types";
7
9
  export * from "./ActionRuntimeEnvironment/ActionConnect/err_nice_connect";
8
10
  export * from "./ActionRuntimeEnvironment/ActionConnect/Transport/err_nice_transport";
9
11
  export * from "./ActionRuntimeEnvironment/ActionConnect/Transport/err_nice_transport_ws";
12
+ export * from "./ActionRuntimeEnvironment/ActionConnect/Transport/Transport";
13
+ export * from "./ActionRuntimeEnvironment/ActionConnect/Transport/Transport.types";
14
+ export * from "./ActionRuntimeEnvironment/ActionConnect/Transport/TransportHttp";
15
+ export * from "./ActionRuntimeEnvironment/ActionConnect/Transport/TransportWebSocket";
10
16
  export { ActionHandler, createHandler, } from "./ActionRuntimeEnvironment/ActionHandler/ActionHandler";
11
17
  export type { IActionHandlerInputs, TAtLeastOne, TExecutionAndResponseHandlers, THandleActionExecutionFn, THandleActionResponseFn, THandleActionResult, } from "./ActionRuntimeEnvironment/ActionHandler/ActionHandler.types";
12
18
  export { ActionRuntimeEnvironment, createActionRuntime, } from "./ActionRuntimeEnvironment/ActionRuntimeEnvironment";
@@ -37,7 +37,7 @@ export type TUseNiceMutationOptions<DOM extends INiceActionDomain, ID extends ke
37
37
  * Passing `null` or `undefined` as `input` disables the query (sets `enabled: false`),
38
38
  * which allows conditional execution while respecting React's rules of hooks.
39
39
  *
40
- * The `envId` option targets a specific named handler/resolver registered on the domain.
40
+ * The `tag` option targets a specific named handler registered on the runtime environment.
41
41
  *
42
42
  * Supports TanStack Query's `select` option with full type inference — if you pass a
43
43
  * `select` transformer, `data` will be typed as the transformer's return type.
@@ -59,7 +59,7 @@ export declare function useNiceQuery<DOM extends INiceActionDomain, ID extends k
59
59
  * Ideal for actions that change server state — form submissions, updates, deletes, etc.
60
60
  * The input is provided at call time via `mutation.mutate(input)` or `mutation.mutateAsync(input)`.
61
61
  *
62
- * The `envId` option targets a specific named handler/resolver registered on the domain.
62
+ * The `tag` option targets a specific named handler registered on the runtime environment.
63
63
  *
64
64
  * @example
65
65
  * const mutation = useNiceMutation(domain.action("createUser"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nice-code/action",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -32,8 +32,8 @@
32
32
  "build-types": "tsc --project tsconfig.build.json"
33
33
  },
34
34
  "dependencies": {
35
- "@nice-code/error": "0.1.0",
36
- "@nice-code/common-errors": "0.1.0",
35
+ "@nice-code/error": "0.1.1",
36
+ "@nice-code/common-errors": "0.1.1",
37
37
  "@standard-schema/spec": "^1.1.0",
38
38
  "http-status-codes": "^2.3.0",
39
39
  "nanoid": "^5.1.9",