@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/doc/kebab-rag.md +1040 -697
- package/index.d.ts +1 -9
- package/index.js +1 -1
- package/lib/core.d.ts +12 -0
- package/lib/core.js +93 -22
- package/lib/cron.js +84 -33
- package/lib/db.d.ts +2 -0
- package/lib/db.js +3 -1
- package/lib/ratelimit.d.ts +47 -0
- package/lib/ratelimit.js +88 -0
- package/package.json +20 -13
- package/sys/child.js +2 -0
- package/sys/cmd.js +153 -0
- package/sys/ctr.d.ts +46 -5
- package/sys/ctr.js +135 -11
- package/sys/master.js +96 -2
- package/sys/route.d.ts +6 -0
- package/sys/route.js +24 -3
- package/www/example/ctr/test.d.ts +34 -0
- package/www/example/ctr/test.js +140 -0
- package/www/example/route.json +2 -1
- package/www/example/stc/view/react-page.bundle.js +97 -0
- package/www/example/stc/view/react-page.css +2 -0
- package/www/example/stc/view/react-page.d.ts +50 -0
- package/www/example/stc/view/react-page.js +136 -0
- package/www/example/stc/view/react-page.tsx +448 -0
- package/www/example/stc/view/react-router-page.bundle.js +81 -0
- package/www/example/stc/view/react-router-page.css +2 -0
- package/www/example/stc/view/react-router-page.d.ts +58 -0
- package/www/example/stc/view/react-router-page.js +142 -0
- package/www/example/stc/view/react-router-page.tsx +426 -0
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
|
|
229
|
+
* --- 通过 header 或 _auth 获取 Basic Auth 鉴权信息 ---
|
|
199
230
|
*/
|
|
200
231
|
getAuthorization(): {
|
|
201
232
|
'user': string;
|
|
202
233
|
'pwd': string;
|
|
203
|
-
} | false
|
|
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
|
-
*
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
*
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
|
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
|
-
|
|
1000
|
-
|
|
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
|
// --- 跳过该文件 ---
|