初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
@@ -0,0 +1,590 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 状态机(单一来源,档案 81 · R1-②)—— 本文件是「回到页面」链路**唯一**的策略实现。
|
||||
*
|
||||
* 事件(触发面) 判定 动作
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* ① 切回页面 / 首次进入 探针(平台 /api/dsh/status + 实例 GET /) 坏 → 恢复;好 → 撤提示条
|
||||
* (visibilitychange / pageshow) 提示条「正在检查工作区连接…」
|
||||
* ② 页面自己发出的请求 401 传输层已透明重放(档案 51);仍失败 → 恢复
|
||||
* 或 404 not_running
|
||||
* ③ 心跳(**页面可见**时每 25 s) soft 模式:**连续两次**失败才动作 恢复(滞后 ≤ 50 s)
|
||||
* ④ EventSource / WebSocket 断开 soft 模式:同上 恢复(比心跳更早)
|
||||
* ⑤ 有界保护 窗口 10 min 内恢复 > 3 次 停手:明确失败态 + 手动「重试」
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 恢复动作:覆盖层「工作区正在恢复…」→ POST /api/dsh/enter → 拿到新 token 的直达地址
|
||||
* → **原地 replace(保留 path/hash)**;拿不到 url → 原地 reload。
|
||||
* 冷却/告警:平台侧 orchestrator 负责崩溃熔断与告警(档案 78);本文件只管"页面这一侧"。
|
||||
* ⚠️ 改这个文件**不需要**再动 proxy.ts(档案 81 R1-① 已把脚本外置),但改完必须跑
|
||||
* `npm run verify`(会做 node --check + 内联回退检查)。
|
||||
* ──────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
(function () {
|
||||
if (window.__dshRecover) return;
|
||||
window.__dshRecover = 1;
|
||||
|
||||
// 一层覆盖层,两种用途:
|
||||
// pending —— 请求长时间未返回(服务端正在拉起实例)→ 显示"正在启动…",请求结束后撤掉
|
||||
// expired —— 401(实例被回收重建,launch token 已轮换)→ 显示后整页 reload
|
||||
// expired 是终态,不可被 pending 覆盖、也不主动撤除(随后就 reload 了)。
|
||||
var state = 'none';
|
||||
var box = null;
|
||||
var ticker = null;
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById('__dshRecoverCss')) return;
|
||||
var st = document.createElement('style');
|
||||
st.id = '__dshRecoverCss';
|
||||
// 档案 77 视觉升级:整套样式一次性注入(含 prefers-reduced-motion 回退)。
|
||||
// ⚠️ 本文件是注入脚本的模板字面量 —— 内容不得含反引号 / 美元花括号;
|
||||
// 故 CSS 里的字体名一律用单引号('Segoe UI'),JS 字符串用双引号。
|
||||
st.textContent = [
|
||||
"@keyframes __dshr-spin{to{transform:rotate(360deg)}}",
|
||||
"@keyframes __dshr-spin-rev{to{transform:rotate(-360deg)}}",
|
||||
"@keyframes __dshr-core{0%,100%{transform:scale(.82);opacity:.6}50%{transform:scale(1.12);opacity:1}}",
|
||||
"@keyframes __dshr-halo{0%,100%{opacity:.45;transform:scale(.96)}50%{opacity:.9;transform:scale(1.04)}}",
|
||||
"@keyframes __dshr-scan{0%{transform:translateX(-115%)}100%{transform:translateX(275%)}}",
|
||||
"@keyframes __dshr-dot{0%,100%{opacity:.25}50%{opacity:1}}",
|
||||
"@keyframes __dshr-sheen{0%{background-position:190% 0}100%{background-position:-90% 0}}",
|
||||
".__dsh-ov{",
|
||||
"position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;",
|
||||
"background:radial-gradient(900px 460px at 50% 26%, rgba(88,166,255,.13), transparent 62%),radial-gradient(760px 420px at 50% 84%, rgba(163,113,247,.13), transparent 62%),rgba(9,11,16,.9);",
|
||||
"}",
|
||||
".__dsh-card{",
|
||||
"position:relative;display:flex;flex-direction:column;align-items:center;gap:13px;",
|
||||
"padding:26px 34px 22px;border-radius:16px;",
|
||||
"background:linear-gradient(180deg, rgba(26,31,43,.86), rgba(17,21,30,.86));",
|
||||
"box-shadow:0 1px 0 rgba(255,255,255,.06) inset,0 0 0 1px rgba(120,150,210,.16),0 18px 48px rgba(0,0,0,.5);",
|
||||
"-webkit-backdrop-filter:blur(9px);backdrop-filter:blur(9px);",
|
||||
"}",
|
||||
".__dsh-orb{position:relative;width:86px;height:86px}",
|
||||
".__dsh-halo{",
|
||||
"position:absolute;inset:-20px;border-radius:50%;",
|
||||
"background:radial-gradient(circle, rgba(88,166,255,.30), rgba(163,113,247,.17) 46%, transparent 70%);",
|
||||
"animation:__dshr-halo 2.6s ease-in-out infinite;",
|
||||
"}",
|
||||
".__dsh-arc{",
|
||||
"position:absolute;inset:0;border-radius:50%;",
|
||||
"background:conic-gradient(from 0deg, rgba(88,166,255,0) 0deg, rgba(88,166,255,.06) 110deg, #58a6ff 296deg, #a371f7 342deg, rgba(163,113,247,0) 360deg);",
|
||||
"-webkit-mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));",
|
||||
"mask:radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 2px));",
|
||||
"animation:__dshr-spin 1.5s linear infinite;",
|
||||
"}",
|
||||
".__dsh-arc2{position:absolute;inset:13px;border-radius:50%;border:1px dashed rgba(163,113,247,.42);animation:__dshr-spin-rev 7s linear infinite}",
|
||||
".__dsh-ring{position:absolute;inset:24px;border-radius:50%;border:1px solid rgba(120,160,230,.20)}",
|
||||
".__dsh-core{",
|
||||
"position:absolute;left:50%;top:50%;width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;",
|
||||
"background:radial-gradient(circle, #eaf3ff 0%, #66b0ff 46%, rgba(88,166,255,0) 74%);",
|
||||
"box-shadow:0 0 12px rgba(88,166,255,.85),0 0 34px rgba(163,113,247,.45);",
|
||||
"animation:__dshr-core 1.6s ease-in-out infinite;",
|
||||
"}",
|
||||
".__dsh-dots{position:absolute;inset:0;animation:__dshr-spin 3.4s linear infinite}",
|
||||
".__dsh-dots i{position:absolute;left:50%;top:-2px;width:5px;height:5px;margin-left:-2.5px;border-radius:50%;background:#a371f7;box-shadow:0 0 8px #a371f7;animation:__dshr-dot 1.4s ease-in-out infinite}",
|
||||
".__dsh-dots i:nth-child(2){top:auto;bottom:-2px;animation-delay:.35s;background:#58a6ff;box-shadow:0 0 8px #58a6ff}",
|
||||
".__dsh-dots i:nth-child(3){left:-2px;top:50%;margin:-2.5px 0 0 0;animation-delay:.7s;background:#7ee0c0;box-shadow:0 0 8px #7ee0c0}",
|
||||
".__dsh-msg{",
|
||||
"font:14.5px/1.6 system-ui,-apple-system,'Segoe UI',sans-serif;color:#e9f1fa;letter-spacing:.2px;text-align:center;",
|
||||
"background:linear-gradient(90deg, rgba(233,241,250,.94) 0%, #ffffff 48%, rgba(233,241,250,.94) 96%);",
|
||||
"-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;",
|
||||
"background-size:230% 100%;animation:__dshr-sheen 3s linear infinite;",
|
||||
"}",
|
||||
".__dsh-label{font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.16em;color:rgba(150,175,215,.72);text-transform:uppercase}",
|
||||
".__dsh-track{position:relative;width:208px;height:3px;border-radius:3px;background:rgba(255,255,255,.09);overflow:hidden}",
|
||||
".__dsh-track i{position:absolute;top:0;bottom:0;width:42%;border-radius:3px;background:linear-gradient(90deg, transparent, #58a6ff 45%, #a371f7 70%, transparent);animation:__dshr-scan 1.45s ease-in-out infinite}",
|
||||
"@media (prefers-reduced-motion: reduce){",
|
||||
".__dsh-arc,.__dsh-arc2,.__dsh-dots,.__dsh-dots i,.__dsh-core,.__dsh-halo,.__dsh-track i,.__dsh-msg{animation:none!important}",
|
||||
"}"
|
||||
].join(String.fromCharCode(10));
|
||||
(document.head || document.documentElement).appendChild(st);
|
||||
}
|
||||
|
||||
// spinner 只创建一次,之后仅改文案 —— 否则每次重设 innerHTML 都会重建元素,
|
||||
// 旋转动画被打断,看起来像"卡住不动"。
|
||||
function ensureBox() {
|
||||
if (box) return;
|
||||
ensureCss();
|
||||
box = document.createElement('div');
|
||||
box.id = '__dshRecover';
|
||||
box.className = '__dsh-ov';
|
||||
// 「AI 核心」加载体:扫描弧(conic 渐变)+ 反向虚线环 + 脉冲核心 + 三颗轨道粒子。
|
||||
// 全部 createElement 构建(不用 innerHTML —— 实例页可能启用 Trusted Types)。
|
||||
var card = document.createElement('div');
|
||||
card.className = '__dsh-card';
|
||||
var orb = document.createElement('div');
|
||||
orb.className = '__dsh-orb';
|
||||
var parts = ['__dsh-halo', '__dsh-arc', '__dsh-arc2', '__dsh-ring', '__dsh-core'];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var el = document.createElement('div');
|
||||
el.className = parts[i];
|
||||
orb.appendChild(el);
|
||||
}
|
||||
var dots = document.createElement('div');
|
||||
dots.className = '__dsh-dots';
|
||||
for (var j = 0; j < 3; j++) dots.appendChild(document.createElement('i'));
|
||||
orb.appendChild(dots);
|
||||
var tx = document.createElement('div');
|
||||
tx.id = '__dshRecoverMsg';
|
||||
tx.className = '__dsh-msg';
|
||||
var lb = document.createElement('div');
|
||||
lb.className = '__dsh-label';
|
||||
lb.textContent = 'DSH · auto recovery';
|
||||
var tr = document.createElement('div');
|
||||
tr.className = '__dsh-track';
|
||||
tr.appendChild(document.createElement('i'));
|
||||
card.appendChild(orb);
|
||||
card.appendChild(tx);
|
||||
card.appendChild(lb);
|
||||
card.appendChild(tr);
|
||||
box.appendChild(card);
|
||||
(document.body || document.documentElement).appendChild(box);
|
||||
}
|
||||
|
||||
function setMsg(msg) {
|
||||
var m = document.getElementById('__dshRecoverMsg');
|
||||
if (m) m.textContent = msg;
|
||||
}
|
||||
|
||||
function showPending(base) {
|
||||
if (state === 'expired') return;
|
||||
state = 'pending';
|
||||
ensureBox();
|
||||
var t0 = Date.now();
|
||||
setMsg(base + '(已等待 0 秒)');
|
||||
if (ticker) clearInterval(ticker);
|
||||
ticker = setInterval(function () {
|
||||
if (state !== 'pending') return;
|
||||
setMsg(base + '(已等待 ' + Math.floor((Date.now() - t0) / 1000) + ' 秒)');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function hidePending() {
|
||||
if (state !== 'pending') return;
|
||||
state = 'none';
|
||||
if (ticker) {
|
||||
clearInterval(ticker);
|
||||
ticker = null;
|
||||
}
|
||||
if (box && box.parentNode) box.parentNode.removeChild(box);
|
||||
box = null;
|
||||
}
|
||||
|
||||
// ── 顶部轻提示条(不遮罩)────────────────────────────────────────────────
|
||||
// 用户明确反馈「回到页面根本没看到过提示」——所以「看得见」本身就是要交付的东西。
|
||||
// 只在"离开 ≥20 秒又回来"时出现;瞬时切换不打扰。至少显示 900ms 再撤,
|
||||
// 否则一闪而过等于没提示。
|
||||
var bar = null;
|
||||
var barAt = 0;
|
||||
var barTimer = null;
|
||||
|
||||
function ensureBar() {
|
||||
if (bar) return;
|
||||
ensureCss();
|
||||
bar = document.createElement('div');
|
||||
bar.id = '__dshConnBar';
|
||||
bar.setAttribute(
|
||||
'style',
|
||||
'position:fixed;top:0;left:0;right:0;z-index:2147483646;padding:7px 12px;' +
|
||||
'text-align:center;font:13px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;' +
|
||||
'color:#fff;background:rgba(24,95,165,.95)'
|
||||
);
|
||||
(document.body || document.documentElement).appendChild(bar);
|
||||
}
|
||||
|
||||
function showBar(msg) {
|
||||
if (state === 'pending' || state === 'expired') return;
|
||||
ensureBar();
|
||||
barAt = Date.now();
|
||||
bar.textContent = msg;
|
||||
if (barTimer) {
|
||||
clearTimeout(barTimer);
|
||||
barTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function hideBar() {
|
||||
if (!bar) return;
|
||||
var wait = Math.max(0, 900 - (Date.now() - barAt));
|
||||
if (barTimer) clearTimeout(barTimer);
|
||||
barTimer = setTimeout(function () {
|
||||
barTimer = null;
|
||||
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
|
||||
bar = null;
|
||||
}, wait + 60);
|
||||
}
|
||||
|
||||
function expire() {
|
||||
if (state === 'expired') return;
|
||||
hideBar();
|
||||
hidePending();
|
||||
state = 'expired';
|
||||
ensureBox();
|
||||
setMsg('正在重新连接你的工作区…');
|
||||
setTimeout(function () {
|
||||
location.reload();
|
||||
}, 900);
|
||||
}
|
||||
|
||||
function isApi(u) {
|
||||
return String(u).indexOf('/api/') >= 0;
|
||||
}
|
||||
|
||||
// ── 实例不可用(空闲回收 / 被关闭 / 崩溃)→ 自动唤醒并重建连接(档案 72)────────────
|
||||
// 为什么需要:原来只认 401。实例被回收后代理对非导航请求返回 404 not_running,
|
||||
// 脚本不认识 → 页面上的请求静默失败、SSE 静默断流 → 用户只能**手动刷新**。
|
||||
//
|
||||
// 注入脚本跑在实例子域上,而平台接口(/api/dsh/*)在门户域 → 必须跨域调用;
|
||||
// server.ts 的 CORS 白名单允许 baseDomain 及其子域 + Allow-Credentials,故带 cookie 可用。
|
||||
function portalOrigin() {
|
||||
var l = location.hostname.split('.');
|
||||
if (l.length > 2) return location.protocol + '//' + l.slice(1).join('.');
|
||||
return location.origin;
|
||||
}
|
||||
|
||||
var recovering = false;
|
||||
// 保留原始 fetch:探针与探活走它,避免被下面的 watch() 当成"业务请求"而误亮覆盖层。
|
||||
var rawFetch = window.fetch;
|
||||
|
||||
// ── 就地恢复(档案 77)───────────────────────────────────────────────────
|
||||
// 不再跳门户过渡页 wake.html:那会换域名(子域 → 门户),且它的 next 是空的,
|
||||
// 恢复后落回根地址、页面状态全丢 —— 用户明确反馈这样"不自然"。
|
||||
// 改为:本页显示覆盖层 → 调一次平台 /api/dsh/enter(会拉起实例并返回**带新 token**
|
||||
// 的直达地址)→ 原地跳回,并尽量保留当前路径与 hash。
|
||||
function withPath(u) {
|
||||
try {
|
||||
var n = new URL(u, location.href);
|
||||
if (n.hostname === location.hostname && location.pathname !== '/') n.pathname = location.pathname;
|
||||
if (location.hash) n.hash = location.hash;
|
||||
return n.href;
|
||||
} catch (e) {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 有界恢复(档案 81 · R1-③)──────────────────────────────────────────────
|
||||
// 现场事故(2026-09-13 · guest):实例被 OOM 反复杀 → 页面「恢复 → 起来 → 又被杀」**无限循环**
|
||||
// (每 ~35 s 一次 GET /)。恢复逻辑本身没错,错在**没有上限**。
|
||||
// 规则:窗口内恢复次数超限后**停止自动恢复**,停在明确的失败态 + 手动「重试」(不自动 reload)。
|
||||
var RECOVER_WINDOW_MS = 10 * 60 * 1000;
|
||||
var RECOVER_MAX = 3;
|
||||
var recFirstAt = 0;
|
||||
var recCount = 0;
|
||||
|
||||
function overRecoverBudget() {
|
||||
var now = Date.now();
|
||||
if (now - recFirstAt > RECOVER_WINDOW_MS) {
|
||||
recFirstAt = now;
|
||||
recCount = 0;
|
||||
}
|
||||
recCount += 1;
|
||||
return recCount > RECOVER_MAX;
|
||||
}
|
||||
|
||||
function addRetryButton() {
|
||||
var box = document.getElementById('__dshRecover');
|
||||
if (!box || document.getElementById('__dshRetryBtn')) return;
|
||||
var b = document.createElement('button');
|
||||
b.id = '__dshRetryBtn';
|
||||
b.type = 'button';
|
||||
b.textContent = '重试';
|
||||
b.setAttribute(
|
||||
'style',
|
||||
'margin-top:14px;padding:8px 18px;border:0;border-radius:8px;cursor:pointer;' +
|
||||
'font:14px/1 system-ui,-apple-system,sans-serif;color:#fff;' +
|
||||
'background:linear-gradient(135deg,#58a6ff,#a371f7);box-shadow:0 0 18px rgba(88,166,255,.35)'
|
||||
);
|
||||
b.addEventListener('click', function () {
|
||||
recFirstAt = 0; // 手动重试 = 重置预算
|
||||
recCount = 0;
|
||||
recovering = false;
|
||||
b.parentNode && b.parentNode.removeChild(b);
|
||||
recover('正在重新连接…');
|
||||
});
|
||||
box.appendChild(b);
|
||||
}
|
||||
|
||||
function showExhausted() {
|
||||
recovering = true; // 保持在"不再自动恢复"的状态
|
||||
hideBar();
|
||||
ensureBox(); // 只建覆盖层,不启动 showPending 的秒表
|
||||
setMsg('工作区暂时不可用(已连续尝试恢复 ' + RECOVER_MAX + ' 次)。可能是实例反复重启或资源不足 —— 可点下方「重试」,或稍后再来。');
|
||||
addRetryButton();
|
||||
}
|
||||
|
||||
function recover(reason) {
|
||||
if (recovering) return;
|
||||
if (overRecoverBudget()) {
|
||||
showExhausted();
|
||||
return;
|
||||
}
|
||||
recovering = true;
|
||||
hideBar();
|
||||
showPending(reason || '工作区正在恢复,请稍候…');
|
||||
var go = function (url) {
|
||||
if (url) {
|
||||
setMsg('已就绪,正在返回你的工作区…');
|
||||
setTimeout(function () {
|
||||
location.replace(withPath(url));
|
||||
}, 700);
|
||||
return;
|
||||
}
|
||||
setMsg('正在重新连接…');
|
||||
setTimeout(function () {
|
||||
location.reload();
|
||||
}, 700);
|
||||
};
|
||||
rawFetch.call(window, portalOrigin() + '/api/dsh/enter', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (j) {
|
||||
go(j && (j.url || j.redirect));
|
||||
})
|
||||
.catch(function () {
|
||||
go(null);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 探针:页面 ↔ 实例的连通性(档案 77 新增,解决"看不到提示")────────────
|
||||
// 为什么需要:门户口 /api/dsh/status 的 running 只说明「进程在不在」,
|
||||
// 而它把 starting(正在启动)也算在跑(dsh.ts 的 alive())。实测每次崩溃后新实例
|
||||
// 1 秒内就起来 ⇒ 用户回到页面时几乎总是 starting/running ⇒ 旧判据永远不动手。
|
||||
// 探针 GET / 走实例自己的鉴权入口:200 = 这个页面确实能用;超时 / 3xx / 异常 = 已脱节。
|
||||
// 返回 404/405(官方改了入口形态)→ 'unknown' → 退回旧判据,**绝不误报**。
|
||||
function poke() {
|
||||
var ctl = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
var t = setTimeout(function () {
|
||||
try {
|
||||
if (ctl) ctl.abort();
|
||||
} catch (e) {}
|
||||
}, 2500);
|
||||
return rawFetch
|
||||
.call(window, '/', {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
redirect: 'manual',
|
||||
signal: ctl ? ctl.signal : undefined,
|
||||
})
|
||||
.then(function (r) {
|
||||
clearTimeout(t);
|
||||
if (r.status === 200) return 'ok';
|
||||
if (r.status === 404 || r.status === 405) return 'unknown';
|
||||
return 'bad';
|
||||
})
|
||||
.catch(function () {
|
||||
clearTimeout(t);
|
||||
return 'bad';
|
||||
});
|
||||
}
|
||||
|
||||
// 主动探活。必要性:实例被回收后,页面上的存量请求不一定失败(SSE 静默断流),
|
||||
// 所以"用户回到页面"这件事本身要主动问一次。
|
||||
// withNotice:真的离开过一会儿又回来 → 先亮顶部提示条(看得见的反馈)。
|
||||
var lastProbe = 0;
|
||||
// soft = true:**静默触发面**(心跳 / 流断)用 —— 必须**连续两次**失败才动手。
|
||||
// 为什么:恢复 = 原地 location.replace,页面内未保存的输入会丢;一次网络抖动不该触发它。
|
||||
var softFails = 0;
|
||||
function probe(withNotice, soft) {
|
||||
if (recovering) return;
|
||||
if (Date.now() - lastProbe < 15000) return;
|
||||
lastProbe = Date.now();
|
||||
if (withNotice) showBar('正在检查工作区连接…');
|
||||
var go = function (reason) {
|
||||
if (soft !== true) {
|
||||
recover(reason);
|
||||
return;
|
||||
}
|
||||
softFails += 1;
|
||||
if (softFails >= 2) recover(reason);
|
||||
};
|
||||
rawFetch
|
||||
.call(window, portalOrigin() + '/api/dsh/status', { credentials: 'include' })
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (j) {
|
||||
if (!j) {
|
||||
hideBar();
|
||||
return;
|
||||
}
|
||||
if (j.running === false) {
|
||||
go('工作区已休眠,正在唤醒…');
|
||||
return;
|
||||
}
|
||||
return poke().then(function (verdict) {
|
||||
if (verdict === 'ok' || verdict === 'unknown') {
|
||||
softFails = 0;
|
||||
hideBar();
|
||||
return;
|
||||
}
|
||||
go('工作区正在恢复,请稍候…');
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
hideBar();
|
||||
});
|
||||
}
|
||||
|
||||
function hit(u, s, readBody) {
|
||||
if (s === 401 && isApi(u)) {
|
||||
expire();
|
||||
return;
|
||||
}
|
||||
// 404 not_running = 实例已被回收或关闭。原先没人管这种情况,用户只能手动刷新。
|
||||
if (s === 404 && isApi(u) && typeof readBody === 'function') {
|
||||
readBody(function (text) {
|
||||
if (String(text).indexOf('not_running') >= 0) {
|
||||
recover('工作区正在恢复,请稍候…');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端在"实例未就绪"时会 hold 住请求(最长 20 秒)等拉起,期间浏览器端原本没有任何反馈
|
||||
// —— 用户点了重连却看不出在等什么。这里给挂起的 API 请求加计时:≥3 秒就亮出覆盖层。
|
||||
// 不会误伤流式接口:SSE / ReadableStream 在**响应头到达**时 promise 就已 resolve,计时已经清掉。
|
||||
var SLOW_MS = 3000;
|
||||
|
||||
function watch(u) {
|
||||
if (!isApi(u)) return function () {};
|
||||
var shown = false;
|
||||
var t = setTimeout(function () {
|
||||
shown = true;
|
||||
showPending('实例正在启动,请稍候…');
|
||||
}, SLOW_MS);
|
||||
return function () {
|
||||
clearTimeout(t);
|
||||
if (shown) hidePending();
|
||||
};
|
||||
}
|
||||
|
||||
var of = window.fetch;
|
||||
if (of) {
|
||||
window.fetch = function (i) {
|
||||
var u = typeof i === 'string' ? i : (i && i.url) || '';
|
||||
var end = watch(u);
|
||||
return of.apply(this, arguments).then(
|
||||
function (r) {
|
||||
end();
|
||||
try {
|
||||
hit(u, r.status, function (cb) {
|
||||
// clone 后再读:不能消费原响应的 body,否则调用方拿不到数据
|
||||
try {
|
||||
r.clone()
|
||||
.text()
|
||||
.then(cb)
|
||||
.catch(function () {});
|
||||
} catch (e) {}
|
||||
});
|
||||
} catch (e) {}
|
||||
return r;
|
||||
},
|
||||
function (e) {
|
||||
end();
|
||||
throw e;
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
var oo = XMLHttpRequest.prototype.open;
|
||||
var os = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (m, u) {
|
||||
this.__u = u;
|
||||
return oo.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function () {
|
||||
var x = this;
|
||||
var end = watch(x.__u);
|
||||
x.addEventListener('loadend', function () {
|
||||
end();
|
||||
try {
|
||||
hit(x.__u, x.status, function (cb) {
|
||||
try {
|
||||
cb(String(x.responseText || ''));
|
||||
} catch (e) {}
|
||||
});
|
||||
} catch (e) {}
|
||||
});
|
||||
return os.apply(this, arguments);
|
||||
};
|
||||
|
||||
// 「用户回到会话页面」= 切回标签页 / 点回窗口 / 从缓存恢复页面。
|
||||
// 只有"真的离开过一会儿又回来"(≥20 秒)才亮顶部提示条 —— 瞬时切换不打扰;
|
||||
// 但无论亮不亮,探针都会跑:一旦判定页面已与实例脱节就恢复并给覆盖层。
|
||||
var LEFT_MS = 20000;
|
||||
var leftAt = 0;
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'visible') {
|
||||
var away = leftAt > 0 && Date.now() - leftAt >= LEFT_MS;
|
||||
leftAt = 0;
|
||||
probe(away);
|
||||
} else {
|
||||
leftAt = Date.now();
|
||||
}
|
||||
});
|
||||
window.addEventListener('focus', function () {
|
||||
probe(false);
|
||||
});
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (e.persisted) probe(true);
|
||||
});
|
||||
|
||||
// ── 档案 77 补丁②(2026-09-13):补齐「连接悄悄断掉」的两条触发面 ─────────
|
||||
// 现场故障:用户**一直盯着页面**(不切标签、不 focus),实例侧连接断了(模型流挂掉),
|
||||
// 页面显示「连接异常」,而上面那些事件一个都不会来 ⇒ 既不提示也不恢复,只能手动刷新。
|
||||
// 说明:原设计(档案 77 §五 方案 B「不做周期性探活」)的前提是"用户在场却不操作没有收益",
|
||||
// 实测证明该前提不成立 —— 于是补两条**静默**触发面;两者都走 soft 模式(连续两次失败才恢复)。
|
||||
// ① 心跳:页面**可见**时每 25 秒静默探一次(不可见时完全不付出成本)
|
||||
// ② 流断:包装 EventSource / WebSocket 的 error / close,立即探一次(比心跳更早发现)
|
||||
var HEARTBEAT_MS = 25000;
|
||||
setInterval(function () {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
probe(false, true);
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
function wrapStreams() {
|
||||
var ES = window.EventSource;
|
||||
if (typeof ES === 'function') {
|
||||
var ES2 = function (url, opts) {
|
||||
var es = new ES(url, opts);
|
||||
es.addEventListener('error', function () {
|
||||
probe(false, true);
|
||||
});
|
||||
return es;
|
||||
};
|
||||
ES2.prototype = ES.prototype;
|
||||
if (ES.CONNECTING !== undefined) {
|
||||
ES2.CONNECTING = ES.CONNECTING;
|
||||
ES2.OPEN = ES.OPEN;
|
||||
ES2.CLOSED = ES.CLOSED;
|
||||
}
|
||||
window.EventSource = ES2;
|
||||
}
|
||||
var WS = window.WebSocket;
|
||||
if (typeof WS === 'function') {
|
||||
var WS2 = function (url, protocols) {
|
||||
var ws = protocols === undefined ? new WS(url) : new WS(url, protocols);
|
||||
ws.addEventListener('close', function () {
|
||||
probe(false, true);
|
||||
});
|
||||
ws.addEventListener('error', function () {
|
||||
probe(false, true);
|
||||
});
|
||||
return ws;
|
||||
};
|
||||
WS2.prototype = WS.prototype;
|
||||
if (WS.CONNECTING !== undefined) {
|
||||
WS2.CONNECTING = WS.CONNECTING;
|
||||
WS2.OPEN = WS.OPEN;
|
||||
WS2.CLOSING = WS.CLOSING;
|
||||
WS2.CLOSED = WS.CLOSED;
|
||||
}
|
||||
window.WebSocket = WS2;
|
||||
}
|
||||
}
|
||||
wrapStreams();
|
||||
})();
|
||||
Reference in new issue
Block a user