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,396 @@
1
+ #!/usr/bin/env node
2
+ // serve.mjs —— 服务入口。所有请求都由这里处理:
3
+ // · /api/* 数据层:调 src/server/** 的实现
4
+ // · /api/file /files 模型资源流式返回(安全路径解析 + MIME 复用 probe.mjs)
5
+ // · /icons/* 头像(资源根配置里的 icons 目录)
6
+ // · /live2d-lib/* Live2D 运行时库(tool/resolve-live2d-runtimes.mjs 探测结果)
7
+ // · /plugins/* 展示类型插件 js
8
+ // · /spine-runtimes/* Spine 运行时(含 index.json 版本清单)
9
+ // · 引擎模块 src/web/engine/**(ESM,随下面的 src/web 静态托管一起发出;无单独的引擎打包产物)
10
+ // · 其余 静态托管 src/web
11
+ //
12
+ // 写接口:`POST /api/roots`(资源根增删改名启停)与 `POST /api/config/import`(导入配置)
13
+ // 均已开放,共用 http/write.mjs 的管道:校验 → 备份 → 写 → 失效缓存 → 回读自检。
14
+ //
15
+ // 用法:PORT=8804 node src/server/serve.mjs
16
+ import http from 'node:http';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { createServerData } from './index.mjs';
21
+ import { resolveSafe } from './assets/probe.mjs';
22
+ import { browseDir } from './assets/browse.mjs';
23
+ import { createStaticRoutes, sendFile } from './http/static.mjs';
24
+ import { createDiag } from './http/diag.mjs';
25
+ import { createReady } from './http/ready.mjs';
26
+ import { createWriteApi, toRootView } from './http/write.mjs';
27
+ import { createBackgrounds } from './http/backgrounds.mjs';
28
+ import { createBackgroundSolids } from './http/background-solids.mjs';
29
+ import { createDiagUpload } from './http/diag-upload.mjs';
30
+ import { createLogger } from './core/log.mjs';
31
+ import { createUnpackStore } from './unpack/plugin-store.mjs';
32
+ import { createUnpackRoutes } from './unpack/routes.mjs';
33
+ import { handleData } from './routes/data.mjs';
34
+ import { handleConfig } from './routes/config.mjs';
35
+ import { handleDiag } from './routes/diag.mjs';
36
+ import { handleReady } from './routes/ready.mjs';
37
+ import { handleBackgrounds } from './routes/backgrounds.mjs';
38
+ import { handleBackgroundSolids } from './routes/background-solids.mjs';
39
+ import { handleEntries, createContextFor } from './routes/entries.mjs';
40
+
41
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
42
+ const WEB_ROOT = path.join(ROOT, 'src', 'web');
43
+ const RUNTIME_DIR = path.join(ROOT, 'src', 'spine-runtimes');
44
+ const PLUGINS_DIR = path.join(ROOT, 'plugins');
45
+ /**
46
+ * 数据根:`MODELSTAGE_DATA_ROOT` 环境变量(缺省空 = 项目根 ⇒ **行为完全不变**)。
47
+ *
48
+ * 只影响 config/ data/ runtime/ 三处;代码目录(plugins/ src/ tool/)始终跟随 ROOT。
49
+ * 为什么需要它:测试与生产共用同一实例时,验证过程中的重启会打断正在加载的页面
50
+ * (实测把 /live2d-lib 的加载掐断过),且两边同时写 config 会互相覆盖。
51
+ * 有了它,测试可以起第二实例(不同端口 + 不同数据根),互不干扰。
52
+ */
53
+ const DATA_ROOT = (process.env.MODELSTAGE_DATA_ROOT || '').trim();
54
+ const PORT = Number(process.env.PORT) || Number(process.argv[2]) || 8804;
55
+
56
+ /**
57
+ * 头像根解析:`--avatar-root <dir>` > 环境变量 `MODELSTAGE_AVATAR_ROOT` > `<项目根>/avatars`(默认占位)。
58
+ * 为什么要可配置:头像仓是独立私有仓,而项目根可能在 CIFS/SMB 上(不允许 chmod ⇒ 那里 clone 不出来),
59
+ * 所以头像仓要能放到任意可写位置再指过来(见 docs/设计/名表.md §4.1)。
60
+ */
61
+ function resolveAvatarRoot() {
62
+ const i = process.argv.indexOf('--avatar-root');
63
+ const fromArg = i >= 0 ? process.argv[i + 1] : '';
64
+ return fromArg || process.env.MODELSTAGE_AVATAR_ROOT || '';
65
+ }
66
+
67
+ /** 服务端日志(统一形状 {at,level,tag,msg,detail};error→stderr,其余→stdout) */
68
+ const log = createLogger('serve');
69
+
70
+ /**
71
+ * 请求活跃度探针:给后台任务判断"现在是不是真空闲"(契约 §八 的"不抢装载资源")。
72
+ *
73
+ * 为什么需要:catalog / 解包这些同步解析会占满主线程(探测单包 ~543ms),
74
+ * 后台任务只要在"有请求在飞"时动 CPU,用户就会看到某个请求莫名慢几百毫秒。
75
+ * 有了这份计数,后台可以主动让路(见 data/shape-probe.mjs 的 waitUntilIdle)。
76
+ *
77
+ * 统计口径:**只算短请求**。SSE(/api/ready/stream)会一直挂着不结束,
78
+ * 把它算进来会得到"永远不空闲"——探测当场饿死。
79
+ */
80
+ const activity = {
81
+ inflight: 0,
82
+ lastRequestAt: Date.now(),
83
+ /** 有短请求正在处理(长连接不算) */
84
+ idle() { return this.inflight <= 0; },
85
+ /** 距上一次短请求开始过了多久(ms) */
86
+ msSinceLastRequest() { return Date.now() - this.lastRequestAt; },
87
+ };
88
+
89
+ // log 注入:后台形态探测(§八)要能把"启动/完成/失败"写进同一条服务端日志,
90
+ // 否则 S10 的"探测启动次数 = 0"没有可核对的凭据。
91
+ // activity 注入:探测据此让路(有请求在飞 / 刚有请求 ⇒ 不推进),避免抢主线程。
92
+ const data = createServerData({
93
+ root: ROOT,
94
+ dataRoot: DATA_ROOT || undefined,
95
+ avatarRoot: resolveAvatarRoot() || undefined,
96
+ log,
97
+ activity,
98
+ });
99
+ // 静态/资源类路由(/plugins、/spine-runtimes、src/web 静态)单独成模块
100
+ // dataRuntimesDir = tool/fetch-runtimes.mjs 的下载落点(<dataRoot>/runtimes/spine)。
101
+ // 传进去之后 /spine-runtimes/index.json 才列得出它、/spine-runtimes/* 才发得出文件
102
+ // —— 否则"下载成功但前端看不到"(resolver 能找到、清单里没有)。
103
+ const staticRoutes = createStaticRoutes({
104
+ root: ROOT,
105
+ webRoot: WEB_ROOT,
106
+ runtimeDir: RUNTIME_DIR,
107
+ pluginsDir: PLUGINS_DIR,
108
+ dataRuntimesDir: path.join(data.paths.dataRoot, 'runtimes', 'spine'),
109
+ live2dRuntimesDir: path.join(data.paths.dataRoot, 'runtimes', 'live2d'),
110
+ dataRoot: data.paths.dataRoot,
111
+ });
112
+ const diag = createDiag(data.paths);
113
+ // 自定义背景图存储:落盘在 data/backgrounds/(运行时数据,换机/备份需单独带走)
114
+ const backgrounds = createBackgrounds(data.paths);
115
+ // 纯色背景预设:落盘 data/backgrounds/solids.json(运行时数据,与背景图同目录)
116
+ const solids = createBackgroundSolids(data.paths);
117
+ // 调试日志上传:落盘在 data/logs/(页面把内存里的日志一次性交上来,供 AI 事后读现场)
118
+ const diagUpload = createDiagUpload({ dataDir: data.paths.dataDir, configDir: data.paths.configDir });
119
+
120
+ // 服务端解包 + 落盘缓存(17b):产物在 data/unpacked/<fingerprint>/,前端命中后只 fetch 文件
121
+ const unpackStore = createUnpackStore({
122
+ rootDir: path.join(data.paths.dataDir, 'unpacked'),
123
+ // 传插件根而不是某一个插件目录:解包插件不止一个(live2d / spine 各一个),
124
+ // 只读其中一个的版本会让另一个升级时缓存不失效(产物陈旧但一切"正常")。
125
+ // plugin-store 会把根下每个插件的版本汇总进指纹。
126
+ pluginDir: data.paths.pluginsDir,
127
+ });
128
+ // 只读注入:catalog 用它查"这个包被哪个插件解出来过"(列表徽标的展示归属)。
129
+ // 只是读缓存索引,不触发任何解包,也不改变 catalog 的缓存行为。
130
+ data.catalog.setUnpackStore(unpackStore);
131
+ const unpackRoutes = createUnpackRoutes({
132
+ store: unpackStore,
133
+ readConfig: () => data.config.readConfig(),
134
+ resolveSafe,
135
+ sendJson,
136
+ readBody,
137
+ });
138
+ // 就绪信号通道(契约 就绪信号契约.md):与 diag 完全分离的状态机,不进调试面板
139
+ const ready = createReady();
140
+ // 写接口(资源根增删改名启停 / 导入配置):读路径与写路径共用同一份 config/spine/names 实例
141
+ const writeApi = createWriteApi({
142
+ paths: data.paths, config: data.config, spine: data.spine, names: data.names,
143
+ });
144
+
145
+ /** 统一的 JSON 响应(与 server.mjs 的 sendJson 同形:无缩进、no-store) */
146
+ function sendJson(res, status, body) {
147
+ // 统一守卫:某个 handler 已响应过、但没 return true 让流程继续时,这里直接放弃,
148
+ // 否则会抛 ERR_HTTP_HEADERS_SENT(曾导致日志刷错 + 进程异常退出)。
149
+ if (res.headersSent || res.writableEnded) return;
150
+ const text = JSON.stringify(body);
151
+ res.writeHead(status, {
152
+ 'Content-Type': 'application/json; charset=utf-8',
153
+ 'Content-Length': Buffer.byteLength(text),
154
+ 'Cache-Control': 'no-store',
155
+ });
156
+ res.end(text);
157
+ }
158
+
159
+ /** 简单文本响应 */
160
+ function sendText(res, status, text, type) {
161
+ res.writeHead(status, { 'Content-Type': type || 'text/plain; charset=utf-8' });
162
+ res.end(text);
163
+ }
164
+
165
+ /**
166
+ * 读请求体;超限直接断开连接(不截断,避免把半个 JSON 当成完整内容去写盘)。
167
+ *
168
+ * ⚠️ 必须**收齐 Buffer 再统一解码**,不能逐 chunk `body += chunk`:
169
+ * chunk 是 Buffer,`+=` 会按 utf8 逐段解码;一个多字节字符(中文正好 3 字节)
170
+ * 被切在两个 chunk 边界上时,两半各自解成 U+FFFD ⇒ 内容被写坏。
171
+ * 实测:导入 588KB 名表时「喀琅施塔得」被写成「喀琅��塔得」,文件凭空多 3 字节。
172
+ *
173
+ * @param {import('node:http').IncomingMessage} req
174
+ * @param {import('node:http').ServerResponse} res
175
+ * @param {(body: string) => void} onBody
176
+ * @param {number} [maxBytes] 上限(字节):默认 2MB;导入配置传 5MB
177
+ */
178
+ function readBody(req, res, onBody, maxBytes = 2e6) {
179
+ const chunks = [];
180
+ let size = 0;
181
+ req.on('data', (chunk) => {
182
+ size += chunk.length;
183
+ if (size > maxBytes) {
184
+ req.destroy();
185
+ return;
186
+ }
187
+ chunks.push(chunk);
188
+ });
189
+ req.on('end', () => {
190
+ // 空请求体也要回调(由各接口自己判断"缺少字段"并给出明确错误)
191
+ const body = Buffer.concat(chunks).toString('utf8');
192
+ try {
193
+ onBody(body);
194
+ } catch (e) {
195
+ log.error('POST 处理异常', { err: (e && e.stack) ? e.stack : String(e) });
196
+ sendJson(res, 500, { ok: false, error: String((e && e.message) || e) });
197
+ }
198
+ });
199
+ }
200
+
201
+ // ------------------------------------------------------------------ 入口
202
+
203
+ /**
204
+ * 请求上下文:路由模块只拿它需要的东西(显式依赖,便于单测替换)。
205
+ * 不把 req/res 之外的全局状态藏进模块级变量——之前那个 127 行的大回调就是这种耦合的产物。
206
+ */
207
+ const ctx = {
208
+ data, staticRoutes, diag, diagUpload, ready, writeApi, backgrounds, solids, unpackRoutes,
209
+ resolveSafe, browseDir, sendFile, toRootView, sendJson, sendText, readBody, fs, path, log,
210
+ // 条目状态(契约 §三):写读一体的存储实例与"条目静态事实"查询
211
+ entryStates: data.entryStates,
212
+ // 后台形态探测(契约 §八):计数/手动重探走 /api/entries/probe(S10 的佐证口)
213
+ shapeProbe: data.shapeProbe,
214
+ catalog: data.catalog,
215
+ contextFor: createContextFor({
216
+ providers: () => data.plugins.readPlugins(),
217
+ describe: (key) => data.catalog.describeEntry(key),
218
+ }),
219
+ };
220
+
221
+ /**
222
+ * 顶层分发:按**前缀归属**依次交给路由模块,谁先认领就结束。
223
+ *
224
+ * 顺序有讲究(与拆分前完全一致,不能随意调换):
225
+ * 1. unpack 三条(/api/unpack/*,与资源读取解耦,命中即返回)
226
+ * 2. diag(/api/diag、/api/diag/upload|uploads)
227
+ * 3. ready(/api/ready GET/POST、/api/ready/stream)
228
+ * 4. config(/api/config/export|import)
229
+ * 5. backgrounds(/api/backgrounds*)
230
+ * 6. data(/api/roots 写 + 只读数据 API + 二进制资源)
231
+ * 7. 静态资源(/plugins、/spine-runtimes、src/web)
232
+ * 8. 兜底:未匹配的 /api/* ⇒ 404 JSON;GET/HEAD ⇒ 静态;其余 ⇒ 405
233
+ *
234
+ * @param {import('node:http').IncomingMessage} req
235
+ * @param {import('node:http').ServerResponse} res
236
+ * @param {string} p
237
+ * @param {URL} url
238
+ */
239
+ /**
240
+ * 版本号:读 package.json(它是版本号的唯一权威,与 bin 的 --version 同源)。
241
+ * 读不到时返回 'unknown' 而不是抛错 —— 探活端点不该因为包元信息缺失而 500。
242
+ */
243
+ function readVersion() {
244
+ try {
245
+ return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version || 'unknown';
246
+ } catch {
247
+ return 'unknown';
248
+ }
249
+ }
250
+
251
+ /**
252
+ * GET /api/health —— 探活端点(`web.sh status` 与外部监控用)。
253
+ *
254
+ * 为什么单独一条而不复用 /api/ready:ready 是"能否开始服务请求"的业务就绪信号
255
+ * (要扫资源根、可能 503),而 health 只回答"进程活着吗、什么版本、数据在哪"。
256
+ * 两者语义不同,混一起会让监控在"资源根还没扫完"时误判进程已死。
257
+ *
258
+ * 只暴露排障必需的粗粒度信息:版本 / 运行时长 / 数据根绝对路径 / 插件数。
259
+ * **不返回**配置内容、资源根清单、任何凭据或环境变量。
260
+ */
261
+ function handleHealth(req, res, p) {
262
+ if (p !== '/api/health') return false;
263
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
264
+ sendJson(res, 405, { ok: false, error: '仅支持 GET' });
265
+ return true;
266
+ }
267
+ // 插件数:直接问数据层(与 /api/plugins 同源)。
268
+ // 早先这里写的是 ctx.providers(),但本函数没有 ctx 参数 —— 异常被 catch 吞成 0,
269
+ // 结果 health 永远报 plugins:0(实测 5 个插件时也报 0)。探活字段报假数比不报更糟,故改为同源直取。
270
+ let pluginCount = 0;
271
+ try {
272
+ pluginCount = (data.plugins.readPlugins() || []).length;
273
+ } catch { /* 插件系统未就绪也算 0,探活不该因此失败 */ }
274
+ sendJson(res, 200, {
275
+ ok: true,
276
+ uptimeSec: Math.round(process.uptime()),
277
+ version: readVersion(),
278
+ dataRoot: data.paths.dataRoot,
279
+ plugins: pluginCount,
280
+ });
281
+ return true;
282
+ }
283
+
284
+ function dispatch(req, res, p, url) {
285
+ // 探活放最前:它不该被其它路由的鉴权/副作用影响(也就不会因为那些逻辑出错而"连探活都挂")
286
+ if (handleHealth(req, res, p)) return true;
287
+ if (unpackRoutes(req, res, p, url)) return true;
288
+ if (handleDiag(req, res, p, url, ctx)) return true;
289
+ if (handleReady(req, res, p, url, ctx)) return true;
290
+ if (handleConfig(req, res, p, ctx)) return true;
291
+ // 注意顺序:/api/backgrounds/solids 是 /api/backgrounds 的**子路径**,
292
+ // 必须先让纯色预设那组认领,否则会被 backgrounds 的 404 分支吃掉
293
+ if (handleBackgroundSolids(req, res, p, ctx)) return true;
294
+ if (handleBackgrounds(req, res, p, ctx)) return true;
295
+ // 条目状态(写读一体):必须在 handleData 之前认领,否则 /api/ 前缀兜底会当未知接口 404
296
+ if (handleEntries(req, res, p, url, ctx)) return true;
297
+ if (handleData(req, res, p, url, ctx)) return true;
298
+ if (staticRoutes.serveAssets(req, res, p)) return true;
299
+ if (res.headersSent) return true;
300
+ if (p.startsWith('/api/')) {
301
+ sendJson(res, 404, { error: `未知接口: ${p}` });
302
+ return true;
303
+ }
304
+ if (req.method === 'GET' || req.method === 'HEAD') return staticRoutes.serveStatic(req, res, p);
305
+ sendText(res, 405, `405: ${req.method} ${p}`);
306
+ return true;
307
+ }
308
+
309
+ const server = http.createServer((req, res) => {
310
+ const url = new URL(req.url, `http://localhost:${PORT}`);
311
+ // 长连接(SSE)不计入活跃度:它一挂几分钟,计入就等于"服务永远忙"
312
+ const longLived = url.pathname.includes('/stream');
313
+ if (!longLived) {
314
+ activity.inflight += 1;
315
+ activity.lastRequestAt = Date.now();
316
+ let counted = true;
317
+ const done = () => {
318
+ if (!counted) return; // close 与 finish 都会来,只减一次
319
+ counted = false;
320
+ activity.inflight -= 1;
321
+ activity.lastRequestAt = Date.now();
322
+ };
323
+ res.on('finish', done);
324
+ res.on('close', done);
325
+ }
326
+ try {
327
+ dispatch(req, res, url.pathname, url);
328
+ } catch (e) {
329
+ log.error('请求处理异常', { err: (e && e.stack) ? e.stack : String(e) });
330
+ try {
331
+ if (!res.headersSent) sendJson(res, 500, { error: `服务内部错误: ${e.message}` });
332
+ } catch {
333
+ try { res.writeHead(500); res.end('internal error'); } catch { /* 忽略 */ }
334
+ }
335
+ }
336
+ });
337
+
338
+ // HTTP keep-alive 超时(消除"客户端复用已被服务端关闭的连接"这类 race):
339
+ // Node 默认 keepAliveTimeout=5s,浏览器常把空闲连接留着复用 ⇒ 拿到已关闭连接 ⇒ fetch 直接 Failed to fetch。
340
+ // 现场表现:加载过很多模型后 Spine 空白(模型文件是几十个并发裸 fetch),而资源包正常(走 blob 不走网络)。
341
+ // 注意 headersTimeout **必须严格大于** keepAliveTimeout,否则 Node 会在读 header 阶段就断连。
342
+ server.keepAliveTimeout = 65000;
343
+ server.headersTimeout = 66000;
344
+ server.requestTimeout = 0; // 0 = 不主动超时(长请求如大模型文件不被中途砍断)
345
+ server.listen(PORT, '0.0.0.0', () => {
346
+ log.info(`内置服务 on http://0.0.0.0:${PORT}`);
347
+ log.info(`静态根 ${WEB_ROOT}`);
348
+ log.info(`数据根 ${data.paths.dataRoot}${DATA_ROOT ? '(MODELSTAGE_DATA_ROOT 覆盖)' : '(默认=项目根)'}`);
349
+ log.info('资源根已登记', { roots: data.config.readConfig().map((r) => `${r.id}=${r.name}`) });
350
+ });
351
+
352
+ // 监听失败要**说人话**:不处理的话用户看到的是 EADDRINUSE 原始堆栈(一大坨,且不说下一步做什么)。
353
+ // 退出码用 2 而不是 1:1 留给"参数/配置错误"这类,端口占用是环境问题,靠退出码就能区分。
354
+ server.on('error', (e) => {
355
+ const code = (e && e.code) || '';
356
+ if (code === 'EADDRINUSE') {
357
+ log.error(`端口 ${PORT} 已被占用:用 --port <其他端口> 换一个,或先停掉占用该端口的进程`);
358
+ log.error(`排查提示:lsof -i :${PORT}(macOS/Linux)或 netstat -ano | findstr :${PORT}(Windows)`);
359
+ process.exit(2);
360
+ }
361
+ // 其它监听错误(EACCES 权限不足、EADDRNOTAVAIL 地址不可用…):保留原始信息,同样给出下一步
362
+ log.error(`监听端口 ${PORT} 失败:${(e && e.message) || e}`, { code });
363
+ log.error('可尝试:换一个端口(--port),或检查是否有权限监听该端口/该地址');
364
+ process.exit(2);
365
+ });
366
+
367
+ /**
368
+ * 收尾钩子:先把**常驻资源**放掉再退出。
369
+ *
370
+ * 为什么必须有:后台形态探测为省掉"每批重建解析沙箱"(一批 4.5s → 76s)会保留一个 worker 线程。
371
+ * `worker.unref()` 只让它不单独撑住进程,挡不住 `worker.on('message')` 把事件循环 hold 住 ——
372
+ * 不显式关掉就表现为"服务收到 SIGTERM 停不下来"(同样的问题在 `node --test` 里就是退出码 124)。
373
+ */
374
+ function registerShutdown() {
375
+ const stop = async (signal) => {
376
+ log.info(`收到 ${signal},开始收尾`);
377
+ try {
378
+ if (data.shapeProbe && typeof data.shapeProbe.close === 'function') await data.shapeProbe.close();
379
+ } catch (e) {
380
+ log.warn('收尾时关闭探测 worker 失败(继续退出)', { err: (e && e.message) || String(e) });
381
+ }
382
+ try { server.close(); } catch { /* 已经关了 */ }
383
+ // 兜底强退:keep-alive 连接可能把 close() 拖住(这里不等它们)
384
+ const t = setTimeout(() => process.exit(0), 200);
385
+ if (typeof t.unref === 'function') t.unref();
386
+ };
387
+ let stopping = false;
388
+ const once = (signal) => {
389
+ if (stopping) return; // 连按两次 Ctrl-C 不重复走流程
390
+ stopping = true;
391
+ stop(signal);
392
+ };
393
+ process.once('SIGINT', () => once('SIGINT'));
394
+ process.once('SIGTERM', () => once('SIGTERM'));
395
+ }
396
+ registerShutdown();
@@ -0,0 +1,217 @@
1
+ // unpack/capacity.mjs —— 解包的准入判据(只按**可用内存**动态判断,不设可调阈值)
2
+ //
3
+ // 设计取舍(前一版是"包大小阈值 + 内存百分比 + 三个 env",属过度设计):
4
+ // 包大小阈值本来就是为了控内存 ⇒ 两者是同一件事。现在直接算「这个包解包后的预估峰值
5
+ // 会不会超过"可用内存的一半"」,一次判断,不拍脑袋定包大小门槛。
6
+ //
7
+ // 三条实测依据(本机测量,决定了下面的分段系数):
8
+ // · VmHWM 峰值:12MB→176MB(14.7×)、19MB→451MB(23.7×)、42MB→479MB(11.4×)
9
+ // ⇒ **非线性**(19MB 比 42MB 还费内存),所以不能用单一倍数,按档取上包络。
10
+ // · 解包耗时:12MB→2.3s、19MB→10.0s、42MB→8.6s(同样非线性,19MB 最费)
11
+ // ⇒ 预估耗时只作为参考信息返回(日志里能看"大概多久"),**不参与任何判断**:
12
+ // 曾经用它做过预热门槛,结果 15MB 以上的包永远进不了预热、服务端缓存永远填不上。
13
+ // · `os.freemem()` 在 Linux 不含可回收 buff/cache,会严重低估 ⇒ 必须优先 /proc/meminfo 的 MemAvailable。
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+
17
+ import { readRatioEnv } from '../core/env-num.mjs';
18
+
19
+ /**
20
+ * 预算比例:只用可用内存的一部分,给系统/渲染留另一半。
21
+ *
22
+ * 缺省 0.5;可用 **MODELSTAGE_ADMIT_CAPACITY_RATIO** 覆盖(服务端容量判据)。
23
+ * 非法值(NaN / ≤0 / >1)⇒ 回落 0.5 + 一条 warn(见 core/env-num.mjs),
24
+ * 绝不把 NaN 传进乘法 —— 那会让预算恒为 NaN、判据静默失效。
25
+ * 每次判断现读(进程内改 env/测试注入即生效;进程外改 env 仍要重启)。
26
+ */
27
+ const DEFAULT_MEM_BUDGET_RATIO = 0.5;
28
+ function memBudgetRatio() {
29
+ return readRatioEnv('MODELSTAGE_ADMIT_CAPACITY_RATIO', DEFAULT_MEM_BUDGET_RATIO);
30
+ }
31
+
32
+ /**
33
+ * 可用内存(MB)——**每次判断时现取,绝不缓存**。
34
+ *
35
+ * 三个分支(跨平台):
36
+ * ① Linux:读 /proc/meminfo 的 MemAvailable。这是内核估算的"真要用了能腾出多少",
37
+ * 含可回收的 buff/cache;`os.freemem()` 不含 ⇒ 本机实测会低估数倍,不能用作准入判据。
38
+ * ② macOS / Windows:`os.freemem()` 相对准确(没有 Linux 那个 buff/cache 的口径问题)。
39
+ * ③ 兜底(拿不到或为 0):总内存的一半 —— 保守方向,宁可少解包也不误放行。
40
+ * @returns {{mb: number, source: 'MemAvailable'|'freemem'|'totalmem/2'}}
41
+ */
42
+ export function memAvailableMb() {
43
+ try {
44
+ const m = /MemAvailable:\s+(\d+)\s*kB/.exec(fs.readFileSync('/proc/meminfo', 'utf8'));
45
+ if (m) return { mb: Math.round(Number(m[1]) / 1024), source: 'MemAvailable' };
46
+ } catch { /* 非 Linux / 无该文件:落到下一分支 */ }
47
+ const free = os.freemem();
48
+ if (free > 0) return { mb: Math.round(free / 1024 / 1024), source: 'freemem' };
49
+ return { mb: Math.round(os.totalmem() / 1024 / 1024 / 2), source: 'totalmem/2' };
50
+ }
51
+
52
+ /**
53
+ * 源包大小 → 预估解包峰值(MB)。**仅供第③段回落**(插件没有声明 memory 时,保证不倒退)。
54
+ * 现在**只作第③段兜底**:准不准是插件的事(它声明 peakBytes 就用它的),核心不再维护容器结构模型。
55
+ * 12MB→240(实测 176,留 36% 余量)
56
+ * 19MB→494(实测 451,留 10% 余量)
57
+ * 42MB→630(实测 479,留 32% 余量)
58
+ * @param {number} sourceMb
59
+ * @returns {number}
60
+ */
61
+ export function estimatePeakMb(sourceMb) {
62
+ if (sourceMb <= 12) return Math.ceil(sourceMb * 20);
63
+ if (sourceMb <= 19) return Math.ceil(sourceMb * 26);
64
+ return Math.ceil(sourceMb * 15);
65
+ }
66
+
67
+ /**
68
+ * 源包大小 → 预估解包耗时(秒)。同样按实测分段(非线性,19MB 那档最费)。
69
+ * 12MB→3s(实测 2.3s)/ 19MB→9.3s(实测 10.0s)/ ≥20MB→9s 起(42MB 实测 8.6s)
70
+ * @param {number} sourceMb
71
+ * @returns {number}
72
+ */
73
+ export function estimateUnpackSec(sourceMb) {
74
+ if (sourceMb <= 12) return +(sourceMb * 0.25).toFixed(1);
75
+ if (sourceMb <= 20) return +(3 + (sourceMb - 12) * 0.9).toFixed(1);
76
+ return 9;
77
+ }
78
+
79
+ /**
80
+ * 当前机器 + 某个包的准入判断(前端只读 `allowWarmup` 一个布尔,不再自己算)。
81
+ * @param {number} [sourceMb] 源包大小(MB);不传则只返回机器级信息,allowWarmup=true(不下结论)
82
+ * @returns {object}
83
+ */
84
+ export function capacity(sourceMb, memory) {
85
+ const avail = memAvailableMb();
86
+ const ratio = memBudgetRatio();
87
+ const budgetMb = Math.round(avail.mb * ratio);
88
+ const base = {
89
+ memTotalMb: Math.round(os.totalmem() / 1024 / 1024),
90
+ memAvailableMb: avail.mb,
91
+ memAvailableSource: avail.source,
92
+ budgetMb,
93
+ ratio,
94
+ rssMb: Math.round(process.memoryUsage().rss / 1048576),
95
+ };
96
+ if (!(sourceMb > 0)) {
97
+ return { ...base, sourceMb: null, peakEstMb: null, estUnpackSec: null, allowWarmup: true, reason: 'no-package', basis: null };
98
+ }
99
+ // 与准入判断(checkUnpackMemory)**同源**:都走 estimatePeakWithBasis,同一个包只该有一个数字
100
+ const est = estimatePeakWithBasis(sourceMb, memory);
101
+ const peakEstMb = est.peakEstMb;
102
+ // 预估耗时只作参考信息(日志里能看"大概多久"),**不参与任何判断**
103
+ const estUnpackSec = estimateUnpackSec(sourceMb);
104
+ if (peakEstMb > budgetMb) {
105
+ return { ...base, sourceMb, peakEstMb, estUnpackSec, allowWarmup: false, reason: 'insufficient-memory', basis: est.basis };
106
+ }
107
+ // 预热只看内存:**不再有时间门槛**。旧实现"预估 >6s 就不预热"导致 15.4MB 以上的包永远进不了预热,
108
+ // data/unpacked 永远填不上 ⇒ 前端一直回落本地解包,服务端为判断付的 probe/内存读取全都白跑。
109
+ return { ...base, sourceMb, peakEstMb, estUnpackSec, allowWarmup: true, reason: 'ok', basis: est.basis };
110
+ }
111
+
112
+ /**
113
+ * 三段回落,选出本次判据用的「预估峰值」。
114
+ *
115
+ * 顺序固定(契约 §2.2):
116
+ * ① 插件/缓存给了 peakBytes → **直接采信,不再叠加安全垫**(责任在声明方)
117
+ * ② 给了 decompressedBytes(或缓存里的实测解压量)→ 按模型算(模型自带 15% 垫)
118
+ * ③ 什么都没有(第三方/旧插件) → 回落到「源包大小分段」= 既有行为,保证不倒退
119
+ *
120
+ * 另外:**实测峰值(measuredPeakBytes)不再参与判定**,只随返回值回传供观测
121
+ * —— 解包搬进 worker 后它含 worker 常驻内存,随执行线程变化(详见下方 ② 的注释)。
122
+ * @param {number} sourceMb 源包大小(MB)
123
+ * @param {{peakBytes?:number, decompressedBytes?:number, measuredPeakBytes?:number}} [memory]
124
+ * @returns {{peakEstMb:number, basis:'plugin-peak'|'model-decompressed'|'legacy-source', declaredMb:number, measuredMb:number}}
125
+ */
126
+ export function estimatePeakWithBasis(sourceMb, memory) {
127
+ const mem = memory || {};
128
+ const toMb = (bytes) => Math.ceil(bytes / (1024 * 1024));
129
+
130
+ // ① 插件声明值(peakFromDecompressed 表,按 max-of-runs 标定)—— 主值
131
+ // 没有声明才退回"源包大小分段粗估"(核心不维护容器结构模型,那是插件的责任)
132
+ const declaredMb = mem.peakBytes > 0 ? toMb(mem.peakBytes) : estimatePeakMb(sourceMb);
133
+ const declaredBasis = mem.peakBytes > 0 ? 'plugin-peak' : 'legacy-source';
134
+
135
+ // ② 实测回填值 —— **只作下限兜底,不能压声明值**
136
+ //
137
+ // measuredPeakBytes 的成因与局限**详见 src/server/unpack/plugin-store.mjs 里
138
+ // recordMeasuredPeak 的注释**(那里讲清了为什么它不是"这一包的成本"、以及实测 225MB vs 1195MB 的证据)。
139
+ //
140
+ // ② 实测回填值 —— **不再参与准入判定**,只作为观测数据回传(probe.json / 日志 / 前端提示)
141
+ //
142
+ // 为什么从"取 max"改成"完全不采信"(实测取证):
143
+ // 解包搬进 worker 后,measured 的公式 `峰值 = 起始 RSS + 本次 VmHWM 增量` 里的**起始 RSS
144
+ // 已经含 worker 的常驻内存**(独立 V8 isolate + 预热装载的插件 lib + vm 沙盒)。
145
+ // 同一个包、同一台机器:搬 worker 前 136.7MB → 搬之后 212~225MB(+55~65%);
146
+ // 即使预热把"首次装载插件"的一次性开销移出了测量窗口,第 2 次仍偏高(212MB)。
147
+ // ⇒ 一个**随执行线程(以及"是不是该线程的第一个包")变化**的数不能当准入依据,
148
+ // 否则同样大小的包会"有时过有时不过"。准入只信 declared(插件声明值,按 max-of-runs 标定)。
149
+ const measuredMb = mem.measuredPeakBytes > 0 ? Math.ceil((mem.measuredPeakBytes / (1024 * 1024)) * 1.05) : 0;
150
+
151
+ return { peakEstMb: declaredMb, basis: declaredBasis, declaredMb, measuredMb };
152
+ }
153
+
154
+ /**
155
+ * 「已放行」时的说明文案(用户关掉了内存保护开关,请求仍然超预算)。
156
+ *
157
+ * 为什么单独抽一个函数:这条文案要在放行与拒绝两种结果里都用同一套数字,
158
+ * 而且必须是可读的——"放行了"不等于"没风险",要让用户看到预估峰值与当前可用。
159
+ * @param {number} peakEstMb
160
+ * @param {number} budgetMb
161
+ * @param {number} availMb
162
+ * @returns {string}
163
+ */
164
+ export function overLimitText(peakEstMb, budgetMb, availMb) {
165
+ return `已按「内存保护」开关放行:预估峰值 ${peakEstMb}MB 超过预算 ${budgetMb}MB`
166
+ + `(当前可用 ${availMb}MB)—— 本次解包可能引起内存紧张,若加载失败请开启内存保护或稍后重试`;
167
+ }
168
+
169
+ /**
170
+ * 解包入口的内存判据(服务端 resolve 用;**不含时间上限**——用户主动加载不能因为"慢"被拒)。
171
+ *
172
+ * 内存保护开关的落地口径:
173
+ * 默认(不传 opts)=现状,超预算就拒绝 —— 保持既有行为,现有调用方零改动;
174
+ * 前端把面板里的「内存保护」开关关掉后,请求带上 allowOverLimit:true ⇒ 这里放行,
175
+ * 但仍把真实数字一起返回(调用方要能提示"你放行了 X MB 的包")。
176
+ * @param {number} sourceBytes 源文件字节数
177
+ * @param {{allowOverLimit?: boolean}} [opts] 是否允许越过内存预算强行解包
178
+ * @returns {{ok: boolean, reason?: string, message?: string, budgetMb: number, peakEstMb: number, availMb: number, overLimit?: boolean}}
179
+ */
180
+ export function checkUnpackMemory(sourceBytes, opts = {}) {
181
+ const sourceMb = sourceBytes / 1024 / 1024;
182
+ const avail = memAvailableMb();
183
+ const ratio = memBudgetRatio();
184
+ const budgetMb = Math.round(avail.mb * ratio);
185
+ const est = estimatePeakWithBasis(sourceMb, opts.memory);
186
+ const peakEstMb = est.peakEstMb;
187
+ if (peakEstMb > budgetMb) {
188
+ // 开关关闭 ⇒ 放行(用户自担风险),但 overLimit 标记与文案都要带出去
189
+ if (opts && opts.allowOverLimit === true) {
190
+ return {
191
+ ok: true,
192
+ reason: 'over-limit-allowed',
193
+ message: overLimitText(peakEstMb, budgetMb, avail.mb),
194
+ budgetMb,
195
+ peakEstMb,
196
+ availMb: avail.mb,
197
+ overLimit: true,
198
+ basis: est.basis,
199
+ };
200
+ }
201
+ return {
202
+ ok: false,
203
+ reason: 'insufficient-memory',
204
+ // 说清"为什么被拒 + 还能怎么办":解包本来就是**串行**的(plugin-store 的 enqueue 链),
205
+ // 所以这里不是"并发太多",而是这个包自己就要这么多 —— 能做的只有等内存回来再试。
206
+ // 不提高全局上限(那等于把守护关掉),也不自动重试(会在内存紧张时形成抖动重试风暴)。
207
+ message: `预估峰值 ${peakEstMb}MB 超过可用预算 ${budgetMb}MB(当前可用 ${avail.mb}MB × ${ratio})`
208
+ + ';解包串行执行,稍后内存释放后可重试(或先关掉其它已加载模型)',
209
+ budgetMb,
210
+ peakEstMb,
211
+ availMb: avail.mb,
212
+ overLimit: true,
213
+ basis: est.basis,
214
+ };
215
+ }
216
+ return { ok: true, budgetMb, peakEstMb, availMb: avail.mb, overLimit: false, basis: est.basis };
217
+ }
@@ -0,0 +1,27 @@
1
+ // server/unpack/mem-probe.mjs —— 读进程内存(RSS 与内核记录的峰值 VmHWM)
2
+ //
3
+ // 为什么抽成模块:解包搬进 worker 后,worker 里也要在解包前后各读一次(算本次峰值增量),
4
+ // 而主线程的准入/统计路径同样要读 —— 两边必须同一份实现、同一口径。
5
+ //
6
+ // **口径不变(关键)**:worker_threads 里 `/proc/self` 指向同一个进程,
7
+ // 所以在 worker 里读与在主线程读得到的是同一组数字,VmHWM 语义完全不变 —— 不需要改测量代码。
8
+
9
+ import fs from 'node:fs';
10
+
11
+ /**
12
+ * 读当前 RSS 与进程峰值。
13
+ * @param {number} [peakFallback] 非 Linux(读不到 /proc)时的兜底峰值基线
14
+ * @returns {{rss:number, peak:number, source:'vmhwm'|'sample'}}
15
+ */
16
+ export function rssNow(peakFallback = 0) {
17
+ try {
18
+ const txt = fs.readFileSync('/proc/self/status', 'utf8');
19
+ const hwm = /VmHWM:\s+(\d+)\s+kB/.exec(txt);
20
+ const rss = /VmRSS:\s+(\d+)\s+kB/.exec(txt);
21
+ if (hwm) return { rss: rss ? Number(rss[1]) * 1024 : process.memoryUsage().rss, peak: Number(hwm[1]) * 1024, source: 'vmhwm' };
22
+ } catch { /* 非 Linux:退回抽样 */ }
23
+ const rss = process.memoryUsage().rss;
24
+ return { rss, peak: Math.max(peakFallback, rss), source: 'sample' };
25
+ }
26
+
27
+ export default rssNow;