modelstage 0.0.0-stage → 0.5.24

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 (286) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -2
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/bin/modelstage.mjs +101 -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 +58 -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 +647 -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 +334 -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/web/README.md +193 -0
  126. package/src/web/app/anim-list.js +222 -0
  127. package/src/web/app/background-solids.js +150 -0
  128. package/src/web/app/backgrounds.js +152 -0
  129. package/src/web/app/camera-log.js +28 -0
  130. package/src/web/app/context.js +72 -0
  131. package/src/web/app/dir-picker.js +242 -0
  132. package/src/web/app/entry-report.js +71 -0
  133. package/src/web/app/memory-bars.js +91 -0
  134. package/src/web/app/memory-budget.js +228 -0
  135. package/src/web/app/memory-guard.js +204 -0
  136. package/src/web/app/model-actions.js +682 -0
  137. package/src/web/app/renderer-setting.js +70 -0
  138. package/src/web/app/root-manager.js +254 -0
  139. package/src/web/app/status.js +138 -0
  140. package/src/web/app/topbar-actions.js +357 -0
  141. package/src/web/app/transport-actions.js +297 -0
  142. package/src/web/app/ui-state.js +559 -0
  143. package/src/web/app/url-params.js +171 -0
  144. package/src/web/app/view-settings.js +186 -0
  145. package/src/web/components.css +465 -0
  146. package/src/web/debug/diag-hud.js +95 -0
  147. package/src/web/debug/drag.js +369 -0
  148. package/src/web/debug/log.js +320 -0
  149. package/src/web/debug/model-cmp.js +292 -0
  150. package/src/web/debug/panel.css +474 -0
  151. package/src/web/debug/panel.js +712 -0
  152. package/src/web/debug/race-probe.js +82 -0
  153. package/src/web/debug/upload.js +130 -0
  154. package/src/web/engine/adapters/atlas.js +47 -0
  155. package/src/web/engine/adapters/index.js +22 -0
  156. package/src/web/engine/adapters/renderer.js +223 -0
  157. package/src/web/engine/adapters/skeleton.js +105 -0
  158. package/src/web/engine/adapters/texture.js +64 -0
  159. package/src/web/engine/animation.js +181 -0
  160. package/src/web/engine/appearance.js +136 -0
  161. package/src/web/engine/camera-adapter.js +212 -0
  162. package/src/web/engine/effect-chain.js +138 -0
  163. package/src/web/engine/engine-state.js +120 -0
  164. package/src/web/engine/filters.js +224 -0
  165. package/src/web/engine/fit.js +197 -0
  166. package/src/web/engine/frame-gate.js +33 -0
  167. package/src/web/engine/gl-driver.js +56 -0
  168. package/src/web/engine/gl.js +77 -0
  169. package/src/web/engine/index.js +20 -0
  170. package/src/web/engine/loader.js +55 -0
  171. package/src/web/engine/model-info.js +26 -0
  172. package/src/web/engine/pipeline.js +200 -0
  173. package/src/web/engine/player.js +427 -0
  174. package/src/web/engine/pma/blend.js +76 -0
  175. package/src/web/engine/pma/detect.js +118 -0
  176. package/src/web/engine/pma/index.js +8 -0
  177. package/src/web/engine/pma/resolve.js +112 -0
  178. package/src/web/engine/pma/upload.js +127 -0
  179. package/src/web/engine/pma.js +5 -0
  180. package/src/web/engine/render-frame.js +93 -0
  181. package/src/web/engine/runtime-capabilities.js +209 -0
  182. package/src/web/engine/runtime.js +37 -0
  183. package/src/web/engine/skins.js +165 -0
  184. package/src/web/engine/textures.js +174 -0
  185. package/src/web/index.html +115 -0
  186. package/src/web/infra/api.js +531 -0
  187. package/src/web/infra/diag.js +509 -0
  188. package/src/web/infra/document.js +269 -0
  189. package/src/web/infra/dom.js +31 -0
  190. package/src/web/infra/events.mjs +27 -0
  191. package/src/web/infra/log.js +153 -0
  192. package/src/web/infra/mem-stats.js +112 -0
  193. package/src/web/infra/plugin-host-registration.mjs +285 -0
  194. package/src/web/infra/plugin-host-report.mjs +32 -0
  195. package/src/web/infra/plugin-host-unload.mjs +79 -0
  196. package/src/web/infra/plugin-host-unpack.mjs +899 -0
  197. package/src/web/infra/plugin-host-warmup.mjs +180 -0
  198. package/src/web/infra/plugin-host.js +628 -0
  199. package/src/web/infra/ready.js +461 -0
  200. package/src/web/infra/schedule.js +51 -0
  201. package/src/web/infra/shortcuts.js +129 -0
  202. package/src/web/infra/slot-registry.js +402 -0
  203. package/src/web/infra/vfs.js +247 -0
  204. package/src/web/live2d/bridge.js +400 -0
  205. package/src/web/live2d/model.js +321 -0
  206. package/src/web/live2d/overlay.js +142 -0
  207. package/src/web/live2d/pixi-app.js +111 -0
  208. package/src/web/live2d/playback-probe.js +92 -0
  209. package/src/web/live2d/probe-geometry.js +329 -0
  210. package/src/web/live2d/probe.js +119 -0
  211. package/src/web/live2d/release.js +66 -0
  212. package/src/web/live2d/runtime-adapter.js +289 -0
  213. package/src/web/live2d/stage-ctx.js +152 -0
  214. package/src/web/live2d/stage.js +204 -0
  215. package/src/web/live2d/vfs-settings.js +174 -0
  216. package/src/web/live2d/view.js +89 -0
  217. package/src/web/main.js +629 -0
  218. package/src/web/panel/background-list.js +373 -0
  219. package/src/web/panel/bg-source.js +291 -0
  220. package/src/web/panel/color-picker.css +199 -0
  221. package/src/web/panel/color-picker.js +278 -0
  222. package/src/web/panel/dialog.css +116 -0
  223. package/src/web/panel/dialog.js +173 -0
  224. package/src/web/panel/dom.js +35 -0
  225. package/src/web/panel/icons.js +58 -0
  226. package/src/web/panel/memory-bar.js +195 -0
  227. package/src/web/panel/panel-settings.js +1263 -0
  228. package/src/web/panel/panel.css +1304 -0
  229. package/src/web/panel/render-choice.js +104 -0
  230. package/src/web/panel/schema.js +784 -0
  231. package/src/web/panel/settings-io.js +291 -0
  232. package/src/web/panel/shell-controls.js +274 -0
  233. package/src/web/panel/slider.js +210 -0
  234. package/src/web/panel/toolbar-schema.js +111 -0
  235. package/src/web/panel/toolbar.js +195 -0
  236. package/src/web/panel/transport-picker.js +375 -0
  237. package/src/web/panel/transport.js +321 -0
  238. package/src/web/panel/view.js +553 -0
  239. package/src/web/plugin/unpack-worker.js +185 -0
  240. package/src/web/render/camera/constants.js +60 -0
  241. package/src/web/render/camera/index.js +123 -0
  242. package/src/web/render/camera/spine.js +124 -0
  243. package/src/web/render/capture.js +30 -0
  244. package/src/web/render/interaction.js +527 -0
  245. package/src/web/render/live2d.js +627 -0
  246. package/src/web/render/playback.js +47 -0
  247. package/src/web/render/reset-view.js +29 -0
  248. package/src/web/render/spine.js +386 -0
  249. package/src/web/render/surface-switch.js +176 -0
  250. package/src/web/render/surface.js +571 -0
  251. package/src/web/render/surfaces.js +70 -0
  252. package/src/web/roster/filter.js +31 -0
  253. package/src/web/roster/list.js +276 -0
  254. package/src/web/roster/persist.js +52 -0
  255. package/src/web/roster/reveal.js +77 -0
  256. package/src/web/roster/rows.js +248 -0
  257. package/src/web/roster/snapshot.js +37 -0
  258. package/src/web/roster/view.js +670 -0
  259. package/src/web/spine/canvas-api-groups.js +267 -0
  260. package/src/web/spine/canvas-api.js +97 -0
  261. package/src/web/spine/canvas-document.js +80 -0
  262. package/src/web/spine/canvas-engine.js +193 -0
  263. package/src/web/spine/canvas-entry.js +28 -0
  264. package/src/web/spine/canvas-pixels.js +36 -0
  265. package/src/web/spine/canvas-runtime.js +127 -0
  266. package/src/web/spine/canvas-view.js +108 -0
  267. package/src/web/spine/canvas.js +7 -0
  268. package/src/web/spine/runtime-hint.js +73 -0
  269. package/src/web/spine/runtimes.js +212 -0
  270. package/src/web/style.css +493 -0
  271. package/tool/lib/bundle-probe-node.mjs +121 -0
  272. package/tool/lib/check-perms.mjs +114 -0
  273. package/tool/lib/contract-checks.mjs +582 -0
  274. package/tool/lib/cubism-core.mjs +122 -0
  275. package/tool/lib/export-bundle.mjs +328 -0
  276. package/tool/lib/paths.mjs +20 -0
  277. package/tool/lib/runtime-guides.mjs +65 -0
  278. package/tool/lib/runtime-package.mjs +403 -0
  279. package/tool/lib/spine-meta.mjs +227 -0
  280. package/tool/lib/spine-motions.mjs +104 -0
  281. package/tool/lib/spine-runtime.mjs +131 -0
  282. package/tool/lib/test-lock.mjs +178 -0
  283. package/tool/lib/test-report.mjs +133 -0
  284. package/tool/parse-jsonc.mjs +86 -0
  285. package/tool/resolve-live2d-runtimes.mjs +276 -0
  286. package/tool/spine-motion-rules.json +101 -0
@@ -0,0 +1,141 @@
1
+ // src/server/unpack/runtime/index.mjs —— 运行时适配器的选择器 + 安全门禁
2
+ //
3
+ // 为什么要有这一层:插件"用什么运行时"从一个硬编码事实(vm 沙盒)变成了清单里的一个字段
4
+ // (plugin.json 的 runtime)。上层(plugin-runner)只认这里的统一接口,不认识具体运行时。
5
+ //
6
+ // 统一接口(两种 runtime 必须同形,上层无感):
7
+ // load(meta, ctx) → { ok: true, api } | { ok: false, error, code? }
8
+ // dispose() → void(进程内沙盒是空操作;exec 通道将来要杀子进程)
9
+ // 其中 api 需提供 canHandle(file) / resolve(file),可选 explainReject(file) / estimateMemory。
10
+ //
11
+ // **安全门禁(本文件的核心职责)**:
12
+ // runtime:"node" 是"进程内沙盒",行为与历史一致;runtime:"exec" 是"在你机器上跑一个陌生可执行文件"。
13
+ // 两者安全模型完全不同,所以 exec **默认关闭**:必须用户在 config 里显式开启才允许装载。
14
+ // 这不是"防君子"的软提示 —— 未开启时装载器会直接拒绝并给出可读原因(不是静默降级)。
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import nodeVm from './node-vm.mjs';
18
+ import execRuntime from './exec.mjs';
19
+ // 配置允许注释:用项目统一的 JSONC 解析器(与 config/plugins.json、resources.json 同一条路径)
20
+ import { parseJsonc } from '../../../../tool/parse-jsonc.mjs';
21
+
22
+ /** 缺省运行时:插件不写 runtime 时按历史行为走 vm 沙盒 */
23
+ export const DEFAULT_RUNTIME = 'node';
24
+
25
+ /** 已注册的适配器表(加新运行时只需在这里登记) */
26
+ const ADAPTERS = new Map([
27
+ [nodeVm.runtime, nodeVm],
28
+ [execRuntime.runtime, execRuntime],
29
+ ]);
30
+
31
+ /** 允许出现在清单里的 runtime 名(用于校验,不用于实例化) */
32
+ export const KNOWN_RUNTIMES = [...ADAPTERS.keys()];
33
+
34
+ /**
35
+ * 危险运行时的开关配置文件:与 config/plugins.json 同级,但**独立成文件**。
36
+ * 为什么独立:这个开关决定「是否允许插件在本机执行任意可执行文件」,
37
+ * 让它在部署时是一个显眼、可单独审计与回收的文件,而不是混在大配置里的一个布尔字段。
38
+ * 内容形如:{"allowExecRuntime": true}
39
+ */
40
+ export const EXEC_PERMISSION_FILE = 'config/plugin-runtime.json';
41
+
42
+ /**
43
+ * 读"是否允许 exec 运行时"。
44
+ *
45
+ * 判据顺序:
46
+ * ① 环境变量 `MODELSTAGE_ALLOW_EXEC_PLUGINS=1`(部署期覆盖,便于容器化)
47
+ * ② 配置文件 `<数据根>/config/plugin-runtime.json` 的 allowExecRuntime === true
48
+ * ③ 都没有 ⇒ **false(默认关闭)**
49
+ * 任何读取/解析失败都按"未开启"处理(失败要保守,不能因为配置坏了就把门打开)。
50
+ *
51
+ * **数据根优先**(与 core/paths.mjs 的 configDir 同口径):设了 MODELSTAGE_DATA_ROOT 的实例
52
+ * (测试实例)读自己的配置,不设的(主实例)读项目根的配置。
53
+ * 早前只按项目根读 ⇒ 测试实例无法单独开关这个危险通道,只能改项目根,
54
+ * 而项目根那份主实例也在用 —— 等于"一开就两个实例全开"。
55
+ * @param {string} root 项目根
56
+ * @returns {{allowed: boolean, source: string, error?: string, file?: string}}
57
+ */
58
+ export function readExecPermission(root) {
59
+ const fromEnv = process.env.MODELSTAGE_ALLOW_EXEC_PLUGINS;
60
+ if (fromEnv === '1' || String(fromEnv).toLowerCase() === 'true') {
61
+ return { allowed: true, source: 'env:MODELSTAGE_ALLOW_EXEC_PLUGINS' };
62
+ }
63
+ const dataRoot = (process.env.MODELSTAGE_DATA_ROOT || '').trim();
64
+ const base = dataRoot ? path.resolve(dataRoot) : String(root || '');
65
+ const file = path.join(base, EXEC_PERMISSION_FILE);
66
+ let text;
67
+ try {
68
+ text = fs.readFileSync(file, 'utf8');
69
+ } catch (e) {
70
+ // 文件不存在是最常见的正常情况(默认关闭),不算错误
71
+ if (e && e.code === 'ENOENT') return { allowed: false, source: 'default', file };
72
+ return { allowed: false, source: 'default', error: `读取 ${EXEC_PERMISSION_FILE} 失败:${e.message}`, file };
73
+ }
74
+ try {
75
+ // 允许注释(与其他配置一致);用项目统一的 JSONC 解析器,不另写一套
76
+ const cfg = parseJsonc(text, EXEC_PERMISSION_FILE);
77
+ if (cfg && cfg.allowExecRuntime === true) return { allowed: true, source: EXEC_PERMISSION_FILE, file };
78
+ return { allowed: false, source: EXEC_PERMISSION_FILE, file };
79
+ } catch (e) {
80
+ // 配置坏了按"未开启":宁可插件用不了,也不要因为一个笔误把危险通道打开
81
+ return { allowed: false, source: EXEC_PERMISSION_FILE, error: `${EXEC_PERMISSION_FILE} 解析失败:${e.message}`, file };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 取某个插件该用的运行时名(缺省 node;非法值直接报错,不猜)。
87
+ * @param {object} meta 插件清单
88
+ * @returns {{ok: true, runtime: string} | {ok: false, error: string}}
89
+ */
90
+ export function resolveRuntimeName(meta) {
91
+ const raw = meta && meta.runtime !== undefined && meta.runtime !== null ? String(meta.runtime) : '';
92
+ if (!raw) return { ok: true, runtime: DEFAULT_RUNTIME };
93
+ if (!ADAPTERS.has(raw)) {
94
+ return { ok: false, error: `未知 runtime:"${raw}"(可用:${KNOWN_RUNTIMES.join(' / ')})` };
95
+ }
96
+ return { ok: true, runtime: raw };
97
+ }
98
+
99
+ /**
100
+ * 按清单选适配器并装载插件(**唯一入口**,plugin-runner 只调这个)。
101
+ *
102
+ * 门禁顺序(先拦后装,任何一步不过都不进适配器):
103
+ * ① runtime 名合法?
104
+ * ② exec ⇒ 用户是否显式开启?(未开启直接拒绝,给出怎么开)
105
+ * ③ 交给对应适配器装载
106
+ * @param {object} meta 插件清单(含 id / files / runtime? / entry? / frameProtocol?)
107
+ * @param {{pluginsDir: string, root?: string, execAllowed?: {allowed:boolean, source:string}}} ctx
108
+ * @returns {{ok: boolean, api?: object, error?: string, code?: string, runtime: string}}
109
+ */
110
+ export function loadWithRuntime(meta, ctx) {
111
+ const rn = resolveRuntimeName(meta);
112
+ if (!rn.ok) return { ok: false, runtime: DEFAULT_RUNTIME, error: rn.error, code: 'UNKNOWN_RUNTIME' };
113
+ const runtime = rn.runtime;
114
+
115
+ if (runtime === 'exec') {
116
+ // ctx.execAllowed 允许调用方(测试/上层)注入判定结果,避免这里自己再读一遍盘;
117
+ // 没注入时按默认门禁读配置
118
+ const gate = ctx && ctx.execAllowed ? ctx.execAllowed : readExecPermission(ctx && ctx.root);
119
+ if (!gate.allowed) {
120
+ return {
121
+ ok: false,
122
+ runtime,
123
+ code: 'EXEC_RUNTIME_DISABLED',
124
+ error: `插件「${meta && meta.id}」声明了 runtime:"exec",但宿主**未开启**该通道,已拒绝装载。`
125
+ + `开启方式(二选一):① 在 ${EXEC_PERMISSION_FILE} 写 {"allowExecRuntime": true};`
126
+ + '② 设环境变量 MODELSTAGE_ALLOW_EXEC_PLUGINS=1。'
127
+ + '注意:exec 通道意味着该插件可以执行任意本地命令(读写你的文件、发起网络请求),'
128
+ + '只应对你信任的插件开启。',
129
+ };
130
+ }
131
+ }
132
+
133
+ const adapter = ADAPTERS.get(runtime);
134
+ const r = adapter.load(meta, ctx);
135
+ return { ...r, runtime };
136
+ }
137
+
138
+ /** 列出已注册运行时(诊断/自检用) */
139
+ export function listRuntimes() {
140
+ return KNOWN_RUNTIMES.map((name) => ({ runtime: name }));
141
+ }
@@ -0,0 +1,111 @@
1
+ // src/server/unpack/runtime/node-vm.mjs —— 「node」运行时适配器(原 plugin-runner 的 vm 沙盒)
2
+ //
3
+ // 职责:把一个纯 JS 的 classic-script 插件装进 vm 沙盒并跑起来,交出统一的 provider 接口。
4
+ // 这段逻辑原本直接写在 plugin-runner.mjs 的 loadPlugin() 里;抽出来的唯一目的是让
5
+ // 「插件用什么运行时」变成一个可选项(将来接 exec 通道),**行为一个字节都不变**。
6
+ //
7
+ // 三条硬约束(从原注释原样保留,都是踩过的坑):
8
+ // 1. **跨 realm 禁止 instanceof**:vm 里建出来的 Map/Blob 与宿主各有一套内置构造器,
9
+ // `instanceof Map` 恒 false、`Object.keys(map)` 恒空(Map 没有自有可枚举键)。
10
+ // 2. **装载只做一次**:沙盒 0.9ms + lib 41ms + index.js 6.4ms(实测 58ms);resolve 本体是秒级,
11
+ // 真正重要的是别每次请求都重建沙盒(会重复 58ms + 丢掉插件内部的 profile 缓存)。
12
+ // 3. **产物不复制**:见 plugin-runner 的 unpackBytes 注释。
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import vm from 'node:vm';
16
+ import zlib from 'node:zlib';
17
+ import crypto from 'node:crypto';
18
+
19
+ /** 本适配器能处理的 runtime 名(plugin.json 的 runtime 字段) */
20
+ export const RUNTIME = 'node';
21
+
22
+ /**
23
+ * 在 vm 沙盒里装载一个纯 JS 插件。
24
+ *
25
+ * 错误隔离:**绝不抛给上层** —— 一个插件语法错误/装不起来,只让它自己返回 {ok:false,error},
26
+ * 其它插件与服务启动都不受影响(调用方按 ok 决定跳过)。
27
+ *
28
+ * @param {{files: string[], entry?: string|null}} meta 插件清单(files 是相对 plugins 目录的 POSIX 路径)
29
+ * @param {{pluginsDir: string}} ctx 主进程侧的路径上下文
30
+ * @returns {{ok: boolean, api?: object, error?: string}}
31
+ */
32
+ export function loadPluginModule(meta, ctx) {
33
+ try {
34
+ const pluginsDir = ctx && ctx.pluginsDir;
35
+ if (!pluginsDir) throw new Error('node-vm 适配器缺少 pluginsDir');
36
+ const files = Array.isArray(meta && meta.files) ? meta.files : [];
37
+ if (!files.length) throw new Error('插件清单里没有 files[](node 运行时需要按序加载的脚本清单)');
38
+
39
+ const sandbox = {
40
+ globalThis: {},
41
+ console,
42
+ TextEncoder,
43
+ TextDecoder,
44
+ Blob,
45
+ // node 侧不该建 blob URL:插件里有能力判断,真调到就是回归
46
+ URL: { createObjectURL: () => { throw new Error('node 侧不应调用 createObjectURL'); } },
47
+ AbortController,
48
+ // 插件会用 fetch 拉 profile;拿不到就走内置默认规则(与 17a 同口径)
49
+ fetch: () => Promise.resolve({ ok: false, status: 404, json: async () => ({}) }),
50
+ setTimeout, clearTimeout, setInterval, clearInterval,
51
+ };
52
+ sandbox.globalThis = sandbox;
53
+ vm.createContext(sandbox);
54
+ // 加载顺序完全取清单 files[]:lib 的先后 + 入口文件都在 plugin.json 里声明,
55
+ // runner 不再维护一串写死的 lib 名(加插件/调顺序都不该改代码)
56
+ for (const rel of files) {
57
+ vm.runInContext(fs.readFileSync(path.join(pluginsDir, rel), 'utf8'), sandbox, { filename: rel });
58
+ }
59
+ const NS = sandbox.__ULB__;
60
+ // 清单里有 lib/ 的插件必须把能力挂到 __ULB__(manifest 与代码不一致时要响亮报错);
61
+ // 没有 lib/ 的插件不建这个命名空间,跳过即可 —— 通用化后不能强制要求它存在
62
+ const hasLibDir = files.some((f) => f.includes('/lib/'));
63
+ if (hasLibDir && (!NS || !NS.bytes)) throw new Error('lib/bytes.js 未能挂载 __ULB__');
64
+ // 以下几块能力在浏览器由 DecompressionStream / 原生 API 提供,node 侧注入等价实现。
65
+ // 均按"插件是否建了该命名空间/该钩子"判空注入:别的插件不一定有 bytes/png。
66
+ if (NS && NS.bytes && typeof NS.bytes.setSha256 === 'function') {
67
+ NS.bytes.setSha256((u8) => crypto.createHash('sha256').update(u8).digest('hex'));
68
+ }
69
+ if (NS && NS.png) {
70
+ NS.png.setInflate((u8) => new Uint8Array(zlib.inflateSync(Buffer.from(u8))));
71
+ NS.png.setDeflate((u8) => new Uint8Array(zlib.deflateSync(Buffer.from(u8), { level: 6 })));
72
+ // 流式版:编码器按 64 行一块喂进来,这里不攒整张、不转 Buffer。
73
+ // 输出与一次性 deflateSync 逐字节相同(deflate 的切分不影响结果,只要不 flush)。
74
+ // 返回**片段数组**(不 concat):编码器会把片段直接 set 进一次预分配的 PNG 缓冲,
75
+ // 多一次 concat 就是多一份 18MB。
76
+ NS.png.setDeflateStream(async (chunks) => {
77
+ const d = zlib.createDeflate({ level: 6 });
78
+ const out = [];
79
+ d.on('data', (c) => out.push(c));
80
+ const done = new Promise((res, rej) => { d.on('end', res); d.on('error', rej); });
81
+ for (const c of chunks) d.write(c);
82
+ d.end();
83
+ await done;
84
+ return out;
85
+ });
86
+ }
87
+ // 统一契约 __PLUGIN_API__;__ULB_NODE__ 是首版键名,保留兼容(老插件继续能装)
88
+ const api = sandbox.__PLUGIN_API__ || sandbox.__ULB_NODE__;
89
+ if (!api || typeof api.resolve !== 'function' || typeof api.canHandle !== 'function') {
90
+ throw new Error('插件未挂载 __PLUGIN_API__(或兼容键 __ULB_NODE__)的 resolve/canHandle');
91
+ }
92
+ return { ok: true, api };
93
+ } catch (e) {
94
+ // 只记原因、不抛:一个插件坏掉不能带走别的插件,也不能带走服务启动
95
+ return { ok: false, error: String((e && e.message) || e) };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * node 运行时的适配器对象(统一接口,见 runtime/index.mjs)。
101
+ * node 运行时的插件是"进程内同步调用",没有子进程要管,所以 dispose 是空操作。
102
+ */
103
+ export default {
104
+ runtime: RUNTIME,
105
+ /** @param {object} meta @param {object} ctx */
106
+ load(meta, ctx) {
107
+ return loadPluginModule(meta, ctx);
108
+ },
109
+ /** 进程内沙盒,没有外部资源可释放(保留接口是为了两种 runtime 同形) */
110
+ dispose() { /* no-op */ },
111
+ };
@@ -0,0 +1,105 @@
1
+ // server/unpack/unpack-worker.mjs —— 解包 worker(常驻、单实例)
2
+ //
3
+ // 搬进来的**整段**:读包 → vm 沙盒装载插件(复用 runtime/index.mjs 的 loadWithRuntime,
4
+ // 由 plugin-runner 内部调用,这里不另写一份)→ 插件 resolve → 原子落盘 → 写 probe。
5
+ //
6
+ // 为什么落盘也搬进来:落盘是"23~39 个小文件写 SMB",实测同样是阻塞源;只搬解压等于把一半
7
+ // 的卡顿留在主线程。落盘后只回传**清单**(相对路径 + 字节数 + 指纹),不回传 files Buffer
8
+ // —— 那可能是几十 MB,postMessage 复制一份毫无必要。
9
+ //
10
+ // 内存口径:worker_threads 里 /proc/self 指向同一进程 ⇒ rssNow/recordMeasuredPeak 的口径
11
+ // 与搬之前完全一致,测量代码一个字都没改(只是换了执行线程)。
12
+
13
+ import { parentPort, workerData } from 'node:worker_threads';
14
+
15
+ import { unpackFile, listProviderPlugins, ensurePlugin } from './plugin-runner.mjs';
16
+ import { writeResult } from './write-result.mjs';
17
+ import { createProbeStore } from './probe-store.mjs';
18
+ import { rssNow } from './mem-probe.mjs';
19
+
20
+ const rootDir = workerData && workerData.rootDir;
21
+ if (!rootDir) throw new Error('unpack-worker: workerData.rootDir 必填');
22
+
23
+ const probeStore = createProbeStore({ rootDir });
24
+ // 非 Linux 兜底峰值(主线程那边是 counters.peakRss,worker 里各自维护一份;VmHWM 可用时用不到)
25
+ let localPeak = 0;
26
+ function sampleRss() {
27
+ const rss = process.memoryUsage().rss;
28
+ if (rss > localPeak) localPeak = rss;
29
+ return rss;
30
+ }
31
+
32
+ // ── 预热:把 provider 插件在**收到任何请求之前**装好 ─────────────────────────────
33
+ // 为什么必须预热:插件 lib(16 个文件 + LZMA 解码器)的装载分配发生在本 worker 首次
34
+ // ensurePlugin 时(由 unpackFile 内部触发)。不预热,这份**一次性开销**会被算进第一个包的
35
+ // measuredPeakBytes —— 实测把锚点包 z46_2 从 136.7MB 顶到 232.6MB(+70%),而该值又被准入
36
+ // 当"下限兜底"用 ⇒ 同样大小的包"有时过有时不过",判据随"是不是第一个包"抖动。
37
+ // worker 常驻、插件本来就要装,预热顺便让第一次解包更快。
38
+ // 失败不阻断:ensurePlugin 本身绝不抛(失败进 {ok:false,error}),这里只记 warn,
39
+ // 后续 unpackFile 自己会再试 —— 预热的目的只是"移出测量窗口",不是"保证装成功"。
40
+ try {
41
+ for (const meta of listProviderPlugins()) {
42
+ const r = ensurePlugin(meta.id);
43
+ if (r && r.ok) continue;
44
+ console.warn(`[unpack-worker] 预热插件失败(不影响后续解包,届时会重试):${meta.id} ${(r && r.error) || ''}`);
45
+ }
46
+ } catch (e) {
47
+ console.warn(`[unpack-worker] 预热插件异常(不影响后续解包):${(e && e.message) || e}`);
48
+ }
49
+
50
+ parentPort.on('message', async (msg) => {
51
+ const { id, rootId, rel, absPath, fp, meta, decl } = msg || {};
52
+ try {
53
+ const before = rssNow(localPeak);
54
+ // 进度是**跨线程**的:主线程没法把回调函数传进来(structured clone 不支持函数),
55
+ // 所以这里把插件上报的进度原样 postMessage 出去,由 worker-client 转成回调。
56
+ // 发送失败(通道已关)只丢这一帧,绝不影响解包本身。
57
+ const res = await unpackFile({
58
+ rootId, rel, absPath,
59
+ onProgress: (progress) => {
60
+ try {
61
+ parentPort.postMessage({ id, progress });
62
+ } catch { /* 主线程已断开:进度丢掉即可 */ }
63
+ },
64
+ });
65
+ if (!res.ok) {
66
+ parentPort.postMessage({ id, ok: false, reason: res.reason, message: res.message });
67
+ return;
68
+ }
69
+ const tWrite0 = Date.now();
70
+ // pluginId 必须记「**真正解出产物的那个**插件」:store 传进来的 meta.pluginId 来自 runnerInfo(),
71
+ // 它恒为**列表第一个**注册插件(多插件环境下常与真正命中的不是同一个)⇒ 与 prefix 自相矛盾
72
+ // (prefix 是 picked.id 算出来的,而 index.json 却记了别人,prefix 校验因此 25/25 报不一致)。
73
+ // 这里用 unpackFile 回传的 picked.id 覆盖,不再多写一次盘(方案 B 才需要回写)。
74
+ const { index } = await writeResult({
75
+ rootDir,
76
+ fp,
77
+ res,
78
+ meta: { ...meta, pluginId: res.pluginId || meta.pluginId },
79
+ probeStore,
80
+ sampleRss,
81
+ });
82
+ const writeMs = Date.now() - tWrite0;
83
+ const after = rssNow(localPeak);
84
+ // **顺序不变**:recordMeasuredPeak 必须在 writeResult 之后 ——
85
+ // 它会在 <fp>/ 下 mkdir + 写 probe.json;若在 rename 之前写,目标目录非空 ⇒ rename 报 ENOTEMPTY。
86
+ const tProbe0 = Date.now();
87
+ probeStore.recordMeasuredPeak(fp, before, after, decl);
88
+ const probeMs = Date.now() - tProbe0;
89
+ parentPort.postMessage({
90
+ id,
91
+ ok: true,
92
+ index,
93
+ prefix: res.prefix,
94
+ entry: res.entry,
95
+ unpackMs: res.unpackMs,
96
+ readMs: res.readMs,
97
+ writeMs,
98
+ probeMs,
99
+ measured: { before, after },
100
+ });
101
+ } catch (e) {
102
+ // 落盘/装载抛错:如实回传,主线程据此走原有的失败分支(计数、日志、返回 HTTP)
103
+ parentPort.postMessage({ id, ok: false, reason: 'worker-error', message: String((e && e.message) || e) });
104
+ }
105
+ });
@@ -0,0 +1,184 @@
1
+ // server/unpack/worker-client.mjs —— 主线程侧的 worker 句柄(常驻 1 个、串行、可自愈)
2
+ //
3
+ // 三条职责,都很窄:
4
+ // ① 常驻复用:Node 起 worker 有几十 ms 开销,而解包本身秒级;每次新建还会让 vm 沙盒/插件
5
+ // lib 重新装载一遍。这里只在"没句柄"时建,进程内一直复用。
6
+ // ② 串行:内部一条 promise 链 —— worker 是单线程,同时 post 两条只会排队;显式串行让
7
+ // "第二个请求在等第一个"这件事在代码里可读,也与上层 store 的 enqueue 语义一致(不并行解包,
8
+ // 并行会把进程内存峰值顶穿)。
9
+ // ③ 自愈:卡死(硬超时)或崩溃(error/exit)都要把本次请求变成**可读错误**、把句柄置空,
10
+ // 下一次 run 自动重建 —— 绝不能让一个死掉的 worker 把队列永久堵住。
11
+ //
12
+ // 硬超时语义(写死在注释里,避免被误读成"包太大被拒"):
13
+ // 120s 只用于**防死循环把队列永久堵住**,不是"约束合法解包"。
14
+ // 锚点包(azurlane_bundle/live2d/z46_2,解压 33.4MB 的 LZMA)实测墙钟 17.83s、unpackMs 9416ms,
15
+ // 120s 有 6.7 倍余量 —— 正常解包一辈子不该触发它。
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { Worker } from 'node:worker_threads';
20
+
21
+ const WORKER_URL = new URL('./unpack-worker.mjs', import.meta.url);
22
+
23
+ /**
24
+ * @param {{
25
+ * rootDir: string,
26
+ * timeoutMs?: number,
27
+ * log?: (level: string, message: string, extra?: object) => void,
28
+ * }} opts
29
+ */
30
+ export function createUnpackWorkerClient(opts) {
31
+ const rootDir = opts && opts.rootDir;
32
+ if (!rootDir) throw new Error('createUnpackWorkerClient: rootDir 必填');
33
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 120000;
34
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
35
+
36
+ /** @type {Worker|null} */
37
+ let worker = null;
38
+ let seq = 0;
39
+ /** @type {{id:number, resolve:Function, reject:Function, timer:any}|null} */
40
+ let current = null;
41
+ /** 内部串行链:保证同一时刻只有一条任务在 worker 里 */
42
+ let chain = Promise.resolve();
43
+
44
+ /** 只清理该 fp 自己的临时目录(writeResult 的命名规则),不扫整个 rootDir */
45
+ function cleanupTmp(fp) {
46
+ try {
47
+ const prefix = `.tmp-${fp}-`;
48
+ for (const name of fs.readdirSync(rootDir)) {
49
+ if (!name.startsWith(prefix)) continue;
50
+ const full = path.join(rootDir, name);
51
+ try {
52
+ fs.rmSync(full, { recursive: true, force: true });
53
+ log('warn', `清理解包临时目录:${full}(上次 worker 被终止,产物未完成)`);
54
+ } catch (e) {
55
+ // 清理是尽力而为:失败只记日志,不许影响本次请求的错误返回
56
+ log('warn', `清理临时目录失败:${full}(${(e && e.message) || e})`);
57
+ }
58
+ }
59
+ } catch (e) {
60
+ log('warn', `扫描临时目录失败:${(e && e.message) || e}`);
61
+ }
62
+ }
63
+
64
+ function onMessage(msg) {
65
+ const cur = current;
66
+ if (!cur || !msg || msg.id !== cur.id) return; // 迟到/陌生消息:忽略(重建后可能收到旧 id)
67
+ // 进度消息:**不终结请求** —— worker 后面还会发真正的回复(契约 §四·⑦)。
68
+ // 所以这里 early return,绝不能落到下面的 clearTimeout + resolve。
69
+ if (msg.progress) {
70
+ if (typeof cur.onProgress === 'function') {
71
+ try {
72
+ cur.onProgress(msg.progress);
73
+ } catch (e) {
74
+ // 进度是旁路信息:回调抛错不能影响本次解包
75
+ log('warn', `进度回调抛错(已忽略):${(e && e.message) || e}`);
76
+ }
77
+ }
78
+ return;
79
+ }
80
+ clearTimeout(cur.timer);
81
+ current = null;
82
+ cur.resolve(msg);
83
+ }
84
+
85
+ /** 该 worker 上的所有等待都失败(崩溃/退出路径) */
86
+ function failCurrent(reason, detail) {
87
+ const cur = current;
88
+ if (!cur) return;
89
+ clearTimeout(cur.timer);
90
+ current = null;
91
+ cur.reject(new Error(`${reason}:${detail}`));
92
+ }
93
+
94
+ function disposeWorker(reason) {
95
+ const w = worker;
96
+ worker = null;
97
+ if (!w) return Promise.resolve();
98
+ // 先把句柄置空再 terminate:exit 回调据此判断"是我们主动丢掉的"(见 ensureWorker 的 exit)
99
+ return w.terminate().catch(() => undefined);
100
+ }
101
+
102
+ function ensureWorker() {
103
+ if (worker) return worker;
104
+ const w = new Worker(WORKER_URL, { workerData: { rootDir } });
105
+ w.on('message', onMessage);
106
+ w.on('error', (e) => {
107
+ // worker 自己抛未捕获异常 ⇒ 该次请求 reject、句柄置空、下次重建
108
+ failCurrent('unpack worker crashed', (e && e.message) || e);
109
+ worker = null;
110
+ });
111
+ w.on('exit', (code) => {
112
+ // 只有"它仍是当前句柄"才算意外退出:超时/close 路径会先把 worker 置空再 terminate,
113
+ // 所以这里判断句柄归属比维护一个全局 terminating 标志更硬 —— 不依赖 exit 与 finally 的先后。
114
+ const wasCurrent = worker === w;
115
+ if (wasCurrent) worker = null;
116
+ if (!wasCurrent) return;
117
+ if (code !== 0) {
118
+ failCurrent('unpack worker exited unexpectedly', `exit code ${code}`);
119
+ log('warn', `解包 worker 异常退出(code ${code}),已丢弃句柄,下次请求将重建`);
120
+ }
121
+ });
122
+ worker = w;
123
+ return w;
124
+ }
125
+
126
+ /**
127
+ * 跑一次解包。串行:前一个没完成时,调用方会在这条链上等待。
128
+ * @param {object} task {rootId, rel, absPath, fp, meta, decl}
129
+ * @param {{onProgress?: (p: {phase: string, ms: number|null}) => void}} [opts]
130
+ * onProgress:worker 线程里 exec 插件上报的进度帧会转成这个回调(可选)。
131
+ * 为什么要回调而不是靠返回值:进度必须**边跑边报**,等 run() 回来就已经晚了。
132
+ * @returns {Promise<object>} worker 的回复(ok:true 带 index,ok:false 带 reason/message)
133
+ */
134
+ function run(task, opts = {}) {
135
+ const go = () => new Promise((resolve, reject) => {
136
+ const w = ensureWorker();
137
+ const id = (seq += 1);
138
+ const fp = task.fp;
139
+ const timer = setTimeout(() => {
140
+ if (!current || current.id !== id) return;
141
+ const waitedMs = timeoutMs;
142
+ current = null;
143
+ log('warn',
144
+ `解包 worker 超时:${task.rootId}/${task.rel} 已等待 ${waitedMs}ms,正在终止并重建 worker`,
145
+ { rootId: task.rootId, rel: task.rel, waitedMs });
146
+ // 先置空句柄再 terminate:exit 回调据"句柄已不是它"判定为主动丢弃,不会再去 reject 已 reject 的请求
147
+ const dead = worker;
148
+ worker = null;
149
+ Promise.resolve(dead && dead.terminate && dead.terminate()).catch(() => undefined);
150
+ // 清理该 fp 的残留临时目录(writeResult 的 .tmp-<fp>-* 命名规则)
151
+ cleanupTmp(fp);
152
+ reject(new Error(`unpack worker timeout after ${timeoutMs}ms(本次解包未完成,已终止并重建 worker)`));
153
+ }, timeoutMs);
154
+ current = { id, resolve, reject, timer, onProgress: typeof opts.onProgress === 'function' ? opts.onProgress : null };
155
+ try {
156
+ w.postMessage({
157
+ id,
158
+ rootId: task.rootId,
159
+ rel: task.rel,
160
+ absPath: task.absPath,
161
+ fp,
162
+ meta: task.meta,
163
+ decl: task.decl,
164
+ });
165
+ } catch (e) {
166
+ clearTimeout(timer);
167
+ current = null;
168
+ reject(e);
169
+ }
170
+ });
171
+ const next = chain.then(go, go);
172
+ chain = next.then(() => undefined, () => undefined);
173
+ return next;
174
+ }
175
+
176
+ /** 关掉 worker(进程退出/测试清理用) */
177
+ async function close() {
178
+ await disposeWorker('close');
179
+ }
180
+
181
+ return { run, close, get worker() { return worker; } };
182
+ }
183
+
184
+ export default createUnpackWorkerClient;
@@ -0,0 +1,84 @@
1
+ // server/unpack/write-result.mjs —— 把一次解包结果**原子落盘**
2
+ //
3
+ // 从 plugin-store.mjs 搬出来的唯一实现(主线程那份已删除):worker 里解完直接落盘,
4
+ // 只把清单回传主线程(几十 MB 的 files Buffer 不过 postMessage)。
5
+ //
6
+ // 原子性靠"同卷 rename":先全写进 `.tmp-<fp>-<pid>-<rand>/`,再 rename 成 `<rootDir>/<fp>/`。
7
+ // rename 前要先取走 probe.json、删掉旧目录再 rename(POSIX rename 到非空目录报 ENOTEMPTY),
8
+ // 然后把 probe 写回 —— 否则"重新解包"会把历史内存实测值一起丢掉。
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+
14
+ /** 建目录(递归;已存在不报错) */
15
+ function ensureDir(dir) {
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ }
18
+
19
+ /**
20
+ * 把一次解包结果原子落盘。**失败一律清掉半成品临时目录**。
21
+ * @param {{
22
+ * rootDir: string, 解包缓存根
23
+ * fp: string, 指纹(目录名)
24
+ * res: object, unpackFile 的返回(含 files 视图 / prefix / entry / unpackMs)
25
+ * meta: object, 写进 index.json 的源信息(rootId/rel/size/mtimeMs/pluginId/pluginVersion)
26
+ * probeStore: object, createProbeStore(...) 的实例(取走/写回 probe)
27
+ * sampleRss?: Function, 采样回调(可选;只要统计不影响正确性)
28
+ * }} args
29
+ * @returns {Promise<{index: object, files: Array<{path:string,size:number}>}>}
30
+ */
31
+ export async function writeResult({ rootDir, fp, res, meta, probeStore, sampleRss }) {
32
+ const tmpDir = path.join(rootDir, `.tmp-${fp}-${process.pid}-${crypto.randomBytes(3).toString('hex')}`);
33
+ const fpDir = path.join(rootDir, fp);
34
+ ensureDir(path.join(tmpDir, 'files'));
35
+ const files = [];
36
+ try {
37
+ for (const { path: relPath } of res.files.entries) {
38
+ const bytes = await res.files.take(relPath);
39
+ if (!bytes) continue;
40
+ const dst = path.join(tmpDir, 'files', relPath);
41
+ ensureDir(path.dirname(dst));
42
+ fs.writeFileSync(dst, bytes);
43
+ files.push({ path: relPath, size: bytes.length });
44
+ // 写完立刻从沙盒 Map 里删掉引用(42MB 包 RSS 增量约 210MB,逐个释放能显著削峰)
45
+ res.files.release(relPath);
46
+ if (typeof sampleRss === 'function') sampleRss();
47
+ }
48
+ const now = new Date().toISOString();
49
+ const index = {
50
+ v: 1,
51
+ fingerprint: fp,
52
+ pluginId: meta.pluginId,
53
+ pluginVersion: meta.pluginVersion,
54
+ source: { rootId: meta.rootId, rel: meta.rel, size: meta.size, mtimeMs: Math.round(meta.mtimeMs) },
55
+ prefix: res.prefix,
56
+ entry: res.entry,
57
+ files,
58
+ createdAt: now,
59
+ lastUsedAt: now,
60
+ unpackMs: res.unpackMs,
61
+ };
62
+ fs.writeFileSync(path.join(tmpDir, 'index.json'), JSON.stringify(index, null, 2), 'utf8');
63
+ // 同卷 rename:原子替换,读者要么看到旧目录要么看到新目录。
64
+ // 目标目录已存在时 POSIX rename 会报 ENOTEMPTY(旧实现直接失败 ⇒
65
+ // "files 缺失要重新解包"这条恢复路径其实一直不可用)。这里先取走 probe 缓存、
66
+ // 删掉旧目录再 rename,然后把缓存写回,避免重新解包把内存实测值一起丢掉。
67
+ const keepProbe = probeStore ? probeStore.read(fp) : null;
68
+ try {
69
+ fs.renameSync(tmpDir, fpDir);
70
+ } catch (e) {
71
+ if (e && (e.code === 'ENOTEMPTY' || e.code === 'EEXIST' || e.code === 'EPERM')) {
72
+ fs.rmSync(fpDir, { recursive: true, force: true });
73
+ fs.renameSync(tmpDir, fpDir);
74
+ if (keepProbe) probeStore.write(fp, keepProbe);
75
+ } else throw e;
76
+ }
77
+ return { index, files };
78
+ } catch (e) {
79
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 清理失败不影响主流程 */ }
80
+ throw e;
81
+ }
82
+ }
83
+
84
+ export default writeResult;