@maiyunnet/kebab 9.16.2 → 9.17.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/index.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * --- 本文件用来定义每个目录实体地址的常量 ---
6
6
  */
7
7
  /** --- 当前系统版本号 --- */
8
- export declare const VER = "9.16.2";
8
+ export declare const VER = "9.17.1";
9
9
  /** --- 框架根目录,以 / 结尾 --- */
10
10
  export declare const ROOT_PATH: string;
11
11
  /** --- 框架的 LIB,以 / 结尾 --- */
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * --- 本文件用来定义每个目录实体地址的常量 ---
7
7
  */
8
8
  /** --- 当前系统版本号 --- */
9
- export const VER = '9.16.2';
9
+ export const VER = '9.17.1';
10
10
  // --- 服务端用的路径 ---
11
11
  const imu = decodeURIComponent(import.meta.url).replace('file://', '').replace(/^\/(\w:)/, '$1');
12
12
  /** --- /xxx/xxx --- */
package/lib/core.d.ts CHANGED
@@ -2,10 +2,13 @@ import * as http from 'http';
2
2
  import * as http2 from 'http2';
3
3
  import * as stream from 'stream';
4
4
  import * as net from 'net';
5
+ import * as valibot from 'valibot';
5
6
  import * as kebab from '#kebab/index.js';
6
7
  import * as lNetResponse from '#kebab/lib/net/response.js';
7
8
  import * as lUndiciResponse from '#kebab/lib/undici/response.js';
8
9
  import * as sCtr from '#kebab/sys/ctr.js';
10
+ /** --- Valibot 数据校验工具,业务侧可通过 lCore.v 使用 --- */
11
+ export { valibot as v };
9
12
  /** --- 全局参数 --- */
10
13
  export declare const globalConfig: kebab.IConfig & {
11
14
  'httpPort': number;
package/lib/core.js CHANGED
@@ -11,6 +11,7 @@ import * as net from 'net';
11
11
  import * as crypto from 'crypto';
12
12
  import Ajv from 'ajv';
13
13
  import addFormats from 'ajv-formats';
14
+ import * as valibot from 'valibot';
14
15
  import * as kebab from '#kebab/index.js';
15
16
  import * as lTime from '#kebab/lib/time.js';
16
17
  import * as lFs from '#kebab/lib/fs.js';
@@ -20,6 +21,8 @@ import * as lCrypto from '#kebab/lib/crypto.js';
20
21
  import * as lNetResponse from '#kebab/lib/net/response.js';
21
22
  import * as lUndiciResponse from '#kebab/lib/undici/response.js';
22
23
  import * as sCtr from '#kebab/sys/ctr.js';
24
+ /** --- Valibot 数据校验工具,业务侧可通过 lCore.v 使用 --- */
25
+ export { valibot as v };
23
26
  /** --- 全局参数 --- */
24
27
  export const globalConfig = {};
25
28
  /** --- JSON Schema 校验器 --- */
package/lib/db/conn.d.ts CHANGED
@@ -53,9 +53,12 @@ export declare class Connection {
53
53
  */
54
54
  isUsing(): boolean;
55
55
  /**
56
- * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功并自动刷新最后时间 ---
56
+ * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功 ---
57
+ * @param opt 获取选项,连接巡检独占时不刷新最后使用时间
57
58
  */
58
- using(): boolean;
59
+ using(opt?: {
60
+ 'refreshLast'?: boolean;
61
+ }): boolean;
59
62
  /**
60
63
  * --- 取消占用 ---
61
64
  */
@@ -85,7 +88,12 @@ export declare class Connection {
85
88
  * --- 关闭连接,一般情况下不使用 ---
86
89
  */
87
90
  end(): Promise<boolean>;
88
- beginTransaction(): Promise<boolean>;
91
+ /**
92
+ * --- 开启事务,只能在独占连接中使用 ---
93
+ * @param logError 失败时是否记录错误,连接池在非最后一次重试时传 false
94
+ * @returns 是否开启成功
95
+ */
96
+ beginTransaction(logError?: boolean): Promise<boolean>;
89
97
  commit(): Promise<boolean>;
90
98
  rollback(): Promise<boolean>;
91
99
  }
package/lib/db/conn.js CHANGED
@@ -99,14 +99,17 @@ export class Connection {
99
99
  return this._using;
100
100
  }
101
101
  /**
102
- * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功并自动刷新最后时间 ---
102
+ * --- 判断是否可用(丢失的也算不可用),返回 true 代表获取成功 ---
103
+ * @param opt 获取选项,连接巡检独占时不刷新最后使用时间
103
104
  */
104
- using() {
105
+ using(opt = {}) {
105
106
  if (this._lost || this._using) {
106
107
  return false;
107
108
  }
108
109
  else {
109
- this.refreshLast();
110
+ if (opt.refreshLast !== false) {
111
+ this.refreshLast();
112
+ }
110
113
  this._using = true;
111
114
  return true;
112
115
  }
@@ -281,6 +284,8 @@ export class Connection {
281
284
  * --- 关闭连接,一般情况下不使用 ---
282
285
  */
283
286
  async end() {
287
+ // --- 驱动的 end 事件可能在异步关闭完成后才触发,提前标记避免连接池在关闭期间重新取到本连接 ---
288
+ this._lost = true;
284
289
  try {
285
290
  await this._link.end();
286
291
  return true;
@@ -289,8 +294,12 @@ export class Connection {
289
294
  return false;
290
295
  }
291
296
  }
292
- // --- 事务,只能在独占连接中使用,pool 创建事务返回独占连接,commit 或 rollback 释放连接回连接池 ---
293
- async beginTransaction() {
297
+ /**
298
+ * --- 开启事务,只能在独占连接中使用 ---
299
+ * @param logError 失败时是否记录错误,连接池在非最后一次重试时传 false
300
+ * @returns 是否开启成功
301
+ */
302
+ async beginTransaction(logError = true) {
294
303
  if (this._using) {
295
304
  try {
296
305
  this._transaction = true;
@@ -306,12 +315,16 @@ export class Connection {
306
315
  this._transaction = false;
307
316
  this._using = false;
308
317
  this._lost = true;
309
- lCore.log({}, '[DB][Connection][beginTransaction] ' + lText.stringifyError(e), '-error');
318
+ if (logError) {
319
+ lCore.log({}, '[DB][Connection][beginTransaction] ' + lText.stringifyError(e), '-error');
320
+ }
310
321
  return false;
311
322
  }
312
323
  }
313
324
  else {
314
- lCore.log({}, '[DB][Connection][beginTransaction] connection is not in use', '-error');
325
+ if (logError) {
326
+ lCore.log({}, '[DB][Connection][beginTransaction] connection is not in use', '-error');
327
+ }
315
328
  return false;
316
329
  }
317
330
  }
package/lib/db/pool.js CHANGED
@@ -67,6 +67,10 @@ async function checkConnection() {
67
67
  }
68
68
  continue;
69
69
  }
70
+ // --- 巡检探活或关闭前先独占连接,避免业务在异步操作期间取到同一连接 ---
71
+ if (!connection.using({ 'refreshLast': false })) {
72
+ continue;
73
+ }
70
74
  if (connection.getLast() <= now - 30) {
71
75
  // --- 超 30 秒未被使用,则关闭 ---
72
76
  await connection.end();
@@ -75,8 +79,9 @@ async function checkConnection() {
75
79
  continue;
76
80
  }
77
81
  // --- 30 秒内使用过,看看连接是否正常 ---
78
- if (await connection.isAvailable(false)) {
82
+ if (await connection.isAvailable(false) && !connection.isLost()) {
79
83
  // --- 正常 ---
84
+ connection.used();
80
85
  continue;
81
86
  }
82
87
  // --- 连接有问题,直接关闭 ---
@@ -120,7 +125,7 @@ export class Pool {
120
125
  */
121
126
  async query(sql, values) {
122
127
  ++this._queries;
123
- // --- 获取并自动 using ---
128
+ // --- 获取并自动 using ---
124
129
  const conn = await this._getConnection();
125
130
  if (!conn) {
126
131
  return {
@@ -167,7 +172,9 @@ export class Pool {
167
172
  lCore.log(ctr ?? {}, `[DB][Pool][beginTransaction] failed to get connection, service: ${lDb.ESERVICE[this._service]}, database: ${this._etc.name ?? ''}`, '-error');
168
173
  return null;
169
174
  }
170
- if (!await conn.beginTransaction()) {
175
+ /** --- 只有最后一次尝试仍失败时才记录原始错误,避免已恢复的重试被误判为业务失败 --- */
176
+ const logError = i === BEGIN_TRANSACTION_MAX_ATTEMPTS - 1;
177
+ if (!await conn.beginTransaction(logError)) {
171
178
  continue;
172
179
  }
173
180
  return new Transaction(ctr, conn);
package/lib/fs.js CHANGED
@@ -440,10 +440,13 @@ export async function readToResponse(path, req, res, stat) {
440
440
  }
441
441
  // --- 这些文件可能需要缓存 ---
442
442
  if (['htm', 'html', 'css', 'js', 'mjs', 'xml', 'jpg', 'jpeg', 'svg', 'gif', 'png', 'json'].includes(mimeData.extension)) {
443
+ /** --- 静态文件默认缓存秒数 --- */
444
+ const cacheTTL = 600;
443
445
  const hash = `W/"${stat.size.toString(16)}-${stat.mtime.getTime().toString(16)}"`;
444
446
  const lastModified = stat.mtime.toUTCString();
445
447
  res.setHeader('etag', hash);
446
- res.setHeader('cache-control', 'public, max-age=600');
448
+ res.setHeader('expires', new Date(Date.now() + cacheTTL * 1_000).toUTCString());
449
+ res.setHeader('cache-control', 'public, max-age=' + cacheTTL.toString());
447
450
  // --- 判断返回 304 吗 ---
448
451
  const noneMatch = req.headers['if-none-match'];
449
452
  const modifiedSince = req.headers['if-modified-since'];
@@ -31,7 +31,7 @@ export declare class Response {
31
31
  /**
32
32
  * --- 获取响应读取流对象 ---
33
33
  */
34
- getStream(): (import("undici/types/readable").default & undici.Dispatcher.BodyMixin) | zlib.Gunzip | zlib.Inflate | zlib.BrotliDecompress | null;
34
+ getStream(): zlib.BrotliDecompress | zlib.Gunzip | zlib.Inflate | (import("undici/types/readable").default & undici.Dispatcher.BodyMixin) | null;
35
35
  /**
36
36
  * --- 获取原生响应读取流对象 ---
37
37
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maiyunnet/kebab",
3
- "version": "9.16.2",
3
+ "version": "9.17.1",
4
4
  "description": "Simple, easy-to-use, and fully-featured Node.js framework that is ready-to-use out of the box.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -22,10 +22,10 @@
22
22
  "#kebab/*": "./*"
23
23
  },
24
24
  "dependencies": {
25
- "@aws-sdk/client-s3": "^3.1096.0",
26
- "@aws-sdk/lib-storage": "^3.1096.0",
25
+ "@aws-sdk/client-s3": "^3.1120.0",
26
+ "@aws-sdk/lib-storage": "^3.1120.0",
27
27
  "@litert/http-client": "^1.1.2",
28
- "@litert/mime": "^0.1.3",
28
+ "@litert/mime": "^2.0.0",
29
29
  "@litert/redis": "^3.2.1",
30
30
  "@litert/websocket": "^0.2.8",
31
31
  "@radix-ui/react-checkbox": "^1.3.11",
@@ -39,30 +39,31 @@
39
39
  "class-variance-authority": "^0.7.1",
40
40
  "clsx": "^2.1.1",
41
41
  "ejs": "^6.0.1",
42
- "esbuild": "^0.28.1",
42
+ "esbuild": "^0.28.2",
43
43
  "jszip": "^3.10.1",
44
- "mysql2": "^3.23.2",
44
+ "mysql2": "^3.24.2",
45
45
  "node-cron": "^4.6.0",
46
- "openai": "^7.0.0",
47
- "pg": "^8.22.0",
46
+ "openai": "^7.8.0",
47
+ "pg": "^8.23.0",
48
48
  "react": "^19.2.8",
49
49
  "react-dom": "^19.2.8",
50
- "react-router-dom": "^7.18.1",
50
+ "react-router-dom": "^7.18.2",
51
51
  "ssh2": "^1.17.0",
52
52
  "svg-captcha": "^1.4.0",
53
53
  "tailwind-merge": "^3.6.0",
54
- "tencentcloud-sdk-nodejs": "^4.1.277",
55
- "undici": "^8.9.0"
54
+ "tencentcloud-sdk-nodejs": "^4.1.303",
55
+ "undici": "^8.10.0",
56
+ "valibot": "^1.4.2"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@litert/eslint-plugin-rules": "^0.3.1",
59
60
  "@types/ejs": "^3.1.5",
60
- "@types/node": "^26.1.2",
61
- "@types/pg": "^8.20.0",
62
- "@types/react": "^19.2.17",
63
- "@types/react-dom": "^19.2.3",
61
+ "@types/node": "^26.4.0",
62
+ "@types/pg": "^8.23.1",
63
+ "@types/react": "^19.2.18",
64
+ "@types/react-dom": "^19.2.5",
64
65
  "typedoc": "^0.28.20",
65
- "typedoc-plugin-markdown": "^4.12.0",
66
+ "typedoc-plugin-markdown": "^4.13.0",
66
67
  "typescript": "^6.0.3"
67
68
  }
68
69
  }
package/sys/ctr.d.ts CHANGED
@@ -1,5 +1,11 @@
1
+ /**
2
+ * Project: Kebab, User: JianSuoQiYue
3
+ * Date: 2020-3-14 17:24:38
4
+ * Last: 2020-3-30 15:31:40, 2022-07-22 16:59:00, 2022-09-12 23:51:56, 2022-09-23 15:53:58, 2022-12-29 01:18:08, 2023-2-28 20:07:57, 2023-12-27 18:39:35, 2024-3-1 19:38:53, 2024-4-9 16:03:58, 2025-2-12 18:55:44, 2025-6-12 16:56:08, 2026-8-22
5
+ */
1
6
  import * as http from 'http';
2
7
  import * as http2 from 'http2';
8
+ import type * as v from 'valibot';
3
9
  import * as kebab from '#kebab/index.js';
4
10
  import * as lSession from '#kebab/lib/session.js';
5
11
  import * as lDb from '#kebab/lib/db.js';
@@ -9,6 +15,49 @@ import * as lWs from '#kebab/lib/ws.js';
9
15
  * --- 清除已经加载的 data 与语言包文件缓存 ---
10
16
  */
11
17
  export declare function clearLocaleData(): void;
18
+ /** --- Valibot 校验失败时可直接返回给客户端的内容或生成函数 --- */
19
+ export type TValibotResponse<TIssue extends v.BaseIssue<unknown>> = kebab.Json[] | ((issues: [TIssue, ...TIssue[]]) => kebab.Json[]);
20
+ /** --- 在 issue 路径完整后,根据 Kebab 生成的语言键翻译消息 --- */
21
+ export type TValibotTranslate = (key: string, issue: v.BaseIssue<unknown>) => string;
22
+ /** --- Valibot 同步校验选项 --- */
23
+ export interface IValibotOptions<TIssue extends v.BaseIssue<unknown>> {
24
+ /** --- 传给 Valibot 的校验配置 --- */
25
+ 'config'?: v.Config<TIssue>;
26
+ /** --- 校验失败时返回给客户端的内容,默认使用首个 issue 的消息 --- */
27
+ 'response'?: TValibotResponse<TIssue>;
28
+ /** --- 使用完整 issue 路径翻译消息 --- */
29
+ 'translate'?: TValibotTranslate;
30
+ }
31
+ /** --- XSRF 校验失败的问题描述 --- */
32
+ export interface IValibotXsrfIssue extends v.BaseIssue<unknown> {
33
+ readonly kind: 'validation';
34
+ readonly type: 'xsrf';
35
+ }
36
+ /** --- 带 XSRF 检测的 Valibot 同步校验选项 --- */
37
+ export interface IValibotXOptions<TIssue extends v.BaseIssue<unknown>> {
38
+ /** --- 传给 Valibot 的校验配置 --- */
39
+ 'config'?: v.Config<TIssue>;
40
+ /** --- 校验失败时返回给客户端的内容,回调同时可能收到 XSRF issue --- */
41
+ 'response'?: TValibotResponse<TIssue | IValibotXsrfIssue>;
42
+ /** --- 使用完整 issue 路径翻译消息,包括 XSRF issue --- */
43
+ 'translate'?: TValibotTranslate;
44
+ /** --- 是否忽略 XSRF 检测 --- */
45
+ 'ignoreXsrf'?: boolean;
46
+ }
47
+ /** --- Valibot 校验结果:仅成功分支提供 schema 推导后的 output --- */
48
+ export type TValibotResult<TSchema extends v.GenericSchema, TIssue extends v.BaseIssue<unknown> = v.InferIssue<TSchema>> = {
49
+ readonly typed: true;
50
+ readonly success: true;
51
+ readonly output: v.InferOutput<TSchema>;
52
+ readonly issues: undefined;
53
+ readonly response: undefined;
54
+ } | {
55
+ readonly typed: boolean;
56
+ readonly success: false;
57
+ readonly output: undefined;
58
+ readonly issues: [TIssue, ...TIssue[]];
59
+ readonly response: kebab.Json[];
60
+ };
12
61
  export declare class Ctr {
13
62
  /** --- 路由参数序列数组 --- */
14
63
  protected _param: string[];
@@ -200,6 +249,42 @@ export declare class Ctr {
200
249
  * @param msgSuffix 可选的消息后缀
201
250
  */
202
251
  private _setCheckError;
252
+ /**
253
+ * --- 生成 Valibot 校验失败结果 ---
254
+ * @param issues Valibot 问题列表
255
+ * @param response 自定义客户端返回值或生成函数
256
+ * @returns 校验失败结果
257
+ */
258
+ private _getValibotFailure;
259
+ /**
260
+ * --- 根据完整 issue 路径生成 Kebab 语言包键 ---
261
+ * @param issue Valibot 校验问题
262
+ * @returns 语言包键
263
+ */
264
+ private _getValibotLocaleKey;
265
+ /**
266
+ * --- 在 Valibot 完成父级路径组装后翻译问题消息 ---
267
+ * @param issues Valibot 问题列表
268
+ * @param translate 消息翻译函数
269
+ * @returns 翻译后的问题列表
270
+ */
271
+ private _translateValibotIssues;
272
+ /**
273
+ * --- 使用 Valibot schema 校验并解析输入,成功后 output 会自动推导类型 ---
274
+ * @param schema Valibot 同步 schema
275
+ * @param input 待校验的输入
276
+ * @param options Valibot 配置和自定义客户端返回值
277
+ * @returns 可判别的校验结果
278
+ */
279
+ protected _valibot<const TSchema extends v.GenericSchema>(schema: TSchema, input: unknown, options?: IValibotOptions<v.InferIssue<TSchema>>): TValibotResult<TSchema>;
280
+ /**
281
+ * --- 使用 Valibot schema 校验并解析输入,同时检测 XSRF ---
282
+ * @param schema Valibot 同步 schema
283
+ * @param input 待校验的输入
284
+ * @param options Valibot 配置、自定义客户端返回值和 XSRF 选项
285
+ * @returns 可判别的校验结果
286
+ */
287
+ protected _valibotx<const TSchema extends v.GenericSchema>(schema: TSchema, input: unknown, options?: IValibotXOptions<v.InferIssue<TSchema>>): TValibotResult<TSchema, v.InferIssue<TSchema> | IValibotXsrfIssue>;
203
288
  /**
204
289
  * --- 检测提交的数据类型 ---
205
290
  * @param input 要校验的输入项
package/sys/ctr.js CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Project: Kebab, User: JianSuoQiYue
3
- * Date: 2020-3-14 17:24:38
4
- * Last: 2020-3-30 15:31:40, 2022-07-22 16:59:00, 2022-09-12 23:51:56, 2022-09-23 15:53:58, 2022-12-29 01:18:08, 2023-2-28 20:07:57, 2023-12-27 18:39:35, 2024-3-1 19:38:53, 2024-4-9 16:03:58, 2025-2-12 18:55:44, 2025-6-12 16:56:08, 2026-8-22
5
- */
6
1
  import ejs from 'ejs';
7
2
  import * as lCore from '#kebab/lib/core.js';
8
3
  import * as lFs from '#kebab/lib/fs.js';
@@ -416,6 +411,123 @@ export class Ctr {
416
411
  rtn[2] = lastVal[2];
417
412
  }
418
413
  }
414
+ /**
415
+ * --- 生成 Valibot 校验失败结果 ---
416
+ * @param issues Valibot 问题列表
417
+ * @param response 自定义客户端返回值或生成函数
418
+ * @returns 校验失败结果
419
+ */
420
+ _getValibotFailure(issues, response, translate) {
421
+ /** --- 路径已完整且消息已翻译的问题列表 --- */
422
+ const translatedIssues = this._translateValibotIssues(issues, translate);
423
+ const clientResponse = typeof response === 'function' ? response(translatedIssues) : response;
424
+ return {
425
+ 'typed': false,
426
+ 'success': false,
427
+ 'output': undefined,
428
+ 'issues': translatedIssues,
429
+ 'response': clientResponse ?? [0, translatedIssues[0].message],
430
+ };
431
+ }
432
+ /**
433
+ * --- 根据完整 issue 路径生成 Kebab 语言包键 ---
434
+ * @param issue Valibot 校验问题
435
+ * @returns 语言包键
436
+ */
437
+ _getValibotLocaleKey(issue) {
438
+ if ((issue.type === 'strict_object') && (issue.expected === 'never')) {
439
+ return 'validation.strict_object';
440
+ }
441
+ const path = lCore.v.getDotPath(issue);
442
+ return path ? `validation.${path}.${issue.type}` : `validation.${issue.type}`;
443
+ }
444
+ /**
445
+ * --- 在 Valibot 完成父级路径组装后翻译问题消息 ---
446
+ * @param issues Valibot 问题列表
447
+ * @param translate 消息翻译函数
448
+ * @returns 翻译后的问题列表
449
+ */
450
+ _translateValibotIssues(issues, translate) {
451
+ if (!translate) {
452
+ return issues;
453
+ }
454
+ const translateIssue = (issue) => {
455
+ const key = this._getValibotLocaleKey(issue);
456
+ const message = translate(key, issue);
457
+ return {
458
+ ...issue,
459
+ /** --- 语言包缺失时保留键名,并回退到 Valibot 原始英文原因 --- */
460
+ 'message': message === `[LocaleError]${key}` ? `[${key}] ${issue.message}` : message,
461
+ };
462
+ };
463
+ return [translateIssue(issues[0]), ...issues.slice(1).map(translateIssue)];
464
+ }
465
+ /**
466
+ * --- 使用 Valibot schema 校验并解析输入,成功后 output 会自动推导类型 ---
467
+ * @param schema Valibot 同步 schema
468
+ * @param input 待校验的输入
469
+ * @param options Valibot 配置和自定义客户端返回值
470
+ * @returns 可判别的校验结果
471
+ */
472
+ _valibot(schema, input, options) {
473
+ const result = lCore.v.safeParse(schema, input, options?.config);
474
+ if (!result.success) {
475
+ return this._getValibotFailure(result.issues, options?.response, options?.translate);
476
+ }
477
+ return {
478
+ ...result,
479
+ 'response': undefined,
480
+ };
481
+ }
482
+ /**
483
+ * --- 使用 Valibot schema 校验并解析输入,同时检测 XSRF ---
484
+ * @param schema Valibot 同步 schema
485
+ * @param input 待校验的输入
486
+ * @param options Valibot 配置、自定义客户端返回值和 XSRF 选项
487
+ * @returns 可判别的校验结果
488
+ */
489
+ _valibotx(schema, input, options) {
490
+ /** --- 用户提交的 XSRF token --- */
491
+ let submittedXsrf;
492
+ /** --- 移除 XSRF 传输字段后交给业务 schema 的输入 --- */
493
+ let schemaInput = input;
494
+ if ((typeof input === 'object') && (input !== null) && !Array.isArray(input)) {
495
+ const inputRecord = input;
496
+ submittedXsrf = inputRecord['_xsrf'];
497
+ schemaInput = { ...inputRecord };
498
+ delete schemaInput['_xsrf'];
499
+ }
500
+ if (!options?.ignoreXsrf) {
501
+ /** --- Cookie 中应匹配的 XSRF token --- */
502
+ const expectedXsrf = this._cookie['XSRF-TOKEN'];
503
+ if (!expectedXsrf || (submittedXsrf !== expectedXsrf)) {
504
+ const issue = {
505
+ 'kind': 'validation',
506
+ 'type': 'xsrf',
507
+ 'input': undefined,
508
+ 'expected': 'valid XSRF token',
509
+ 'received': submittedXsrf === undefined ? 'undefined' : typeof submittedXsrf,
510
+ 'message': 'Bad request, no permission.',
511
+ 'path': [{
512
+ 'type': 'unknown',
513
+ 'origin': 'value',
514
+ 'input': undefined,
515
+ 'key': '_xsrf',
516
+ 'value': undefined,
517
+ }],
518
+ };
519
+ return this._getValibotFailure([issue], options?.response, options?.translate);
520
+ }
521
+ }
522
+ const result = lCore.v.safeParse(schema, schemaInput, options?.config);
523
+ if (!result.success) {
524
+ return this._getValibotFailure(result.issues, options?.response, options?.translate);
525
+ }
526
+ return {
527
+ ...result,
528
+ 'response': undefined,
529
+ };
530
+ }
419
531
  /**
420
532
  * --- 检测提交的数据类型 ---
421
533
  * @param input 要校验的输入项
@@ -18,6 +18,8 @@ export default class extends sCtr.Ctr {
18
18
  ctrCheckinput1(): Promise<kebab.Json[]>;
19
19
  ctrCheckinputSchema(): string;
20
20
  ctrCheckinputSchema1(): Promise<kebab.Json[]>;
21
+ ctrValibot(): Promise<kebab.Json[] | string>;
22
+ ctrValibot1(): Promise<kebab.Json[]>;
21
23
  ctrLocale(): Promise<kebab.Json[] | string>;
22
24
  ctrCachettl(): string;
23
25
  ctrHttpcode(): string;
@@ -28,6 +28,15 @@ import * as sCtr from '#kebab/sys/ctr.js';
28
28
  // --- mod ---
29
29
  import mTest from '../mod/test.js';
30
30
  import mTestData from '../mod/testdata.js';
31
+ /** --- Kebab 核心库提供的 Valibot 对象 --- */
32
+ const v = lCore.v;
33
+ /** --- Valibot 示例支持的语言 --- */
34
+ const valibotLocaleSchema = v.picklist(['en', 'sc', 'tc', 'ja']);
35
+ /** --- 模块级复用的 Valibot 输入规则,不绑定任何请求或语言 --- */
36
+ const valibotInputSchema = v.strictObject({
37
+ 'title': v.pipe(v.string(), v.nonEmpty()),
38
+ 'count': v.pipe(v.string(), v.toNumber(), v.integer()),
39
+ });
31
40
  export default class extends sCtr.Ctr {
32
41
  _internalUrl = '';
33
42
  onLoad() {
@@ -114,6 +123,7 @@ export default class extends sCtr.Ctr {
114
123
  '<br><br><b>Ctr:</b>',
115
124
  `<br><br><a href="${this._config.const.urlBase}test/ctr-xsrf">View "test/ctr-xsrf"</a>`,
116
125
  `<br><a href="${this._config.const.urlBase}test/ctr-checkinput">View "test/ctr-checkinput"</a> <a href="${this._config.const.urlBase}test/ctr-checkinput-schema">schema</a>`,
126
+ `<br><a href="${this._config.const.urlBase}test/ctr-valibot">View "test/ctr-valibot"</a>`,
117
127
  `<br><a href="${this._config.const.urlBase}test/ctr-locale">View "test/ctr-locale"</a>`,
118
128
  `<br><a href="${this._config.const.urlBase}test/ctr-cachettl">View "test/ctr-cachettl"</a>`,
119
129
  `<br><a href="${this._config.const.urlBase}test/ctr-httpcode">View "test/ctr-httpcode"</a>`,
@@ -546,6 +556,88 @@ function post(p) {
546
556
  }
547
557
  return [1, { 'post': this._post }];
548
558
  }
559
+ async ctrValibot() {
560
+ const locale = this._valibot(valibotLocaleSchema, this._get['lang'] ?? 'en', {
561
+ 'response': [0, 'Wrong language.'],
562
+ });
563
+ if (!locale.success) {
564
+ return locale.response;
565
+ }
566
+ if (!await this._loadLocale(locale.output, 'test')) {
567
+ return [0, 'Could not load locale.'];
568
+ }
569
+ this._enabledXsrf();
570
+ const echo = [
571
+ '<b>Test _valibotx with inferred output and request locale</b><br><br>',
572
+ `<a href="${this._config.const.urlBase}test/ctr-valibot?lang=en">English</a> | ` +
573
+ `<a href="${this._config.const.urlBase}test/ctr-valibot?lang=sc">简体中文</a> | ` +
574
+ `<a href="${this._config.const.urlBase}test/ctr-valibot?lang=tc">繁體中文</a> | ` +
575
+ `<a href="${this._config.const.urlBase}test/ctr-valibot?lang=ja">日本語</a><br><br>`,
576
+ `<b>Current locale:</b> ${locale.output}<br><br>`,
577
+ `<pre>const v = lCore.v;
578
+ const valibotInputSchema = v.strictObject({
579
+ 'title': v.pipe(v.string(), v.nonEmpty()),
580
+ 'count': v.pipe(v.string(), v.toNumber(), v.integer()),
581
+ });</pre>`,
582
+ ];
583
+ const posts = [
584
+ { 'title': 'Kebab', 'count': '2' },
585
+ { 'title': '', 'count': '2' },
586
+ { 'title': 'Kebab', 'count': 'abc' },
587
+ { 'title': 'Kebab', 'count': '1.5' },
588
+ { 'title': 'Kebab', 'count': '2', 'extra': true },
589
+ ];
590
+ for (const item of posts) {
591
+ const str = lText.stringifyJson(item).replace(/"/g, '&quot;');
592
+ echo.push(`<input type="button" value="Post '${str}'" onclick="post('${str}')"><br>`);
593
+ }
594
+ echo.push(`<script>
595
+ function post(p) {
596
+ const data = JSON.parse(p);
597
+ data._xsrf = '${this._xsrf}';
598
+ document.getElementById('result').innerText = 'Waiting...';
599
+ fetch('${this._config.const.urlBase}test/ctr-valibot1?lang=${locale.output}', {
600
+ method: 'POST',
601
+ headers: {
602
+ 'content-type': 'application/json'
603
+ },
604
+ body: JSON.stringify(data)
605
+ }).then(function(r) {
606
+ return r.text();
607
+ }).then(function(t) {
608
+ document.getElementById('result').innerText = t;
609
+ });
610
+ }
611
+ </script>
612
+ <br>Result:<pre id="result">Nothing.</pre>`);
613
+ return echo.join('') + this._getEnd();
614
+ }
615
+ async ctrValibot1() {
616
+ if (!await this._handleFormData()) {
617
+ return [0];
618
+ }
619
+ const locale = this._valibot(valibotLocaleSchema, this._get['lang'] ?? 'en', {
620
+ 'response': [0, 'Wrong language.'],
621
+ });
622
+ if (!locale.success) {
623
+ return locale.response;
624
+ }
625
+ if (!await this._loadLocale(locale.output, 'test')) {
626
+ return [0, 'Could not load locale.'];
627
+ }
628
+ const parsed = this._valibotx(valibotInputSchema, this._post, {
629
+ 'translate': key => this._l(key),
630
+ 'response': issues => [0, issues[0].message, {
631
+ 'issues': v.flatten(issues),
632
+ }],
633
+ });
634
+ if (!parsed.success) {
635
+ return parsed.response;
636
+ }
637
+ return [1, {
638
+ 'post': parsed.output,
639
+ }];
640
+ }
549
641
  async ctrLocale() {
550
642
  const rtn = [];
551
643
  if (!this._checkInput(this._get, {
@@ -4,5 +4,22 @@
4
4
  "test": "The ? test ?.",
5
5
  "test2": {
6
6
  "test3": "test4"
7
+ },
8
+ "validation": {
9
+ "strict_object": "The submitted data must be an object and contain only supported fields.",
10
+ "_xsrf": {
11
+ "xsrf": "Bad request, no permission."
12
+ },
13
+ "title": {
14
+ "strict_object": "The title is required.",
15
+ "string": "The title must be a string.",
16
+ "non_empty": "Please enter a title."
17
+ },
18
+ "count": {
19
+ "strict_object": "The count is required.",
20
+ "string": "The count must be a string.",
21
+ "to_number": "The count must be a number.",
22
+ "integer": "The count must be an integer."
23
+ }
7
24
  }
8
- }
25
+ }