@maiyunnet/kebab 8.6.5 → 9.0.0

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/sys/cmd.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * Last: 2020-3-7 23:51:18, 2022-07-22 14:14:09, 2022-9-27 14:52:19, 2023-5-23 21:42:46, 2024-7-2 15:12:28, 2026-2-23 13:08:11
5
5
  */
6
6
  import * as http from 'http';
7
+ import * as childProcess from 'child_process';
8
+ import { fileURLToPath } from 'url';
7
9
  import * as lFs from '#kebab/lib/fs.js';
8
10
  import * as lText from '#kebab/lib/text.js';
9
11
  import * as lTime from '#kebab/lib/time.js';
@@ -78,6 +80,7 @@ async function run() {
78
80
  config.max ??= 64;
79
81
  config.hosts ??= [];
80
82
  config.ind ??= [];
83
+ config.logFormat ??= 'jsonl';
81
84
  // --- config - set ---
82
85
  config.set ??= {};
83
86
  config.set.timezone ??= 8;
@@ -345,6 +348,156 @@ async function run() {
345
348
  // --- 退出进程 ---
346
349
  process.exit();
347
350
  }
351
+ if (cmds[0] === 'build') {
352
+ // --- 构建命令:使用 esbuild 打包入口 .tsx 文件为自包含 bundle ---
353
+ // --- 只打包「直属于指定目录」的 .tsx,子目录内的组件文件不会被打包 ---
354
+ // --- 推荐目录结构:stc/view/app.tsx(入口)+ stc/view/pages/home.tsx(子组件)---
355
+ // --- 用法:node ./source/main build [-d www/myapp/stc/view] ---
356
+ let targetDir = '';
357
+ for (let i = 1; i < cmds.length; i++) {
358
+ if ((cmds[i] === '-d' || cmds[i] === '--dir') && cmds[i + 1]) {
359
+ targetDir = cmds[i + 1];
360
+ i++;
361
+ }
362
+ }
363
+ /** --- 扫描目录下直属的 .tsx 文件(非递归),子目录内文件不作为打包入口 --- */
364
+ const entryPoints = [];
365
+ const scanDir = async (dir) => {
366
+ const items = await lFs.readDir(dir);
367
+ for (const item of items) {
368
+ if (item.name === '.' || item.name === '..') {
369
+ continue;
370
+ }
371
+ if (!item.isDirectory() && item.name.endsWith('.tsx')) {
372
+ entryPoints.push(`${dir}/${item.name}`);
373
+ }
374
+ }
375
+ };
376
+ if (targetDir) {
377
+ // --- 指定了目录,只扫描该目录下的直属 .tsx ---
378
+ const absTarget = targetDir.startsWith('/') || /^[A-Za-z]:/.test(targetDir)
379
+ ? targetDir
380
+ : kebab.ROOT_CWD + targetDir.replace(/^\//, '');
381
+ if (await lFs.isDir(absTarget)) {
382
+ await scanDir(absTarget);
383
+ }
384
+ else {
385
+ lCore.display('KEBAB', 'BUILD', '[DIR NOT FOUND]', absTarget);
386
+ process.exit();
387
+ return;
388
+ }
389
+ }
390
+ else {
391
+ // --- 未指定目录:扫描 www/*/stc/ 及其直接子目录下的 .tsx ---
392
+ // --- 即扫描 stc/*.tsx 和 stc/*/*.tsx(如 stc/view/app.tsx),不再深入 ---
393
+ const wwwItems = await lFs.readDir(kebab.WWW_CWD);
394
+ for (const wwwItem of wwwItems) {
395
+ if (wwwItem.name === '.' || wwwItem.name === '..' || !wwwItem.isDirectory()) {
396
+ continue;
397
+ }
398
+ const stcPath = `${kebab.WWW_CWD}${wwwItem.name}/stc`;
399
+ if (!await lFs.isDir(stcPath)) {
400
+ continue;
401
+ }
402
+ // --- 扫描 stc/ 直属文件 ---
403
+ await scanDir(stcPath);
404
+ // --- 扫描 stc/ 的直接子目录(如 view/),取其顶层 .tsx 作为入口 ---
405
+ const stcItems = await lFs.readDir(stcPath);
406
+ for (const stcItem of stcItems) {
407
+ if (stcItem.name === '.' || stcItem.name === '..' || !stcItem.isDirectory()) {
408
+ continue;
409
+ }
410
+ await scanDir(`${stcPath}/${stcItem.name}`);
411
+ }
412
+ }
413
+ }
414
+ if (entryPoints.length === 0) {
415
+ lCore.display('KEBAB', 'BUILD', 'No .tsx files found.');
416
+ process.exit();
417
+ return;
418
+ }
419
+ lCore.display('KEBAB', 'BUILD', `Found ${entryPoints.length} file(s).`);
420
+ const esbuild = await import('esbuild');
421
+ for (const entry of entryPoints) {
422
+ const outfile = entry.replace(/\.tsx$/, '.bundle.js');
423
+ lCore.display('KEBAB', 'BUILD', entry.replace(kebab.ROOT_CWD, ''), '→', outfile.replace(kebab.ROOT_CWD, ''));
424
+ try {
425
+ /** --- 组件文件名(不含扩展名),用于生成 stdin 入口的 import 语句 --- */
426
+ const basename = entry.split('/').pop().replace(/\.tsx$/, '');
427
+ /** --- 组件所在目录,esbuild 以此为基准解析相对 import --- */
428
+ const resolveDir = entry.substring(0, entry.lastIndexOf('/'));
429
+ // --- stdin 入口:含水合逻辑,_routerBase 在 props 中时自动包裹 BrowserRouter ---
430
+ // --- React/react-dom/react-router-dom 全部打入 bundle,浏览器只加载一个 JS ---
431
+ const stdinContent = [
432
+ `import{hydrateRoot}from'react-dom/client';`,
433
+ `import{createElement}from'react';`,
434
+ `import{BrowserRouter}from'react-router-dom';`,
435
+ `import App from'./${basename}.tsx';`,
436
+ `const el=document.getElementById('__kebab_props__');`,
437
+ `if(el){`,
438
+ `const p=JSON.parse(el.textContent??'{}');`,
439
+ `if(typeof p._routerBase==='string'){`,
440
+ `hydrateRoot(document,createElement(BrowserRouter,{basename:p._routerBase},createElement(App,p)));`,
441
+ `}else{`,
442
+ `hydrateRoot(document,createElement(App,p));`,
443
+ `}`,
444
+ `}`,
445
+ ].join('');
446
+ await esbuild.build({
447
+ 'stdin': {
448
+ 'contents': stdinContent,
449
+ 'resolveDir': resolveDir,
450
+ 'sourcefile': basename + '.hydrate.tsx',
451
+ 'loader': 'tsx',
452
+ },
453
+ 'bundle': true,
454
+ // --- 无 external:React 全部打入 bundle,生成自包含文件,浏览器只加载一个 JS ---
455
+ 'format': 'esm',
456
+ 'jsx': 'automatic',
457
+ 'jsxImportSource': 'react',
458
+ 'platform': 'browser',
459
+ 'target': 'es2022',
460
+ 'minify': true,
461
+ 'outfile': outfile,
462
+ });
463
+ lCore.display('KEBAB', 'BUILD', 'JS', outfile.replace(kebab.ROOT_CWD, ''));
464
+ // --- 构建 Tailwind CSS:生成同名 .css 文件 ---
465
+ const cssOut = entry.replace(/\.tsx$/, '.css');
466
+ /** --- 临时 CSS 输入文件,@source 扫描当前目录及子目录所有 tsx 文件 --- */
467
+ const tmpCss = entry + '.__tw__.css';
468
+ await lFs.putContent(tmpCss, `@import "tailwindcss";\n@source "./**/*.tsx";\n`);
469
+ try {
470
+ /** --- 直接用当前 Node.js 执行 CLI 的 JS 入口,跨平台无需 shell --- */
471
+ const twJs = fileURLToPath(new URL('../node_modules/@tailwindcss/cli/dist/index.mjs', import.meta.url));
472
+ await new Promise((resolve, reject) => {
473
+ const proc = childProcess.spawn(process.execPath, [twJs, '-i', tmpCss, '-o', cssOut, '--minify'], { 'shell': false });
474
+ const errLines = [];
475
+ proc.stderr.on('data', (d) => errLines.push(d.toString()));
476
+ proc.on('close', (code) => {
477
+ if (code === 0) {
478
+ resolve();
479
+ }
480
+ else {
481
+ reject(new Error(errLines.join('').trim() || `tailwindcss exit ${code ?? 'null'}`));
482
+ }
483
+ });
484
+ });
485
+ lCore.display('KEBAB', 'BUILD', 'CSS', cssOut.replace(kebab.ROOT_CWD, ''));
486
+ }
487
+ catch (e) {
488
+ lCore.display('KEBAB', 'BUILD', 'CSS SKIP (tailwindcss not installed?)', e.message ?? '');
489
+ }
490
+ finally {
491
+ await lFs.unlink(tmpCss);
492
+ }
493
+ }
494
+ catch (e) {
495
+ lCore.display('KEBAB', 'BUILD', 'FAILED', entry.replace(kebab.ROOT_CWD, ''), e.message ?? '');
496
+ }
497
+ }
498
+ lCore.display('DONE');
499
+ process.exit();
500
+ }
348
501
  // --- 读取配置文件 ---
349
502
  const configContent = await lFs.getContent(kebab.CONF_CWD + 'config.json', 'utf8');
350
503
  if (!configContent) {
package/sys/ctr.d.ts CHANGED
@@ -154,6 +154,37 @@ export declare class Ctr {
154
154
  * @param data
155
155
  */
156
156
  protected _loadView(path: string, data?: kebab.Json): Promise<string>;
157
+ /**
158
+ * --- 加载 React 全页面进行 SSR 渲染,组件需渲染完整 HTML 文档(含 html/head/body),无需 EJS ---
159
+ * --- 框架自动注入 props:_urlBase/_urlFull/_urlStc/_staticVer/_staticPath/_staticPathFull ---
160
+ * --- 多语言:自动注入 _locale(当前语言名)和 _localeData(已载语言包的合并键值对) ---
161
+ * --- 组件内创建:const l = (key: string, ...args: string[]): string => { let i = 0; return (_localeData[key] ?? key).replace(/\?/g, () => args[i++] ?? ''); }; ---
162
+ * @param path 页面组件路径(相对于 stc/ 目录,不含扩展名,tsx 编译后的 .js)
163
+ * @param props 传入组件的 props,框架常量自动合并,整体序列化为内联 JSON 供客户端水合复用
164
+ * @param opt 可选配置
165
+ */
166
+ protected _loadReactPage(path: string, props?: Record<string, kebab.Json>, opt?: {
167
+ /** --- 是否注入客户端水合脚本(import map + hydrateRoot),默认 true --- */
168
+ 'hydrate'?: boolean;
169
+ /** --- react/react-dom/react-router-dom 版本号,用于 esm.sh CDN,默认 19 --- */
170
+ 'reactVer'?: string;
171
+ /**
172
+ * --- 路由模式,不传则不注入任何 Router,组件自行管理路由(如 MemoryRouter)或无路由 ---
173
+ * --- 'browser':服务端用 StaticRouter,客户端用 BrowserRouter,地址栏与路由联动 ---
174
+ * --- 组件本身只需使用 Routes/Route/Link 等,不要包含任何 Router 包裹层 ---
175
+ */
176
+ 'router'?: 'browser';
177
+ /**
178
+ * --- BrowserRouter 的 basename,相对于 urlBase,默认空字符串 ---
179
+ * --- 例如组件挂载在 /test/react-router-page,则填 'test/react-router-page' ---
180
+ */
181
+ 'routerBase'?: string;
182
+ /**
183
+ * --- 静态资源基础路径,覆盖 config.set.staticPath,用于指定 CDN 或自定义路径 ---
184
+ * --- 影响 _staticPath prop 以及水合脚本中 JS 文件的 URL 前缀 ---
185
+ */
186
+ 'staticPath'?: string;
187
+ }): Promise<string>;
157
188
  /**
158
189
  * --- 设置校验错误返回值 ---
159
190
  * @param rtn 返回值数组
@@ -195,12 +226,12 @@ export declare class Ctr {
195
226
  /** --- auth 对象,user, pwd --- */
196
227
  private _authorization;
197
228
  /**
198
- * --- 通过 header 或 _auth 获取鉴权信息或 JWT 信息(不解析) ---
229
+ * --- 通过 header 或 _auth 获取 Basic Auth 鉴权信息 ---
199
230
  */
200
231
  getAuthorization(): {
201
232
  'user': string;
202
233
  'pwd': string;
203
- } | false | string;
234
+ } | false;
204
235
  /**
205
236
  * --- 获取 data 数据 ---
206
237
  * @param path 文件路径(不含扩展名)
@@ -236,9 +267,19 @@ export declare class Ctr {
236
267
  protected _getLocale(): string;
237
268
  /**
238
269
  * --- 开启跨域请求 ---
239
- * 返回 true 接续执行,返回 false 需要中断用户本次访问(options请求)
240
- */
241
- protected _cross(): boolean;
270
+ * @param opt 可选 CORS 配置
271
+ * 返回 true 接续执行,返回 false 需要中断用户本次访问(options 请求)
272
+ */
273
+ protected _cross(opt?: {
274
+ /** --- 允许的来源列表,留空为 '*' --- */
275
+ 'origins'?: string[];
276
+ /** --- 允许的请求头 --- */
277
+ 'headers'?: string;
278
+ /** --- 允许的方法 --- */
279
+ 'methods'?: string;
280
+ /** --- 是否允许发送凭据(cookie),默认 false --- */
281
+ 'credentials'?: boolean;
282
+ }): boolean;
242
283
  /**
243
284
  * --- 获取语言包值 ---
244
285
  * @param key
package/sys/ctr.js CHANGED
@@ -239,6 +239,117 @@ export class Ctr {
239
239
  };
240
240
  return lCore.purify(ejs.render(content, data, {}));
241
241
  }
242
+ /**
243
+ * --- 加载 React 全页面进行 SSR 渲染,组件需渲染完整 HTML 文档(含 html/head/body),无需 EJS ---
244
+ * --- 框架自动注入 props:_urlBase/_urlFull/_urlStc/_staticVer/_staticPath/_staticPathFull ---
245
+ * --- 多语言:自动注入 _locale(当前语言名)和 _localeData(已载语言包的合并键值对) ---
246
+ * --- 组件内创建:const l = (key: string, ...args: string[]): string => { let i = 0; return (_localeData[key] ?? key).replace(/\?/g, () => args[i++] ?? ''); }; ---
247
+ * @param path 页面组件路径(相对于 stc/ 目录,不含扩展名,tsx 编译后的 .js)
248
+ * @param props 传入组件的 props,框架常量自动合并,整体序列化为内联 JSON 供客户端水合复用
249
+ * @param opt 可选配置
250
+ */
251
+ async _loadReactPage(path, props = {}, opt = {}) {
252
+ // --- 组件 JS 从 stc 目录读取,浏览器同样通过 staticPath(支持 CDN)下载 ---
253
+ const componentPath = this._config.const.rootPath + 'stc/' + path + '.js';
254
+ if (!await lFs.isFile(componentPath)) {
255
+ return '';
256
+ }
257
+ try {
258
+ const reactDomServer = await import('react-dom/server');
259
+ const importPath = componentPath.startsWith('/')
260
+ ? componentPath
261
+ : `file:///${componentPath.replace(/\\/g, '/')}`;
262
+ const mod = await import(importPath);
263
+ const component = mod.default;
264
+ const react = await import('react');
265
+ // --- 语言包数据:合并所有已加载包的键值,供组件使用 _localeData 实现多语言 ---
266
+ const localeData = {};
267
+ for (const pkg in this._localeData) {
268
+ Object.assign(localeData, this._localeData[pkg]);
269
+ }
270
+ // --- 把框架常量合并进 props,与 _loadView 行为一致 ---
271
+ const staticPath = opt.staticPath ?? this._config.set.staticPath;
272
+ const fullProps = {
273
+ ...props,
274
+ '_urlBase': this._config.const.urlBase,
275
+ '_urlFull': this._config.const.urlFull,
276
+ '_urlStc': this._config.const.urlStc,
277
+ '_staticVer': this._config.set.staticVer,
278
+ '_staticPath': staticPath,
279
+ '_staticPathFull': this._config.set.staticPathFull,
280
+ '_locale': this._locale,
281
+ '_localeData': localeData,
282
+ };
283
+ if (opt.hydrate !== false) {
284
+ const reactVer = opt.reactVer ?? '19';
285
+ const esm = 'https://esm.sh/';
286
+ // --- 检查是否有 npx kebab build 生成的自包含预构建包 ---
287
+ const bundlePath = this._config.const.rootPath + 'stc/' + path + '.bundle.js';
288
+ const hasBundle = await lFs.isFile(bundlePath);
289
+ if (opt.router === 'browser') {
290
+ // --- BrowserRouter 模式:_routerBase 注入 props,bundle 读取此值决定是否包裹 BrowserRouter ---
291
+ const base = opt.routerBase ?? '';
292
+ const routerBase = this._config.const.urlBase + base.replace(/^\//, '');
293
+ fullProps['_routerBase'] = routerBase.replace(/\/$/, '');
294
+ }
295
+ if (hasBundle) {
296
+ // --- bundle 模式:bundle 自包含 React + 水合逻辑,无需 import map,一个 JS 文件搞定 ---
297
+ const clientUrl = `${staticPath}${path}.bundle.js?v=${this._config.set.staticVer}`;
298
+ fullProps['_hydrateScript'] = `import'${clientUrl}';`;
299
+ }
300
+ else {
301
+ // --- 开发模式(tsc 编译 .js):通过 esm.sh import map 解析 bare import ---
302
+ const clientUrl = `${staticPath}${path}.js?v=${this._config.set.staticVer}`;
303
+ fullProps['_importMapJson'] = lText.stringifyJson({
304
+ 'imports': {
305
+ 'react': `${esm}react@${reactVer}`,
306
+ 'react-dom': `${esm}react-dom@${reactVer}`,
307
+ 'react-dom/client': `${esm}react-dom@${reactVer}/client`,
308
+ 'react/jsx-runtime': `${esm}react@${reactVer}/jsx-runtime`,
309
+ 'react-router-dom': `${esm}react-router-dom@7?external=react,react-dom`,
310
+ },
311
+ });
312
+ if (opt.router === 'browser') {
313
+ fullProps['_hydrateScript'] =
314
+ `import{hydrateRoot}from'react-dom/client';` +
315
+ `import{createElement}from'react';` +
316
+ `import{BrowserRouter}from'react-router-dom';` +
317
+ `import App from'${clientUrl}';` +
318
+ `const p=JSON.parse(document.getElementById('__kebab_props__').textContent);` +
319
+ `hydrateRoot(document,createElement(BrowserRouter,{basename:p._routerBase},createElement(App,p)));`;
320
+ }
321
+ else {
322
+ fullProps['_hydrateScript'] =
323
+ `import{hydrateRoot}from'react-dom/client';` +
324
+ `import{createElement}from'react';` +
325
+ `import App from'${clientUrl}';` +
326
+ `const p=JSON.parse(document.getElementById('__kebab_props__').textContent);` +
327
+ `hydrateRoot(document,createElement(App,p));`;
328
+ }
329
+ }
330
+ // --- _propsJson 序列化当前 fullProps(不含 _propsJson 本身,避免循环引用)---
331
+ // --- 客户端水合时读取此 JSON,_propsJson 缺失,suppressHydrationWarning 处理差异 ---
332
+ fullProps['_propsJson'] = lText.stringifyJson(fullProps).replace(/<\/script>/gi, '<\\/script>');
333
+ }
334
+ // --- BrowserRouter 模式:服务端用 StaticRouter 渲染,与客户端的 BrowserRouter 等价 ---
335
+ // --- component 来自动态 import,TypeScript 无法精确推断,需要明确限定 element 类型 ---
336
+ let element = react.createElement(component, fullProps);
337
+ if (opt.router === 'browser') {
338
+ // --- StaticRouter 在 react-router-dom v7 中从主包直接导出,无需 /server 子路径 ---
339
+ const lReactRouter = await import('react-router-dom');
340
+ const reqUrl = this._req.url ?? '/';
341
+ element = react.createElement(lReactRouter.StaticRouter, {
342
+ 'location': reqUrl,
343
+ 'basename': fullProps['_routerBase'],
344
+ }, react.createElement(component, fullProps));
345
+ }
346
+ return '<!DOCTYPE html>' + reactDomServer.renderToString(element);
347
+ }
348
+ catch (e) {
349
+ lCore.debug(`[CTR][_loadReactPage] ${e.message ?? ''}`);
350
+ return '';
351
+ }
352
+ }
242
353
  /**
243
354
  * --- 设置校验错误返回值 ---
244
355
  * @param rtn 返回值数组
@@ -484,7 +595,7 @@ export class Ctr {
484
595
  /** --- auth 对象,user, pwd --- */
485
596
  _authorization = null;
486
597
  /**
487
- * --- 通过 header 或 _auth 获取鉴权信息或 JWT 信息(不解析) ---
598
+ * --- 通过 header 或 _auth 获取 Basic Auth 鉴权信息 ---
488
599
  */
489
600
  getAuthorization() {
490
601
  if (this._authorization !== null) {
@@ -507,10 +618,6 @@ export class Ctr {
507
618
  if (authArr[1] === undefined) {
508
619
  return false;
509
620
  }
510
- if (authArr[1].includes('.')) {
511
- // --- 不解析,解析使用 JWT 类解析 ---
512
- return authArr[1];
513
- }
514
621
  if (!(auth = lCrypto.base64Decode(authArr[1]))) {
515
622
  return false;
516
623
  }
@@ -625,12 +732,29 @@ export class Ctr {
625
732
  }
626
733
  /**
627
734
  * --- 开启跨域请求 ---
628
- * 返回 true 接续执行,返回 false 需要中断用户本次访问(options请求)
629
- */
630
- _cross() {
631
- this._res.setHeader('access-control-allow-origin', '*');
632
- this._res.setHeader('access-control-allow-headers', '*');
633
- this._res.setHeader('access-control-allow-methods', '*');
735
+ * @param opt 可选 CORS 配置
736
+ * 返回 true 接续执行,返回 false 需要中断用户本次访问(options 请求)
737
+ */
738
+ _cross(opt = {}) {
739
+ if (opt.origins?.length) {
740
+ const reqOrigin = this._headers['origin'] ?? '';
741
+ if (opt.origins.includes(reqOrigin)) {
742
+ this._res.setHeader('access-control-allow-origin', reqOrigin);
743
+ this._res.setHeader('vary', 'Origin');
744
+ }
745
+ else {
746
+ this._res.setHeader('access-control-allow-origin', opt.origins[0]);
747
+ this._res.setHeader('vary', 'Origin');
748
+ }
749
+ }
750
+ else {
751
+ this._res.setHeader('access-control-allow-origin', '*');
752
+ }
753
+ this._res.setHeader('access-control-allow-headers', opt.headers ?? '*');
754
+ this._res.setHeader('access-control-allow-methods', opt.methods ?? '*');
755
+ if (opt.credentials) {
756
+ this._res.setHeader('access-control-allow-credentials', 'true');
757
+ }
634
758
  if (this._req.method === 'OPTIONS') {
635
759
  this._res.setHeader('access-control-max-age', '3600');
636
760
  this._httpCode = 204;
package/sys/master.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Project: Kebab, User: JianSuoQiYue
3
3
  * Date: 2019-5-2 21:03:42
4
- * Last: 2020-3-7 10:33:17, 2022-07-22 13:40:10, 2022-09-06 22:40:58, 2024-2-7 01:44:59, 2024-7-2 15:17:09, 2025-6-13 13:06:43, 2025-12-5 13:15:03
4
+ * Last: 2020-3-7 10:33:17, 2022-07-22 13:40:10, 2022-09-06 22:40:58, 2024-2-7 01:44:59, 2024-7-2 15:17:09, 2025-6-13 13:06:43, 2025-12-5 13:15:03, 2026-3-22 00:00:00
5
5
  */
6
6
  import cluster from 'cluster';
7
7
  import * as os from 'os';
8
+ import * as fs from 'fs';
8
9
  import * as http from 'http';
9
10
  // --- 库和定义 ---
10
11
  import * as kebab from '#kebab/index.js';
@@ -32,17 +33,21 @@ async function run() {
32
33
  ],
33
34
  });
34
35
  // --- 读取配置文件 ---
36
+ await lCore.loadEnv(kebab.ROOT_CWD);
35
37
  const configContent = await lFs.getContent(kebab.CONF_CWD + 'config.json', 'utf8');
36
38
  if (!configContent) {
37
39
  throw `File '${kebab.CONF_CWD}config.json' not found.`;
38
40
  }
39
41
  /** --- 系统 config.json --- */
40
42
  const config = lText.parseJson(configContent);
43
+ lCore.resolveEnvVars(config);
41
44
  for (const key in config) {
42
45
  lCore.globalConfig[key] = config[key];
43
46
  }
44
47
  // --- 监听 RPC 命令 ---
45
48
  createRpcListener();
49
+ // --- 开发模式下启用文件监听自动重载 ---
50
+ startFileWatcher();
46
51
  // --- 30 秒检测一次是否有丢失的子进程 ---
47
52
  setInterval(function () {
48
53
  checkWorkerLost().catch(function (e) {
@@ -281,7 +286,9 @@ function createRpcListener() {
281
286
  }
282
287
  case 'log': {
283
288
  // --- 获取日志信息 ---
284
- const path = kebab.LOG_CWD + msg.hostname + (msg.fend ?? '') + '/' + msg.path + '.csv';
289
+ const format = lCore.globalConfig.logFormat ?? 'jsonl';
290
+ const ext = format === 'jsonl' ? '.jsonl' : '.csv';
291
+ const path = kebab.LOG_CWD + msg.hostname + (msg.fend ?? '') + '/' + msg.path + ext;
285
292
  if (!await lFs.isFile(path)) {
286
293
  res.end(lText.stringifyJson({
287
294
  'result': 1,
@@ -496,6 +503,20 @@ async function createChildProcess(cpu) {
496
503
  workerList[msg.pid].hbtime = Date.now();
497
504
  break;
498
505
  }
506
+ case 'ws-broadcast': {
507
+ // --- 将 WebSocket 广播消息转发给所有其他子进程 ---
508
+ for (const pid in workerList) {
509
+ if (workerList[pid].worker === worker) {
510
+ continue;
511
+ }
512
+ workerList[pid].worker.send({
513
+ 'action': 'ws-broadcast',
514
+ 'channel': msg.channel,
515
+ 'data': msg.data,
516
+ });
517
+ }
518
+ break;
519
+ }
499
520
  }
500
521
  })().catch(function (e) {
501
522
  lCore.display('[createChildProcess] [message]', e);
@@ -510,6 +531,79 @@ async function createChildProcess(cpu) {
510
531
  });
511
532
  }
512
533
  }
534
+ /**
535
+ * --- 开发模式下的文件监听自动重载(HMR),仅在 debug: true 时启用 ---
536
+ */
537
+ function startFileWatcher() {
538
+ if (!lCore.globalConfig.debug) {
539
+ return;
540
+ }
541
+ /** --- 防抖定时器 --- */
542
+ let debounceTimer = null;
543
+ /** --- 是否正在重启中 --- */
544
+ let restarting = false;
545
+ /**
546
+ * --- 监听指定目录的文件变化 ---
547
+ * @param dir 要监听的目录路径
548
+ */
549
+ const watchDir = (dir) => {
550
+ try {
551
+ fs.watch(dir, { 'recursive': true }, (eventType, filename) => {
552
+ if (!filename) {
553
+ return;
554
+ }
555
+ // --- 仅关注 .js 文件和 .json 配置文件的变更 ---
556
+ if (!filename.endsWith('.js') &&
557
+ !filename.endsWith('.json')) {
558
+ return;
559
+ }
560
+ // --- 忽略日志目录和临时目录 ---
561
+ if (filename.includes('log/') || filename.includes('ftmp/') || filename.includes('node_modules/')) {
562
+ return;
563
+ }
564
+ // --- 防抖:500ms 内多次变化只触发一次 ---
565
+ if (debounceTimer) {
566
+ clearTimeout(debounceTimer);
567
+ }
568
+ debounceTimer = setTimeout(() => {
569
+ debounceTimer = null;
570
+ if (restarting) {
571
+ return;
572
+ }
573
+ restarting = true;
574
+ lCore.display(`[HMR] File changed: ${filename}, reloading workers...`);
575
+ (async () => {
576
+ // --- 为所有子进程发送 stop 信息并重启 ---
577
+ for (const pid in workerList) {
578
+ workerList[pid].worker.send({
579
+ 'action': 'stop'
580
+ });
581
+ await createChildProcess(workerList[pid].cpu);
582
+ delete workerList[pid];
583
+ }
584
+ restarting = false;
585
+ lCore.display('[HMR] All workers reloaded.');
586
+ })().catch((e) => {
587
+ restarting = false;
588
+ lCore.display('[HMR] Reload error:', e);
589
+ });
590
+ }, 500);
591
+ });
592
+ lCore.display(`[HMR] Watching directory: ${dir}`);
593
+ }
594
+ catch {
595
+ lCore.display(`[HMR] Cannot watch directory: ${dir}`);
596
+ }
597
+ };
598
+ // --- 监听 www/ 目录(用户项目代码)---
599
+ watchDir(kebab.WWW_CWD);
600
+ // --- 监听 ind/ 目录(独立任务代码)---
601
+ watchDir(kebab.IND_CWD);
602
+ // --- 监听 lib/ 目录(用户自定义库)---
603
+ watchDir(kebab.LIB_CWD);
604
+ // --- 监听 mod/ 目录(用户模型)---
605
+ watchDir(kebab.MOD_CWD);
606
+ }
513
607
  run().catch(function (e) {
514
608
  lCore.display('[master] ------ [Process fatal Error] ------');
515
609
  lCore.display(e);
package/sys/route.d.ts CHANGED
@@ -61,6 +61,7 @@ export declare function getPost(req: http2.Http2ServerRequest | http.IncomingMes
61
61
  * --- 获取 formdata 的 post ---
62
62
  * @param req 请求头
63
63
  * @param events 文件处理情况
64
+ * @param limits 文件上传限制
64
65
  */
65
66
  export declare function getFormData(req: http2.Http2ServerRequest | http.IncomingMessage, events?: {
66
67
  /**
@@ -71,6 +72,11 @@ export declare function getFormData(req: http2.Http2ServerRequest | http.Incomin
71
72
  onfiledata?: (chunk: Buffer) => void;
72
73
  /** --- 文件上传结束时触发,仅 start 返回 true 时触发 --- */
73
74
  onfileend?: () => void;
75
+ }, limits?: {
76
+ /** --- 单个文件最大字节数 --- */
77
+ 'maxFileSize'?: number;
78
+ /** --- 允许的文件扩展名(含点号),如 ['.jpg', '.png', '.pdf'] --- */
79
+ 'allowedExts'?: string[];
74
80
  }): Promise<{
75
81
  'post': Record<string, kebab.Json>;
76
82
  'files': Record<string, kebab.IPostFile | kebab.IPostFile[]>;
package/sys/route.js CHANGED
@@ -864,8 +864,9 @@ export function getPost(req) {
864
864
  * --- 获取 formdata 的 post ---
865
865
  * @param req 请求头
866
866
  * @param events 文件处理情况
867
+ * @param limits 文件上传限制
867
868
  */
868
- export function getFormData(req, events = {}) {
869
+ export function getFormData(req, events = {}, limits = {}) {
869
870
  return new Promise(function (resolve) {
870
871
  if (req.readableEnded) {
871
872
  resolve({ 'post': {}, 'files': {} });
@@ -947,6 +948,16 @@ export function getFormData(req, events = {}) {
947
948
  ++writeFileLength;
948
949
  state = EState.FILE;
949
950
  fileName = match[1];
951
+ // --- 检查文件扩展名限制 ---
952
+ if (limits.allowedExts?.length) {
953
+ const extIo = fileName.lastIndexOf('.');
954
+ const ext = extIo !== -1 ? fileName.slice(extIo).toLowerCase() : '';
955
+ if (!limits.allowedExts.includes(ext)) {
956
+ // --- 扩展名不允许,跳过该文件 ---
957
+ ftmpName = '';
958
+ break;
959
+ }
960
+ }
950
961
  const fr = events.onfilestart?.(name);
951
962
  if (fr !== true) {
952
963
  // --- 创建文件流 ---
@@ -996,8 +1007,18 @@ export function getFormData(req, events = {}) {
996
1007
  // --- 没找到结束标语,将预留 boundary 长度之前的写入到文件 ---
997
1008
  const writeBuffer = buffer.subarray(0, -boundary.length - 4);
998
1009
  if (ftmpName) {
999
- ftmpStream.write(writeBuffer);
1000
- ftmpSize += Buffer.byteLength(writeBuffer);
1010
+ // --- 检查文件大小限制 ---
1011
+ if (limits.maxFileSize &&
1012
+ (ftmpSize + Buffer.byteLength(writeBuffer) > limits.maxFileSize)) {
1013
+ ftmpStream.destroy();
1014
+ lFs.unlink(kebab.FTMP_CWD + ftmpName).catch(() => { });
1015
+ ftmpName = '';
1016
+ --writeFileLength;
1017
+ }
1018
+ else {
1019
+ ftmpStream.write(writeBuffer);
1020
+ ftmpSize += Buffer.byteLength(writeBuffer);
1021
+ }
1001
1022
  }
1002
1023
  else {
1003
1024
  // --- 跳过该文件 ---