@nice-code/action 0.1.0 → 0.1.2

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
@@ -3393,6 +3393,7 @@ var EErrId_NiceTransport;
3393
3393
  ((EErrId_NiceTransport2) => {
3394
3394
  EErrId_NiceTransport2["timeout"] = "timeout";
3395
3395
  EErrId_NiceTransport2["not_found"] = "not_found";
3396
+ EErrId_NiceTransport2["not_available"] = "not_available";
3396
3397
  EErrId_NiceTransport2["initialization_failed"] = "initialization_failed";
3397
3398
  EErrId_NiceTransport2["send_failed"] = "send_failed";
3398
3399
  EErrId_NiceTransport2["invalid_action_response"] = "invalid_action_response";
@@ -3406,6 +3407,9 @@ var err_nice_transport = err_nice_connect.createChildDomain({
3406
3407
  ["not_found" /* not_found */]: err({
3407
3408
  message: ({ actionId, routeKey, tag }) => `No connected transport found for action "${actionId}"${routeKey ? ` with route key "${routeKey}"` : ``}${tag ? ` and action tag "${tag}"` : ""}.`
3408
3409
  }),
3410
+ ["not_available" /* not_available */]: err({
3411
+ message: ({ transportCount }) => `${transportCount} Transport(s) found but were filtered out via filterUsage().`
3412
+ }),
3409
3413
  ["initialization_failed" /* initialization_failed */]: err({
3410
3414
  message: ({ actionId, routeKey, tag }) => `Transports found for action "${actionId}"${routeKey ? ` with route key "${routeKey}"` : ""}${tag ? ` and action tag "${tag}"` : ""}, but none are ready.`
3411
3415
  }),
@@ -3487,13 +3491,21 @@ class Transport {
3487
3491
  def;
3488
3492
  type;
3489
3493
  requestResolvers = new Map;
3494
+ _filterUsage;
3490
3495
  constructor(def) {
3491
3496
  this.def = def;
3492
3497
  this.type = def.type;
3498
+ this._filterUsage = def.filterUsage;
3493
3499
  }
3494
3500
  get status() {
3495
3501
  return this._status;
3496
3502
  }
3503
+ filterUsage(primed) {
3504
+ if (this._filterUsage == null) {
3505
+ return true;
3506
+ }
3507
+ return this._filterUsage(primed);
3508
+ }
3497
3509
  checkAndPrepare() {
3498
3510
  return this._status;
3499
3511
  }
@@ -3528,6 +3540,21 @@ class Transport {
3528
3540
  }
3529
3541
  }
3530
3542
 
3543
+ // src/ActionRuntimeEnvironment/ActionConnect/Transport/Transport.types.ts
3544
+ var ETransportType;
3545
+ ((ETransportType2) => {
3546
+ ETransportType2["ws"] = "ws";
3547
+ ETransportType2["http"] = "http";
3548
+ ETransportType2["custom"] = "custom";
3549
+ })(ETransportType ||= {});
3550
+ var ETransportStatus;
3551
+ ((ETransportStatus2) => {
3552
+ ETransportStatus2["uninitialized"] = "uninitialized";
3553
+ ETransportStatus2["initializing"] = "initializing";
3554
+ ETransportStatus2["ready"] = "ready";
3555
+ ETransportStatus2["failed"] = "failed";
3556
+ })(ETransportStatus ||= {});
3557
+
3531
3558
  // src/ActionRuntimeEnvironment/ActionConnect/Transport/TransportHttp.ts
3532
3559
  class TransportHttp extends Transport {
3533
3560
  abortControllers = new Map;
@@ -3607,6 +3634,7 @@ var err_nice_transport_ws = err_nice_transport.createChildDomain({
3607
3634
  class TransportWebSocket extends Transport {
3608
3635
  websocket;
3609
3636
  _status = { status: "uninitialized" /* uninitialized */ };
3637
+ _customMessageSerde;
3610
3638
  constructor(def) {
3611
3639
  super(def);
3612
3640
  }
@@ -3628,21 +3656,24 @@ class TransportWebSocket extends Transport {
3628
3656
  waitForInitialization
3629
3657
  };
3630
3658
  }
3631
- handleMessage(data) {
3659
+ handlePureActionResponseMessage(message) {
3632
3660
  let json;
3633
3661
  try {
3634
- json = JSON.parse(data);
3662
+ json = JSON.parse(message);
3635
3663
  } catch {
3636
3664
  return;
3637
3665
  }
3638
3666
  if (!isActionResponseJsonObject(json)) {
3639
3667
  return;
3640
3668
  }
3641
- const pending = this.requestResolvers.get(json.cuid);
3669
+ return json;
3670
+ }
3671
+ handleResponse(response) {
3672
+ const pending = this.requestResolvers.get(response.cuid);
3642
3673
  if (pending == null) {
3643
3674
  return;
3644
3675
  }
3645
- this.respond(pending.primed.coreAction.actionDomain.hydrateResponse(json));
3676
+ this.respond(pending.primed.coreAction.actionDomain.hydrateResponse(response));
3646
3677
  }
3647
3678
  rejectPendingWebSocketRequests(error) {
3648
3679
  for (const [, pending] of this.requestResolvers) {
@@ -3662,7 +3693,9 @@ class TransportWebSocket extends Transport {
3662
3693
  }
3663
3694
  };
3664
3695
  try {
3665
- this.websocket = await this.def.createWebSocket();
3696
+ const { ws, customMessageSerde } = await this.def.createWebSocket();
3697
+ this.websocket = ws;
3698
+ this._customMessageSerde = customMessageSerde;
3666
3699
  } catch (e2) {
3667
3700
  console.error("Failed to create WebSocket:", e2);
3668
3701
  const error = err_nice_transport_ws.fromId("ws_create_failed" /* ws_create_failed */, {
@@ -3687,7 +3720,10 @@ class TransportWebSocket extends Transport {
3687
3720
  });
3688
3721
  this.websocket.addEventListener("message", (event) => {
3689
3722
  if (typeof event.data === "string") {
3690
- this.handleMessage(event.data);
3723
+ const response = this._customMessageSerde?.deserialize?.(event.data) ?? this.handlePureActionResponseMessage(event.data);
3724
+ if (response) {
3725
+ this.handleResponse(response);
3726
+ }
3691
3727
  }
3692
3728
  });
3693
3729
  this.websocket.addEventListener("close", (event) => {
@@ -3759,7 +3795,23 @@ class ConnectionConfig {
3759
3795
  async dispatch(primed, defaultTimeout) {
3760
3796
  const timeout = this.config.defaultTimeout ?? defaultTimeout;
3761
3797
  const initializingWaiters = [];
3798
+ const unavailableTransports = [];
3762
3799
  for (const transport of this._transports) {
3800
+ const isAvailable = transport.filterUsage(primed);
3801
+ if (isAvailable instanceof Promise) {
3802
+ try {
3803
+ if (!await isAvailable) {
3804
+ unavailableTransports.push(transport);
3805
+ continue;
3806
+ }
3807
+ } catch {
3808
+ unavailableTransports.push(transport);
3809
+ continue;
3810
+ }
3811
+ } else if (!isAvailable) {
3812
+ unavailableTransports.push(transport);
3813
+ continue;
3814
+ }
3763
3815
  const statusInfo = transport.checkAndPrepare();
3764
3816
  if (statusInfo.status === "ready" /* ready */) {
3765
3817
  return transport.makeRequest(primed, timeout);
@@ -3774,6 +3826,11 @@ class ConnectionConfig {
3774
3826
  }
3775
3827
  }
3776
3828
  if (initializingWaiters.length === 0) {
3829
+ if (unavailableTransports.length > 0) {
3830
+ throw err_nice_transport.fromId("not_available" /* not_available */, {
3831
+ transportCount: unavailableTransports.length
3832
+ });
3833
+ }
3777
3834
  throw err_nice_transport.fromId("not_found" /* not_found */, {
3778
3835
  actionId: primed.id
3779
3836
  });
@@ -4200,16 +4257,22 @@ export {
4200
4257
  createActionRuntime,
4201
4258
  createActionRootDomain,
4202
4259
  action,
4203
- ConnectionConfig as Transport,
4260
+ TransportWebSocket,
4261
+ TransportHttp,
4262
+ Transport,
4204
4263
  NiceActionSchema,
4264
+ NiceActionRootDomain,
4205
4265
  NiceActionResponse,
4206
4266
  NiceActionPrimed,
4207
4267
  NiceActionDomain,
4208
4268
  NiceAction,
4269
+ ETransportType,
4270
+ ETransportStatus,
4209
4271
  EErrId_NiceTransport_WebSocket,
4210
4272
  EErrId_NiceTransport,
4211
4273
  EErrId_NiceAction,
4212
4274
  EActionState,
4275
+ ConnectionConfig,
4213
4276
  ActionRuntimeEnvironment,
4214
4277
  ActionHandler,
4215
4278
  ActionConnect
@@ -1,6 +1,5 @@
1
1
  import type { NiceActionSchema } from "../ActionSchema/NiceActionSchema";
2
2
  import type { INiceActionErrorDeclaration, TTransportedValue } from "../ActionSchema/NiceActionSchema.types";
3
- export type MaybePromise<T> = T | Promise<T>;
4
3
  export type TPossibleDomainId = string;
5
4
  export type TPossibleDomainIdList = [TPossibleDomainId, ...TPossibleDomainId[]];
6
5
  export type TNiceActionDomainSchema = Record<string, NiceActionSchema<TTransportedValue<any, any>, TTransportedValue<any, any>, readonly INiceActionErrorDeclaration<any, any>[]>>;
@@ -1,3 +1,4 @@
1
+ import type { MaybePromise } from "../../..";
1
2
  import type { NiceActionPrimed } from "../../../NiceAction/NiceActionPrimed";
2
3
  import type { NiceActionResponse } from "../../../NiceAction/NiceActionResponse";
3
4
  import { type ITransportPendingRequest, type TActionTransportDef, type TTransportStatusInfo } from "./Transport.types";
@@ -6,8 +7,10 @@ export declare abstract class Transport<DEF extends TActionTransportDef> {
6
7
  readonly type: DEF["type"];
7
8
  readonly requestResolvers: Map<string, ITransportPendingRequest>;
8
9
  protected abstract _status: TTransportStatusInfo;
10
+ protected _filterUsage?: (primed: NiceActionPrimed<any>) => MaybePromise<boolean>;
9
11
  constructor(def: DEF);
10
12
  get status(): TTransportStatusInfo;
13
+ filterUsage(primed: NiceActionPrimed<any>): MaybePromise<boolean>;
11
14
  checkAndPrepare(): TTransportStatusInfo;
12
15
  protected abstract send(primed: NiceActionPrimed<any>): Promise<void>;
13
16
  abstract disconnect(): void;
@@ -1,10 +1,12 @@
1
- import type { NiceError } from "@nice-code/error";
1
+ import type { MaybePromise, NiceError } from "@nice-code/error";
2
+ import type { INiceActionPrimed_JsonObject, TNiceActionResponse_JsonObject } from "../../../NiceAction/NiceAction.types";
2
3
  import type { NiceActionPrimed } from "../../../NiceAction/NiceActionPrimed";
3
4
  import type { NiceActionResponse } from "../../../NiceAction/NiceActionResponse";
4
5
  import type { Transport } from "./Transport";
5
6
  export declare enum ETransportType {
6
7
  ws = "ws",
7
- http = "http"
8
+ http = "http",
9
+ custom = "custom"
8
10
  }
9
11
  export declare enum ETransportStatus {
10
12
  uninitialized = "uninitialized",
@@ -31,16 +33,34 @@ export type TTransportStatusInfo = ITransportStatusInfo_Base<ETransportStatus.un
31
33
  export interface IActionTransport_Base {
32
34
  /** Per-transport timeout override (ms) */
33
35
  timeout?: number;
36
+ filterUsage?: (primed: NiceActionPrimed<any>) => MaybePromise<boolean>;
37
+ }
38
+ export interface ICustomWebsocketMessageSerde {
39
+ serialize?: (primedJson: INiceActionPrimed_JsonObject<any>) => string;
40
+ deserialize?: (message: string) => TNiceActionResponse_JsonObject<any>;
34
41
  }
35
42
  export interface IActionTransportDef_Ws extends IActionTransport_Base {
36
43
  type: ETransportType.ws;
37
- createWebSocket: () => Promise<WebSocket>;
44
+ createWebSocket: () => Promise<{
45
+ ws: WebSocket;
46
+ customMessageSerde?: ICustomWebsocketMessageSerde;
47
+ }>;
38
48
  }
39
49
  export interface IActionTransportDef_Http extends IActionTransport_Base {
40
50
  type: ETransportType.http;
41
51
  url: string;
42
52
  }
43
- export type TActionTransportDef = IActionTransportDef_Ws | IActionTransportDef_Http;
53
+ export interface ICustomActionTransport {
54
+ checkAndPrepare: () => TTransportStatusInfo;
55
+ handleAction: (primed: NiceActionPrimed<any>, onResponse: (response: NiceActionResponse<any>) => void) => Promise<void>;
56
+ onDisconnect: () => void;
57
+ }
58
+ export interface IActionTransportDef_Custom extends IActionTransport_Base {
59
+ type: ETransportType.custom;
60
+ initialStatus: TTransportStatusInfo;
61
+ createTransport: () => ICustomActionTransport;
62
+ }
63
+ export type TActionTransportDef = IActionTransportDef_Ws | IActionTransportDef_Http | IActionTransportDef_Custom;
44
64
  export interface ITransportPendingRequest {
45
65
  type: ETransportType;
46
66
  resolve: (response: NiceActionResponse<any>) => void;
@@ -0,0 +1,12 @@
1
+ import type { NiceActionPrimed } from "../../../NiceAction/NiceActionPrimed";
2
+ import { Transport } from "./Transport";
3
+ import { type IActionTransportDef_Custom, type TTransportStatusInfo } from "./Transport.types";
4
+ export declare class TransportCustom extends Transport<IActionTransportDef_Custom> {
5
+ readonly abortControllers: Map<string, AbortController>;
6
+ protected _status: TTransportStatusInfo;
7
+ private _customTransport;
8
+ constructor(def: IActionTransportDef_Custom);
9
+ checkAndPrepare(): TTransportStatusInfo;
10
+ send(primed: NiceActionPrimed<any>): Promise<void>;
11
+ disconnect(): void;
12
+ }
@@ -4,10 +4,12 @@ import { type IActionTransportDef_Ws, type TTransportStatusInfo } from "./Transp
4
4
  export declare class TransportWebSocket extends Transport<IActionTransportDef_Ws> {
5
5
  websocket?: WebSocket;
6
6
  protected _status: TTransportStatusInfo;
7
+ private _customMessageSerde?;
7
8
  constructor(def: IActionTransportDef_Ws);
8
9
  checkAndPrepare(): TTransportStatusInfo;
9
10
  private startInitializing;
10
- private handleMessage;
11
+ private handlePureActionResponseMessage;
12
+ private handleResponse;
11
13
  private rejectPendingWebSocketRequests;
12
14
  private _connect;
13
15
  protected send(primed: NiceActionPrimed<any>): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  export declare enum EErrId_NiceTransport {
2
2
  timeout = "timeout",
3
3
  not_found = "not_found",
4
+ not_available = "not_available",
4
5
  initialization_failed = "initialization_failed",
5
6
  send_failed = "send_failed",
6
7
  invalid_action_response = "invalid_action_response"
@@ -17,6 +18,9 @@ export declare const err_nice_transport: import("@nice-code/error").NiceErrorDom
17
18
  routeKey?: string;
18
19
  tag?: string;
19
20
  }, import("@nice-code/error").JSONSerializableValue>;
21
+ not_available: import("@nice-code/error").INiceErrorIdMetadata<{
22
+ transportCount: number;
23
+ }, import("@nice-code/error").JSONSerializableValue>;
20
24
  initialization_failed: import("@nice-code/error").INiceErrorIdMetadata<{
21
25
  actionId: string;
22
26
  routeKey?: string;
@@ -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
  */
@@ -1,8 +1,9 @@
1
- import type { INiceActionDomain, MaybePromise, TInferOutputFromSchema } from "../../ActionDomain/NiceActionDomain.types";
1
+ import type { INiceActionDomain, TInferOutputFromSchema } from "../../ActionDomain/NiceActionDomain.types";
2
2
  import type { INiceAction, TNiceActionResponse_JsonObject } from "../../NiceAction/NiceAction.types";
3
3
  import type { TDistributedDomainActions } from "../../NiceAction/NiceActionCombined.types";
4
4
  import type { NiceActionPrimed } from "../../NiceAction/NiceActionPrimed";
5
5
  import type { NiceActionResponse } from "../../NiceAction/NiceActionResponse";
6
+ import type { MaybePromise } from "../../utils/maybePromise";
6
7
  import type { IRuntimeEnvironmentMeta } from "../ActionRuntimeEnvironment.types";
7
8
  export type TAtLeastOne<T extends object> = {
8
9
  [K in keyof T]-?: Required<Pick<T, K>> & Partial<Omit<T, K>>;
@@ -1,4 +1,5 @@
1
- import type { INiceActionDomain, MaybePromise } from "../../ActionDomain/NiceActionDomain.types";
1
+ import type { INiceActionDomain } from "../../ActionDomain/NiceActionDomain.types";
2
+ import type { MaybePromise } from "../../utils/maybePromise";
2
3
  import type { INiceAction } from "../NiceAction.types";
3
4
  import type { TNarrowActionType } from "../NiceActionCombined.types";
4
5
  type TMatchHandler<A extends INiceAction<any>> = (action: A) => MaybePromise<void>;
@@ -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, 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";
@@ -24,3 +30,4 @@ export { NiceActionPrimed } from "./NiceAction/NiceActionPrimed";
24
30
  export { NiceActionResponse } from "./NiceAction/NiceActionResponse";
25
31
  export * from "./utils/isActionResponseJsonObject";
26
32
  export * from "./utils/isPrimedActionJsonObject";
33
+ export * from "./utils/maybePromise";
@@ -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"));
@@ -0,0 +1 @@
1
+ export type MaybePromise<T> = T | Promise<T>;
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.2",
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.2",
36
+ "@nice-code/common-errors": "0.1.2",
37
37
  "@standard-schema/spec": "^1.1.0",
38
38
  "http-status-codes": "^2.3.0",
39
39
  "nanoid": "^5.1.9",