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.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +137 -0
  5. package/config/diag-upload.example.json +7 -0
  6. package/config/models/live2d.assetbundls.json +1 -0
  7. package/config/models/live2d.azurlane.json +1 -0
  8. package/config/models/live2d.azurlane_bundle.json +1 -0
  9. package/config/models/live2d.azurlane_spine.json +1 -0
  10. package/config/models/live2d.bd2.json +1 -0
  11. package/config/models/live2d.bluearchive.json +1 -0
  12. package/config/models/live2d.live2d.json +1 -0
  13. package/config/models/live2d.nikke.json +1 -0
  14. package/config/models/live2d.nikke_2.json +1 -0
  15. package/config/models/live2d.npc.json +1 -0
  16. package/config/models/spine.assetbundls.json +1 -0
  17. package/config/models/spine.azurlane.json +1 -0
  18. package/config/models/spine.azurlane_bundle.json +1 -0
  19. package/config/models/spine.azurlane_spine.json +1 -0
  20. package/config/models/spine.bd2.json +1 -0
  21. package/config/models/spine.bluearchive.json +1 -0
  22. package/config/models/spine.live2d.json +1 -0
  23. package/config/models/spine.nikke.json +1 -0
  24. package/config/models/spine.nikke_2.json +1 -0
  25. package/config/models/spine.npc.json +1 -0
  26. package/config/names/NIKKE.json +1058 -0
  27. package/config/names/README.md +47 -0
  28. package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
  29. package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
  30. package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
  31. package/config/plugins.json +13 -0
  32. package/config/resources.example.json +40 -0
  33. package/package.json +63 -4
  34. package/plugins/unity-live2d-bundle/README.md +98 -0
  35. package/plugins/unity-live2d-bundle/index.js +891 -0
  36. package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
  37. package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
  38. package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
  39. package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
  40. package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
  41. package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
  42. package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
  43. package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
  44. package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  45. package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
  46. package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
  47. package/plugins/unity-live2d-bundle/lib/png.js +424 -0
  48. package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
  49. package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
  50. package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
  51. package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
  52. package/plugins/unity-live2d-bundle/plugin.json +81 -0
  53. package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
  54. package/plugins/unity-spine-bundle/index.js +535 -0
  55. package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
  56. package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
  57. package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
  58. package/plugins/unity-spine-bundle/lib/errors.js +79 -0
  59. package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
  60. package/plugins/unity-spine-bundle/lib/extract.js +242 -0
  61. package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
  62. package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
  63. package/plugins/unity-spine-bundle/lib/png.js +424 -0
  64. package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
  65. package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
  66. package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
  67. package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
  68. package/plugins/unity-spine-bundle/plugin.json +75 -0
  69. package/src/server/README.md +69 -0
  70. package/src/server/assets/browse.mjs +56 -0
  71. package/src/server/assets/bundle-probe.mjs +94 -0
  72. package/src/server/assets/probe.mjs +184 -0
  73. package/src/server/core/config.mjs +173 -0
  74. package/src/server/core/env-num.mjs +60 -0
  75. package/src/server/core/env.mjs +49 -0
  76. package/src/server/core/fs-portable.mjs +37 -0
  77. package/src/server/core/log.mjs +107 -0
  78. package/src/server/core/paths.mjs +44 -0
  79. package/src/server/data/catalog.mjs +1091 -0
  80. package/src/server/data/entry-states.mjs +499 -0
  81. package/src/server/data/live2d.mjs +272 -0
  82. package/src/server/data/manifest-cache.mjs +58 -0
  83. package/src/server/data/manifest-stamp.mjs +135 -0
  84. package/src/server/data/model-meta.mjs +114 -0
  85. package/src/server/data/names.mjs +106 -0
  86. package/src/server/data/shape-probe-worker.mjs +33 -0
  87. package/src/server/data/shape-probe.mjs +626 -0
  88. package/src/server/data/spine.mjs +336 -0
  89. package/src/server/data/tree.mjs +126 -0
  90. package/src/server/http/atomic-json.mjs +145 -0
  91. package/src/server/http/background-solids.mjs +270 -0
  92. package/src/server/http/backgrounds.mjs +388 -0
  93. package/src/server/http/diag-upload.mjs +264 -0
  94. package/src/server/http/diag.mjs +173 -0
  95. package/src/server/http/ready.mjs +167 -0
  96. package/src/server/http/static.mjs +312 -0
  97. package/src/server/http/write.mjs +654 -0
  98. package/src/server/index.mjs +114 -0
  99. package/src/server/plugins/builtin-providers.json +30 -0
  100. package/src/server/plugins/registry.mjs +650 -0
  101. package/src/server/plugins/slot-catalog.mjs +286 -0
  102. package/src/server/routes/background-solids.mjs +56 -0
  103. package/src/server/routes/backgrounds.mjs +77 -0
  104. package/src/server/routes/config.mjs +38 -0
  105. package/src/server/routes/data.mjs +313 -0
  106. package/src/server/routes/diag.mjs +37 -0
  107. package/src/server/routes/entries.mjs +170 -0
  108. package/src/server/routes/ready.mjs +122 -0
  109. package/src/server/serve.mjs +396 -0
  110. package/src/server/unpack/capacity.mjs +217 -0
  111. package/src/server/unpack/mem-probe.mjs +27 -0
  112. package/src/server/unpack/plugin-runner.mjs +529 -0
  113. package/src/server/unpack/plugin-store.mjs +736 -0
  114. package/src/server/unpack/probe-store.mjs +73 -0
  115. package/src/server/unpack/routes.mjs +359 -0
  116. package/src/server/unpack/runtime/exec.mjs +558 -0
  117. package/src/server/unpack/runtime/index.mjs +141 -0
  118. package/src/server/unpack/runtime/node-vm.mjs +111 -0
  119. package/src/server/unpack/unpack-worker.mjs +105 -0
  120. package/src/server/unpack/worker-client.mjs +184 -0
  121. package/src/server/unpack/write-result.mjs +84 -0
  122. package/src/shared/bundle-shape.mjs +83 -0
  123. package/src/shared/entry-reasons.mjs +119 -0
  124. package/src/shared/names.mjs +45 -0
  125. package/src/shared/type-labels.mjs +31 -0
  126. package/src/web/README.md +193 -0
  127. package/src/web/app/anim-list.js +222 -0
  128. package/src/web/app/background-solids.js +150 -0
  129. package/src/web/app/backgrounds.js +152 -0
  130. package/src/web/app/camera-log.js +28 -0
  131. package/src/web/app/context.js +72 -0
  132. package/src/web/app/dir-picker.js +242 -0
  133. package/src/web/app/entry-report.js +71 -0
  134. package/src/web/app/memory-bars.js +91 -0
  135. package/src/web/app/memory-budget.js +228 -0
  136. package/src/web/app/memory-guard.js +204 -0
  137. package/src/web/app/model-actions.js +682 -0
  138. package/src/web/app/renderer-setting.js +70 -0
  139. package/src/web/app/root-manager.js +254 -0
  140. package/src/web/app/status.js +138 -0
  141. package/src/web/app/topbar-actions.js +357 -0
  142. package/src/web/app/transport-actions.js +297 -0
  143. package/src/web/app/ui-state.js +559 -0
  144. package/src/web/app/url-params.js +171 -0
  145. package/src/web/app/view-settings.js +186 -0
  146. package/src/web/components.css +524 -0
  147. package/src/web/debug/diag-hud.js +95 -0
  148. package/src/web/debug/drag.js +369 -0
  149. package/src/web/debug/log.js +320 -0
  150. package/src/web/debug/model-cmp.js +292 -0
  151. package/src/web/debug/panel.css +474 -0
  152. package/src/web/debug/panel.js +712 -0
  153. package/src/web/debug/race-probe.js +82 -0
  154. package/src/web/debug/upload.js +130 -0
  155. package/src/web/engine/adapters/atlas.js +47 -0
  156. package/src/web/engine/adapters/index.js +22 -0
  157. package/src/web/engine/adapters/renderer.js +223 -0
  158. package/src/web/engine/adapters/skeleton.js +105 -0
  159. package/src/web/engine/adapters/texture.js +64 -0
  160. package/src/web/engine/animation.js +181 -0
  161. package/src/web/engine/appearance.js +136 -0
  162. package/src/web/engine/camera-adapter.js +212 -0
  163. package/src/web/engine/effect-chain.js +138 -0
  164. package/src/web/engine/engine-state.js +120 -0
  165. package/src/web/engine/filters.js +224 -0
  166. package/src/web/engine/fit.js +197 -0
  167. package/src/web/engine/frame-gate.js +33 -0
  168. package/src/web/engine/gl-driver.js +56 -0
  169. package/src/web/engine/gl.js +77 -0
  170. package/src/web/engine/index.js +20 -0
  171. package/src/web/engine/loader.js +55 -0
  172. package/src/web/engine/model-info.js +26 -0
  173. package/src/web/engine/pipeline.js +200 -0
  174. package/src/web/engine/player.js +427 -0
  175. package/src/web/engine/pma/blend.js +76 -0
  176. package/src/web/engine/pma/detect.js +118 -0
  177. package/src/web/engine/pma/index.js +8 -0
  178. package/src/web/engine/pma/resolve.js +112 -0
  179. package/src/web/engine/pma/upload.js +127 -0
  180. package/src/web/engine/pma.js +5 -0
  181. package/src/web/engine/render-frame.js +93 -0
  182. package/src/web/engine/runtime-capabilities.js +209 -0
  183. package/src/web/engine/runtime.js +37 -0
  184. package/src/web/engine/skins.js +165 -0
  185. package/src/web/engine/textures.js +174 -0
  186. package/src/web/index.html +117 -0
  187. package/src/web/infra/api.js +531 -0
  188. package/src/web/infra/diag.js +509 -0
  189. package/src/web/infra/document.js +269 -0
  190. package/src/web/infra/dom.js +31 -0
  191. package/src/web/infra/events.mjs +27 -0
  192. package/src/web/infra/log.js +153 -0
  193. package/src/web/infra/mem-stats.js +112 -0
  194. package/src/web/infra/plugin-host-registration.mjs +285 -0
  195. package/src/web/infra/plugin-host-report.mjs +32 -0
  196. package/src/web/infra/plugin-host-unload.mjs +79 -0
  197. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  198. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  199. package/src/web/infra/plugin-host.js +628 -0
  200. package/src/web/infra/ready.js +461 -0
  201. package/src/web/infra/schedule.js +51 -0
  202. package/src/web/infra/shortcuts.js +129 -0
  203. package/src/web/infra/slot-registry.js +402 -0
  204. package/src/web/infra/vfs.js +247 -0
  205. package/src/web/live2d/bridge.js +400 -0
  206. package/src/web/live2d/model.js +321 -0
  207. package/src/web/live2d/overlay.js +142 -0
  208. package/src/web/live2d/pixi-app.js +111 -0
  209. package/src/web/live2d/playback-probe.js +92 -0
  210. package/src/web/live2d/probe-geometry.js +329 -0
  211. package/src/web/live2d/probe.js +119 -0
  212. package/src/web/live2d/release.js +66 -0
  213. package/src/web/live2d/runtime-adapter.js +289 -0
  214. package/src/web/live2d/stage-ctx.js +152 -0
  215. package/src/web/live2d/stage.js +204 -0
  216. package/src/web/live2d/vfs-settings.js +174 -0
  217. package/src/web/live2d/view.js +89 -0
  218. package/src/web/main.js +629 -0
  219. package/src/web/panel/background-list.js +373 -0
  220. package/src/web/panel/bg-source.js +291 -0
  221. package/src/web/panel/color-picker.css +199 -0
  222. package/src/web/panel/color-picker.js +278 -0
  223. package/src/web/panel/dialog.css +116 -0
  224. package/src/web/panel/dialog.js +173 -0
  225. package/src/web/panel/dom.js +35 -0
  226. package/src/web/panel/icons.js +58 -0
  227. package/src/web/panel/memory-bar.js +195 -0
  228. package/src/web/panel/panel-settings.js +1263 -0
  229. package/src/web/panel/panel.css +1304 -0
  230. package/src/web/panel/render-choice.js +104 -0
  231. package/src/web/panel/schema.js +784 -0
  232. package/src/web/panel/settings-io.js +291 -0
  233. package/src/web/panel/shell-controls.js +274 -0
  234. package/src/web/panel/slider.js +210 -0
  235. package/src/web/panel/toolbar-schema.js +111 -0
  236. package/src/web/panel/toolbar.js +195 -0
  237. package/src/web/panel/transport-picker.js +375 -0
  238. package/src/web/panel/transport.js +321 -0
  239. package/src/web/panel/view.js +553 -0
  240. package/src/web/plugin/unpack-worker.js +185 -0
  241. package/src/web/render/camera/constants.js +60 -0
  242. package/src/web/render/camera/index.js +123 -0
  243. package/src/web/render/camera/spine.js +124 -0
  244. package/src/web/render/capture.js +30 -0
  245. package/src/web/render/interaction.js +527 -0
  246. package/src/web/render/live2d.js +627 -0
  247. package/src/web/render/playback.js +47 -0
  248. package/src/web/render/reset-view.js +29 -0
  249. package/src/web/render/spine.js +386 -0
  250. package/src/web/render/surface-switch.js +176 -0
  251. package/src/web/render/surface.js +571 -0
  252. package/src/web/render/surfaces.js +70 -0
  253. package/src/web/roster/filter.js +31 -0
  254. package/src/web/roster/list.js +276 -0
  255. package/src/web/roster/persist.js +92 -0
  256. package/src/web/roster/reveal.js +77 -0
  257. package/src/web/roster/rows.js +248 -0
  258. package/src/web/roster/snapshot.js +39 -0
  259. package/src/web/roster/type-filter.js +97 -0
  260. package/src/web/roster/view.js +761 -0
  261. package/src/web/spine/canvas-api-groups.js +267 -0
  262. package/src/web/spine/canvas-api.js +97 -0
  263. package/src/web/spine/canvas-document.js +80 -0
  264. package/src/web/spine/canvas-engine.js +193 -0
  265. package/src/web/spine/canvas-entry.js +28 -0
  266. package/src/web/spine/canvas-pixels.js +36 -0
  267. package/src/web/spine/canvas-runtime.js +127 -0
  268. package/src/web/spine/canvas-view.js +108 -0
  269. package/src/web/spine/canvas.js +7 -0
  270. package/src/web/spine/runtime-hint.js +73 -0
  271. package/src/web/spine/runtimes.js +212 -0
  272. package/src/web/style.css +493 -0
  273. package/tool/lib/bundle-probe-node.mjs +121 -0
  274. package/tool/lib/paths.mjs +20 -0
  275. package/tool/lib/runtime-guides.mjs +65 -0
  276. package/tool/lib/runtime-package.mjs +403 -0
  277. package/tool/lib/spine-meta.mjs +227 -0
  278. package/tool/lib/spine-motions.mjs +104 -0
  279. package/tool/parse-jsonc.mjs +86 -0
  280. package/tool/resolve-live2d-runtimes.mjs +276 -0
  281. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,313 @@
1
+ // routes/data.mjs —— 只读数据 API 与资源文件路由
2
+ //
3
+ // 覆盖:
4
+ // /api/roots /api/plugins /api/brand /api/web-config 资源根/插件/品牌/前端配置
5
+ // /api/tree /api/spine/models /api/live2d/models /api/names 目录/模型清单/名表
6
+ // /api/browse 服务端目录浏览(登记资源根用)
7
+ // /api/catalog /api/bundle/probe /api/model-meta 按需 import 的三条
8
+ // /api/file /files/<rootId>/<rel> /icons/... /live2d-lib/... 二进制资源(含越界防护)
9
+ // POST /api/roots 资源根增删改名启停(写路径)
10
+ //
11
+ // 为什么归一组:它们共享同一份 data.*(config/models/names/live2d/catalog…)实例,
12
+ // 读写口径必须一致——资源根视图走 http/write.mjs 的 toRootView,避免读写两处漂移。
13
+
14
+ // 缺 Live2D 运行时时的引导文案:503(解析器不可用)与 404(单个文件不存在)**共用同一份**,
15
+ // 否则两处各说各话(一个说去官网下、另一个只说文件名),用户被堵在门口。
16
+ import { live2dGuide } from '../../../tool/lib/runtime-guides.mjs';
17
+ import { readRatioEnv } from '../core/env-num.mjs';
18
+ // 插槽目录(S1-a):与校验同源,见 plugins/slot-catalog.mjs
19
+ import { SLOT_CATALOG, CURRENT_STAGE } from '../plugins/slot-catalog.mjs';
20
+
21
+ /** 资源根 / 插件 / 品牌 / 前端配置 */
22
+ function apiBasics(ctx, res, p) {
23
+ const { data, sendJson, toRootView } = ctx;
24
+ if (p === '/api/roots') {
25
+ // 视图映射与写接口共用同一份(http/write.mjs 的 toRootView),避免读写两处口径漂移
26
+ const list = toRootView(data.config.readConfig());
27
+ return sendJson(res, 200, { roots: list, groups: data.config.buildGroups(list) });
28
+ }
29
+ if (p === '/api/plugins') {
30
+ return sendJson(res, 200, {
31
+ ok: true,
32
+ dir: 'plugins',
33
+ // 插槽目录全表(S1-a):前端要在**装之前**知道"有哪些插槽、各自什么风险、开到哪一阶段",
34
+ // 否则只能等注册失败才知道。与校验同源(都来自 plugins/slot-catalog.mjs),不会漂移。
35
+ // 服务端**当前已开放到哪个阶段**(值与 slotCatalog 同源,都来自 plugins/slot-catalog.mjs)。
36
+ // 为什么单独下发:前端原来自己有一份 CURRENT_SLOT_STAGE 常量 —— 同一事实两处表达,
37
+ // 抬阶段时只改一处就会出现"服务端放行、前端还拒"(或反之)的漂移。
38
+ // 注意它与 slotCatalog[i].stage 是**两个概念**:后者是"该插槽从哪个阶段起开放"(固定),
39
+ // 这里是"现在一共开放到哪"(会随阶段推进变)。别把两者合并。
40
+ currentStage: CURRENT_STAGE,
41
+ slotCatalog: SLOT_CATALOG,
42
+ plugins: data.plugins.readPlugins(),
43
+ // 角色注册表(角色 → 插件 id):前端按 provides 决定把插件挂到哪个注册表;
44
+ // 排障时也能一眼看出"某角色有没有插件认领"
45
+ roles: data.plugins.roleIndex(),
46
+ });
47
+ }
48
+ if (p === '/api/brand') {
49
+ const env = data.env.readDotEnv();
50
+ const title = (env.APP_TITLE || '').trim();
51
+ const icon = (env.APP_ICON || '').trim();
52
+ const looksLikeImage = /^(https?:|data:|\/)/i.test(icon) || /\.(png|jpe?g|svg|webp|gif|ico)$/i.test(icon);
53
+ return sendJson(res, 200, { ok: true, title: title || null, icon: icon || null, iconKind: !icon ? null : (looksLikeImage ? 'image' : 'text') });
54
+ }
55
+ if (p === '/api/web-config') {
56
+ const env = data.env.readDotEnv();
57
+ return sendJson(res, 200, {
58
+ ok: true,
59
+ autoRestoreLast: data.env.parseEnvFlag(env.AUTO_RESTORE_LAST),
60
+ debugLog: data.env.parseEnvFlag(env.DEBUG_LOG),
61
+ // 准入堆比例:前端读不到 env,所以由服务端下发(env 覆盖见 core/env-num.mjs)。
62
+ // 非法值在服务端就回落缺省并 warn,不透传脏值给浏览器。
63
+ admitHeapRatio: readRatioEnv('MODELSTAGE_ADMIT_HEAP_RATIO', 0.85),
64
+ // 容量系数一并下发,只为"面板/排障能看到当前实际生效值",不参与前端判定
65
+ admitCapacityRatio: readRatioEnv('MODELSTAGE_ADMIT_CAPACITY_RATIO', 0.5),
66
+ });
67
+ }
68
+ return false;
69
+ }
70
+
71
+ /** 目录 / 模型清单 / 名表 / 配置导出 / 目录浏览 */
72
+ function apiData(ctx, res, p, url) {
73
+ const { data, sendJson, browseDir } = ctx;
74
+ if (p === '/api/tree') {
75
+ const r = data.tree.listTree(url.searchParams.get('root') || '', url.searchParams.get('path') || '');
76
+ return sendJson(res, r.status, r.body);
77
+ }
78
+ // /api/spine/models 与 /api/live2d/models 是**单路原始扫描**(各自只管一种渲染器,
79
+ // 返回形状也各不相同)。要"某个根下的全部模型"一律走 /api/catalog(它内部复用这两个,
80
+ // 输出统一 ModelEntry)。历史教训:曾叫 /api/models,名字像"所有模型"但实际只返回 Spine,
81
+ // 导致在纯 Live2D 的资源根上翻页/加载拿到 0 个模型而误判为"根目录为空"。
82
+ if (p === '/api/spine/models' || p === '/api/live2d/models') {
83
+ const rootId = url.searchParams.get('root') || '';
84
+ const force = url.searchParams.get('refresh') === '1';
85
+ const cfg = data.config.readConfig().find((r) => r.id === rootId);
86
+ if (!cfg) return sendJson(res, 400, { error: `未找到资源根: ${rootId}` });
87
+ try {
88
+ const isSpine = p === '/api/spine/models';
89
+ const r = isSpine ? data.spine.getSpineModels(cfg, force) : data.live2d.getLive2DModels(cfg, force);
90
+ return sendJson(res, 200, {
91
+ root: { id: cfg.id, name: cfg.name },
92
+ count: r.models.length,
93
+ cached: r.cached,
94
+ scannedAt: new Date(r.ts).toISOString(),
95
+ models: r.models,
96
+ });
97
+ } catch (e) {
98
+ return sendJson(res, 500, { error: `${p === '/api/spine/models' ? 'Spine 扫描失败' : 'Live2D 扫描失败'}: ${e.message}` });
99
+ }
100
+ }
101
+ if (p === '/api/names') return apiNames(ctx, res, url);
102
+ // /api/config/export 与 /api/config/import 同属配置组,见 routes/config.mjs(此处不重复实现)
103
+ if (p === '/api/browse') {
104
+ const r = browseDir(url.searchParams.get('path') || '/');
105
+ return sendJson(res, r.status, r.body);
106
+ }
107
+ return false;
108
+ }
109
+
110
+ /**
111
+ * 名表(v2 index 优先、v1 兜底):返回**归一化**后的表。
112
+ *
113
+ * 为什么不再每次现读 JSON:名表是纯数据文件(见 docs/设计/名表.md),读取只做「读 + 缓存 + 原样返回」
114
+ * 只在 names.mjs 里存在一份;这里再读一遍就等于复制第二份解析规则。
115
+ * 缓存失效由 names.clearNameCache()(导入配置时)负责,对外行为仍是"导入后立刻生效"。
116
+ *
117
+ * 三种情况必须可区分(不能静默返回空表 —— 那会让整片名字消失且界面无任何提示):
118
+ * ① 该根没配 names → 200 + names:null + reason(不是错误,是配置选择)
119
+ * ② 配了但两个文件都没有 → 404 + 明确说明"index 与 v1 都不存在"
120
+ * ③ 文件坏了 → 500 + 解析失败原因
121
+ */
122
+ function apiNames(ctx, res, url) {
123
+ const { data, sendJson } = ctx;
124
+ const rootId = url.searchParams.get('root') || '';
125
+ const cfg = data.config.readConfig().find((r) => r.id === rootId);
126
+ if (!cfg) return sendJson(res, 400, { error: `未找到资源根: ${rootId}` });
127
+ if (!cfg.names) return sendJson(res, 200, { root: rootId, names: null, reason: '该资源根未配置 names 名表' });
128
+
129
+ const { table, error, detail } = data.names.loadNamesTable(cfg.names);
130
+ if (!table) {
131
+ const status = error === 'BROKEN' ? 500 : 404;
132
+ return sendJson(res, status, { root: rootId, tableId: cfg.names, error: detail, code: error });
133
+ }
134
+ // 原样透传(规范 §二 的唯一出口形状):多挑一个字段就是"服务端理解结构",
135
+ // 名表一改形状就得改这里 —— 结构解释全在 src/shared/names.mjs 与前端。
136
+ return sendJson(res, 200, { root: rootId, tableId: cfg.names, ...table });
137
+ }
138
+
139
+ /** 需要按需 import 的三条(catalog / bundle probe / model meta) */
140
+ function apiAsync(ctx, req, res, p, url) {
141
+ const { data, sendJson } = ctx;
142
+ const done = (r) => sendJson(res, r.status, r.body);
143
+ const fail = (label) => (e) => sendJson(res, 500, { ok: false, error: `${label}: ${e.message}` });
144
+ // 最近一次各根 catalog 构建的服务端耗时(只读内存,零副作用:不会触发任何重建)。
145
+ // 必须放在 /api/catalog 分支之前:两条件互斥(等值比较),但顺序写清更不会被后来人改错。
146
+ if (p === '/api/catalog/stats') {
147
+ const s = data.catalog.getCatalogStats();
148
+ return sendJson(res, 200, { ok: true, at: s.at, totalMs: s.totalMs, roots: s.roots });
149
+ }
150
+ if (p === '/api/catalog') {
151
+ // 入参分流(只在路由层做,不动 listCatalog 内部报错):
152
+ // · 缺 root / 空串 ⇒ 400(调用方错误)。以前一律回 404,于是"路径拼错了"
153
+ // 和"根真的不存在"看起来一模一样,排查时白费一轮。
154
+ // · 给了非空但找不到的 id ⇒ 仍交 listCatalog 回 404(那才是"资源不存在")。
155
+ const rootId = (url.searchParams.get('root') || '').trim();
156
+ if (!rootId) {
157
+ const known = data.config.readConfig().map((r) => r.id);
158
+ return sendJson(res, 400, { ok: false, error: '缺少 root 参数(?root=<资源根 id>)', known });
159
+ }
160
+ return done(data.catalog.listCatalog(rootId));
161
+ }
162
+ if (p === '/api/bundle/probe') {
163
+ data.bundleProbe.probeBundle(url.searchParams.get('root') || '', url.searchParams.get('path') || '')
164
+ .then(done).catch(fail('预检失败'));
165
+ return true;
166
+ }
167
+ if (p === '/api/model-meta') {
168
+ data.modelMeta.readModelMeta(url.searchParams.get('root') || '', url.searchParams.get('path') || '')
169
+ .then(done).catch(fail('读取元数据失败'));
170
+ return true;
171
+ }
172
+ return false;
173
+ }
174
+
175
+ // ---------------------------------------------------------------- 二进制路由
176
+
177
+ /** /api/file?root=&path= 与 /files/<rootId>/<rel>:模型资源流(带 CORS,供纹理跨源) */
178
+ function serveModelFile(ctx, req, res, p, url) {
179
+ const { data, fs, resolveSafe, sendJson, sendFile } = ctx;
180
+ let rootId;
181
+ let rel;
182
+ let cors = { 'Access-Control-Allow-Origin': '*' };
183
+ if (p === '/api/file') {
184
+ rootId = url.searchParams.get('root') || '';
185
+ rel = url.searchParams.get('path') || '';
186
+ } else if (p.startsWith('/files/')) {
187
+ const rest = decodeURIComponent(p.slice('/files/'.length));
188
+ const slash = rest.indexOf('/');
189
+ rootId = slash < 0 ? rest : rest.slice(0, slash);
190
+ rel = slash < 0 ? '' : rest.slice(slash + 1);
191
+ } else {
192
+ return false;
193
+ }
194
+ const cfg = data.config.readConfig().find((r) => r.id === rootId);
195
+ if (!cfg) {
196
+ if (p === '/api/file') { sendJson(res, 400, { error: `未找到资源根: ${rootId}` }); return true; }
197
+ res.writeHead(404); res.end('root not found');
198
+ return true;
199
+ }
200
+ const abs = resolveSafe(cfg.path, rel);
201
+ if (!abs) {
202
+ if (p === '/api/file') { sendJson(res, 403, { error: '路径越界' }); return true; }
203
+ res.writeHead(403); res.end('forbidden');
204
+ return true;
205
+ }
206
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
207
+ if (p === '/api/file') { sendJson(res, 404, { error: '文件不存在', path: rel }); return true; }
208
+ res.writeHead(404); res.end('file not found');
209
+ return true;
210
+ }
211
+ sendFile(req, res, abs, cors);
212
+ return true;
213
+ }
214
+
215
+ /** /icons/<rootId>/<文件名>:头像目录在资源根之外,单独一条(含越界防护) */
216
+ function serveIcon(ctx, req, res, p) {
217
+ const { data, fs, path, sendFile } = ctx;
218
+ if (!p.startsWith('/icons/')) return false;
219
+ const rest = decodeURIComponent(p.slice('/icons/'.length));
220
+ const slash = rest.indexOf('/');
221
+ const rootId = slash < 0 ? rest : rest.slice(0, slash);
222
+ const name = path.basename(slash < 0 ? '' : rest.slice(slash + 1));
223
+ const cfg = data.config.readConfig().find((r) => r.id === rootId);
224
+ if (!cfg || !cfg.icons || !cfg.icons.dir) { res.writeHead(404); res.end('no icons dir'); return true; }
225
+ const base = path.resolve(cfg.icons.dir);
226
+ const abs = path.resolve(base, name);
227
+ if (abs !== base && !abs.startsWith(base + path.sep)) { res.writeHead(403); res.end('forbidden'); return true; }
228
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { res.writeHead(404); res.end('not found'); return true; }
229
+ sendFile(req, res, abs);
230
+ return true;
231
+ }
232
+
233
+ /**
234
+ * /avatars/<game>/<文件名>:头像仓(v2 主路)。
235
+ *
236
+ * 与 /icons/ 的区别:`/icons/` 读的是"每个资源根自己配的 icons.dir"(v1 遗留,一个根一套);
237
+ * `/avatars/` 读的是**全局可配置的头像根**(`<avatarRoot>/<game>/<file>`),
238
+ * 因为头像仓是独立私有仓、与资源根无关(见 docs/设计/名表.md §4.1)。
239
+ *
240
+ * `<game>` 直接落在文件系统路径里 ⇒ 必须防越界:只接受一层合法目录名 + 一层合法文件名,
241
+ * 两级都做 path.resolve 后的前缀校验(防 `../` 与绝对路径注入)。
242
+ */
243
+ function serveAvatar(ctx, req, res, p) {
244
+ const { data, fs, path, sendFile } = ctx;
245
+ if (!p.startsWith('/avatars/')) return false;
246
+ const rest = decodeURIComponent(p.slice('/avatars/'.length));
247
+ const parts = rest.split('/').filter(Boolean);
248
+ if (parts.length !== 2) { res.writeHead(404); res.end('bad avatar path'); return true; }
249
+ const game = path.basename(parts[0]);
250
+ const name = path.basename(parts[1]);
251
+ if (game !== parts[0] || name !== parts[1]) { res.writeHead(403); res.end('forbidden'); return true; }
252
+ const root = path.resolve(data.paths.avatarRoot);
253
+ const abs = path.resolve(root, game, name);
254
+ if (!abs.startsWith(root + path.sep)) { res.writeHead(403); res.end('forbidden'); return true; }
255
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) { res.writeHead(404); res.end('not found'); return true; }
256
+ sendFile(req, res, abs);
257
+ return true;
258
+ }
259
+
260
+ /** /live2d-lib/<文件>:Cubism Core 等运行时库(由 resolver 探测出的映射表) */
261
+ function serveLive2DLib(ctx, req, res, p) {
262
+ const { data, path, sendJson, sendFile } = ctx;
263
+ if (!p.startsWith('/live2d-lib/')) return false;
264
+ const name = path.basename(decodeURIComponent(p.slice('/live2d-lib/'.length)));
265
+ let files;
266
+ try {
267
+ files = data.live2d.getLive2DRuntimeFiles();
268
+ } catch (e) {
269
+ sendJson(res, 503, { error: `Live2D 运行时不可用: ${e.message}` });
270
+ return true;
271
+ }
272
+ const abs = files[name];
273
+ if (!abs) {
274
+ // 404(某个文件缺失)与 503(整套库不可用)用户撞到的概率相当 ⇒ 都给完整引导。
275
+ // 文案复用 live2dGuide(与 503 同源),只补"该往哪放、从哪下、为什么不代下"。
276
+ sendJson(res, 404, { error: `未找到运行时文件: ${name}\n` + live2dGuide(data.paths.dataRoot) });
277
+ return true;
278
+ }
279
+ sendFile(req, res, abs);
280
+ return true;
281
+ }
282
+
283
+ /**
284
+ * 入口:按顺序尝试本组路由。
285
+ * @param {import('node:http').IncomingMessage} req
286
+ * @param {import('node:http').ServerResponse} res
287
+ * @param {string} p
288
+ * @param {URL} url
289
+ * @param {object} ctx 需要 data/fs/path/sendJson/sendFile/resolveSafe/browseDir/toRootView/writeApi/readBody
290
+ * @returns {boolean} 是否已处理
291
+ */
292
+ export function handleData(req, res, p, url, ctx) {
293
+ const { sendJson, readBody, writeApi } = ctx;
294
+
295
+ // 资源根写操作(增 / 删 / 改名 / 启停):校验 → 备份 → 写 → 失效缓存
296
+ // 具体管道在 http/write.mjs;这里只负责读请求体与发响应。
297
+ if (p === '/api/roots' && req.method === 'POST') {
298
+ readBody(req, res, (body) => {
299
+ writeApi.writeRoots(body)
300
+ .then((r) => sendJson(res, r.status, r.body))
301
+ .catch((e) => sendJson(res, 500, { ok: false, error: `写资源根异常: ${(e && e.message) || e}` }));
302
+ });
303
+ return true;
304
+ }
305
+ if (apiBasics(ctx, res, p)) return true;
306
+ if (apiData(ctx, res, p, url)) return true;
307
+ if (apiAsync(ctx, req, res, p, url)) return true;
308
+ if (serveModelFile(ctx, req, res, p, url)) return true;
309
+ if (serveIcon(ctx, req, res, p)) return true;
310
+ if (serveAvatar(ctx, req, res, p)) return true;
311
+ if (serveLive2DLib(ctx, req, res, p)) return true;
312
+ return false;
313
+ }
@@ -0,0 +1,37 @@
1
+ // routes/diag.mjs —— 诊断相关三条:/api/diag(GET 读最近条目 / POST 上报)、/api/diag/upload(s)(落盘与查询)
2
+ //
3
+ // 为什么单独一组:诊断是"只写 data/ 的旁路"——它不参与资源根/模型读取,
4
+ // 出问题时即使模型链路坏了,这条通道也应该还能用(便于事后读现场)。
5
+ //
6
+ // 约定:handler 返回 true 表示"这个请求我处理了(可能已写响应)",返回 false 表示没我的事。
7
+
8
+ /**
9
+ * @param {import('node:http').IncomingMessage} req
10
+ * @param {import('node:http').ServerResponse} res
11
+ * @param {string} p 已解析的 pathname
12
+ * @param {URL} url
13
+ * @param {object} ctx 由 serve.mjs 组装的依赖(sendJson / readBody / diag / diagUpload)
14
+ * @returns {boolean} 是否已处理
15
+ */
16
+ export function handleDiag(req, res, p, url, ctx) {
17
+ const { diag, diagUpload, sendJson, readBody } = ctx;
18
+
19
+ // 只读:取最近的诊断条目(页面自己记录的那份,供外部/AI 直接读现场)
20
+ if (p === '/api/diag' && req.method === 'GET') {
21
+ const limit = Number(url.searchParams.get('limit')) || undefined;
22
+ const sid = url.searchParams.get('sid') || undefined;
23
+ sendJson(res, 200, diag.readDiag({ limit, sid }));
24
+ return true;
25
+ }
26
+ if (p === '/api/diag' && req.method === 'POST') {
27
+ readBody(req, res, (body) => sendJson(res, 200, diag.appendDiag(body)));
28
+ return true;
29
+ }
30
+ // 调试日志上传(data/logs/):先**同步**判命中,再异步处理
31
+ // (handle 是 async,直接判返回值会恒真、把后续路由整条截断——踩过)。
32
+ if (diagUpload.matches(req.method, p)) {
33
+ diagUpload.handle(req, res, p);
34
+ return true;
35
+ }
36
+ return false;
37
+ }
@@ -0,0 +1,170 @@
1
+ // routes/entries.mjs —— 条目状态的两条接口(契约《条目状态与展示归属》§三)
2
+ //
3
+ // POST /api/entries/result 写读一体:上报"最近一次装载尝试",响应直接带回重算后的三字段
4
+ // GET /api/entries/result?key=… 单条查询(零扫描、零重建)
5
+ // GET /api/entries/probe 后台探测的计数与队列深度(S10 的佐证口)
6
+ // POST /api/entries/probe?root=… 手动重探一轮(§八 触发判据 c)
7
+ //
8
+ // 为什么把"写"和"读"做成同一次响应(这是契约 S1/S2 的根治点):
9
+ // 前端装载完成那一刻既要"记下结果",又要"立刻把这一行画对"。
10
+ // 若拆成两次请求,中间必然存在"前端已改行、服务端还没存"的窗口,
11
+ // 表现为"当场变了色、刷新又变回去"(或反过来)——两张皮就是这么来的。
12
+ // 这里写入与读取用的是**同一个 recompute()**,响应就是存储层算出来的那份数据。
13
+ //
14
+ // 旁路原则(INV-D3):本接口出任何错都不得影响装载。前端调用处必须吞掉失败只记日志。
15
+
16
+ // 指纹判据只有一份实现(INV-D6):上报路径漏传 providerFingerprint 时,
17
+ // recompute 里规则①会因"没有当前指纹"整段跳过 —— 插件升级后旧结论永远挂着。
18
+ import { providerFingerprintOf, assetFingerprintOf } from '../data/entry-states.mjs';
19
+
20
+ /** 从请求体里挑出 put() 需要的字段(其余一律忽略,不把未知字段写进存储) */
21
+ function pickIncoming(body) {
22
+ const src = body && typeof body === 'object' ? body : {};
23
+ const result = src.result === 'ok' ? 'ok' : (src.result === 'failed' ? 'failed' : null);
24
+ if (!result) return null;
25
+ const out = { result };
26
+ if (typeof src.reason === 'string' && src.reason) out.reason = src.reason;
27
+ if (typeof src.message === 'string' && src.message) out.message = src.message;
28
+ if (typeof src.provider === 'string' && src.provider) out.provider = src.provider;
29
+ if (Number.isFinite(src.at)) out.at = Number(src.at);
30
+ if (src.source === 'server' || src.source === 'client') out.source = src.source;
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * 条目状态这一族的入口:按路径分给两个处理函数(各自再管自己的方法)。
36
+ * @param {object} ctx serve.mjs 注入:{ entryStates, shapeProbe, catalog, contextFor, sendJson, readBody, log }
37
+ */
38
+ export function handleEntries(req, res, p, url, ctx) {
39
+ // 解构放函数最前面:两条分支都要用(放在中间会踩 TDZ,实测报
40
+ // "Cannot access 'sendJson' before initialization")
41
+ const { sendJson } = ctx;
42
+ if (p === '/api/entries/probe') return handleProbeRoute(req, res, url, ctx);
43
+ if (p !== '/api/entries/result') return false;
44
+ if (!ctx.entryStates) {
45
+ // 存储没装配(理论上不会):如实 500,不假装成功——前端会按"上报失败只记日志"处理
46
+ sendJson(res, 500, { ok: false, error: '条目状态存储未就绪' });
47
+ return true;
48
+ }
49
+ if (req.method === 'GET') return handleResultGet(req, res, url, ctx);
50
+ if (req.method === 'POST') return handleResultPost(req, res, ctx);
51
+ sendJson(res, 405, { ok: false, error: `不支持的方法:${req.method}` });
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * GET /api/entries/result?key=…
57
+ *
58
+ * 查询也走 recompute:否则"存储里有、算出来没有"的字段会让诊断和列表对不上。
59
+ * @returns {true} 已处理
60
+ */
61
+ function handleResultGet(req, res, url, ctx) {
62
+ const key = (url.searchParams.get('key') || '').trim();
63
+ if (!key) {
64
+ ctx.sendJson(res, 400, { ok: false, error: '缺少 key 参数(?key=<rootId>/<rel> 或条目 id)' });
65
+ return true;
66
+ }
67
+ const computed = ctx.entryStates.recompute(ctx.entryStates.get(key), ctx.contextFor(key));
68
+ ctx.sendJson(res, 200, { ok: true, key, entry: computed });
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * POST /api/entries/result:写入一次装载结果,响应带回重算后的三字段(写读一体)。
74
+ *
75
+ * readBody 回调给的是**原始字符串**(各路由自己 parse,与 backgrounds/ready 同口径)。
76
+ * @returns {true} 已处理(响应在回调里发出)
77
+ */
78
+ function handleResultPost(req, res, ctx) {
79
+ const { sendJson, readBody } = ctx;
80
+ readBody(req, res, (raw) => {
81
+ let body = {};
82
+ try { body = raw ? JSON.parse(raw) : {}; } catch {
83
+ sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
84
+ return;
85
+ }
86
+ const key = String((body && body.key) || '').trim();
87
+ if (!key) {
88
+ sendJson(res, 400, { ok: false, error: '缺少 key(形如 <rootId>/<rel>)' });
89
+ return;
90
+ }
91
+ writeResult(key, body, res, ctx);
92
+ });
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * 真正的写入动作(从 POST 回调里拆出来,单独可读)。
98
+ * 旁路原则:存储写失败只记日志 + 如实 500,绝不升级成"装载失败"。
99
+ */
100
+ function writeResult(key, body, res, ctx) {
101
+ const { sendJson, entryStates } = ctx;
102
+ const incoming = pickIncoming(body);
103
+ if (!incoming) {
104
+ sendJson(res, 400, { ok: false, error: 'result 必须是 ok 或 failed' });
105
+ return;
106
+ }
107
+ try {
108
+ // put() 内部先落内存表再 recompute:返回的就是"现在存储的真相"
109
+ const r = entryStates.put(key, incoming, ctx.contextFor(key, body));
110
+ sendJson(res, 200, { ok: true, key, applied: r.applied === true, entry: r.computed });
111
+ } catch (e) {
112
+ try {
113
+ if (ctx.log && ctx.log.warn) ctx.log.warn('条目状态写入失败', { err: (e && e.message) || String(e) });
114
+ } catch { /* 日志本身失败也不能影响响应 */ }
115
+ sendJson(res, 500, { ok: false, error: `条目状态写入失败:${(e && e.message) || e}` });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * /api/entries/probe:后台形态探测的状态(GET)与手动重探(POST)。
121
+ * 与 /result 同属"条目状态"这一族,共用同一份存储,所以放在同一模块。
122
+ */
123
+ function handleProbeRoute(req, res, url, ctx) {
124
+ const { sendJson, shapeProbe } = ctx;
125
+ const stats = shapeProbe ? shapeProbe.stats() : null;
126
+ if (req.method === 'GET') {
127
+ const queue = ctx.catalog && typeof ctx.catalog.probeQueueSize === 'function'
128
+ ? ctx.catalog.probeQueueSize() : null;
129
+ sendJson(res, 200, { ok: true, stats, queue, running: shapeProbe ? shapeProbe.isRunning() : false });
130
+ return true;
131
+ }
132
+ if (req.method === 'POST') {
133
+ // 手动重探(调试入口):不 await —— 探测按根串行、跑完即停,调用方拿到受理回执即可。
134
+ // 触发来源标 manual:日志里要能与 catalog/fingerprint 区分(三者混成一条就查不出谁触发的)
135
+ const rootId = (url.searchParams.get('root') || '').trim() || undefined;
136
+ const running = shapeProbe ? shapeProbe.kick(rootId, 'manual') : null;
137
+ sendJson(res, 200, { ok: true, accepted: !!running, stats });
138
+ return true;
139
+ }
140
+ sendJson(res, 405, { ok: false, error: `不支持的方法:${req.method}` });
141
+ return true;
142
+ }
143
+
144
+ /**
145
+ * 造 contextFor:把"这条目的静态事实"(候选提供者、缺贴图、尺寸)从 catalog 侧喂给 recompute。
146
+ *
147
+ * 为什么放在路由层而不是 entry-states 里:存储层不该知道 catalog 的形状(谁认领这个条目、
148
+ * 目录型缺哪些贴图,都是扫描阶段的事);它只负责"把状态和上下文合成三字段"。
149
+ * @param {{providers: Function, describe: Function}} deps
150
+ */
151
+ export function createContextFor(deps) {
152
+ return function contextFor(key, body) {
153
+ const providers = (deps.providers && deps.providers()) || [];
154
+ const desc = (deps.describe && deps.describe(key)) || {};
155
+ // size 优先取请求体(前端最清楚自己装的是哪个文件),其次取扫描结果
156
+ const size = Number.isFinite(body && body.size) ? Number(body.size) : (Number.isFinite(desc.size) ? desc.size : undefined);
157
+ return {
158
+ providers,
159
+ candidates: Array.isArray(desc.candidates) ? desc.candidates : [],
160
+ missingTextures: Array.isArray(desc.missingTextures) ? desc.missingTextures : undefined,
161
+ assetDependency: desc.assetDependency || null,
162
+ size,
163
+ // 三处写入方必须传同源指纹(INV-D6):
164
+ // providerFingerprint = 当前注册表指纹(规则①判据),
165
+ // assetFingerprint = 同一份 size 派生的字符串(规则②判据,三处同格式)
166
+ providerFingerprint: providerFingerprintOf(providers),
167
+ assetFingerprint: assetFingerprintOf(size),
168
+ };
169
+ };
170
+ }
@@ -0,0 +1,122 @@
1
+ // routes/ready.mjs —— 就绪信号通道(契约 docs/契约/交互与实现约束.md)
2
+ //
3
+ // 三条同路径不同方法:
4
+ // GET /api/ready 快照(幂等;未登记阶段名 ⇒ 400,已登记未上报 ⇒ 200 pending)
5
+ // GET /api/ready/stream 长连接(先推 snapshot,再推 update)
6
+ // POST /api/ready 页面侧上报入口
7
+ //
8
+ // 为什么与 diag 分开:就绪信号描述"能不能开始渲染",是状态机;调试日志描述"发生了什么",是流水。
9
+ // 两者混在一起会让"未就绪"被淹没在日志噪音里(契约里明确要求分离,不进调试面板)。
10
+ //
11
+ // 拆分口径(原单函数圈复杂度 28):这个函数不是"采集 + 判定"那种形态,而是**三个独立路由分支**
12
+ // 各自带自己的错误处理。所以按「一条路由一个函数」拆,另把"上报体处理"与"到达日志"再各提一层 ——
13
+ // 边界按"一条路由的完整处理"划,而不是硬套采集/判定。响应结构、状态码、字段名一字未改。
14
+
15
+ /**
16
+ * 就绪通道入口:只做**方法 + 路径分发**,具体处理交给下面几个函数。
17
+ */
18
+
19
+ // 错误消息写进日志前截断的字符上限:避免一条超长异常把日志撑爆。
20
+ // 两个值不同(100 / 120)是原样保留的历史取值,不要合并成一个。
21
+ const ERR_MSG_MAX_BAD_JSON = 100;
22
+ const ERR_MSG_MAX_APPLY = 120;
23
+
24
+ /**
25
+ * @param {import('node:http').IncomingMessage} req
26
+ * @param {import('node:http').ServerResponse} res
27
+ * @param {string} reqPath 请求路径(如 /api/ready)
28
+ * @param {URL} url
29
+ * @param {object} ctx 需要 ready / diag / sendJson / readBody
30
+ * @returns {boolean} 是否已处理
31
+ */
32
+ export function handleReady(req, res, reqPath, url, ctx) {
33
+ const { ready, diag, sendJson, readBody } = ctx;
34
+
35
+ if (reqPath === '/api/ready' && req.method === 'GET') return sendReadySnapshot(url, res, ready, sendJson);
36
+ if (reqPath === '/api/ready/stream' && req.method === 'GET') {
37
+ ready.subscribe(req, res); // 长连接:先 snapshot 事件,后 update 事件
38
+ return true;
39
+ }
40
+ if (reqPath === '/api/ready' && req.method === 'POST') return sendReadyPost(req, res, ready, diag, sendJson, readBody);
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * GET /api/ready —— 返回快照。
46
+ * 未登记的阶段名 = 调用方写错(契约 §三:阶段必须先登记)⇒ 400 并回可用清单;
47
+ * 已登记但尚未上报的返回 state:'pending'(200),两者语义不同,别混。
48
+ */
49
+ function sendReadySnapshot(url, res, ready, sendJson) {
50
+ const stage = url.searchParams.get('stage') || '';
51
+ const snap = ready.snapshot(stage || undefined);
52
+ sendJson(res, snap && snap.ok === false ? 400 : 200, snap);
53
+ return true;
54
+ }
55
+
56
+ /** POST /api/ready —— 读请求体后交给 handleReadyBody 处理 */
57
+ function sendReadyPost(req, res, ready, diag, sendJson, readBody) {
58
+ const arrivedAt = Date.now();
59
+ readBody(req, res, (raw) => handleReadyBody(raw, res, ready, diag, sendJson, arrivedAt));
60
+ return true;
61
+ }
62
+
63
+ /**
64
+ * 处理上报体(原 readBody 回调体)。
65
+ * 注意:readBody 给的是**原始字符串**(diag 自己在内部 parse),这里必须自行解析。
66
+ */
67
+ function handleReadyBody(raw, res, ready, diag, sendJson, arrivedAt) {
68
+ const logReady = makeReadyLog(diag, arrivedAt, raw);
69
+ const parsed = parseReadyBody(raw, logReady);
70
+ if (!parsed.ok) return sendJson(res, 400, { ok: false, error: '请求体不是合法 JSON' });
71
+ return applyReadyBody(parsed.body, res, ready, logReady, sendJson);
72
+ }
73
+
74
+ /** 解析上报体。失败时写一条到达日志并返回 {ok:false}(状态码由调用方给) */
75
+ function parseReadyBody(raw, logReady) {
76
+ try {
77
+ return { ok: true, body: raw ? JSON.parse(raw) : {} };
78
+ } catch (e) {
79
+ logReady(false, { reason: 'bad-json', msg: String((e && e.message) || e).slice(0, ERR_MSG_MAX_BAD_JSON) });
80
+ return { ok: false };
81
+ }
82
+ }
83
+
84
+ /** 成功上报时写进日志的附加字段(updates 条数 / 是否清空) */
85
+ function readyLogExtra(body) {
86
+ return { updates: (body && body.updates && body.updates.length) || 0, clear: body && body.clear === true };
87
+ }
88
+
89
+ /** 应用上报体并输出。apply 抛错时写日志并回 500(错误文本原样回给调用方) */
90
+ function applyReadyBody(body, res, ready, logReady, sendJson) {
91
+ try {
92
+ const out = ready.apply(body);
93
+ logReady(true, readyLogExtra(body));
94
+ return sendJson(res, 200, out);
95
+ } catch (e) {
96
+ logReady(false, { reason: 'apply-threw', msg: String((e && e.message) || e).slice(0, ERR_MSG_MAX_APPLY) });
97
+ return sendJson(res, 500, { ok: false, error: String((e && e.message) || e) });
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 构造「到达日志」写入器。
103
+ *
104
+ * **到达日志**:这条通道以前完全不记录 ⇒ 出现 "Failed to fetch" 时无法区分
105
+ * "请求没到达" / "到达后出错" / "返回了但回不到浏览器"。第一步必须能看见。
106
+ * 只记到达 + 字节数 + 耗时,不记 body 内容(会很吵)。
107
+ */
108
+ function makeReadyLog(diag, arrivedAt, raw) {
109
+ return (ok, extra) => {
110
+ try {
111
+ diag.appendDiag(JSON.stringify({
112
+ sid: 'server',
113
+ entries: [{
114
+ level: ok ? 'info' : 'warn',
115
+ at: arrivedAt,
116
+ event: ok ? 'ready.post' : 'ready.post.error',
117
+ data: { bytes: raw ? raw.length : 0, ms: Date.now() - arrivedAt, ...extra },
118
+ }],
119
+ }));
120
+ } catch { /* 日志失败不影响就绪通道 */ }
121
+ };
122
+ }