@nice-code/action 0.2.8 → 0.2.10
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 +273 -1
- package/build/index.js +43 -7505
- package/build/react-query/index.js +3 -43
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1 +1,273 @@
|
|
|
1
|
-
# @nice-code/action
|
|
1
|
+
# @nice-code/action
|
|
2
|
+
|
|
3
|
+
Typed, transport-agnostic action system for calling functions across client/server boundaries with full TypeScript inference.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @nice-code/action
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer deps: `valibot` (or any [Standard Schema](https://github.com/standard-schema/standard-schema) library), `@tanstack/react-query` (for `@nice-code/action/react-query`).
|
|
12
|
+
|
|
13
|
+
## Core concepts
|
|
14
|
+
|
|
15
|
+
- **ActionDomain** — a named group of typed actions (like an API surface)
|
|
16
|
+
- **ActionSchema** — input/output schema + declared error types for one action
|
|
17
|
+
- **ActionRuntime** — processes incoming requests and dispatches them to handlers
|
|
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…)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Defining actions
|
|
25
|
+
|
|
26
|
+
### 1. Create a root domain (shared between client and server)
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createActionRootDomain, actionSchema } from "@nice-code/action";
|
|
30
|
+
import * as v from "valibot";
|
|
31
|
+
|
|
32
|
+
// Root domain — no actions, just a namespace anchor
|
|
33
|
+
export const appRoot = createActionRootDomain({ domain: "app_root" });
|
|
34
|
+
|
|
35
|
+
// Child domain with actions
|
|
36
|
+
export const userDomain = appRoot.createChildDomain({
|
|
37
|
+
domain: "user",
|
|
38
|
+
actions: {
|
|
39
|
+
getUser: actionSchema()
|
|
40
|
+
.input({ schema: v.object({ userId: v.string() }) })
|
|
41
|
+
.output({ schema: v.object({ id: v.string(), name: v.string() }) })
|
|
42
|
+
.throws(err_user, ["not_found"]), // from @nice-code/error
|
|
43
|
+
|
|
44
|
+
updateName: actionSchema()
|
|
45
|
+
.input({ schema: v.object({ userId: v.string(), name: v.string() }) })
|
|
46
|
+
.output({ schema: v.object({ success: v.boolean() }) }),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 2. Serialization for non-JSON-native types
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
createAt: actionSchema()
|
|
55
|
+
.output(
|
|
56
|
+
{ schema: v.object({ createdAt: v.date() }) },
|
|
57
|
+
({ createdAt }) => ({ createdAt: createdAt.toISOString() }), // serialize
|
|
58
|
+
({ createdAt }) => ({ createdAt: new Date(createdAt) }), // deserialize
|
|
59
|
+
),
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 3. Declare thrown errors
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { defineNiceError, err } from "@nice-code/error";
|
|
66
|
+
|
|
67
|
+
const err_user = defineNiceError({
|
|
68
|
+
domain: "err_user",
|
|
69
|
+
schema: {
|
|
70
|
+
not_found: err<{ userId: string }>({
|
|
71
|
+
message: ({ userId }) => `User not found: ${userId}`,
|
|
72
|
+
httpStatusCode: 404,
|
|
73
|
+
context: { required: true },
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
} as const);
|
|
77
|
+
|
|
78
|
+
// Attach to an action schema
|
|
79
|
+
actionSchema()
|
|
80
|
+
.throws(err_user) // any id from err_user
|
|
81
|
+
.throws(err_user, ["not_found"]) // only specific ids
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Setting up runtimes
|
|
87
|
+
|
|
88
|
+
### Server (local handler)
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { ActionRuntime, createLocalHandler, RuntimeCoordinate } from "@nice-code/action";
|
|
92
|
+
|
|
93
|
+
// Identify this environment
|
|
94
|
+
export const serverCoord = RuntimeCoordinate.env("backend");
|
|
95
|
+
|
|
96
|
+
// Implement the actions
|
|
97
|
+
const userHandler = createLocalHandler()
|
|
98
|
+
.forDomain(userDomain, async (request) => {
|
|
99
|
+
if (request.id === "getUser") {
|
|
100
|
+
const user = await db.users.find(request.input.userId);
|
|
101
|
+
if (!user) throw err_user.fromId("not_found", { userId: request.input.userId });
|
|
102
|
+
return user;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Or use the map syntax (preferred)
|
|
107
|
+
const userHandler = createLocalHandler().forDomainActionCases(userDomain, {
|
|
108
|
+
getUser: async (req) => {
|
|
109
|
+
const user = await db.users.find(req.input.userId);
|
|
110
|
+
if (!user) throw err_user.fromId("not_found", { userId: req.input.userId });
|
|
111
|
+
return user;
|
|
112
|
+
},
|
|
113
|
+
updateName: async (req) => {
|
|
114
|
+
await db.users.update(req.input.userId, { name: req.input.name });
|
|
115
|
+
return { success: true };
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Or wrap an object directly
|
|
120
|
+
const userHandler = userDomain.wrapAsLocalHandler({
|
|
121
|
+
getUser: async ({ userId }) => { /* ... */ },
|
|
122
|
+
updateName: async ({ userId, name }) => { /* ... */ },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Wire up the runtime
|
|
126
|
+
export const serverRuntime = new ActionRuntime(serverCoord)
|
|
127
|
+
.addHandlers([userHandler])
|
|
128
|
+
.apply();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Handling incoming requests (HTTP endpoint)
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// Hono example
|
|
135
|
+
app.post("/resolve_action", async (c) => {
|
|
136
|
+
const wire = await c.req.json();
|
|
137
|
+
const runningAction = await serverRuntime.handleActionPayloadWire(wire);
|
|
138
|
+
const result = await runningAction.waitForResultPayload();
|
|
139
|
+
return c.json(result.toJsonObject());
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Client (external handler)
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import {
|
|
147
|
+
ActionRuntime,
|
|
148
|
+
ActionExternalClientHandler,
|
|
149
|
+
RuntimeCoordinate,
|
|
150
|
+
ETransportType,
|
|
151
|
+
ETransportStatus,
|
|
152
|
+
} from "@nice-code/action";
|
|
153
|
+
|
|
154
|
+
export const clientCoord = RuntimeCoordinate.env("frontend");
|
|
155
|
+
export const serverCoord = RuntimeCoordinate.env("backend");
|
|
156
|
+
|
|
157
|
+
const serverHandler = new ActionExternalClientHandler({
|
|
158
|
+
externalClientSpecifier: serverCoord,
|
|
159
|
+
transports: [
|
|
160
|
+
{
|
|
161
|
+
type: ETransportType.http,
|
|
162
|
+
initialize: () => ({
|
|
163
|
+
getTransport: () => ({
|
|
164
|
+
status: ETransportStatus.ready,
|
|
165
|
+
readyData: {
|
|
166
|
+
createRequest: () => ({ url: "https://api.example.com/resolve_action" }),
|
|
167
|
+
},
|
|
168
|
+
}),
|
|
169
|
+
}),
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
}).forDomain(userDomain);
|
|
173
|
+
|
|
174
|
+
export const clientRuntime = new ActionRuntime(clientCoord)
|
|
175
|
+
.addHandlers([serverHandler])
|
|
176
|
+
.apply();
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Calling actions
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
// Create a request payload
|
|
185
|
+
const request = userDomain.action.getUser.request({ userId: "u_123" });
|
|
186
|
+
|
|
187
|
+
// Run it — returns a RunningAction
|
|
188
|
+
const runningAction = await userDomain.runAction(request);
|
|
189
|
+
|
|
190
|
+
// Wait for the result
|
|
191
|
+
const result = await runningAction.waitForResultPayload();
|
|
192
|
+
console.log(result.output); // { id: "u_123", name: "Alice" }
|
|
193
|
+
|
|
194
|
+
// Or run and get the output directly (throws on error)
|
|
195
|
+
const output = await userDomain.action.getUser
|
|
196
|
+
.request({ userId: "u_123" })
|
|
197
|
+
.runToOutput();
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## React Query integration
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { useActionQuery, useActionMutation } from "@nice-code/action/react-query";
|
|
206
|
+
|
|
207
|
+
// Query
|
|
208
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
209
|
+
const { data } = useActionQuery(
|
|
210
|
+
userDomain.action.getUser,
|
|
211
|
+
{ userId },
|
|
212
|
+
{ queryKey: ["user", userId] },
|
|
213
|
+
);
|
|
214
|
+
return <div>{data?.name}</div>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Mutation
|
|
218
|
+
function RenameUser() {
|
|
219
|
+
const { mutate } = useActionMutation(userDomain.action.updateName);
|
|
220
|
+
return <button onClick={() => mutate({ userId: "u_1", name: "Bob" })}>Rename</button>;
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## WebSocket transport
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
{
|
|
230
|
+
type: ETransportType.ws,
|
|
231
|
+
initialize: () => ({
|
|
232
|
+
getTransport: () => ({
|
|
233
|
+
status: ETransportStatus.ready,
|
|
234
|
+
readyData: {
|
|
235
|
+
ws: new WebSocket("wss://api.example.com/resolve_action/ws"),
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
}),
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Multiple transports can be registered; the runtime picks the best available one (WebSocket preferred for lower latency, HTTP as fallback).
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## RuntimeCoordinate
|
|
247
|
+
|
|
248
|
+
Identifies a runtime environment and is used to route actions to the right handler.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
RuntimeCoordinate.env("backend") // named env
|
|
252
|
+
RuntimeCoordinate.env("backend").specify({ perId: "worker-1" }) // env + instance
|
|
253
|
+
RuntimeCoordinate.unknown // unspecified
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Error handling in actions
|
|
259
|
+
|
|
260
|
+
Actions declared with `.throws(domain, ids?)` surface typed errors at the call site:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import { castNiceError } from "@nice-code/error";
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const output = await userDomain.action.getUser.request({ userId }).runToOutput();
|
|
267
|
+
} catch (e) {
|
|
268
|
+
const error = castNiceError(e);
|
|
269
|
+
if (err_user.isExact(error) && error.hasId("not_found")) {
|
|
270
|
+
console.log("User not found:", error.getContext("not_found").userId);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|