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,73 @@
1
+ // server/unpack/probe-store.mjs —— probe.json(内存声明/实测缓存)的读写
2
+ //
3
+ // 为什么抽成独立模块:解包搬进 worker 后,**worker 里要写 probe.json**(recordMeasuredPeak 与
4
+ // writeResult 的"删旧目录前先取走 probe、rename 后再写回"逻辑强耦合);而主线程的命中/准入
5
+ // 路径也要读它。两边必须是**同一份实现** —— 否则"改一处、另一处静默失效"。
6
+ //
7
+ // 口径(一个字都不许改,改动等于把历史 measuredPeakBytes 变成不可比):
8
+ // 峰值 = 起始 RSS + 本次 VmHWM 增量
9
+ // 即 guess = before.rss + max(0, after.peak - before.peak),再与历史值取 max 单调不降。
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ /** probe 缓存文件名(与解包产物同指纹同目录,跟着 LRU 一起回收) */
15
+ export const PROBE_FILE = 'probe.json';
16
+
17
+ /**
18
+ * 造一个 probe 读写器。
19
+ * @param {{rootDir: string}} opts rootDir = 解包缓存根(如 <dataRoot>/unpacked)
20
+ */
21
+ export function createProbeStore(opts) {
22
+ if (typeof (opts || {}).rootDir !== 'string' || !opts.rootDir) {
23
+ throw new Error('createProbeStore: rootDir 必填(解包缓存根目录)');
24
+ }
25
+ const rootDir = opts.rootDir;
26
+ const fpDir = (fp) => path.join(rootDir, fp);
27
+ const probePath = (fp) => path.join(fpDir(fp), PROBE_FILE);
28
+
29
+ /** 读 probe 缓存(不存在/损坏都当没有,不让缓存问题阻断解包) */
30
+ function read(fp) {
31
+ try { return JSON.parse(fs.readFileSync(probePath(fp), 'utf8')); } catch { return null; }
32
+ }
33
+
34
+ /** 合并写 probe 缓存(写不进去不影响正确性) */
35
+ function write(fp, patch) {
36
+ const next = { ...(read(fp) || {}), ...patch };
37
+ try {
38
+ fs.mkdirSync(fpDir(fp), { recursive: true });
39
+ fs.writeFileSync(probePath(fp), `${JSON.stringify(next, null, 2)}\n`, 'utf8');
40
+ } catch { /* 缓存写不进去不影响正确性 */ }
41
+ return next;
42
+ }
43
+
44
+ /**
45
+ * 记录本次解包的实测峰值。
46
+ *
47
+ * 语义与搬 worker 之前**逐字相同**:
48
+ * · 峰值口径 = 起始 RSS + 本次 VmHWM 增量(VmHWM 是进程级、只增不减,所以要看"增量")
49
+ * · measuredPeakBytes 与历史值取 max(单调不降,跨多次解包保留最高水位)
50
+ * @param {string} fp
51
+ * @param {{rss:number, peak:number}} before 解包前(worker 里读的,与主线程同源)
52
+ * @param {{rss:number, peak:number}} after 解包后
53
+ * @param {{decompressedBytes?:number, compressedBytes?:number, probeAt?:number}|null} decl 内存声明
54
+ */
55
+ function recordMeasuredPeak(fp, before, after, decl) {
56
+ const guess = before.rss + Math.max(0, after.peak - before.peak);
57
+ const prev = read(fp) || {};
58
+ const measuredPeakBytes = Math.max(prev.measuredPeakBytes || 0, guess);
59
+ return write(fp, {
60
+ // 声明值存档(供下次准入直接用,避免再 probe)
61
+ decompressedBytes: (decl && decl.decompressedBytes) || prev.decompressedBytes || 0,
62
+ compressedBytes: (decl && decl.compressedBytes) || prev.compressedBytes || 0,
63
+ probeAt: (decl && decl.probeAt) || prev.probeAt || Date.now(),
64
+ measuredPeakBytes,
65
+ measuredAt: Date.now(),
66
+ samples: (prev.samples || 0) + 1,
67
+ });
68
+ }
69
+
70
+ return { fpDir, probePath, read, write, recordMeasuredPeak };
71
+ }
72
+
73
+ export default createProbeStore;
@@ -0,0 +1,359 @@
1
+ // unpack/routes.mjs —— 解包缓存的三条路由(17b 第三步)
2
+ //
3
+ // POST /api/unpack/resolve {rootId, rel} → 命中直接返回清单;未命中解包落盘后返回
4
+ // GET /api/unpack/file?fp=&path= → 取单个产物文件(白名单校验 + 强缓存)
5
+ // GET /api/unpack/stats → 计数器 / peakRss / 目录占用(验收与回归用)
6
+ //
7
+ // 三条纪律:
8
+ // 1. **安全**:`path` 必须命中该 fp 的 index.json 清单(白名单),再做一次"解析后仍在目录内"校验 ——
9
+ // 不做"拼完就信"的路径拼接(`../` 之类必须被挡在渲染层之外)。
10
+ // 2. **强缓存**:文件 URL 里含指纹(内容不可变)⇒ 可以给 `immutable`;这是唯一能安全用长缓存的地方。
11
+ // 3. **失败可注入**:`UNPACK_FORCE_FAIL=1` 时 resolve 直接 500,用来验收"前端回落自己解包"。
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { sendFile } from '../http/static.mjs';
16
+ import { capacity } from './capacity.mjs';
17
+ // 包内形态探测(依赖包判定):与 /api/bundle/probe、前端 plugin-host 共用同一份判据与文案
18
+ import { probeBundleBytes } from '../../../tool/lib/bundle-probe-node.mjs';
19
+ // 条目状态(契约《条目状态与展示归属》§三):resolve 成功/失败都要留痕 ——
20
+ // 否则列表永远显示"未试过",S4 的"点击后错误文案与 ⚠ 同源"就断了。
21
+ import { getSharedEntryStates, providerFingerprintOf, assetFingerprintOf } from '../data/entry-states.mjs';
22
+ import { createPaths } from '../core/paths.mjs';
23
+ import { createLogger } from '../core/log.mjs';
24
+ import { readPlugins } from '../plugins/registry.mjs';
25
+
26
+ const log = createLogger('unpack');
27
+ /** 项目根(本文件在 src/server/unpack/ ⇒ 三层);只为算 plugins/config 路径,与 serve.mjs 同口径 */
28
+ const ROUTES_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
29
+ const ROUTES_PATHS = createPaths({
30
+ root: ROUTES_ROOT,
31
+ dataRoot: (process.env.MODELSTAGE_DATA_ROOT || '').trim() || undefined,
32
+ });
33
+
34
+ /**
35
+ * SSE 心跳间隔(毫秒)。
36
+ * 2s 是权衡:太短浪费 CPU/带宽(每个打开的流都在发),太长会让中间代理/浏览器把空闲连接判死。
37
+ * 提成常量是为了让"为什么是 2s"有地方写,也便于以后按实测调。
38
+ */
39
+ const HEARTBEAT_INTERVAL_MS = 2000;
40
+
41
+ /** 强制失败开关(验收"回退可用"用;默认关) */
42
+ function forcedFail() {
43
+ return process.env.UNPACK_FORCE_FAIL === '1';
44
+ }
45
+
46
+ /** 产物文件 URL(前端拿到后逐条 fetch) */
47
+ function fileUrl(fp, relPath) {
48
+ return `/api/unpack/file?fp=${encodeURIComponent(fp)}&path=${encodeURIComponent(relPath)}`;
49
+ }
50
+
51
+ /**
52
+ * 创建路由处理器。
53
+ * @param {{store: object, readConfig: Function, resolveSafe: Function, sendJson: Function, readBody: Function}} deps
54
+ * @returns {(req, res, p: string, url: URL) => boolean} 返回 true 表示已处理
55
+ */
56
+ export function createUnpackRoutes(deps) {
57
+ const { store, readConfig, resolveSafe, sendJson, readBody } = deps;
58
+
59
+ /** 资源根 id + 相对路径 → 磁盘绝对路径(越界/不存在一律 null) */
60
+ function resolveRequest(rootId, rel) {
61
+ const cfg = readConfig().find((r) => r.id === rootId);
62
+ if (!cfg) return { error: `未找到资源根: ${rootId}` };
63
+ const abs = resolveSafe(cfg.path, rel);
64
+ if (!abs) return { error: '路径越界' };
65
+ try {
66
+ if (!fs.statSync(abs).isFile()) return { error: '不是文件' };
67
+ } catch {
68
+ return { error: '文件不存在' };
69
+ }
70
+ return { abs };
71
+ }
72
+
73
+ /**
74
+ * 读文件并探包内形态(依赖包判定用)。任何失败都返回 null ——
75
+ * 这是"给主错误补一句人话"的辅助,绝不能反过来把主错误路径搞坏。
76
+ * @param {string} absPath 资源包绝对路径
77
+ * @returns {{assetDependency?: boolean, dependencyReason?: string}|null}
78
+ */
79
+ function describeBundleShapeSafe(absPath) {
80
+ try {
81
+ const bytes = new Uint8Array(fs.readFileSync(absPath));
82
+ return probeBundleBytes(bytes);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 把一次 resolve 的结果写进条目状态存储(契约 §三)。
90
+ *
91
+ * 为什么**成功和失败都写**:列表上的 ⚠ 与 loadResult 是同一份数据;
92
+ * 只写成功会让"点开失败"的条目永远显示"未试过",用户看不出这条已经试过且失败了。
93
+ *
94
+ * 旁路原则(INV-D3):这里任何异常都只记日志 —— 状态上报失败绝不能把解包主流程带崩。
95
+ * @param {{rootId: string, rel: string, absPath: string, ok: boolean,
96
+ * reason?: string|null, message?: string|null, provider?: string|null}} p
97
+ */
98
+ function recordEntryResult(p) {
99
+ try {
100
+ const states = getSharedEntryStates({ log });
101
+ const providers = readPlugins(ROUTES_PATHS.pluginsDir, ROUTES_PATHS.pluginSwitchFile);
102
+ let assetFingerprint = null;
103
+ try {
104
+ const st = fs.statSync(p.absPath);
105
+ // 用与 catalog / 上报路由**同一个派生函数**(只用 size)。
106
+ // 原先这里算 `size-mtime`,另两处只能拿到 size ⇒ 同一个文件在两条路上指纹不同,
107
+ // 切一次模型就被判"文件变了"、结论反复失效(规则②被误触发,最难查的一种漂移)。
108
+ assetFingerprint = assetFingerprintOf(st.size);
109
+ } catch { /* 文件不在了 ⇒ 指纹留空,失效规则②不参与判定 */ }
110
+ const key = `${p.rootId}/${p.rel}`;
111
+ states.put(key, {
112
+ result: p.ok ? 'ok' : 'failed',
113
+ reason: p.ok ? null : (p.reason || null),
114
+ message: p.message || null,
115
+ provider: p.ok ? (p.provider || null) : null,
116
+ at: Date.now(),
117
+ source: 'server',
118
+ }, {
119
+ providers,
120
+ providerFingerprint: providerFingerprintOf(providers),
121
+ assetFingerprint,
122
+ });
123
+ // 去抖写盘(CIFS 上禁止一请求一写);这里不 await,避免拖慢响应
124
+ states.flush().catch(() => {});
125
+ } catch (e) {
126
+ log.warn('写入条目状态失败(不影响解包结果)', { key: `${p.rootId}/${p.rel}`, err: e.message });
127
+ }
128
+ }
129
+
130
+ /** POST /api/unpack/resolve */
131
+ function handleResolve(req, res) {
132
+ return readBody(req, res, async (raw) => {
133
+ let body = {};
134
+ try {
135
+ body = raw ? JSON.parse(raw) : {};
136
+ } catch {
137
+ return sendJson(res, 400, { ok: false, reason: 'bad-json', message: '请求体不是合法 JSON' });
138
+ }
139
+ if (forcedFail()) {
140
+ return sendJson(res, 500, { ok: false, reason: 'forced-fail', message: 'UNPACK_FORCE_FAIL=1(验收注入)' });
141
+ }
142
+ const rootId = String(body.rootId || '');
143
+ const rel = String(body.rel || '');
144
+ if (!rootId || !rel) return sendJson(res, 400, { ok: false, reason: 'bad-args', message: '需要 rootId 与 rel' });
145
+ const r = resolveRequest(rootId, rel);
146
+ if (r.error) return sendJson(res, 404, { ok: false, reason: 'not-found', message: r.error });
147
+ // ── 事件流(契约 §二/§五):只有 body.stream === true 才走这里 ──────────────
148
+ // 分流点必须在 404 判定**之后**:开流前的错误(bad-json / bad-args / not-found)
149
+ // 一律按现状回一次性 JSON,绝不"先开流再发 error"(契约 §六)。
150
+ if (body.stream === true) {
151
+ return handleStream(req, res, {
152
+ rootId,
153
+ rel,
154
+ absPath: r.abs,
155
+ hitOnly: body.hitOnly === true,
156
+ allowOverLimit: body.allowOverLimit === true,
157
+ });
158
+ }
159
+ // hitOnly:**只查缓存,绝不在这里解包**(形态 C 的前台路径)。miss 时返回 hit:false,
160
+ // 前端据此走本地解包;真正的解包由「空闲预热」另一次请求触发。
161
+ if (body.hitOnly === true) {
162
+ const p = store.peek({ rootId, rel, absPath: r.abs });
163
+ if (!p.ok) return sendJson(res, 404, { ok: false, reason: p.reason || 'peek-failed' });
164
+ return sendJson(res, 200, {
165
+ ok: true,
166
+ hit: p.hit,
167
+ fingerprint: p.fingerprint,
168
+ missReason: p.hit ? null : (p.reason || 'no-index'),
169
+ prefix: p.prefix || null,
170
+ entry: p.entry || null,
171
+ unpackMs: 0,
172
+ files: (p.files || []).map((f) => ({ path: f.path, size: f.size, url: fileUrl(p.fingerprint, f.path) })),
173
+ });
174
+ }
175
+ try {
176
+ // allowOverLimit:面板「内存保护」开关关闭时前端会带这个标记(默认不带 ⇒ 行为与从前完全一致)
177
+ const out = await store.resolve({ rootId, rel, absPath: r.abs, allowOverLimit: body.allowOverLimit === true });
178
+ if (!out.ok) {
179
+ // 没有插件认领时先判包内形态:美术依赖包要说清"为什么打不开 + 下一步"。
180
+ // 判据与文案来自 src/shared/bundle-shape.mjs(与前端 plugin-host 同一份),
181
+ // 否则"页面点开"与"直接调这个接口"会对同一个包给出两种答案。
182
+ // 两个 reason 是同义:前端 plugin-host 叫 no-provider,服务端 plugin-runner 叫 not-handled
183
+ // (见 plugin-runner.mjs 的 kind 表:not-handled = 所有插件都说不处理)。
184
+ if (out.reason === 'no-provider' || out.reason === 'not-handled') {
185
+ const shape = describeBundleShapeSafe(r.abs);
186
+ if (shape && shape.assetDependency) {
187
+ // 失败也要留痕(契约 §三 定序裁定):否则列表永远显示"未试过"
188
+ recordEntryResult({ rootId, rel, absPath: r.abs, ok: false,
189
+ reason: 'asset-dependency', message: shape.dependencyReason });
190
+ return sendJson(res, 500, { ok: false, reason: 'asset-dependency', message: shape.dependencyReason });
191
+ }
192
+ }
193
+ recordEntryResult({ rootId, rel, absPath: r.abs, ok: false,
194
+ reason: out.reason || 'unpack-failed', message: out.message || '解包失败' });
195
+ // 把判据数字一起回给调用方(insufficient-memory 时要能看到 预算/预估峰值/当前可用)
196
+ return sendJson(res, 500, {
197
+ ok: false,
198
+ reason: out.reason || 'unpack-failed',
199
+ message: out.message || '解包失败',
200
+ ...(out.budgetMb !== undefined ? { budgetMb: out.budgetMb, peakEstMb: out.peakEstMb, availMb: out.availMb } : {}),
201
+ });
202
+ }
203
+ // 成功:provider 由 store 回报(index.json 里记录的 pluginId,唯一可信来源)
204
+ recordEntryResult({ rootId, rel, absPath: r.abs, ok: true, provider: out.provider || null });
205
+ return sendJson(res, 200, {
206
+ ok: true,
207
+ hit: out.hit,
208
+ fingerprint: out.fingerprint,
209
+ prefix: out.prefix,
210
+ entry: out.entry,
211
+ unpackMs: out.unpackMs,
212
+ // 判据来源(验收与排障用):measured / plugin-peak / model-decompressed / legacy-source
213
+ ...(out.memBasis ? { memBasis: out.memBasis, peakEstMb: out.peakEstMb } : {}),
214
+ // 分段耗时(解压 / 落盘 / 索引探针):优化前必须先知道时间花在哪
215
+ ...(out.phases ? { phases: out.phases } : {}),
216
+ files: out.files.map((f) => ({ path: f.path, size: f.size, url: fileUrl(out.fingerprint, f.path) })),
217
+ });
218
+ } catch (e) {
219
+ return sendJson(res, 500, { ok: false, reason: 'exception', message: String((e && e.message) || e) });
220
+ }
221
+ });
222
+ }
223
+
224
+ /**
225
+ * 事件流分支(契约 `unpack/events/v1`):SSE 分帧 + 心跳 + 断连清理。
226
+ * 只有 `body.stream === true` 会进来;其余请求完全走原路径(契约 §1.3 逐字同现状)。
227
+ */
228
+ async function handleStream(req, res, { rootId, rel, absPath, hitOnly, allowOverLimit }) {
229
+ res.writeHead(200, {
230
+ 'Content-Type': 'text/event-stream; charset=utf-8',
231
+ // no-transform 禁中间层改写/压缩;X-Accel-Buffering 是 nginx 认的"别缓冲"开关(设计 §三)
232
+ 'Cache-Control': 'no-cache, no-transform',
233
+ 'X-Accel-Buffering': 'no',
234
+ Connection: 'keep-alive',
235
+ });
236
+ res.flushHeaders(); // 必须先发头,客户端才能开始读
237
+ let closed = false;
238
+ /** 写一帧;写已断开的连接静默吞(契约 §五:客户端走了是正常现象) */
239
+ const writeFrame = (evt) => {
240
+ if (closed) return;
241
+ try {
242
+ res.write(`event: ${evt.type}\ndata: ${JSON.stringify(evt.data || {})}\n\n`);
243
+ } catch { /* 静默吞 */ }
244
+ };
245
+ // 往已销毁的连接写通常**不同步抛错**,而是异步 emit 'error' ⇒ 必须挂空处理器,
246
+ // 否则会变成 uncaught error 把进程带走(try/catch 兜不住异步事件)。
247
+ res.on('error', () => {});
248
+ const heartbeat = setInterval(() => writeFrame({ type: 'ping', data: {} }), HEARTBEAT_INTERVAL_MS);
249
+ heartbeat.unref?.();
250
+ res.on('close', () => {
251
+ // 客户端走了:停心跳、不再写帧;**不中止解包**(契约 §五:解完写缓存,下次命中)
252
+ closed = true;
253
+ clearInterval(heartbeat);
254
+ });
255
+ try {
256
+ if (hitOnly) {
257
+ // hitOnly + stream:沿用 hitOnly 语义(只查缓存、绝不解包),命中/未命中都只发一帧 done
258
+ const p = store.peek({ rootId, rel, absPath });
259
+ if (!p.ok) {
260
+ writeFrame({ type: 'error', data: { reason: p.reason || 'peek-failed', message: '缓存索引不可读' } });
261
+ } else {
262
+ writeFrame({
263
+ type: 'done',
264
+ data: { fp: p.fingerprint, hit: !!p.hit, prefix: p.prefix || null, entry: p.entry || null, files: p.files || [], unpackMs: 0 },
265
+ });
266
+ }
267
+ return;
268
+ }
269
+ const out = await store.resolve({ rootId, rel, absPath, allowOverLimit }, { onEvent: writeFrame });
270
+ // 成功时 store 已通过 onEvent 发过 done(含命中路径),这里只补失败
271
+ if (!out.ok) {
272
+ writeFrame({ type: 'error', data: { reason: out.reason || 'unpack-failed', message: out.message || '解包失败' } });
273
+ }
274
+ } catch (e) {
275
+ writeFrame({ type: 'error', data: { reason: 'exception', message: String((e && e.message) || e) } });
276
+ } finally {
277
+ clearInterval(heartbeat);
278
+ closed = true;
279
+ try { res.end(); } catch { /* 忽略:连接可能已断 */ }
280
+ }
281
+ }
282
+
283
+ /** GET /api/unpack/file?fp=&path= */
284
+ function handleFile(req, res, url) {
285
+ const fp = String(url.searchParams.get('fp') || '');
286
+ const relPath = String(url.searchParams.get('path') || '');
287
+ if (!/^[0-9a-f]{8,40}$/.test(fp) || !relPath) {
288
+ sendJson(res, 400, { ok: false, error: '需要 fp 与 path' });
289
+ return;
290
+ }
291
+ const index = store.readIndex(fp);
292
+ if (!index) {
293
+ sendJson(res, 404, { ok: false, error: '缓存目录不存在或索引损坏' });
294
+ return;
295
+ }
296
+ // 白名单:只发清单里声明过的文件(这条挡掉 ../ 与任意读盘)
297
+ const item = index.files.find((f) => f.path === relPath);
298
+ if (!item) {
299
+ sendJson(res, 404, { ok: false, error: '不在该包的产物清单里' });
300
+ return;
301
+ }
302
+ const abs = store.filePathOf(fp, relPath);
303
+ // 纵深防御:解析后必须仍在 files/ 目录内
304
+ const filesDir = path.dirname(path.dirname(abs));
305
+ if (!path.resolve(abs).startsWith(path.resolve(filesDir) + path.sep) && path.resolve(abs) !== path.resolve(filesDir)) {
306
+ sendJson(res, 403, { ok: false, error: '路径越界' });
307
+ return;
308
+ }
309
+ // 内容不可变(URL 里带指纹)⇒ 可以长缓存 + immutable
310
+ sendFile(req, res, abs, {
311
+ 'Cache-Control': 'public, max-age=31536000, immutable',
312
+ ETag: `"${fp}-${item.size}"`,
313
+ 'Access-Control-Allow-Origin': '*', // 纹理走 <img>/WebGL 时需要
314
+ });
315
+ }
316
+
317
+ return function dispatchRoute(req, res, p, url) {
318
+ if (p === '/api/unpack/resolve' && req.method === 'POST') {
319
+ handleResolve(req, res);
320
+ return true;
321
+ }
322
+ if (p === '/api/unpack/file' && req.method === 'GET') {
323
+ handleFile(req, res, url);
324
+ return true;
325
+ }
326
+ if (p === '/api/unpack/capacity' && req.method === 'GET') {
327
+ // 前端读不到 os.totalmem / MemAvailable,容量必须服务端算好下发。
328
+ // 带上 rootId+rel 时服务端自己 stat 源包大小,前端不用传 size(少一处不一致的可能)。
329
+ const rootId = String(url.searchParams.get('rootId') || '');
330
+ const rel = String(url.searchParams.get('rel') || '');
331
+ let sourceMb = 0;
332
+ let memory = null;
333
+ if (rootId && rel) {
334
+ // 优先用 rootId+rel:服务端自己 stat,前端不用传 size(少一处"两边算得不一样"的可能)
335
+ const r = resolveRequest(rootId, rel);
336
+ if (r.error) {
337
+ sendJson(res, 200, { ...capacity(), sourceMb: null, allowWarmup: true, reason: 'source-unresolved' });
338
+ return true;
339
+ }
340
+ sourceMb = fs.statSync(r.abs).size / 1024 / 1024;
341
+ // 带上插件的内存声明(peakBytes 优先):这样 capacity 与真正的准入判断**同源**,
342
+ // 同一个包不会出现两个数字(旧实现这里走源包×倍数、准入走解压容器模型)。
343
+ try { memory = store.memoryOf({ rootId, rel, absPath: r.abs }); } catch { memory = null; }
344
+ } else {
345
+ // 只有字节数时(前端只知道包多大、不想暴露路径):用 sourceMb 直接问
346
+ // —— 这样"包字节 → 预估峰值"的翻译始终只有服务端这一份权威实现
347
+ const q = Number(url.searchParams.get('sourceMb') || 0);
348
+ if (Number.isFinite(q) && q > 0) sourceMb = q;
349
+ }
350
+ sendJson(res, 200, capacity(sourceMb || undefined, memory || undefined));
351
+ return true;
352
+ }
353
+ if (p === '/api/unpack/stats' && req.method === 'GET') {
354
+ sendJson(res, 200, store.stats());
355
+ return true;
356
+ }
357
+ return false;
358
+ };
359
+ }