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.
- package/LICENSE +21 -0
- package/README.md +267 -2
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/bin/modelstage.mjs +101 -0
- package/config/diag-upload.example.json +7 -0
- package/config/models/live2d.assetbundls.json +1 -0
- package/config/models/live2d.azurlane.json +1 -0
- package/config/models/live2d.azurlane_bundle.json +1 -0
- package/config/models/live2d.azurlane_spine.json +1 -0
- package/config/models/live2d.bd2.json +1 -0
- package/config/models/live2d.bluearchive.json +1 -0
- package/config/models/live2d.live2d.json +1 -0
- package/config/models/live2d.nikke.json +1 -0
- package/config/models/live2d.nikke_2.json +1 -0
- package/config/models/live2d.npc.json +1 -0
- package/config/models/spine.assetbundls.json +1 -0
- package/config/models/spine.azurlane.json +1 -0
- package/config/models/spine.azurlane_bundle.json +1 -0
- package/config/models/spine.azurlane_spine.json +1 -0
- package/config/models/spine.bd2.json +1 -0
- package/config/models/spine.bluearchive.json +1 -0
- package/config/models/spine.live2d.json +1 -0
- package/config/models/spine.nikke.json +1 -0
- package/config/models/spine.nikke_2.json +1 -0
- package/config/models/spine.npc.json +1 -0
- package/config/names/NIKKE.json +1058 -0
- package/config/names/README.md +47 -0
- package/config/names//345/244/251/345/221/275/344/271/213/345/255/220.json +42897 -0
- package/config/names//346/243/225/350/211/262/345/260/230/345/237/2032.json +4016 -0
- package/config/names//347/242/247/350/223/235/350/210/252/347/272/277.json +38342 -0
- package/config/plugins.json +13 -0
- package/config/resources.example.json +40 -0
- package/package.json +58 -4
- package/plugins/unity-live2d-bundle/README.md +98 -0
- package/plugins/unity-live2d-bundle/index.js +891 -0
- package/plugins/unity-live2d-bundle/lib/anim-to-motion.js +402 -0
- package/plugins/unity-live2d-bundle/lib/bundle-probe.js +244 -0
- package/plugins/unity-live2d-bundle/lib/bytes.js +189 -0
- package/plugins/unity-live2d-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-live2d-bundle/lib/dxt.js +197 -0
- package/plugins/unity-live2d-bundle/lib/errors.js +71 -0
- package/plugins/unity-live2d-bundle/lib/etc2.js +371 -0
- package/plugins/unity-live2d-bundle/lib/extract.js +242 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-live2d-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-live2d-bundle/lib/model3.js +137 -0
- package/plugins/unity-live2d-bundle/lib/png.js +424 -0
- package/plugins/unity-live2d-bundle/lib/serialized.js +233 -0
- package/plugins/unity-live2d-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-live2d-bundle/lib/typetree.js +215 -0
- package/plugins/unity-live2d-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-live2d-bundle/plugin.json +81 -0
- package/plugins/unity-live2d-bundle/profiles/unityfs-live2d.json +26 -0
- package/plugins/unity-spine-bundle/index.js +535 -0
- package/plugins/unity-spine-bundle/lib/bytes.js +189 -0
- package/plugins/unity-spine-bundle/lib/common-strings.js +46 -0
- package/plugins/unity-spine-bundle/lib/dxt.js +197 -0
- package/plugins/unity-spine-bundle/lib/errors.js +79 -0
- package/plugins/unity-spine-bundle/lib/etc2.js +371 -0
- package/plugins/unity-spine-bundle/lib/extract.js +242 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.LICENSE.txt +21 -0
- package/plugins/unity-spine-bundle/lib/lzma-d-min.js +34 -0
- package/plugins/unity-spine-bundle/lib/png.js +424 -0
- package/plugins/unity-spine-bundle/lib/serialized.js +233 -0
- package/plugins/unity-spine-bundle/lib/texdecode.js +190 -0
- package/plugins/unity-spine-bundle/lib/typetree.js +215 -0
- package/plugins/unity-spine-bundle/lib/unityfs.js +396 -0
- package/plugins/unity-spine-bundle/plugin.json +75 -0
- package/src/server/README.md +69 -0
- package/src/server/assets/browse.mjs +56 -0
- package/src/server/assets/bundle-probe.mjs +94 -0
- package/src/server/assets/probe.mjs +184 -0
- package/src/server/core/config.mjs +173 -0
- package/src/server/core/env-num.mjs +60 -0
- package/src/server/core/env.mjs +49 -0
- package/src/server/core/fs-portable.mjs +37 -0
- package/src/server/core/log.mjs +107 -0
- package/src/server/core/paths.mjs +44 -0
- package/src/server/data/catalog.mjs +1091 -0
- package/src/server/data/entry-states.mjs +499 -0
- package/src/server/data/live2d.mjs +272 -0
- package/src/server/data/manifest-cache.mjs +58 -0
- package/src/server/data/manifest-stamp.mjs +135 -0
- package/src/server/data/model-meta.mjs +114 -0
- package/src/server/data/names.mjs +106 -0
- package/src/server/data/shape-probe-worker.mjs +33 -0
- package/src/server/data/shape-probe.mjs +626 -0
- package/src/server/data/spine.mjs +336 -0
- package/src/server/data/tree.mjs +126 -0
- package/src/server/http/atomic-json.mjs +145 -0
- package/src/server/http/background-solids.mjs +270 -0
- package/src/server/http/backgrounds.mjs +388 -0
- package/src/server/http/diag-upload.mjs +264 -0
- package/src/server/http/diag.mjs +173 -0
- package/src/server/http/ready.mjs +167 -0
- package/src/server/http/static.mjs +312 -0
- package/src/server/http/write.mjs +647 -0
- package/src/server/index.mjs +114 -0
- package/src/server/plugins/builtin-providers.json +30 -0
- package/src/server/plugins/registry.mjs +650 -0
- package/src/server/plugins/slot-catalog.mjs +286 -0
- package/src/server/routes/background-solids.mjs +56 -0
- package/src/server/routes/backgrounds.mjs +77 -0
- package/src/server/routes/config.mjs +38 -0
- package/src/server/routes/data.mjs +313 -0
- package/src/server/routes/diag.mjs +37 -0
- package/src/server/routes/entries.mjs +170 -0
- package/src/server/routes/ready.mjs +122 -0
- package/src/server/serve.mjs +334 -0
- package/src/server/unpack/capacity.mjs +217 -0
- package/src/server/unpack/mem-probe.mjs +27 -0
- package/src/server/unpack/plugin-runner.mjs +529 -0
- package/src/server/unpack/plugin-store.mjs +736 -0
- package/src/server/unpack/probe-store.mjs +73 -0
- package/src/server/unpack/routes.mjs +359 -0
- package/src/server/unpack/runtime/exec.mjs +558 -0
- package/src/server/unpack/runtime/index.mjs +141 -0
- package/src/server/unpack/runtime/node-vm.mjs +111 -0
- package/src/server/unpack/unpack-worker.mjs +105 -0
- package/src/server/unpack/worker-client.mjs +184 -0
- package/src/server/unpack/write-result.mjs +84 -0
- package/src/shared/bundle-shape.mjs +83 -0
- package/src/shared/entry-reasons.mjs +119 -0
- package/src/shared/names.mjs +45 -0
- package/src/web/README.md +193 -0
- package/src/web/app/anim-list.js +222 -0
- package/src/web/app/background-solids.js +150 -0
- package/src/web/app/backgrounds.js +152 -0
- package/src/web/app/camera-log.js +28 -0
- package/src/web/app/context.js +72 -0
- package/src/web/app/dir-picker.js +242 -0
- package/src/web/app/entry-report.js +71 -0
- package/src/web/app/memory-bars.js +91 -0
- package/src/web/app/memory-budget.js +228 -0
- package/src/web/app/memory-guard.js +204 -0
- package/src/web/app/model-actions.js +682 -0
- package/src/web/app/renderer-setting.js +70 -0
- package/src/web/app/root-manager.js +254 -0
- package/src/web/app/status.js +138 -0
- package/src/web/app/topbar-actions.js +357 -0
- package/src/web/app/transport-actions.js +297 -0
- package/src/web/app/ui-state.js +559 -0
- package/src/web/app/url-params.js +171 -0
- package/src/web/app/view-settings.js +186 -0
- package/src/web/components.css +465 -0
- package/src/web/debug/diag-hud.js +95 -0
- package/src/web/debug/drag.js +369 -0
- package/src/web/debug/log.js +320 -0
- package/src/web/debug/model-cmp.js +292 -0
- package/src/web/debug/panel.css +474 -0
- package/src/web/debug/panel.js +712 -0
- package/src/web/debug/race-probe.js +82 -0
- package/src/web/debug/upload.js +130 -0
- package/src/web/engine/adapters/atlas.js +47 -0
- package/src/web/engine/adapters/index.js +22 -0
- package/src/web/engine/adapters/renderer.js +223 -0
- package/src/web/engine/adapters/skeleton.js +105 -0
- package/src/web/engine/adapters/texture.js +64 -0
- package/src/web/engine/animation.js +181 -0
- package/src/web/engine/appearance.js +136 -0
- package/src/web/engine/camera-adapter.js +212 -0
- package/src/web/engine/effect-chain.js +138 -0
- package/src/web/engine/engine-state.js +120 -0
- package/src/web/engine/filters.js +224 -0
- package/src/web/engine/fit.js +197 -0
- package/src/web/engine/frame-gate.js +33 -0
- package/src/web/engine/gl-driver.js +56 -0
- package/src/web/engine/gl.js +77 -0
- package/src/web/engine/index.js +20 -0
- package/src/web/engine/loader.js +55 -0
- package/src/web/engine/model-info.js +26 -0
- package/src/web/engine/pipeline.js +200 -0
- package/src/web/engine/player.js +427 -0
- package/src/web/engine/pma/blend.js +76 -0
- package/src/web/engine/pma/detect.js +118 -0
- package/src/web/engine/pma/index.js +8 -0
- package/src/web/engine/pma/resolve.js +112 -0
- package/src/web/engine/pma/upload.js +127 -0
- package/src/web/engine/pma.js +5 -0
- package/src/web/engine/render-frame.js +93 -0
- package/src/web/engine/runtime-capabilities.js +209 -0
- package/src/web/engine/runtime.js +37 -0
- package/src/web/engine/skins.js +165 -0
- package/src/web/engine/textures.js +174 -0
- package/src/web/index.html +115 -0
- package/src/web/infra/api.js +531 -0
- package/src/web/infra/diag.js +509 -0
- package/src/web/infra/document.js +269 -0
- package/src/web/infra/dom.js +31 -0
- package/src/web/infra/events.mjs +27 -0
- package/src/web/infra/log.js +153 -0
- package/src/web/infra/mem-stats.js +112 -0
- package/src/web/infra/plugin-host-registration.mjs +285 -0
- package/src/web/infra/plugin-host-report.mjs +32 -0
- package/src/web/infra/plugin-host-unload.mjs +79 -0
- package/src/web/infra/plugin-host-unpack.mjs +899 -0
- package/src/web/infra/plugin-host-warmup.mjs +180 -0
- package/src/web/infra/plugin-host.js +628 -0
- package/src/web/infra/ready.js +461 -0
- package/src/web/infra/schedule.js +51 -0
- package/src/web/infra/shortcuts.js +129 -0
- package/src/web/infra/slot-registry.js +402 -0
- package/src/web/infra/vfs.js +247 -0
- package/src/web/live2d/bridge.js +400 -0
- package/src/web/live2d/model.js +321 -0
- package/src/web/live2d/overlay.js +142 -0
- package/src/web/live2d/pixi-app.js +111 -0
- package/src/web/live2d/playback-probe.js +92 -0
- package/src/web/live2d/probe-geometry.js +329 -0
- package/src/web/live2d/probe.js +119 -0
- package/src/web/live2d/release.js +66 -0
- package/src/web/live2d/runtime-adapter.js +289 -0
- package/src/web/live2d/stage-ctx.js +152 -0
- package/src/web/live2d/stage.js +204 -0
- package/src/web/live2d/vfs-settings.js +174 -0
- package/src/web/live2d/view.js +89 -0
- package/src/web/main.js +629 -0
- package/src/web/panel/background-list.js +373 -0
- package/src/web/panel/bg-source.js +291 -0
- package/src/web/panel/color-picker.css +199 -0
- package/src/web/panel/color-picker.js +278 -0
- package/src/web/panel/dialog.css +116 -0
- package/src/web/panel/dialog.js +173 -0
- package/src/web/panel/dom.js +35 -0
- package/src/web/panel/icons.js +58 -0
- package/src/web/panel/memory-bar.js +195 -0
- package/src/web/panel/panel-settings.js +1263 -0
- package/src/web/panel/panel.css +1304 -0
- package/src/web/panel/render-choice.js +104 -0
- package/src/web/panel/schema.js +784 -0
- package/src/web/panel/settings-io.js +291 -0
- package/src/web/panel/shell-controls.js +274 -0
- package/src/web/panel/slider.js +210 -0
- package/src/web/panel/toolbar-schema.js +111 -0
- package/src/web/panel/toolbar.js +195 -0
- package/src/web/panel/transport-picker.js +375 -0
- package/src/web/panel/transport.js +321 -0
- package/src/web/panel/view.js +553 -0
- package/src/web/plugin/unpack-worker.js +185 -0
- package/src/web/render/camera/constants.js +60 -0
- package/src/web/render/camera/index.js +123 -0
- package/src/web/render/camera/spine.js +124 -0
- package/src/web/render/capture.js +30 -0
- package/src/web/render/interaction.js +527 -0
- package/src/web/render/live2d.js +627 -0
- package/src/web/render/playback.js +47 -0
- package/src/web/render/reset-view.js +29 -0
- package/src/web/render/spine.js +386 -0
- package/src/web/render/surface-switch.js +176 -0
- package/src/web/render/surface.js +571 -0
- package/src/web/render/surfaces.js +70 -0
- package/src/web/roster/filter.js +31 -0
- package/src/web/roster/list.js +276 -0
- package/src/web/roster/persist.js +52 -0
- package/src/web/roster/reveal.js +77 -0
- package/src/web/roster/rows.js +248 -0
- package/src/web/roster/snapshot.js +37 -0
- package/src/web/roster/view.js +670 -0
- package/src/web/spine/canvas-api-groups.js +267 -0
- package/src/web/spine/canvas-api.js +97 -0
- package/src/web/spine/canvas-document.js +80 -0
- package/src/web/spine/canvas-engine.js +193 -0
- package/src/web/spine/canvas-entry.js +28 -0
- package/src/web/spine/canvas-pixels.js +36 -0
- package/src/web/spine/canvas-runtime.js +127 -0
- package/src/web/spine/canvas-view.js +108 -0
- package/src/web/spine/canvas.js +7 -0
- package/src/web/spine/runtime-hint.js +73 -0
- package/src/web/spine/runtimes.js +212 -0
- package/src/web/style.css +493 -0
- package/tool/lib/bundle-probe-node.mjs +121 -0
- package/tool/lib/check-perms.mjs +114 -0
- package/tool/lib/contract-checks.mjs +582 -0
- package/tool/lib/cubism-core.mjs +122 -0
- package/tool/lib/export-bundle.mjs +328 -0
- package/tool/lib/paths.mjs +20 -0
- package/tool/lib/runtime-guides.mjs +65 -0
- package/tool/lib/runtime-package.mjs +403 -0
- package/tool/lib/spine-meta.mjs +227 -0
- package/tool/lib/spine-motions.mjs +104 -0
- package/tool/lib/spine-runtime.mjs +131 -0
- package/tool/lib/test-lock.mjs +178 -0
- package/tool/lib/test-report.mjs +133 -0
- package/tool/parse-jsonc.mjs +86 -0
- package/tool/resolve-live2d-runtimes.mjs +276 -0
- package/tool/spine-motion-rules.json +101 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// api.js —— 同源 HTTP 封装(唯一一层:重试 / 退避+抖动 / 超时 / in-flight 去重)
|
|
2
|
+
//
|
|
3
|
+
// 做什么:给上层提供 getJson() / postJson(),把「网络失败、超时、非 2xx、非 JSON」四类问题
|
|
4
|
+
// 在这一层统一处理,上层调用点的语义保持不变(仍旧是"成功拿数据、失败抛 Error(message)")��
|
|
5
|
+
// ⚠️ **一个刻意的例外:postStream()**(事件流,见文件末尾)。它**不走**重试/退避/固定超时那一套 ——
|
|
6
|
+
// 流式响应的判据是"有没有进展"(连续无帧才算停摆),不是"总时长":套用 8.5s 会重演
|
|
7
|
+
// "慢机器被误判成失败"的老坑。除它以外,本文件所有请求仍统一走 fetchWithRetry。
|
|
8
|
+
// 为什么集中在这一层:M2 之后调用点越来越多(tree/models/model-meta/plugins…),
|
|
9
|
+
// 重试与超时若各写一遍必然漂移;而网络抖动是全局问题,不是某个接口的个性。
|
|
10
|
+
//
|
|
11
|
+
// 三条策略要点(都有实测依据,别随手改):
|
|
12
|
+
// 1. **只重试"可能自愈"的失败**:网络错误(Failed to fetch)、超时、5xx。
|
|
13
|
+
// **4xx 一律不重试**——参数错/不存在,重试只是浪费时间并刷日志。
|
|
14
|
+
// 2. **退避 + 抖动**:100/300/900ms 基准,±20% 随机抖动,避免大量请求同时重试打成一波。
|
|
15
|
+
// 3. **"防抖"= in-flight 去重,不是延迟发送**:同一 GET 并发合并成一个 Promise;
|
|
16
|
+
// **POST 绝不合并**(有副作用,合并会让"点两次"变成"点一次")。
|
|
17
|
+
//
|
|
18
|
+
// 已知边界:
|
|
19
|
+
// · 调用方自带 signal 的 GET **不参与去重**(各自可能取消,合并会让一方取消影响另一方)。
|
|
20
|
+
// · 去重键是「method + path」,不含请求头;同路径不同 Accept 会被合并——本项目 API 都是同源 JSON,
|
|
21
|
+
// 不存在这种用法。
|
|
22
|
+
|
|
23
|
+
/** 单次请求默认超时:8.5s(比常见反代 10s 略短,让"卡住"更早暴露成本地超时而非网关 504) */
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 8500;
|
|
25
|
+
|
|
26
|
+
/** 退避基准(毫秒):第 1/2/3 次重试前的等待;抖动 ±20% */
|
|
27
|
+
const BACKOFF_MS = [100, 300, 900];
|
|
28
|
+
|
|
29
|
+
/** 默认最多重试次数(即最多尝试 3 次:首发 + 2 次重试) */
|
|
30
|
+
const DEFAULT_RETRIES = 2;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 幂等方法:失败重发是安全的(服务端不产生副作用)。
|
|
34
|
+
*
|
|
35
|
+
* 非幂等方法(POST/PUT/PATCH/DELETE)**默认 retries: 0** —— 重发写请求就是重复写:
|
|
36
|
+
* 实测过的形态:POST /api/backgrounds/solids 创建成功但响应在网络上丢了 ⇒ 自动重发 ⇒ 建出两个纯色。
|
|
37
|
+
* 需要重试的非幂等调用方必须**显式**传 retries(例如 /api/ready 的阶段上报本身幂等,
|
|
38
|
+
* 它在调用点写了 retries: 2,那是有意为之,不是这里的默认值)。
|
|
39
|
+
*/
|
|
40
|
+
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 解析本次请求的"是否幂等"(决定默认重试次数)。
|
|
44
|
+
* @param {string} method
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isIdempotentMethod(method) {
|
|
48
|
+
return IDEMPOTENT_METHODS.has(String(method || 'GET').toUpperCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 抖动比例(±该比例):避免多请求同一时刻重试打成一波 */
|
|
52
|
+
const JITTER_RATIO = 0.2;
|
|
53
|
+
|
|
54
|
+
/** 按 JITTER_RATIO 加随机抖动 */
|
|
55
|
+
function jitter(ms) {
|
|
56
|
+
const delta = ms * JITTER_RATIO;
|
|
57
|
+
return Math.max(0, Math.round(ms - delta + Math.random() * delta * 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 该 HTTP 状态是否值得重试:只有 5xx;4xx 明确不重试 */
|
|
61
|
+
function retryableStatus(status) {
|
|
62
|
+
return status >= 500 && status <= 599;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 是否"网络层失败"(fetch 抛 TypeError,或超时导致的 TimeoutError) */
|
|
66
|
+
function retryableNetworkError(err) {
|
|
67
|
+
if (!err) return false;
|
|
68
|
+
const name = String(err.name || '');
|
|
69
|
+
const msg = String(err.message || '');
|
|
70
|
+
if (name === 'TimeoutError') return true; // AbortSignal.timeout 触发的超时
|
|
71
|
+
if (name === 'AbortError') return false; // 调用方主动取消:不能重试
|
|
72
|
+
return err instanceof TypeError || /Failed to fetch|network|fetch failed/i.test(msg);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 单条诊断日志(重试/最终失败各一条,不刷屏) */
|
|
76
|
+
function note(message) {
|
|
77
|
+
try {
|
|
78
|
+
console.warn(`[api] ${message}`);
|
|
79
|
+
} catch {
|
|
80
|
+
/* 日志失败不影响请求 */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 读响应体:空体返回 null;非 JSON 抛带原文片段的错误 */
|
|
85
|
+
async function readBody(res, path, method) {
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
if (!text) return null;
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error(`${method} ${path} 返回非 JSON(HTTP ${res.status}):${text.slice(0, 200)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 单次尝试:返回解析后的 body,或抛出带 status 的错误 */
|
|
96
|
+
async function attemptOnce(path, { method, body, headers, timeoutMs, signal, cache }) {
|
|
97
|
+
// 每次尝试都有独立超时(AbortSignal.timeout):卡死的请求不会无限挂住页面
|
|
98
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
99
|
+
const merged = signal && typeof AbortSignal.any === 'function'
|
|
100
|
+
? AbortSignal.any([signal, timeoutSignal])
|
|
101
|
+
: (signal || timeoutSignal);
|
|
102
|
+
const res = await fetch(path, {
|
|
103
|
+
method,
|
|
104
|
+
headers: {
|
|
105
|
+
Accept: MIME_JSON,
|
|
106
|
+
...(body !== undefined ? { 'Content-Type': MIME_JSON } : {}),
|
|
107
|
+
...(headers || {}),
|
|
108
|
+
},
|
|
109
|
+
...(body !== undefined ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}),
|
|
110
|
+
// cache 必须能透传:插件清单 / 运行时清单这类"改完要立刻看到"的请求带 no-store,
|
|
111
|
+
// 丢掉它会读到浏览器缓存(新增插件在前端看不见)。以前 api.js 不透传,调用方只能裸 fetch。
|
|
112
|
+
...(cache ? { cache } : {}),
|
|
113
|
+
signal: merged,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (res.ok) return readBody(res, path, method);
|
|
117
|
+
|
|
118
|
+
// 非 2xx:优先用服务端 JSON 错误体的 error 字段
|
|
119
|
+
let parsed = null;
|
|
120
|
+
let raw = '';
|
|
121
|
+
try {
|
|
122
|
+
raw = await res.text();
|
|
123
|
+
parsed = raw ? JSON.parse(raw) : null;
|
|
124
|
+
} catch {
|
|
125
|
+
parsed = null;
|
|
126
|
+
}
|
|
127
|
+
const detail = parsed && typeof parsed.error === 'string' ? parsed.error : `HTTP ${res.status}`;
|
|
128
|
+
const err = new Error(`${method} ${path} 失败:${detail}`);
|
|
129
|
+
err.status = res.status; // 供重试判定与上层细分(不改变 message 语义)
|
|
130
|
+
err.body = parsed;
|
|
131
|
+
if (raw && !parsed) err.raw = raw.slice(0, 200);
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 抓取统计(**验收核心指标**):重试是补救,真正要盯的是「首次尝试成功率」。
|
|
137
|
+
* firstTryOk / calls ≥ 99% 才算根因已除;90~99% 有残留 race;<90% 说明重试在掩盖问题。
|
|
138
|
+
*/
|
|
139
|
+
export const retryStats = {
|
|
140
|
+
calls: 0, // fetchWithRetry 调用次数
|
|
141
|
+
firstTryOk: 0, // 第 1 次尝试就成功
|
|
142
|
+
retried: 0, // 走了重试(且最终成功)
|
|
143
|
+
exhausted: 0, // 重试耗尽仍失败
|
|
144
|
+
attemptsTotal: 0, // 实际发出的 HTTP 尝试总数(对比 calls 可看出平均重试次数)
|
|
145
|
+
inflight: 0, // 当前在飞的 fetchWithRetry 数
|
|
146
|
+
maxInflight: 0, // 峰值并发(验收"解包期间在飞请求 ≤4")
|
|
147
|
+
slowestMs: 0, // 单个请求最长耗时(验收"无请求超过 8s")
|
|
148
|
+
byLabel: {}, // label → {calls, firstTryOk, retried, exhausted}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** 取/建某 label 的计数桶 */
|
|
152
|
+
function bucketOf(label) {
|
|
153
|
+
const k = String(label || 'unknown');
|
|
154
|
+
if (!retryStats.byLabel[k]) retryStats.byLabel[k] = { calls: 0, firstTryOk: 0, retried: 0, exhausted: 0 };
|
|
155
|
+
return retryStats.byLabel[k];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 重置统计(测试/验收开始前调用,保证数字干净) */
|
|
159
|
+
/** JSON 的 MIME:请求头与响应头两处都用它(同一个字面量原来写了三遍) */
|
|
160
|
+
const MIME_JSON = 'application/json';
|
|
161
|
+
export function resetRetryStats() {
|
|
162
|
+
retryStats.calls = 0;
|
|
163
|
+
retryStats.firstTryOk = 0;
|
|
164
|
+
retryStats.retried = 0;
|
|
165
|
+
retryStats.exhausted = 0;
|
|
166
|
+
retryStats.attemptsTotal = 0;
|
|
167
|
+
retryStats.inflight = 0;
|
|
168
|
+
retryStats.maxInflight = 0;
|
|
169
|
+
retryStats.slowestMs = 0;
|
|
170
|
+
retryStats.byLabel = {};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 并发受限的 map(不引依赖)。
|
|
175
|
+
*
|
|
176
|
+
* 为什么需要:资源包解包后要取 39 个文件,若一次全发会**吃满浏览器同源 6 个连接槽**,
|
|
177
|
+
* 后续请求(Spine 模型文件、/api/ready)全部排队直至超时 ⇒ 现场表现"资源包之后全挂"。
|
|
178
|
+
* 限到 4 个并发可让其它请求始终有槽可用。
|
|
179
|
+
*
|
|
180
|
+
* @param {Array} items
|
|
181
|
+
* @param {number} limit 并发上限
|
|
182
|
+
* @param {(item: any, index: number) => Promise<any>} fn
|
|
183
|
+
* @returns {Promise<Array>} 与 items 同序的结果
|
|
184
|
+
*/
|
|
185
|
+
export async function mapLimit(items, limit, fn) {
|
|
186
|
+
const list = Array.isArray(items) ? items : [];
|
|
187
|
+
const out = new Array(list.length);
|
|
188
|
+
const n = Math.max(1, Math.min(Number(limit) || 1, list.length || 1));
|
|
189
|
+
let cursor = 0;
|
|
190
|
+
await Promise.all(Array.from({ length: n }, async () => {
|
|
191
|
+
for (;;) {
|
|
192
|
+
const k = cursor;
|
|
193
|
+
cursor += 1;
|
|
194
|
+
if (k >= list.length) return;
|
|
195
|
+
out[k] = await fn(list[k], k);
|
|
196
|
+
}
|
|
197
|
+
}));
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 是否输出逐请求明细(默认关,避免刷屏):URL 带 ?netlog=1 时开启。
|
|
203
|
+
* 用途:区分"同一条请求重试连发"与"多个请求并发"——日志里带 reqId 与 attempt。
|
|
204
|
+
*/
|
|
205
|
+
let netlogOn = null;
|
|
206
|
+
function netlogEnabled() {
|
|
207
|
+
if (netlogOn === null) {
|
|
208
|
+
try { netlogOn = typeof location !== 'undefined' && /[?&]netlog=1/.test(String(location.search || '')); } catch { netlogOn = false; }
|
|
209
|
+
attachRetryStats();
|
|
210
|
+
}
|
|
211
|
+
return netlogOn;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let reqSeq = 0;
|
|
215
|
+
|
|
216
|
+
/** 把统计挂到页面上(验收/排障用)。__WEB__ 可能晚于本模块创建 ⇒ 首次抓取时再挂一次。 */
|
|
217
|
+
function attachRetryStats() {
|
|
218
|
+
try {
|
|
219
|
+
if (typeof globalThis !== 'undefined' && globalThis.__WEB__ && !globalThis.__WEB__.retryStats) {
|
|
220
|
+
globalThis.__WEB__.retryStats = () => retryStats;
|
|
221
|
+
}
|
|
222
|
+
} catch { /* 忽略 */ }
|
|
223
|
+
}
|
|
224
|
+
attachRetryStats();
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 带重试的**原始 fetch**(返回 Response,不解析 body)。
|
|
228
|
+
*
|
|
229
|
+
* 为什么需要它:模型文件(.skel/.atlas/.png)是几十个并发裸 fetch,一旦碰上 HTTP keep-alive race
|
|
230
|
+
* (客户端复用了服务端已关闭的连接)就直接 Failed to fetch ⇒ Spine 空白。
|
|
231
|
+
* 这类失败**必须重试**,否则一个文件失败整个模型就画不出来。
|
|
232
|
+
*
|
|
233
|
+
* 为什么放在这里而不是各调用点各写一份:重试判定(网络错误/5xx 才重试、4xx 与主动取消不重试)、
|
|
234
|
+
* 退避与抖动必须只有一处实现——两份必然漂移(本项目已吃过"两套实现不一致"的亏)。
|
|
235
|
+
*
|
|
236
|
+
* @param {string} url 同源路径或绝对 URL
|
|
237
|
+
* @param {{method?: string, body?: any, timeoutMs?: number, retries?: number, headers?: object,
|
|
238
|
+
* signal?: AbortSignal, label?: string, keepalive?: boolean}} [opts]
|
|
239
|
+
* @returns {Promise<Response>} 成功(res.ok)的响应;4xx/重试耗尽会抛错
|
|
240
|
+
*/
|
|
241
|
+
export async function fetchWithRetry(url, opts = {}) {
|
|
242
|
+
const timeoutMs = Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
243
|
+
// 非幂等方法默认不重试(重发写请求=重复写);调用方显式给 retries 时以显式为准
|
|
244
|
+
const retries = Number.isInteger(opts.retries)
|
|
245
|
+
? opts.retries
|
|
246
|
+
: (isIdempotentMethod(opts.method) ? DEFAULT_RETRIES : 0);
|
|
247
|
+
const maxAttempts = Math.max(1, retries + 1);
|
|
248
|
+
const label = opts.label || String(url).split('?')[0].slice(-60);
|
|
249
|
+
let lastErr = null;
|
|
250
|
+
// 统计埋点:验收要看「首次尝试成功率」,不能只看"最终成功"
|
|
251
|
+
const bucket = bucketOf(label);
|
|
252
|
+
retryStats.calls += 1;
|
|
253
|
+
bucket.calls += 1;
|
|
254
|
+
const reqId = (reqSeq += 1);
|
|
255
|
+
const verbose = netlogEnabled();
|
|
256
|
+
attachRetryStats();
|
|
257
|
+
retryStats.inflight += 1;
|
|
258
|
+
if (retryStats.inflight > retryStats.maxInflight) retryStats.maxInflight = retryStats.inflight;
|
|
259
|
+
const startedAt = Date.now();
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < maxAttempts; i += 1) {
|
|
262
|
+
retryStats.attemptsTotal += 1;
|
|
263
|
+
try {
|
|
264
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
265
|
+
const merged = opts.signal && typeof AbortSignal.any === 'function'
|
|
266
|
+
? AbortSignal.any([opts.signal, timeoutSignal])
|
|
267
|
+
: (opts.signal || timeoutSignal);
|
|
268
|
+
const res = await fetch(url, {
|
|
269
|
+
method: opts.method || 'GET',
|
|
270
|
+
headers: opts.headers,
|
|
271
|
+
...(opts.body !== undefined ? { body: typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body) } : {}),
|
|
272
|
+
...(opts.keepalive ? { keepalive: true } : {}),
|
|
273
|
+
signal: merged,
|
|
274
|
+
});
|
|
275
|
+
if (res.ok) {
|
|
276
|
+
retryStats.inflight -= 1;
|
|
277
|
+
const okCost = Date.now() - startedAt;
|
|
278
|
+
if (okCost > retryStats.slowestMs) retryStats.slowestMs = okCost;
|
|
279
|
+
if (i === 0) { retryStats.firstTryOk += 1; bucket.firstTryOk += 1; }
|
|
280
|
+
else { retryStats.retried += 1; bucket.retried += 1; }
|
|
281
|
+
if (verbose) note(`net req#${reqId} ${label} attempt=${i + 1}/${maxAttempts} → ${res.status}`);
|
|
282
|
+
return res;
|
|
283
|
+
}
|
|
284
|
+
const err = new Error(`HTTP ${res.status}(${label})`);
|
|
285
|
+
err.status = res.status;
|
|
286
|
+
throw err;
|
|
287
|
+
} catch (e) {
|
|
288
|
+
lastErr = e;
|
|
289
|
+
const status = e && e.status;
|
|
290
|
+
const worth = status !== undefined ? retryableStatus(status) : retryableNetworkError(e);
|
|
291
|
+
const isLast = i === maxAttempts - 1;
|
|
292
|
+
if (!worth || isLast) break;
|
|
293
|
+
const wait = jitter(BACKOFF_MS[Math.min(i, BACKOFF_MS.length - 1)]);
|
|
294
|
+
if (verbose) note(`net req#${reqId} ${label} attempt=${i + 1}/${maxAttempts} 失败:${status ? `HTTP ${status}` : (e && e.name) || '网络错误'};${wait}ms 后重试`);
|
|
295
|
+
else note(`取文件失败(${label})第 ${i + 1} 次:${status ? `HTTP ${status}` : (e && e.name) || '网络错误'};${wait}ms 后重试`);
|
|
296
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const status = lastErr && lastErr.status;
|
|
300
|
+
const why = status ? `HTTP ${status}` : ((lastErr && lastErr.message) || '未知错误');
|
|
301
|
+
retryStats.inflight -= 1;
|
|
302
|
+
const costMs = Date.now() - startedAt;
|
|
303
|
+
if (costMs > retryStats.slowestMs) retryStats.slowestMs = costMs;
|
|
304
|
+
if (maxAttempts > 1 && (status === undefined || retryableStatus(status))) { retryStats.exhausted += 1; bucket.exhausted += 1; }
|
|
305
|
+
const err = new Error(`取文件失败(${label}):${why}${maxAttempts > 1 && (status === undefined || retryableStatus(status)) ? `(重试 ${maxAttempts - 1} 次后仍失败)` : ''}`);
|
|
306
|
+
err.status = status;
|
|
307
|
+
err.cause = lastErr;
|
|
308
|
+
throw err;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 核心:带重试/退避/超时的 JSON 请求。
|
|
313
|
+
* @param {string} path 同源路径(以 / 开头)
|
|
314
|
+
* @param {{method?: string, body?: any, headers?: object, timeoutMs?: number, retries?: number, signal?: AbortSignal}} opts
|
|
315
|
+
* @returns {Promise<any>}
|
|
316
|
+
*/
|
|
317
|
+
async function requestJson(path, opts = {}) {
|
|
318
|
+
const method = opts.method || 'GET';
|
|
319
|
+
const timeoutMs = Number(opts.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
320
|
+
// 非幂等方法默认不重试(重发写请求=重复写);调用方显式给 retries 时以显式为准
|
|
321
|
+
const retries = Number.isInteger(opts.retries)
|
|
322
|
+
? opts.retries
|
|
323
|
+
: (isIdempotentMethod(method) ? DEFAULT_RETRIES : 0);
|
|
324
|
+
const maxAttempts = Math.max(1, retries + 1);
|
|
325
|
+
let lastErr = null;
|
|
326
|
+
|
|
327
|
+
for (let i = 0; i < maxAttempts; i += 1) {
|
|
328
|
+
try {
|
|
329
|
+
return await attemptOnce(path, { method, body: opts.body, headers: opts.headers, timeoutMs, signal: opts.signal, cache: opts.cache });
|
|
330
|
+
} catch (e) {
|
|
331
|
+
lastErr = e;
|
|
332
|
+
const status = e && e.status;
|
|
333
|
+
const worth = status !== undefined ? retryableStatus(status) : retryableNetworkError(e);
|
|
334
|
+
const isLast = i === maxAttempts - 1;
|
|
335
|
+
if (!worth || isLast) break;
|
|
336
|
+
const wait = jitter(BACKOFF_MS[Math.min(i, BACKOFF_MS.length - 1)]);
|
|
337
|
+
note(`${method} ${path} 第 ${i + 1} 次失败(${status ? `HTTP ${status}` : (e && e.name) || '网络错误'}),${wait}ms 后重试`);
|
|
338
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 错误信息必须能区分「首次即失败」与「重试耗尽」——否则排障时又得猜
|
|
343
|
+
const status = lastErr && lastErr.status;
|
|
344
|
+
const why = status ? `HTTP ${status}` : ((lastErr && lastErr.message) || '未知错误');
|
|
345
|
+
const exhausted = maxAttempts > 1 && (status === undefined || retryableStatus(status));
|
|
346
|
+
if (exhausted) {
|
|
347
|
+
const err = new Error(`${method} ${path} 失败(重试 ${maxAttempts - 1} 次后仍失败):${why}`);
|
|
348
|
+
err.status = status;
|
|
349
|
+
err.retries = maxAttempts - 1;
|
|
350
|
+
err.cause = lastErr;
|
|
351
|
+
note(err.message);
|
|
352
|
+
throw err;
|
|
353
|
+
}
|
|
354
|
+
// 首次即失败(4xx / 调用方取消 / 不可重试):保持原文案口径,上层无需改动
|
|
355
|
+
throw lastErr;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** in-flight 去重表:key = method + path(仅 GET 且调用方未自带 signal 时使用) */
|
|
359
|
+
const inflight = new Map();
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* GET JSON。同一 URL 的并发请求会**合并成一个真实请求**(防抖的正解)。
|
|
363
|
+
* @param {string} path
|
|
364
|
+
* @param {{signal?: AbortSignal, timeoutMs?: number, retries?: number, headers?: object}} [options]
|
|
365
|
+
* @returns {Promise<any>}
|
|
366
|
+
*/
|
|
367
|
+
export function getJson(path, options = {}) {
|
|
368
|
+
const dedupe = !options.signal; // 自带 signal 的调用方各自取消,不参与合并(见文件头"已知边界")
|
|
369
|
+
const key = `GET ${path}`;
|
|
370
|
+
if (dedupe && inflight.has(key)) return inflight.get(key);
|
|
371
|
+
const p = requestJson(path, { ...options, method: 'GET' }).finally(() => {
|
|
372
|
+
if (dedupe && inflight.get(key) === p) inflight.delete(key);
|
|
373
|
+
});
|
|
374
|
+
if (dedupe) inflight.set(key, p);
|
|
375
|
+
return p;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* POST JSON。**不合并**(有副作用:合并会让"点两次"变成"点一次")。
|
|
380
|
+
* @param {string} path
|
|
381
|
+
* @param {any} body 会被 JSON.stringify(字符串原样发送)
|
|
382
|
+
* @param {{signal?: AbortSignal, timeoutMs?: number, retries?: number, headers?: object}} [options]
|
|
383
|
+
* @returns {Promise<any>}
|
|
384
|
+
*/
|
|
385
|
+
export function postJson(path, body, options = {}) {
|
|
386
|
+
return requestJson(path, { ...options, method: 'POST', body });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 解析一帧 SSE 文本(`event: <name>` + `data: <json>`,帧间以空行分隔)。
|
|
391
|
+
* 只认这两行;其余字段(id:/retry:/注释行)按 SSE 规范忽略。
|
|
392
|
+
* @param {string} frame
|
|
393
|
+
* @returns {{name:string, data:object}|null}
|
|
394
|
+
*/
|
|
395
|
+
function parseSseFrame(frame) {
|
|
396
|
+
const text = String(frame == null ? '' : frame);
|
|
397
|
+
if (!text.trim()) return null;
|
|
398
|
+
let name = 'message';
|
|
399
|
+
const dataLines = [];
|
|
400
|
+
for (const line of text.split('\n')) {
|
|
401
|
+
if (line.startsWith('event:')) name = line.slice(6).trim();
|
|
402
|
+
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
|
|
403
|
+
}
|
|
404
|
+
let data = {};
|
|
405
|
+
if (dataLines.length) {
|
|
406
|
+
try { data = JSON.parse(dataLines.join('\n')); } catch { data = { raw: dataLines.join('\n') }; }
|
|
407
|
+
}
|
|
408
|
+
return { name, data };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* POST 并按**事件流**增量读取(SSE 风格分帧),供"后台预热 / 等服务端解包"用。
|
|
413
|
+
*
|
|
414
|
+
* 为什么不复用 fetchWithRetry:它按「单次请求 8.5s」判失败并自动重发,而流式响应的正确判据是
|
|
415
|
+
* **有没有进展**(契约 §四)。重发会把"慢但正常"变成"重复排进串行队列",总时长上限更是
|
|
416
|
+
* 会把慢机器误判成失败 —— 那正是本设计要消灭的东西。
|
|
417
|
+
* ⇒ 本函数刻意不设总时长上限,只用「连续无帧」判停摆;命名与文案一律用 stalled/停摆,
|
|
418
|
+
* 绝不出现 timeout/超时(`setTimeout`/`clearTimeout` 是浏览器 API 名,不是文案,无法也不该改)。
|
|
419
|
+
*
|
|
420
|
+
* 兼容(契约 §七 + E10):服务端不认识 `stream` 时会回一次性 JSON;开流前就失败(400/404)也是 JSON。
|
|
421
|
+
* 两者都靠 **Content-Type** 分辨:不是 text/event-stream ⇒ 整体解析后原样返回,调用方无需分支。
|
|
422
|
+
*
|
|
423
|
+
* @param {string} path
|
|
424
|
+
* @param {any} body
|
|
425
|
+
* @param {{onEvent?:Function, signal?:AbortSignal, idleMs?:number, onStall?:Function, headers?:object}} [opts]
|
|
426
|
+
* onEvent(name, data) 每帧回调(含 ping);onStall(info) 停摆时回调一次
|
|
427
|
+
* @returns {Promise<object>} done 的 data(`{ok:true,...}`)/ error 的 `{ok:false,reason,message}` /
|
|
428
|
+
* 停摆的 `{ok:false,reason:'stalled'}` / 一次性 JSON 原样
|
|
429
|
+
*/
|
|
430
|
+
export async function postStream(path, body, opts = {}) {
|
|
431
|
+
const idleMs = Number(opts.idleMs) > 0 ? Number(opts.idleMs) : 15000;
|
|
432
|
+
const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
|
|
433
|
+
const onStall = typeof opts.onStall === 'function' ? opts.onStall : () => {};
|
|
434
|
+
const ctrl = new AbortController();
|
|
435
|
+
const onOuterAbort = () => { try { ctrl.abort(); } catch { /* 已结束 */ } };
|
|
436
|
+
if (opts.signal) {
|
|
437
|
+
if (opts.signal.aborted) onOuterAbort();
|
|
438
|
+
else opts.signal.addEventListener('abort', onOuterAbort, { once: true });
|
|
439
|
+
}
|
|
440
|
+
let idleTimer = null;
|
|
441
|
+
let stalled = false;
|
|
442
|
+
const stopIdle = () => { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } };
|
|
443
|
+
// 每收到任意一帧(含 ping)就重置:ping 每 2s 一条 ⇒ 15s 容忍连丢 7 个心跳,够扛反代抖动
|
|
444
|
+
const bumpIdle = () => {
|
|
445
|
+
stopIdle();
|
|
446
|
+
idleTimer = setTimeout(() => { stalled = true; onOuterAbort(); }, idleMs);
|
|
447
|
+
};
|
|
448
|
+
try {
|
|
449
|
+
bumpIdle();
|
|
450
|
+
const res = await fetch(path, {
|
|
451
|
+
method: 'POST',
|
|
452
|
+
headers: { 'content-type': MIME_JSON, ...(opts.headers || {}) },
|
|
453
|
+
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
454
|
+
signal: ctrl.signal,
|
|
455
|
+
});
|
|
456
|
+
const ctype = String((res.headers && res.headers.get && res.headers.get('content-type')) || '');
|
|
457
|
+
if (!/text\/event-stream/i.test(ctype)) {
|
|
458
|
+
// 老服务端 / 开流前失败:整体解析,形状与服务端 JSON 一致,调用方无需分支
|
|
459
|
+
stopIdle();
|
|
460
|
+
const text = await res.text();
|
|
461
|
+
let data = null;
|
|
462
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = null; }
|
|
463
|
+
if (!res.ok) {
|
|
464
|
+
throw new Error((data && (data.message || data.error)) || `HTTP ${res.status}`);
|
|
465
|
+
}
|
|
466
|
+
if (data && typeof data === 'object') return { ...data, streamFallback: true };
|
|
467
|
+
throw new Error(`响应既不是事件流也不是 JSON(Content-Type=${ctype || '无'})`);
|
|
468
|
+
}
|
|
469
|
+
const reader = res.body.getReader();
|
|
470
|
+
const dec = new TextDecoder();
|
|
471
|
+
let buf = '';
|
|
472
|
+
let last = null;
|
|
473
|
+
for (;;) {
|
|
474
|
+
const chunk = await reader.read();
|
|
475
|
+
if (chunk.done) break;
|
|
476
|
+
buf += dec.decode(chunk.value, { stream: true });
|
|
477
|
+
let cut = buf.indexOf('\n\n');
|
|
478
|
+
while (cut >= 0) {
|
|
479
|
+
const frameText = buf.slice(0, cut);
|
|
480
|
+
buf = buf.slice(cut + 2); // 不完整帧留在 buffer 等下一块,不丢
|
|
481
|
+
cut = buf.indexOf('\n\n');
|
|
482
|
+
const ev = parseSseFrame(frameText);
|
|
483
|
+
if (!ev) continue;
|
|
484
|
+
bumpIdle();
|
|
485
|
+
if (ev.name === 'done') {
|
|
486
|
+
last = { ok: true, ...(ev.data || {}) };
|
|
487
|
+
} else if (ev.name === 'error') {
|
|
488
|
+
last = {
|
|
489
|
+
ok: false,
|
|
490
|
+
reason: (ev.data && ev.data.reason) || 'unpack-failed',
|
|
491
|
+
message: (ev.data && ev.data.message) || '服务端解包失败',
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
onEvent(ev.name, ev.data || {});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
stopIdle();
|
|
498
|
+
if (last) return last;
|
|
499
|
+
return { ok: false, reason: 'stream-ended', message: '事件流已结束但没收到 done/error' };
|
|
500
|
+
} catch (e) {
|
|
501
|
+
stopIdle();
|
|
502
|
+
if (stalled) {
|
|
503
|
+
// 措辞铁律:这里是"停摆",不是"超时"(见契约 §四)
|
|
504
|
+
const info = { ok: false, reason: 'stalled', message: `服务端停摆(${Math.round(idleMs / 1000)}s 无任何事件),已放弃等待` };
|
|
505
|
+
try { onStall(info); } catch { /* 回调失败不影响结果 */ }
|
|
506
|
+
return info;
|
|
507
|
+
}
|
|
508
|
+
if (e && (e.name === 'AbortError' || /abort/i.test(String(e.message || '')))) {
|
|
509
|
+
return { ok: false, reason: 'aborted', message: '已取消等待' };
|
|
510
|
+
}
|
|
511
|
+
throw e;
|
|
512
|
+
} finally {
|
|
513
|
+
stopIdle();
|
|
514
|
+
if (opts.signal) { try { opts.signal.removeEventListener('abort', onOuterAbort); } catch { /* 忽略 */ } }
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 把错误对象格式化成一行可读文本(界面直接显示,也方便诊断日志落盘)。
|
|
520
|
+
* @param {unknown} err
|
|
521
|
+
* @returns {string}
|
|
522
|
+
*/
|
|
523
|
+
export function formatError(err) {
|
|
524
|
+
if (err instanceof Error) return err.message;
|
|
525
|
+
return String(err);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 测试用:清空 in-flight 表(生产代码不应调用) */
|
|
529
|
+
export function __clearInflightForTest() {
|
|
530
|
+
inflight.clear();
|
|
531
|
+
}
|