@kevisual/router 0.2.12 → 0.2.14

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
@@ -60,10 +60,10 @@ import { App } from '@kevisual/router/browser';
60
60
 
61
61
  | 方法 | 参数 | 说明 |
62
62
  | ----------------------------------- | ----------------------------------------- | -------------------------------------------- |
63
- | `ctx.call(msg, ctx?)` | `{ path, key?, payload?, ... } \| { rid }` | 调用其他路由,返回完整 context |
64
- | `ctx.run(msg, ctx?)` | `{ path, key?, payload? }` | 调用其他路由,返回 `{ code, data, message }` |
63
+ | `ctx.run(msg, ctx?)` | `{ path, key?, payload?, ... } \| { rid }` | 调用其他路由,返回 `{ code, data, message }` |
65
64
  | `ctx.forward(res)` | `{ code, data?, message? }` | 设置响应结果 |
66
65
  | `ctx.throw(code?, message?, tips?)` | - | 抛出自定义错误 |
66
+ | `ctx.safeParseAsync(data?, opts?)` | `{ schema?, zodOptions?, stop? }` | 校验路由参数,失败时返回 issues 或自动抛出 422 错误 |
67
67
 
68
68
  ## 完整示例
69
69
 
@@ -162,18 +162,26 @@ app
162
162
 
163
163
  ```ts
164
164
  import { App } from '@kevisual/router';
165
+ import { z } from 'zod';
165
166
  const app = new App();
166
167
 
167
168
  app
168
- .router({
169
+ .route({
169
170
  path: 'dog',
170
171
  key: 'info',
171
172
  description: '获取小狗的信息',
172
173
  metadata: {
174
+ // args: 定义请求参数的 zod schema,用于参数校验和类型推断
173
175
  args: {
174
176
  name: z.string().describe('小狗的姓名'),
175
177
  age: z.number().describe('小狗的年龄'),
176
178
  },
179
+ // returns: 定义响应数据的 zod schema,用于返回结果类型推断
180
+ returns: {
181
+ content: z.string().describe('小狗的信息描述'),
182
+ },
183
+ // check: true 会在路由执行前自动校验 args,失败时返回 422
184
+ check: true,
177
185
  },
178
186
  })
179
187
  .define(async (ctx) => {
@@ -185,20 +193,169 @@ app
185
193
  .addTo(app);
186
194
  ```
187
195
 
188
- ## 注意事项
196
+ ### metadata 字段说明
189
197
 
190
- 1. **path key 的组合是路由的唯一标识**,同一个 path+key 只能添加一个路由,后添加的会覆盖之前的。
198
+ | 字段 | 类型 | 说明 |
199
+ |------|------|------|
200
+ | `args` | `Record<string, z.ZodTypeAny> \| z.ZodObject` | 请求参数的 zod schema,用于参数校验和类型推断 |
201
+ | `returns` | `Record<string, z.ZodTypeAny> \| z.ZodObject` | 响应数据的 zod schema,用于返回值类型推断 |
202
+ | `check` | `boolean` | 设为 `true` 时,路由执行前自动校验 `args`,校验失败返回 422 |
191
203
 
192
- 2. `ctx.run` 返回 `{ code, data, message }` 格式,data 即 body
204
+ ### metadata.args 参数说明
193
205
 
194
- 3. **ctx.throw 会自动结束执行**,抛出自定义错误。
206
+ `args` 是一个 zod schema 对象,用于定义路由的请求参数结构。每个字段的 key 对应请求时传入的参数名,value 为 zod 的类型定义。
195
207
 
196
- 4. **payload 会自动合并到 query**,调用 `ctx.run({ path, key, payload })` 时,payload 会合并到 query。
208
+ | 参数名 | 类型 | 必填 | 说明 |
209
+ |--------|------|------|------|
210
+ | `name` | `z.string()` | 是 | 小狗的姓名,字符串类型 |
211
+ | `age` | `z.number()` | 是 | 小狗的年龄,数字类型 |
212
+
213
+ **调用示例:**
214
+ ```ts
215
+ // 调用 dog/info 路由
216
+ const res = await app.run({
217
+ path: 'dog',
218
+ key: 'info',
219
+ payload: { name: '旺财', age: 3 }
220
+ });
221
+ // res.data.content => "这是一只3岁的小狗,名字是旺财"
222
+ ```
223
+
224
+ ### metadata.returns 返回值说明
225
+
226
+ `returns` 是一个 zod schema 对象,用于定义路由响应的数据结构。主要用于:
227
+ 1. 类型安全:配合 `runAction` 方法进行返回值的类型推断
228
+ 2. 文档化:自动生成 API 文档
229
+
230
+ | 返回字段 | 类型 | 说明 |
231
+ |----------|------|------|
232
+ | `content` | `z.string()` | 小狗的信息描述,字符串类型 |
233
+
234
+ **返回结构:**
235
+ ```ts
236
+ {
237
+ code: 200, // HTTP 状态码
238
+ data: { // 返回的数据,类型由 returns schema 推断
239
+ content: "这是一只3岁的小狗,名字是旺财"
240
+ },
241
+ message: "success" // 响应消息
242
+ }
243
+ ```
244
+
245
+ ### 配合 runAction 使用
246
+
247
+ 当你使用 `runAction` 方法调用路由时,`args` 和 `returns` 会参与类型推断:
248
+
249
+ ```ts
250
+ import { App } from '@kevisual/router';
251
+ import { z } from 'zod';
252
+
253
+ const app = new App();
254
+
255
+ // 定义 API 结构
256
+ const dogAPI = {
257
+ path: 'dog',
258
+ key: 'info',
259
+ metadata: {
260
+ args: {
261
+ name: z.string(),
262
+ age: z.number(),
263
+ },
264
+ returns: {
265
+ content: z.string(),
266
+ }
267
+ }
268
+ } as const;
269
+
270
+ // runAction 会根据 metadata.args 推断 payload 类型
271
+ // 根据 metadata.returns 推断返回数据的类型
272
+ const res = await app.runAction(dogAPI, { name: '旺财', age: 3 });
273
+ // res.data.content 会被正确推断为 string 类型
274
+ ```
275
+
276
+ ## 参数校验
277
+
278
+ 框架集成了 [Zod](https://zod.dev/) v4 进行参数校验,提供两种使用方式。
197
279
 
198
- 5. **nextQuery 用于传递给 nextRoute**,在当前路由中设置 `ctx.nextQuery`,会在执行 nextRoute 时合并到 query。
280
+ ### 自动校验(metadata.check)
199
281
 
200
- 6. **避免 nextRoute 循环调用**,默认最大深度为 40 次,超过会返回 500 错误。
282
+ 在路由定义中设置 `metadata.check: true`,框架会在路由函数执行前自动校验 `metadata.args` 中定义的参数。校验失败时自动返回 HTTP 422,`body` zod issues 数组。
201
283
 
202
- 7. **needSerialize 默认为 true**,会自动对 body 进行 JSON 序列化和反序列化。
284
+ ```ts
285
+ app
286
+ .route({
287
+ path: 'user',
288
+ key: 'create',
289
+ metadata: {
290
+ args: {
291
+ name: z.string(),
292
+ age: z.number(),
293
+ },
294
+ check: true, // 开启自动校验
295
+ },
296
+ })
297
+ .define(async (ctx) => {
298
+ // 走到这里时参数已通过校验
299
+ const { name, age } = ctx.query;
300
+ ctx.body = { name, age };
301
+ })
302
+ .addTo(app);
303
+
304
+ // 校验失败时响应示例:
305
+ // { code: 422, message: 'Validation Error:...', data: [{ code: 'invalid_type', path: ['age'], message: '...' }] }
306
+ ```
307
+
308
+ ### 手动校验(ctx.safeParseAsync)
309
+
310
+ 在路由函数内部手动调用 `ctx.safeParseAsync()` 进行校验,可以更灵活地处理校验结果。
311
+
312
+ ```ts
313
+ app
314
+ .route({
315
+ path: 'user',
316
+ key: 'update',
317
+ metadata: {
318
+ args: {
319
+ id: z.string(),
320
+ name: z.string().optional(),
321
+ },
322
+ },
323
+ })
324
+ .define(async (ctx) => {
325
+ // stop: true(默认)时校验失败会自动 throw 422
326
+ // stop: false 时返回结果由你自行处理
327
+ const res = await ctx.safeParseAsync(null, { stop: false });
328
+ if (!res.success) {
329
+ // res.error.issues 为 zod v4 的错误列表(注意:zod v4 用 issues,不再是 errors)
330
+ const { fieldErrors } = res.error.flatten();
331
+ ctx.code = 422;
332
+ ctx.body = { fieldErrors };
333
+ return;
334
+ }
335
+ ctx.body = { ok: true };
336
+ })
337
+ .addTo(app);
338
+ ```
339
+
340
+ **`ctx.safeParseAsync` 参数说明:**
341
+
342
+ | 参数 | 类型 | 默认值 | 说明 |
343
+ |------|------|--------|------|
344
+ | `data` | `any` | `null` | 额外合并到校验数据中(会与 `ctx.query` 合并) |
345
+ | `opts.schema` | `Record<string, z.ZodTypeAny>` | - | 额外追加的 zod schema 字段 |
346
+ | `opts.zodOptions` | `any` | - | 透传给 zod `safeParseAsync` 的选项 |
347
+ | `opts.stop` | `boolean` | `true` | 校验失败时是否自动 throw 422,`false` 时由调用方自行处理 |
348
+
349
+ > **注意:** 项目使用 Zod v4,错误信息字段为 `res.error.issues`
350
+
351
+ ## 注意事项
352
+
353
+ 1. **path 和 key 的组合是路由的唯一标识**,同一个 path+key 只能添加一个路由,后添加的会覆盖之前的。
354
+
355
+ 2. `ctx.run` 返回 `{ code, data, message }` 格式,data 即 body。
356
+
357
+ 3. **ctx.throw 会自动结束执行**,抛出自定义错误。支持传入 `data` 字段,错误时 `ctx.body` 会被设为该值。
358
+
359
+ 4. **payload 会自动合并到 query**,调用 `ctx.run({ path, key, payload })` 时,payload 会合并到 query。
203
360
 
204
- 8. **progress 记录执行路径**,可用于调试和追踪路由调用链。
361
+ 5. **校验失败响应**:`metadata.check: true` 或 `ctx.safeParseAsync` 默认 stop 模式下,校验失败返回 `code: 422`,`body` 为 zod issues 数组,可直接用于前端表单错误展示。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package",
3
3
  "name": "@kevisual/router",
4
- "version": "0.2.12",
4
+ "version": "0.2.14",
5
5
  "description": "",
6
6
  "type": "module",
7
7
  "main": "./dist/router.js",
@@ -27,19 +27,19 @@
27
27
  "@kevisual/dts": "^0.0.4",
28
28
  "@kevisual/js-filter": "^0.0.6",
29
29
  "@kevisual/local-proxy": "^0.0.8",
30
- "@kevisual/query": "^0.0.56",
30
+ "@kevisual/query": "^0.0.58",
31
31
  "@kevisual/remote-app": "^0.0.7",
32
32
  "@kevisual/use-config": "^1.0.30",
33
- "@opencode-ai/plugin": "^1.14.30",
34
- "@types/bun": "^1.3.13",
33
+ "@opencode-ai/plugin": "^1.15.13",
34
+ "@types/bun": "^1.3.14",
35
35
  "@types/crypto-js": "^4.2.2",
36
- "@types/node": "^25.6.0",
36
+ "@types/node": "^25.9.1",
37
37
  "@types/send": "^1.2.1",
38
38
  "@types/ws": "^8.18.1",
39
39
  "@types/xml2js": "^0.4.14",
40
- "commander": "^14.0.3",
40
+ "commander": "^15.0.0",
41
41
  "crypto-js": "^4.2.0",
42
- "es-toolkit": "^1.46.1",
42
+ "es-toolkit": "^1.47.0",
43
43
  "eventemitter3": "^5.0.4",
44
44
  "fast-glob": "^3.3.3",
45
45
  "nanoid": "^5.1.11",
@@ -54,7 +54,7 @@
54
54
  "url": "git+https://github.com/abearxiong/kevisual-router.git"
55
55
  },
56
56
  "dependencies": {
57
- "zod": "^4.4.1"
57
+ "zod": "^4.4.3"
58
58
  },
59
59
  "publishConfig": {
60
60
  "access": "public"
@@ -1,6 +1,7 @@
1
1
  export type CustomErrorOptions = {
2
2
  cause?: Error | string;
3
3
  code?: number;
4
+ data?: any;
4
5
  message?: string;
5
6
  }
6
7
  /** 自定义错误 */
@@ -19,6 +20,9 @@ export class CustomError extends Error {
19
20
  this.name = 'RouterError';
20
21
  let codeNum = opts?.code || (typeof code === 'number' ? code : undefined);
21
22
  this.code = codeNum ?? 500;
23
+ if (opts.data) {
24
+ this.data = opts.data;
25
+ }
22
26
  this.message = message!;
23
27
  // 这一步可不写,默认会保存堆栈追踪信息到自定义错误构造函数之前,
24
28
  // 而如果写成 `Error.captureStackTrace(this)` 则自定义错误的构造函数也会被保存到堆栈追踪信息
package/src/route.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { CustomError, throwError } from './result/error.ts';
2
2
  import { pick } from './utils/pick.ts';
3
3
  import { listenProcess, MockProcess } from './utils/listen-process.ts';
4
- import { z } from 'zod';
4
+ import { z, ZodError } from 'zod';
5
5
  import { hashIdMd5Sync, randomId } from './utils/random.ts';
6
6
  import * as schema from './validator/schema.ts';
7
7
  import type { RunActionPayload, RunActionReturns } from './types/index.ts'
@@ -57,6 +57,11 @@ export type RouteContext<T = { code?: number }, U extends SimpleObject = {}, S =
57
57
  * 进度
58
58
  */
59
59
  progress?: [string, string][];
60
+ safeParseAsync?: (data?: any, opts?: {
61
+ schema?: { [key: string]: z.ZodTypeAny },
62
+ zodOptions?: any,
63
+ stop?: boolean, // 如果验证失败,是否停止后续的 route 执行,默认 true
64
+ }) => Promise<{ success: boolean; data?: any; error?: any }>;
60
65
  // onlyForNextRoute will be clear after next route
61
66
  nextQuery?: { [key: string]: any };
62
67
  // end
@@ -93,7 +98,7 @@ export type RouteOpts<U = {}, T = SimpleObject> = {
93
98
  run?: Run<U>;
94
99
  nextRoute?: NextRoute; // route to run after this route
95
100
  description?: string;
96
- metadata?: T;
101
+ metadata?: Metadata<T>;
97
102
  middleware?: RouteMiddleware[]; // middleware
98
103
  type?: 'route' | 'middleware' | 'compound'; // compound表示这个 route 作为一个聚合体,没有实际的 run,而是一个 router 的聚合列表
99
104
  isDebug?: boolean;
@@ -129,12 +134,16 @@ export const createSkill = <T = SimpleObject>(skill: Skill<T>): Skill<T> => {
129
134
  }
130
135
 
131
136
  export type RouteInfo = Pick<Route, (typeof pickValue)[number]>;
132
-
137
+ export type Metadata<T = SimpleObject> = {
138
+ args?: Record<string, z.ZodTypeAny> | z.ZodObject<any>;
139
+ returns?: Record<string, z.ZodTypeAny> | z.ZodObject<any>;
140
+ check?: boolean;
141
+ } & T;
133
142
  /**
134
143
  * @M 是 route的 metadate的类型,默认是 SimpleObject
135
144
  * @U 是 RouteContext 里 state的类型
136
145
  */
137
- export class Route<M extends SimpleObject = SimpleObject, U extends SimpleObject = SimpleObject> implements throwError {
146
+ export class Route<M extends Metadata = Metadata, U extends SimpleObject = SimpleObject> implements throwError {
138
147
  /**
139
148
  * 一级路径
140
149
  */
@@ -315,6 +324,21 @@ export class QueryRouter<T extends SimpleObject = SimpleObject> implements throw
315
324
  removeById(uniqueId: string) {
316
325
  this.routes = this.routes.filter((r) => r.rid !== uniqueId);
317
326
  }
327
+ safeParseAsyncRoute(data: any, opts: { route: RouteInfo, schema?: { [key: string]: z.ZodTypeAny }, zodOptions?: any }): Promise<{ success: boolean; data?: any; error?: any }> {
328
+ const route = opts.route;
329
+ const argZod = route.metadata?.args as Record<string, z.ZodTypeAny>;
330
+ const schemaZod = opts.schema || {};
331
+ const zodOptions = opts.zodOptions;
332
+ const keys = Object.keys(argZod || {});
333
+ if (argZod && keys.length > 0) {
334
+ const mgZod = z.object({
335
+ ...argZod,
336
+ ...schemaZod
337
+ });
338
+ return mgZod.safeParseAsync(data, zodOptions);
339
+ }
340
+ return Promise.resolve({ success: true, data });
341
+ }
318
342
  /**
319
343
  * 执行route
320
344
  * @param path
@@ -332,6 +356,21 @@ export class QueryRouter<T extends SimpleObject = SimpleObject> implements throw
332
356
  ctx.currentRoute = route;
333
357
  ctx.index = (ctx.index || 0) + 1;
334
358
  const progress = [path, key] as [string, string];
359
+ ctx.safeParseAsync = async (data?: any, opts?: { schema?: { [key: string]: z.ZodTypeAny }, zodOptions?: any, stop?: boolean }) => {
360
+ const stop = opts?.stop ?? true;
361
+ const _query = { ...ctx.query, ...data };
362
+ const res = await this.safeParseAsyncRoute(_query, { route: route, ...opts });
363
+ if (!res.success && stop) {
364
+ const issues = res.error.issues;
365
+ ctx.throw({
366
+ // Unprocessable Entity
367
+ code: 422,
368
+ data: issues,
369
+ message: 'Validation Error:' + JSON.stringify(issues, null, 2),
370
+ })
371
+ }
372
+ return res;
373
+ }
335
374
  if (ctx.progress) {
336
375
  ctx.progress.push(progress);
337
376
  } else {
@@ -406,7 +445,7 @@ export class QueryRouter<T extends SimpleObject = SimpleObject> implements throw
406
445
  if (e instanceof CustomError || e?.code) {
407
446
  ctx.code = e.code;
408
447
  ctx.message = e.message;
409
- ctx.body = null;
448
+ ctx.body = e.data;
410
449
  } else {
411
450
  console.error(`[router error] fn:${route.path}-${route.key}:${route.rid}`);
412
451
  console.error(`[router error] middleware:${middleware.path}-${middleware.key}:${middleware.rid}`);
@@ -427,6 +466,9 @@ export class QueryRouter<T extends SimpleObject = SimpleObject> implements throw
427
466
  if (route) {
428
467
  if (route.run) {
429
468
  try {
469
+ if (route.metadata?.check) {
470
+ await ctx.safeParseAsync(null, { stop: true });
471
+ }
430
472
  await route.run(ctx as Required<RouteContext<T>>);
431
473
  } catch (e) {
432
474
  if (route?.isDebug) {
@@ -437,13 +479,14 @@ export class QueryRouter<T extends SimpleObject = SimpleObject> implements throw
437
479
  if (e instanceof CustomError || e?.code) {
438
480
  ctx.code = e.code;
439
481
  ctx.message = e.message;
482
+ ctx.body = e.data;
440
483
  } else {
441
484
  console.error(`[router error] fn:${route.path}-${route.key}:${route.rid}`);
442
485
  console.error(`[router error] error`, e);
443
486
  ctx.code = 500;
444
487
  ctx.message = 'Internal Server Error';
488
+ ctx.body = null;
445
489
  }
446
- ctx.body = null;
447
490
  return ctx;
448
491
  }
449
492
  if (ctx.end) {
@@ -1,15 +1,29 @@
1
1
  import { QueryRouterServer } from "@/route.ts";
2
2
  import z from "zod";
3
+ import { tr } from "zod/v4/locales";
3
4
 
4
5
  const router = new QueryRouterServer()
5
6
 
6
7
  router.route({
8
+ path: 'test',
7
9
  metadata: {
8
10
  args: {
9
11
  a: z.string(),
10
- }
12
+ },
13
+ check: true,
11
14
  },
12
15
  }).define(async (ctx) => {
13
- const argA: string = ctx.args.a;
16
+ const argZod = ctx.currentRoute.metadata.args as Record<string, z.ZodTypeAny>;
17
+ const mgZod = z.object(argZod);
18
+ // console.log('argZod', argZod);
19
+ // const argA: string = ctx.args.a;
20
+ // const res = await ctx.safeParseAsync(null, { stop: true });
21
+ // // console.log('argA', argA);
22
+ // if (!res.success) {
23
+ // console.log('res===', res.error.issues);
24
+ // }
14
25
  ctx.body = '1';
15
- }).addTo(router);
26
+ }).addTo(router);
27
+
28
+ const res = await router.run({ path: 'test', payload: { a: 'abc' } });
29
+ console.log('res', res);