modelstage 0.0.0-stage → 0.5.28
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/LICENSE +21 -0
- package/README.md +274 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +137 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +63 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +654 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +396 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/shared/type-labels.mjs +31 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +524 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +117 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +92 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +39 -0
- package/src/web/roster/type-filter.js +97 -0
- package/src/web/roster/view.js +761 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// http/diag-upload.mjs —— 「上传调试日志」的落盘与两条接口
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有:页面里的调试日志只在内存(刷新即丢),出问题时用户描述不清、AI 又读不到浏览器。
|
|
4
|
+
// 这里提供一个"用户点一下、日志落到服务端"的出口,之后 AI 靠 GET /api/diag/uploads 就能
|
|
5
|
+
// 自己查到"文件在哪、多大、几条",不必再问用户。
|
|
6
|
+
//
|
|
7
|
+
// 两条接口(路由在 serve.mjs 里挂,这里只做数据与校验):
|
|
8
|
+
// POST /api/diag/upload 收 { logs, meta } → 落盘 data/logs/diag-<yyyyMMdd-HHmmss>.json
|
|
9
|
+
// GET /api/diag/uploads 按时间倒序列出已上传文件 [{ file, at, bytes, count, truncated }]
|
|
10
|
+
//
|
|
11
|
+
// 四条硬约束:
|
|
12
|
+
// 1. **文件名一律服务端生成**:客户端即使传了 file/path 也直接忽略(防路径穿越);
|
|
13
|
+
// 落盘路径只用 path.join(LOGS_DIR, basename),并校验最终路径确实在 LOGS_DIR 之内
|
|
14
|
+
// 2. **边读边计数**:请求体超过上限立刻中断并返回 413,不允许"先全读进内存再判"
|
|
15
|
+
// 3. **条数/体积上限走配置**(config/diag-upload.json),不写死;超条数时截断并在文件里标 truncated
|
|
16
|
+
// 4. **索引 index.json 走 tmp+rename 原子写 + 串行队列**,并发上传不会互相覆盖
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { parseJsonc } from '../../../tool/parse-jsonc.mjs';
|
|
21
|
+
|
|
22
|
+
/** 默认上限(配置文件缺失时用它):条数与请求体积分开限,前者防日志爆炸、后者防内存 */
|
|
23
|
+
const DEFAULTS = {
|
|
24
|
+
maxCount: 5000, // 单次上传最多保留多少条(超出即截断,文件里标 truncated)
|
|
25
|
+
maxBytes: 5 * 1024 * 1024, // 单次请求体上限 5MB(约 5000 条中等日志的 3~5 倍余量)
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** 索引文件名(放 data/logs/ 里,与日志文件同目录,便于一起带走) */
|
|
29
|
+
const INDEX_FILE = 'index.json';
|
|
30
|
+
|
|
31
|
+
/** 时间戳:文件名里的 yyyyMMdd-HHmmss(本地时区,给人看的) */
|
|
32
|
+
function stampFile(d = new Date()) {
|
|
33
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
34
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 读上传上限配置(JSONC,与 config/resources.json 同一套解析)。
|
|
39
|
+
* 文件不存在/损坏就用 DEFAULTS —— 配置只是"允许调",不是"必须存在"。
|
|
40
|
+
* @param {string} configFile
|
|
41
|
+
* @returns {{maxCount: number, maxBytes: number}}
|
|
42
|
+
*/
|
|
43
|
+
function readLimits(configFile) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = fs.readFileSync(configFile, 'utf8');
|
|
46
|
+
const cfg = parseJsonc(raw, 'config/diag-upload.json');
|
|
47
|
+
const maxCount = Number(cfg.maxCount);
|
|
48
|
+
const maxBytes = Number(cfg.maxBytes);
|
|
49
|
+
return {
|
|
50
|
+
maxCount: Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : DEFAULTS.maxCount,
|
|
51
|
+
maxBytes: Number.isFinite(maxBytes) && maxBytes > 0 ? Math.floor(maxBytes) : DEFAULTS.maxBytes,
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return { ...DEFAULTS };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 吐 JSON(与 serve.mjs 的 sendJson 同口径;这里自带一份避免跨模块耦合) */
|
|
59
|
+
function sendJson(res, status, body) {
|
|
60
|
+
const text = JSON.stringify(body);
|
|
61
|
+
res.writeHead(status, {
|
|
62
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
63
|
+
'Content-Length': Buffer.byteLength(text),
|
|
64
|
+
'Cache-Control': 'no-store',
|
|
65
|
+
});
|
|
66
|
+
res.end(text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 限制体积地读完整请求体。
|
|
71
|
+
* 超限时**立刻**回 413 并停止累计(不清空缓冲区也会被 GC 回收,不会 OOM)。
|
|
72
|
+
* @param {import('node:http').IncomingMessage} req
|
|
73
|
+
* @param {import('node:http').ServerResponse} res
|
|
74
|
+
* @param {number} maxBytes
|
|
75
|
+
* @returns {Promise<Buffer|null>} 超限/出错返回 null(调用方已收到响应)
|
|
76
|
+
*/
|
|
77
|
+
function readBodyLimited(req, res, maxBytes) {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let total = 0;
|
|
81
|
+
let done = false;
|
|
82
|
+
const finish = (value) => { if (!done) { done = true; resolve(value); } };
|
|
83
|
+
req.on('data', (chunk) => {
|
|
84
|
+
if (done) return;
|
|
85
|
+
total += chunk.length;
|
|
86
|
+
if (total > maxBytes) {
|
|
87
|
+
sendJson(res, 413, { ok: false, error: `请求体超过上限(${maxBytes} 字节)`, maxBytes });
|
|
88
|
+
// 多余数据丢弃:继续 drain 会白占内存,destroy 前给响应一点时间发出去
|
|
89
|
+
setTimeout(() => { try { req.destroy(); } catch { /* 已断开 */ } }, 300);
|
|
90
|
+
finish(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
chunks.push(chunk);
|
|
94
|
+
});
|
|
95
|
+
req.on('end', () => finish(Buffer.concat(chunks)));
|
|
96
|
+
req.on('error', () => {
|
|
97
|
+
sendJson(res, 400, { ok: false, error: '读取请求体失败' });
|
|
98
|
+
finish(null);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 同步判定这两条路由是否命中。
|
|
105
|
+
*
|
|
106
|
+
* 为什么必须有同步的 matches:真正的处理要异步读请求体(handle 是 async),而 serve.mjs 里
|
|
107
|
+
* 写的是 `if (xxx.handle(...)) return;` —— 直接判 async 函数会**恒真**(返回 Promise),
|
|
108
|
+
* 把后面所有路由都截断掉(实测:挂上后整站 404)。所以拆成"同步匹配 + 异步执行"两步。
|
|
109
|
+
* @param {string} method
|
|
110
|
+
* @param {string} p 路径
|
|
111
|
+
* @returns {boolean} 是否命中
|
|
112
|
+
*/
|
|
113
|
+
export function matches(method, p) {
|
|
114
|
+
if (p === '/api/diag/upload') return method === 'POST';
|
|
115
|
+
if (p === '/api/diag/uploads') return method === 'GET';
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 建"落盘仓库":只管文件的读写与索引,不关心 HTTP。
|
|
121
|
+
* @param {{logsDir: string}} opts
|
|
122
|
+
* @returns {{logsDir: string, save: Function, list: Function, makeFileName: Function}}
|
|
123
|
+
*/
|
|
124
|
+
function createStore({ logsDir }) {
|
|
125
|
+
const indexPath = path.join(logsDir, INDEX_FILE);
|
|
126
|
+
// 串行队列:索引是"读-改-写",并发写会丢条目(背景图那边同样的坑)
|
|
127
|
+
let queue = Promise.resolve();
|
|
128
|
+
fs.mkdirSync(logsDir, { recursive: true });
|
|
129
|
+
|
|
130
|
+
/** 读索引(缺失/损坏都当空表) */
|
|
131
|
+
function readIndex() {
|
|
132
|
+
try {
|
|
133
|
+
const arr = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
134
|
+
return Array.isArray(arr) ? arr : [];
|
|
135
|
+
} catch {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 原子写索引(tmp + rename),并串行化,避免并发上传互相覆盖 */
|
|
141
|
+
function appendIndex(entry) {
|
|
142
|
+
queue = queue.then(() => {
|
|
143
|
+
const list = readIndex();
|
|
144
|
+
list.push(entry);
|
|
145
|
+
const tmp = `${indexPath}.tmp`;
|
|
146
|
+
fs.writeFileSync(tmp, JSON.stringify(list, null, 1));
|
|
147
|
+
fs.renameSync(tmp, indexPath);
|
|
148
|
+
}, () => { /* 上一次失败不影响这一次 */ });
|
|
149
|
+
return queue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 生成不冲突的文件名(同一秒内多次上传时追加序号)。
|
|
154
|
+
* 名字**完全由服务端决定**,客户端给什么都不参与。
|
|
155
|
+
* @returns {string} 形如 diag-20261024-143000.json
|
|
156
|
+
*/
|
|
157
|
+
function makeFileName() {
|
|
158
|
+
const base = `diag-${stampFile()}`;
|
|
159
|
+
let candidate = `${base}.json`;
|
|
160
|
+
let n = 1;
|
|
161
|
+
while (fs.existsSync(path.join(logsDir, candidate))) {
|
|
162
|
+
n += 1;
|
|
163
|
+
candidate = `${base}-${n}.json`;
|
|
164
|
+
}
|
|
165
|
+
return candidate;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 落盘一次上传(条数超限只保留最新的 N 条并标记截断)。
|
|
170
|
+
* @param {object} payload 客户端请求体 { logs, meta }
|
|
171
|
+
* @param {{maxCount:number,maxBytes:number}} limits
|
|
172
|
+
* @returns {{ok:true, file:string, path:string, count:number, bytes:number, truncated:boolean}}
|
|
173
|
+
*/
|
|
174
|
+
function save(payload, limits) {
|
|
175
|
+
const all = Array.isArray(payload && payload.logs) ? payload.logs : null;
|
|
176
|
+
if (!all) {
|
|
177
|
+
const err = new Error('logs 必须是数组');
|
|
178
|
+
err.status = 400;
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
// 条数上限:只保留**最新的** N 条(日志尾部才有现场价值),并显式标记截断
|
|
182
|
+
const truncated = all.length > limits.maxCount;
|
|
183
|
+
const logs = truncated ? all.slice(-limits.maxCount) : all;
|
|
184
|
+
const file = makeFileName();
|
|
185
|
+
// 二次防线:即便文件名生成逻辑被改坏,也不允许落到 logsDir 之外
|
|
186
|
+
const fullPath = path.join(logsDir, path.basename(file));
|
|
187
|
+
if (path.dirname(fullPath) !== logsDir) throw new Error('落盘路径越界');
|
|
188
|
+
|
|
189
|
+
const doc = {
|
|
190
|
+
meta: (payload && payload.meta && typeof payload.meta === 'object') ? payload.meta : {},
|
|
191
|
+
count: logs.length,
|
|
192
|
+
truncated,
|
|
193
|
+
received: all.length,
|
|
194
|
+
uploadedAt: new Date().toISOString(),
|
|
195
|
+
logs,
|
|
196
|
+
};
|
|
197
|
+
const text = JSON.stringify(doc, null, 1);
|
|
198
|
+
const tmp = `${fullPath}.tmp`;
|
|
199
|
+
fs.writeFileSync(tmp, text);
|
|
200
|
+
fs.renameSync(tmp, fullPath); // 原子可见:不会读到写了一半的文件
|
|
201
|
+
|
|
202
|
+
const bytes = Buffer.byteLength(text);
|
|
203
|
+
appendIndex({ file, at: Date.now(), bytes, count: logs.length, truncated });
|
|
204
|
+
return { ok: true, file, path: fullPath, count: logs.length, bytes, truncated };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 列表:按时间倒序(最新的在前),带绝对路径供工具直接读 */
|
|
208
|
+
function list() {
|
|
209
|
+
return readIndex()
|
|
210
|
+
.filter((e) => e && typeof e.file === 'string')
|
|
211
|
+
.sort((a, b) => (b.at || 0) - (a.at || 0))
|
|
212
|
+
.map((e) => ({
|
|
213
|
+
file: e.file,
|
|
214
|
+
at: e.at,
|
|
215
|
+
bytes: e.bytes,
|
|
216
|
+
count: e.count,
|
|
217
|
+
truncated: e.truncated === true,
|
|
218
|
+
path: path.join(logsDir, path.basename(e.file)),
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { logsDir, save, list, makeFileName };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 创建"上传调试日志"模块(路由在 serve.mjs 里挂)。
|
|
227
|
+
* @param {{dataDir: string, configDir?: string}} paths 服务端数据目录与配置目录
|
|
228
|
+
* @returns {{matches: Function, handle: Function, listUploads: Function, logsDir: string, configFile: string}}
|
|
229
|
+
*/
|
|
230
|
+
export function createDiagUpload(paths) {
|
|
231
|
+
const logsDir = path.join(paths.dataDir, 'logs');
|
|
232
|
+
const configFile = path.join(paths.configDir || path.join(path.dirname(paths.dataDir), 'config'), 'diag-upload.json');
|
|
233
|
+
const store = createStore({ logsDir });
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 异步执行(只在 matches 命中后调用;调用方不要 await,让它自己写完响应)。
|
|
237
|
+
* @returns {Promise<void>}
|
|
238
|
+
*/
|
|
239
|
+
async function handle(req, res, p) {
|
|
240
|
+
if (p === '/api/diag/uploads') {
|
|
241
|
+
sendJson(res, 200, { ok: true, uploads: store.list() });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const limits = readLimits(configFile);
|
|
245
|
+
const raw = await readBodyLimited(req, res, limits.maxBytes);
|
|
246
|
+
if (raw === null) return; // 已回 413/400
|
|
247
|
+
let payload = null;
|
|
248
|
+
try {
|
|
249
|
+
payload = JSON.parse(raw.toString('utf8') || '{}');
|
|
250
|
+
} catch {
|
|
251
|
+
sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
sendJson(res, 200, store.save(payload, limits));
|
|
256
|
+
} catch (e) {
|
|
257
|
+
sendJson(res, e.status || 500, { ok: false, error: e.message || '上传失败' });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { matches, handle, listUploads: store.list, logsDir, configFile };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { DEFAULTS as DIAG_UPLOAD_DEFAULTS, stampFile };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// diag.mjs —— 前端诊断落盘(POST /api/diag 的数据层)
|
|
2
|
+
//
|
|
3
|
+
// 与旧服务同一套规则(行数/体积/轮转一致),只写 data/ 目录,不触碰用户配置:
|
|
4
|
+
// · 一行一条 JSON,追加写;
|
|
5
|
+
// · 单请求最多 500 条、单条最长 8000 字(防止一次把磁盘写满);
|
|
6
|
+
// · 文件超过 8MB 时把当前文件改名 diag.jsonl.1(只保留一代)再继续写;
|
|
7
|
+
// · 任何异常都只回 {ok:false},绝不抛到路由外(否则会打挂进程)。
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createLogger } from '../core/log.mjs';
|
|
11
|
+
|
|
12
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
13
|
+
const MAX_ENTRIES_PER_REQ = 500;
|
|
14
|
+
const MAX_LINE_CHARS = 8000;
|
|
15
|
+
// 每个缓冲只留最近几个会话(防长期运行内存增长)。
|
|
16
|
+
// 必须放在模块级:getSession() 是模块级函数,放在 createDiag() 的闭包里它看不见,
|
|
17
|
+
// 走到淘汰分支就抛 ReferenceError,被上层 catch 成「落盘失败(降级为仅内存)」——
|
|
18
|
+
// 表现为诊断日志从第 5 个会话起悄悄不再写文件,而页面一切正常。
|
|
19
|
+
const SESSION_MAX = 4;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{dataDir: string, diagFile: string}} paths
|
|
23
|
+
*/
|
|
24
|
+
/** 服务端日志(统一形状) */
|
|
25
|
+
const log = createLogger('diag');
|
|
26
|
+
|
|
27
|
+
export function createDiag(paths) {
|
|
28
|
+
// 服务端侧的环形缓冲(GET /api/diag 读它,与面板内容同源)。
|
|
29
|
+
// 按会话 id 分组:页面重载会换 sid,旧条目不会混进"当前这一轮"的统计。
|
|
30
|
+
const sessions = new Map(); // sid -> { recent, counts, lastAt }
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 追加一批诊断条目。
|
|
34
|
+
* @param {string} body 原始请求体
|
|
35
|
+
* @returns {{ok: boolean, written?: number, dropped?: number, error?: string}}
|
|
36
|
+
*/
|
|
37
|
+
function appendDiag(body) {
|
|
38
|
+
let payload;
|
|
39
|
+
try {
|
|
40
|
+
payload = JSON.parse(body || '{}');
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false, error: '请求体不是合法 JSON' };
|
|
43
|
+
}
|
|
44
|
+
const entries = payload && Array.isArray(payload.entries) ? payload.entries : null;
|
|
45
|
+
if (!entries) return { ok: false, error: '缺少 entries 数组' };
|
|
46
|
+
|
|
47
|
+
const take = entries.slice(0, MAX_ENTRIES_PER_REQ);
|
|
48
|
+
try {
|
|
49
|
+
if (!fs.existsSync(paths.dataDir)) fs.mkdirSync(paths.dataDir, { recursive: true });
|
|
50
|
+
rotateIfNeeded(paths.diagFile);
|
|
51
|
+
const sid = String((payload && payload.sid) || 'anonymous');
|
|
52
|
+
remember(getSession(sessions, sid), take);
|
|
53
|
+
const lines = take.map(serialize).filter(Boolean);
|
|
54
|
+
if (lines.length) fs.appendFileSync(paths.diagFile, lines.join('\n') + '\n', 'utf8');
|
|
55
|
+
return { ok: true, written: lines.length, dropped: entries.length - take.length };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
log.warn('落盘失败(前端会降级为仅内存)', { err: e.message });
|
|
58
|
+
return { ok: false, error: e.message };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 读取最近的诊断条目(只读:不接受调用方指定文件/路径)。
|
|
64
|
+
* 返回结构与面板一致:{ t, level, src, event, data },外加 total 与级别分布。
|
|
65
|
+
* @param {{limit?: number, sid?: string}} [options] sid 为页面实例 id(前端每次加载都换)
|
|
66
|
+
* @returns {{ok: true, sid: string|null, sidRequested: string|null, sidSource: 'query'|'latest'|'none', total: number, counts: object, entries: object[]}}
|
|
67
|
+
*/
|
|
68
|
+
function readDiag(options = {}) {
|
|
69
|
+
const limit = Math.min(Math.max(Number(options.limit) || CAPACITY, 1), CAPACITY);
|
|
70
|
+
const wanted = options.sid ? String(options.sid) : '';
|
|
71
|
+
// 选会话并**标明来源**:
|
|
72
|
+
// query = 按调用方给的 sid 精确命中(前端读"当前这一轮"走这条)
|
|
73
|
+
// latest = 没给 sid 或给的 sid 尚未产生条目 ⇒ 兜底最近活跃会话(不传就永远空最难查)
|
|
74
|
+
// none = 一条会话都没有(页面还没上报过)
|
|
75
|
+
// 注意:兜底时**绝不能回显请求里的 sid** —— 那会让调用方以为"这个 sid 有数据但内容为空"。
|
|
76
|
+
let picked = wanted ? sessions.get(wanted) || null : null;
|
|
77
|
+
let sidSource = picked ? 'query' : null;
|
|
78
|
+
if (!picked) {
|
|
79
|
+
picked = pickSession(sessions, null);
|
|
80
|
+
sidSource = picked ? 'latest' : 'none';
|
|
81
|
+
}
|
|
82
|
+
const base = { ok: true, sidRequested: wanted || null, sidSource };
|
|
83
|
+
if (!picked) {
|
|
84
|
+
return { ...base, sid: null, total: 0, counts: { info: 0, warn: 0, error: 0, debug: 0 }, entries: [] };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
...base,
|
|
88
|
+
sid: picked.sid,
|
|
89
|
+
total: picked.recent.length,
|
|
90
|
+
counts: { ...picked.counts },
|
|
91
|
+
entries: picked.recent.slice(-limit).map(shapeEntry),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { appendDiag, readDiag };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 服务端环形缓冲容量(与前端 diag 默认容量一致) */
|
|
99
|
+
const CAPACITY = 500;
|
|
100
|
+
|
|
101
|
+
/** 取(或建)一个会话的缓冲;超过上限时淘汰最久未活动的会话 */
|
|
102
|
+
function getSession(sessions, sid) {
|
|
103
|
+
let s = sessions.get(sid);
|
|
104
|
+
if (!s) {
|
|
105
|
+
s = { sid, recent: [], counts: { info: 0, warn: 0, error: 0, debug: 0 }, lastAt: Date.now() };
|
|
106
|
+
sessions.set(sid, s);
|
|
107
|
+
if (sessions.size > SESSION_MAX) {
|
|
108
|
+
let oldestKey = null;
|
|
109
|
+
let oldestAt = Infinity;
|
|
110
|
+
for (const [key, val] of sessions) if (val.lastAt < oldestAt) { oldestAt = val.lastAt; oldestKey = key; }
|
|
111
|
+
if (oldestKey) sessions.delete(oldestKey);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
s.lastAt = Date.now();
|
|
115
|
+
return s;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 选会话:指定 sid 优先;没指定就取最近活跃的那个 */
|
|
119
|
+
function pickSession(sessions, sid) {
|
|
120
|
+
if (sid) return sessions.get(String(sid)) || null;
|
|
121
|
+
let best = null;
|
|
122
|
+
for (const val of sessions.values()) if (!best || val.lastAt > best.lastAt) best = val;
|
|
123
|
+
return best;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 入环形缓冲 + 累计级别分布(容量满丢最旧;分布只统计当前缓冲内的条目) */
|
|
127
|
+
function remember(session, entries) {
|
|
128
|
+
const { recent, counts } = session;
|
|
129
|
+
for (const raw of entries) {
|
|
130
|
+
const level = raw && typeof raw.level === 'string' ? raw.level : 'info';
|
|
131
|
+
recent.push({ level, src: raw && raw.src, event: raw && raw.event, data: raw && raw.data, t: raw && raw.t });
|
|
132
|
+
if (counts[level] === undefined) counts[level] = 0;
|
|
133
|
+
counts[level] += 1;
|
|
134
|
+
if (recent.length > CAPACITY) {
|
|
135
|
+
const dropped = recent.shift();
|
|
136
|
+
const key = dropped && dropped.level;
|
|
137
|
+
if (key && counts[key] !== undefined) counts[key] = Math.max(0, counts[key] - 1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 单条对外形状(稳定字段:t/level/src/event/data) */
|
|
143
|
+
function shapeEntry(e) {
|
|
144
|
+
return {
|
|
145
|
+
t: e && e.t ? String(e.t) : null,
|
|
146
|
+
level: (e && e.level) || 'info',
|
|
147
|
+
src: (e && e.src) || null,
|
|
148
|
+
event: (e && e.event) || null,
|
|
149
|
+
data: e && e.data !== undefined ? e.data : null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 超过上限就把当前文件挪成 .1(只保留一代);轮转失败不阻断本次写入 */
|
|
154
|
+
function rotateIfNeeded(diagFile) {
|
|
155
|
+
try {
|
|
156
|
+
if (fs.existsSync(diagFile) && fs.statSync(diagFile).size > MAX_BYTES) {
|
|
157
|
+
fs.renameSync(diagFile, `${diagFile}.1`);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
/* 忽略 */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 单条序列化 + 截断;失败返回 null(跳过该条,不影响整批) */
|
|
165
|
+
function serialize(entry) {
|
|
166
|
+
try {
|
|
167
|
+
const s = JSON.stringify(entry);
|
|
168
|
+
if (typeof s !== 'string') return null;
|
|
169
|
+
return s.length > MAX_LINE_CHARS ? s.slice(0, MAX_LINE_CHARS) : s;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// http/ready.mjs —— 就绪信号通道(服务端状态机 + SSE)
|
|
2
|
+
//
|
|
3
|
+
// 契约:docs/契约/交互与实现约束.md(实现以它为准)。要点:
|
|
4
|
+
// · 与 diag 完全分离:这里存**状态机**(每阶段只保留最新值),不是流水,也不进调试面板;
|
|
5
|
+
// · 每阶段只前进不后退(failed 可覆盖任何状态,且必须带 reason);
|
|
6
|
+
// · rev 单调递增:测试据此判断快照是否过期;
|
|
7
|
+
// · GET /api/ready 幂等(任何时刻都返回完整快照,不依赖是否连过流);
|
|
8
|
+
// · GET /api/ready/stream 连上时**先推全量 snapshot,再推增量 update**
|
|
9
|
+
// —— 只推增量会让"页面早就绪之后才连上"的测试永远等不到(死锁),这是契约 §5.3 的重点。
|
|
10
|
+
//
|
|
11
|
+
// 结构:状态放在一个普通 store 里,读写都是模块级函数(不塞进工厂闭包)——
|
|
12
|
+
// 每个函数都能单独读、单独测,也不会被"单函数 100+ 行"的门禁拦下。
|
|
13
|
+
|
|
14
|
+
/** 阶段清单(契约 §三:新增阶段必须先登记,不允许随手加字符串) */
|
|
15
|
+
export const STAGES = [
|
|
16
|
+
'shell', 'topbar', 'sidebarLeft', 'sidebarRight', 'panel',
|
|
17
|
+
'bottombar', 'debugPanel', 'renderer', 'model',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// 合法状态(契约 §四):
|
|
21
|
+
// pending = 尚未上报(等级最低,任何真实状态都能覆盖它;不用 unknown,契约里没有这个值)
|
|
22
|
+
// painted = 模型专属中间级(§2.2)——漏进白名单会让它被静默丢弃(实测踩过)
|
|
23
|
+
export const STATES = ['pending', 'mounted', 'painted', 'settled', 'interactive', 'failed'];
|
|
24
|
+
|
|
25
|
+
/** 前进顺序:同一阶段不得退回低等级(failed 例外);painted 与 settled 同档 */
|
|
26
|
+
const RANK = { pending: 0, mounted: 1, painted: 2, settled: 2, interactive: 3 };
|
|
27
|
+
|
|
28
|
+
/** SSE 心跳间隔:反代/浏览器会掐掉长时间静默的连接 */
|
|
29
|
+
const HEARTBEAT_MS = 15000;
|
|
30
|
+
|
|
31
|
+
/** 校验阶段名与状态;非法输入返回 { ignore: 原因 } */
|
|
32
|
+
function validateUpdate(u) {
|
|
33
|
+
const name = String((u && u.stage) || '');
|
|
34
|
+
const state = String((u && u.state) || '');
|
|
35
|
+
if (!STAGES.includes(name)) return { ignore: `${name || '(空)'}: 未登记的阶段` };
|
|
36
|
+
if (!STATES.includes(state)) return { ignore: `${name}: 非法状态 ${state}` };
|
|
37
|
+
return { name, state };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 由一条上报构造记录(detail 可选;failed 必带 reason) */
|
|
41
|
+
function buildRecord(u, state) {
|
|
42
|
+
const rec = { state, at: Number(u.at) || Date.now() };
|
|
43
|
+
if (u.detail && typeof u.detail === 'object') rec.detail = u.detail;
|
|
44
|
+
if (state === 'failed') rec.reason = String(u.reason || '未提供原因');
|
|
45
|
+
return rec;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 把一条上报解析成记录;非法输入返回 { ignore: 原因 } */
|
|
49
|
+
function toRecord(u) {
|
|
50
|
+
const v = validateUpdate(u);
|
|
51
|
+
if (v.ignore) return v;
|
|
52
|
+
return { name: v.name, state: v.state, rec: buildRecord(u, v.state), reset: u.reset === true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 该不该接受这次变更:只前进不后退,failed 可覆盖任何状态,reset 后从零起算 */
|
|
56
|
+
function shouldAdvance(state, prev) {
|
|
57
|
+
if (state === 'failed' || !prev || prev.state === 'failed') return true;
|
|
58
|
+
return (RANK[state] || 0) > (RANK[prev.state] || 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 推增量给所有在线的 SSE 客户端 */
|
|
62
|
+
function broadcast(store, items) {
|
|
63
|
+
for (const res of store.clients) {
|
|
64
|
+
for (const it of items) {
|
|
65
|
+
try {
|
|
66
|
+
res.write(`event: update\ndata: ${JSON.stringify(it)}\n\n`);
|
|
67
|
+
} catch {
|
|
68
|
+
store.clients.delete(res);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 应用一条上报;返回 { change } 或 { ignored } */
|
|
75
|
+
function applyOne(store, u) {
|
|
76
|
+
const parsed = toRecord(u);
|
|
77
|
+
if (parsed.ignore) return { ignored: parsed.ignore };
|
|
78
|
+
if (parsed.reset) store.stages.delete(parsed.name);
|
|
79
|
+
const prev = store.stages.get(parsed.name);
|
|
80
|
+
if (!shouldAdvance(parsed.rec.state, prev)) return {};
|
|
81
|
+
store.stages.set(parsed.name, parsed.rec);
|
|
82
|
+
store.rev += 1;
|
|
83
|
+
return { change: { stage: parsed.name, ...parsed.rec, rev: store.rev } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** clear:页面重载/新实例接入时清空上一轮阶段(rev 继续递增,不回退版本号) */
|
|
87
|
+
function clearAll(store) {
|
|
88
|
+
store.stages = new Map();
|
|
89
|
+
store.rev += 1;
|
|
90
|
+
broadcast(store, [{ stage: '*', state: 'cleared', at: Date.now(), rev: store.rev }]);
|
|
91
|
+
return { ok: true, rev: store.rev, changed: 1, ignored: [] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 应用一批更新(页面侧上报入口) */
|
|
95
|
+
function applyUpdates(store, body) {
|
|
96
|
+
if (body && body.clear === true) return clearAll(store);
|
|
97
|
+
const list = Array.isArray(body && body.updates) ? body.updates : [];
|
|
98
|
+
const changed = [];
|
|
99
|
+
const ignored = [];
|
|
100
|
+
for (const u of list) {
|
|
101
|
+
const r = applyOne(store, u);
|
|
102
|
+
if (r.ignored) ignored.push(r.ignored);
|
|
103
|
+
if (r.change) changed.push(r.change);
|
|
104
|
+
}
|
|
105
|
+
if (changed.length) broadcast(store, changed);
|
|
106
|
+
return { ok: true, rev: store.rev, changed: changed.length, ignored };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 取快照(幂等)。单阶段:未登记 → ok:false(路由给 400);已登记未上报 → pending */
|
|
110
|
+
function snapshotOf(store, stage) {
|
|
111
|
+
const at = Date.now();
|
|
112
|
+
if (!stage) {
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const [name, rec] of store.stages) out[name] = rec;
|
|
115
|
+
return { at, rev: store.rev, stages: out };
|
|
116
|
+
}
|
|
117
|
+
const name = String(stage);
|
|
118
|
+
if (!STAGES.includes(name)) return { ok: false, error: `未登记的阶段:${name}`, known: STAGES.slice() };
|
|
119
|
+
const rec = store.stages.get(name);
|
|
120
|
+
return rec ? { at, rev: store.rev, stage: name, ...rec } : { at, rev: store.rev, stage: name, state: 'pending' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 订阅 SSE:先推全量快照,再推增量(契约 §5.3) */
|
|
124
|
+
function subscribeSse(store, req, res) {
|
|
125
|
+
res.writeHead(200, {
|
|
126
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
127
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
128
|
+
Connection: 'keep-alive',
|
|
129
|
+
// 反代(nginx)默认缓冲响应,SSE 会看起来"一直没数据"
|
|
130
|
+
'X-Accel-Buffering': 'no',
|
|
131
|
+
});
|
|
132
|
+
try {
|
|
133
|
+
res.write('retry: 1000\n\n');
|
|
134
|
+
res.write(`event: snapshot\ndata: ${JSON.stringify(snapshotOf(store))}\n\n`);
|
|
135
|
+
} catch {
|
|
136
|
+
try { res.end(); } catch { /* 忽略 */ }
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
store.clients.add(res);
|
|
140
|
+
const hb = setInterval(() => {
|
|
141
|
+
try { res.write(': ping\n\n'); } catch { /* 由 close 清理 */ }
|
|
142
|
+
}, HEARTBEAT_MS);
|
|
143
|
+
const bye = () => {
|
|
144
|
+
clearInterval(hb);
|
|
145
|
+
store.clients.delete(res);
|
|
146
|
+
try { res.end(); } catch { /* 已断开 */ }
|
|
147
|
+
};
|
|
148
|
+
if (req && typeof req.on === 'function') {
|
|
149
|
+
req.on('close', bye);
|
|
150
|
+
req.on('error', bye);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 创建就绪信号状态机。
|
|
156
|
+
* @returns {{apply: Function, snapshot: Function, subscribe: Function, reset: Function}}
|
|
157
|
+
*/
|
|
158
|
+
export function createReady() {
|
|
159
|
+
const store = { stages: new Map(), rev: 0, clients: new Set() };
|
|
160
|
+
return {
|
|
161
|
+
apply: (body) => applyUpdates(store, body),
|
|
162
|
+
snapshot: (stage) => snapshotOf(store, stage),
|
|
163
|
+
subscribe: (req, res) => subscribeSse(store, req, res),
|
|
164
|
+
/** 清空(测试用;rev 复位) */
|
|
165
|
+
reset: () => { store.stages = new Map(); store.rev = 0; },
|
|
166
|
+
};
|
|
167
|
+
}
|