@mrrisega/dsh-remote 0.7.1 → 0.7.2-beta.1
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/clients/dsh-remote/dsh-bridge.mjs +16 -0
- package/clients/dsh-remote/mirror-diag-script.js +154 -0
- package/clients/dsh-remote/mirror-diag.mjs +106 -0
- package/clients/dsh-remote/test/bridge-html-inject.test.mjs +46 -10
- package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +40 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +82 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +16 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +154 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag.mjs +106 -0
|
@@ -76,6 +76,7 @@ import { promisify } from "node:util";
|
|
|
76
76
|
import { gzip as gzipCb } from "node:zlib";
|
|
77
77
|
// 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
|
|
78
78
|
import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
|
|
79
|
+
import { maybeInjectMirrorDiag } from "./mirror-diag.mjs";
|
|
79
80
|
import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
|
|
80
81
|
// 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
|
|
81
82
|
import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
|
|
@@ -862,6 +863,15 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
862
863
|
buf = m.buf;
|
|
863
864
|
armMirrorLoadWatch(path);
|
|
864
865
|
}
|
|
866
|
+
// 镜像页加载故障采集器(2026-10-17,**与 e2ee 无关**):出问题的用户往往没开 E2EE,
|
|
867
|
+
// 所以无条件注入。只在失败时上报(见 mirror-diag-script.js),零 DOM 改动。
|
|
868
|
+
{
|
|
869
|
+
const d = maybeInjectMirrorDiag({ buf, contentType });
|
|
870
|
+
if (d.injected) {
|
|
871
|
+
console.log(`[bridge] mirror-diag 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(d.buf.length / 1024).toFixed(0)}KB`);
|
|
872
|
+
buf = d.buf;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
865
875
|
// E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
|
|
866
876
|
if (e2ee.enabled) {
|
|
867
877
|
const s = maybeInjectE2eeShim({ buf, contentType });
|
|
@@ -1235,6 +1245,12 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1235
1245
|
console.log(`[bridge] ws-error ${path} (id=${id}): ${e.message || ""}`);
|
|
1236
1246
|
if (!session.opened) {
|
|
1237
1247
|
wsSessions.delete(id);
|
|
1248
|
+
// ★ 上游可能已经换端口(桌面端 App 重启会换本地端口,生产日志里抓到过
|
|
1249
|
+
// `upstream_ws_open_failed ... ECONNREFUSED 127.0.0.1:19387`)。
|
|
1250
|
+
// HTTP 失败路径早就会触发重新发现,**WS 失败路径之前漏了** —— 而镜像页常常
|
|
1251
|
+
// 先开 WS(控制通道/数据通道),于是端口变了以后一直 502、页面白屏,直到用户
|
|
1252
|
+
// 手动重开。这里补上:本次失败照旧回 502,但立即触发重新发现,下一次重试就命中新端口。
|
|
1253
|
+
void refreshUpstream("ws-open 上游连接失败");
|
|
1238
1254
|
send({ id, type: "ws-open", ok: false, code: 502, reason: String(e.message || "ws error") });
|
|
1239
1255
|
}
|
|
1240
1256
|
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mirror-diag — 镜像页「加载故障」采集器(由 bridge 注入到 text/html 的 </head> 前)。
|
|
3
|
+
*
|
|
4
|
+
* 由来(2026-10-17):用户反馈「装了 UI 插件后打不开界面 / Failed to load plugins」,
|
|
5
|
+
* 但本地与账号环境都复现不出来。生产日志里只看到 `/plugins/??…` 的 499 中断,
|
|
6
|
+
* 拿不到浏览器侧的真实失败项(哪个 URL、多少毫秒、什么错误)。这个采集器就是
|
|
7
|
+
* **让下一次故障自己带证据回来**。
|
|
8
|
+
*
|
|
9
|
+
* 设计约束(都是硬要求):
|
|
10
|
+
* 1. **绝不打扰用户**:不改 DOM、不加 UI、不阻塞渲染;任何异常都在内部吞掉。
|
|
11
|
+
* 2. **只在出错时上报**:没有失败资源/脚本错误 → 一个字节都不发(零流量)。
|
|
12
|
+
* 3. **不泄密**:只上报**路径**(剥掉 query/hash)、状态码、耗时、字节数、错误消息前 500 字;
|
|
13
|
+
* 不上报页面内容、请求体、cookie、token、用户信息。
|
|
14
|
+
* 4. **可关**:`?mirror_diag=0` 或 bridge 环境变量 `DSH_MIRROR_DIAG=0` → 完全不注入。
|
|
15
|
+
* 5. **慢请求也记**:`/plugins/`、`/assets/` 加载超过 8s 记一条(这正是"插件聚合包被
|
|
16
|
+
* 限速拖死 → 浏览器 499"的现场)。
|
|
17
|
+
*
|
|
18
|
+
* 上报地址:`/relay-api/api/mirror-diag`(企业端,需登录态;token 从 localStorage.dsh_token 取)。
|
|
19
|
+
*/
|
|
20
|
+
(function () {
|
|
21
|
+
"use strict";
|
|
22
|
+
var W = window, D = document;
|
|
23
|
+
var VERSION = 1;
|
|
24
|
+
var SLOW_MS = 8000;
|
|
25
|
+
var MAX_ITEMS = 20;
|
|
26
|
+
var FLUSH_DELAY = 2500;
|
|
27
|
+
var ENDPOINT = "/relay-api/api/mirror-diag";
|
|
28
|
+
var items = [];
|
|
29
|
+
var bootFailed = [];
|
|
30
|
+
var sent = false;
|
|
31
|
+
var timer = null;
|
|
32
|
+
|
|
33
|
+
function now() { return Date.now(); }
|
|
34
|
+
function clip(s, n) { s = String(s == null ? "" : s); return s.length > n ? s.slice(0, n) : s; }
|
|
35
|
+
/** 只保留同源路径(剥 query/hash),跨源整条丢弃 —— 避免把第三方 URL 带出去。 */
|
|
36
|
+
function pathOf(u) {
|
|
37
|
+
try {
|
|
38
|
+
var x = new URL(String(u || ""), W.location.href);
|
|
39
|
+
if (x.origin !== W.location.origin) return null;
|
|
40
|
+
return clip(x.pathname, 300);
|
|
41
|
+
} catch (e) { return null; }
|
|
42
|
+
}
|
|
43
|
+
function add(item) {
|
|
44
|
+
if (!item || items.length >= MAX_ITEMS || sent) return;
|
|
45
|
+
items.push(item);
|
|
46
|
+
if (timer === null) timer = setTimeout(flush, FLUSH_DELAY);
|
|
47
|
+
}
|
|
48
|
+
function interesting(p) {
|
|
49
|
+
return !!p && (p.indexOf("/plugins/") === 0 || p.indexOf("/assets/") === 0
|
|
50
|
+
|| p.indexOf("/remote/") === 0 || p.indexOf("/api/") === 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ① 资源加载失败(script/link/img/fetch 的 error 事件在捕获阶段拿到 target)
|
|
54
|
+
W.addEventListener("error", function (e) {
|
|
55
|
+
try {
|
|
56
|
+
var t = e && e.target;
|
|
57
|
+
if (t && t !== W && (t.src || t.href)) {
|
|
58
|
+
var p = pathOf(t.src || t.href);
|
|
59
|
+
if (interesting(p)) add({ t: "res", url: p, tag: String(t.tagName || "").toLowerCase() });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (e && e.message) add({ t: "err", msg: clip(e.message, 500), src: pathOf(e.filename) });
|
|
63
|
+
} catch (x) { /* 采集器自身绝不抛 */ }
|
|
64
|
+
}, true);
|
|
65
|
+
|
|
66
|
+
// ② 未处理异常 / Promise 拒绝
|
|
67
|
+
W.addEventListener("unhandledrejection", function (e) {
|
|
68
|
+
try {
|
|
69
|
+
var r = e && e.reason;
|
|
70
|
+
var msg = r && (r.message || r.stack) ? (r.message || "") + " " + (r.stack || "") : String(r);
|
|
71
|
+
add({ t: "rej", msg: clip(msg, 500) });
|
|
72
|
+
} catch (x) {}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ③ 慢资源(插件聚合包/前端主干)—— 用 Resource Timing,不额外发请求
|
|
76
|
+
try {
|
|
77
|
+
if (W.PerformanceObserver) {
|
|
78
|
+
var po = new W.PerformanceObserver(function (list) {
|
|
79
|
+
try {
|
|
80
|
+
list.getEntries().forEach(function (en) {
|
|
81
|
+
var p = pathOf(en.name);
|
|
82
|
+
if (!interesting(p)) return;
|
|
83
|
+
if (en.duration >= SLOW_MS) {
|
|
84
|
+
add({ t: "slow", url: p, ms: Math.round(en.duration), bytes: en.transferSize || 0 });
|
|
85
|
+
} else if (en.transferSize === 0 && en.duration > 0 && en.responseEnd > 0) {
|
|
86
|
+
// transferSize 为 0 且非缓存命中 → 常见于"请求被中断/取消"
|
|
87
|
+
add({ t: "zero", url: p, ms: Math.round(en.duration) });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
} catch (x) {}
|
|
91
|
+
});
|
|
92
|
+
po.observe({ type: "resource", buffered: true });
|
|
93
|
+
}
|
|
94
|
+
} catch (x) {}
|
|
95
|
+
|
|
96
|
+
// ④ 首页启动失败文案(`Failed to load plugins` / `did not activate`)—— 抓具体插件名
|
|
97
|
+
function scanBootFailure() {
|
|
98
|
+
try {
|
|
99
|
+
var txt = (D.body && D.body.innerText) || "";
|
|
100
|
+
if (txt.indexOf("Failed to load plugins") < 0 && txt.indexOf("did not activate") < 0) return;
|
|
101
|
+
var lines = txt.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
102
|
+
for (var i = 0; i < lines.length; i++) {
|
|
103
|
+
if (/import failed|did not activate|pending \(waiting|: [a-z]+ failed/i.test(lines[i])) {
|
|
104
|
+
if (bootFailed.length < 10) bootFailed.push(clip(lines[i], 200));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (bootFailed.length === 0) bootFailed.push("Failed to load plugins(无明细文本)");
|
|
108
|
+
if (timer === null) timer = setTimeout(flush, 300);
|
|
109
|
+
} catch (x) {}
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
var mo = new W.MutationObserver(scanBootFailure);
|
|
113
|
+
if (D.documentElement) mo.observe(D.documentElement, { childList: true, subtree: true, characterData: true });
|
|
114
|
+
setTimeout(scanBootFailure, 6000);
|
|
115
|
+
setTimeout(scanBootFailure, 15000);
|
|
116
|
+
} catch (x) {}
|
|
117
|
+
|
|
118
|
+
function token() {
|
|
119
|
+
try {
|
|
120
|
+
var t = W.localStorage && W.localStorage.getItem("dsh_token");
|
|
121
|
+
if (t) return t;
|
|
122
|
+
var m = D.cookie.match(/(?:^|;\s*)dsh_token=([^;]+)/);
|
|
123
|
+
return m ? decodeURIComponent(m[1]) : "";
|
|
124
|
+
} catch (e) { return ""; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function flush() {
|
|
128
|
+
timer = null;
|
|
129
|
+
if (sent || (items.length === 0 && bootFailed.length === 0)) return;
|
|
130
|
+
sent = true;
|
|
131
|
+
var tk = token();
|
|
132
|
+
var payload = {
|
|
133
|
+
v: VERSION,
|
|
134
|
+
kind: bootFailed.length ? "boot" : "runtime",
|
|
135
|
+
page: clip(W.location.pathname, 200),
|
|
136
|
+
ua: clip(W.navigator && W.navigator.userAgent, 300),
|
|
137
|
+
items: items.slice(0, MAX_ITEMS),
|
|
138
|
+
boot_failed: bootFailed.slice(0, 10),
|
|
139
|
+
at: now()
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
fetch(ENDPOINT, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
credentials: "same-origin",
|
|
145
|
+
headers: { "content-type": "application/json", ...(tk ? { authorization: "Bearer " + tk } : {}) },
|
|
146
|
+
body: JSON.stringify(payload),
|
|
147
|
+
keepalive: true
|
|
148
|
+
}).catch(function () { /* 上报失败静默:绝不影响页面 */ });
|
|
149
|
+
} catch (e) { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 页面卸载前兜底发一次(keepalive 允许在卸载后继续)
|
|
153
|
+
W.addEventListener("pagehide", function () { try { if (!sent) flush(); } catch (x) {} });
|
|
154
|
+
})();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirror-diag.mjs — bridge 侧注入的「镜像页加载故障采集器」。
|
|
3
|
+
*
|
|
4
|
+
* 注入目标:经隧道回传的**官方 dsh web 文档**(与 mobile-adapter / e2ee-shim 同一注入管线、
|
|
5
|
+
* 同一宿主特征门),在 `</head>` 前插入 `<script id="dsh-mirror-diag-js">`。
|
|
6
|
+
*
|
|
7
|
+
* 为什么需要它(2026-10-17):用户反馈「装了 UI 插件后打不开界面 / Failed to load plugins」,
|
|
8
|
+
* 本地复现不出来。生产日志只能看到 `/plugins/??…` 出现 499(客户端中断),拿不到浏览器侧
|
|
9
|
+
* 到底哪个资源失败、耗时多少、什么错误。采集器让下一次故障**自己带证据回来**。
|
|
10
|
+
*
|
|
11
|
+
* 与 e2ee 的关系:**刻意不依赖** e2ee.enabled —— 出问题的用户往往没开 E2EE,
|
|
12
|
+
* 只有无条件注入才能覆盖他们。脚本本身零 DOM 改动、只在失败时上报(见 mirror-diag-script.js)。
|
|
13
|
+
*
|
|
14
|
+
* 开关:
|
|
15
|
+
* - `DSH_MIRROR_DIAG=0` → 完全不注入(逃生口);
|
|
16
|
+
* - `?mirror_diag=0` → 页面侧由脚本自身判定(见 mirror-diag-script.js 的注释)。
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
export const DIAG_ID = "dsh-mirror-diag";
|
|
24
|
+
export const DIAG_SCRIPT_PATH = path.join(THIS_DIR, "mirror-diag-script.js");
|
|
25
|
+
|
|
26
|
+
/** 与 mobile-adapter / e2ee-shim 同源的「官方 dsh web 文档」特征门。 */
|
|
27
|
+
const HOST_FEATURE_RE =
|
|
28
|
+
/(__ModuleLoader__|@deepseek-ai\/dsh-client-modules|@deepseek-ai\/dsh-client-connection|<title>\s*DeepSeek Harness)/i;
|
|
29
|
+
|
|
30
|
+
/** 采集器源码:只在首次注入时读盘并缓存(读失败 → 永不注入,绝不影响主链路)。 */
|
|
31
|
+
let scriptCache = null;
|
|
32
|
+
function diagScriptText() {
|
|
33
|
+
if (scriptCache !== null) return scriptCache;
|
|
34
|
+
try {
|
|
35
|
+
scriptCache = fs.readFileSync(DIAG_SCRIPT_PATH, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
scriptCache = "";
|
|
38
|
+
}
|
|
39
|
+
return scriptCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `DSH_MIRROR_DIAG=0` 关闭注入(默认开启);测试里可用同样的环境变量。 */
|
|
43
|
+
export function mirrorDiagEnabled() {
|
|
44
|
+
const v = process.env.DSH_MIRROR_DIAG;
|
|
45
|
+
return !(v === "0" || v === "false" || v === "off");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 注入门:text/html + 宿主特征 + 未注入过。 */
|
|
49
|
+
export function shouldInjectMirrorDiag({ contentType, html }) {
|
|
50
|
+
const ct = String(contentType || "").toLowerCase();
|
|
51
|
+
if (!ct.includes("text/html")) return false;
|
|
52
|
+
const text = String(html || "");
|
|
53
|
+
if (text.length === 0) return false;
|
|
54
|
+
if (!text.toLowerCase().includes("</head>")) return false;
|
|
55
|
+
if (text.includes(`data-${DIAG_ID}`)) return false; // 幂等:重复注入直接跳过
|
|
56
|
+
return HOST_FEATURE_RE.test(text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 纯函数:把采集器注入到上游 HTML 的 `</head>` 之前。
|
|
61
|
+
* @param {string} html
|
|
62
|
+
* @returns {{html: string, injected: boolean}}
|
|
63
|
+
*/
|
|
64
|
+
export function injectMirrorDiag(html) {
|
|
65
|
+
const src = String(html);
|
|
66
|
+
if (src.includes(`data-${DIAG_ID}`)) return { html: src, injected: false };
|
|
67
|
+
// ★ 注入点必须是 <head> **开头**,不能是 </head> 前:
|
|
68
|
+
// 官方 dsh web 的插件/主干 <script> 就在 <head> 里,注入到 </head> 前会**晚于**
|
|
69
|
+
// 那些脚本执行 —— 启动期错误(本次要抓的正是这类)就漏了。
|
|
70
|
+
// 用字符扫描而不是正则,避免 HTML 里的属性引号/大小写差异把注入点算错。
|
|
71
|
+
const low = src.toLowerCase();
|
|
72
|
+
const headIdx = low.indexOf("<head");
|
|
73
|
+
let pos = -1;
|
|
74
|
+
if (headIdx !== -1) {
|
|
75
|
+
const gt = low.indexOf(">", headIdx);
|
|
76
|
+
pos = gt === -1 ? headIdx : gt + 1;
|
|
77
|
+
} else {
|
|
78
|
+
pos = low.indexOf("</head>"); // 兜底:没有 <head> 时退化为 </head> 前
|
|
79
|
+
}
|
|
80
|
+
if (pos === -1) return { html: src, injected: false };
|
|
81
|
+
const script = diagScriptText();
|
|
82
|
+
if (!script) return { html: src, injected: false };
|
|
83
|
+
// 注入体不能含 </script>(会把文档提前截断)—— 采集器源码里本来就没有,这里再守一道。
|
|
84
|
+
if (/<\/script/i.test(script)) {
|
|
85
|
+
console.warn(`[${DIAG_ID}] 采集器源码含 </script>,拒绝注入`);
|
|
86
|
+
return { html: src, injected: false };
|
|
87
|
+
}
|
|
88
|
+
const block = `<script id="${DIAG_ID}-js" data-${DIAG_ID}>${script}</script>`;
|
|
89
|
+
return { html: src.slice(0, pos) + block + src.slice(pos), injected: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {{buf: Buffer, contentType: string}} input
|
|
94
|
+
* @returns {{buf: Buffer, injected: boolean}}
|
|
95
|
+
*/
|
|
96
|
+
export function maybeInjectMirrorDiag({ buf, contentType }) {
|
|
97
|
+
if (!mirrorDiagEnabled() || !Buffer.isBuffer(buf) || buf.length === 0) {
|
|
98
|
+
return { buf, injected: false };
|
|
99
|
+
}
|
|
100
|
+
const text = buf.toString("utf8");
|
|
101
|
+
if (!shouldInjectMirrorDiag({ contentType, html: text })) return { buf, injected: false };
|
|
102
|
+
const out = injectMirrorDiag(text);
|
|
103
|
+
return { buf: Buffer.from(out.html, "utf8"), injected: out.injected };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default { injectMirrorDiag, maybeInjectMirrorDiag, shouldInjectMirrorDiag, mirrorDiagEnabled };
|
|
@@ -70,16 +70,50 @@ const decode = (reply) => {
|
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
test("集成:官方特征 html 经 doHttp 回传时注入适配层", async () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
// 2026-10-17 起还有一份**独立的**故障采集器注入(与 mobile-adapter 无关);
|
|
74
|
+
// 本用例只验适配层,用 DSH_MIRROR_DIAG=0 把它关掉,保证这里断言的是"仅 splice、原文不动"。
|
|
75
|
+
process.env.DSH_MIRROR_DIAG = "0";
|
|
76
|
+
try {
|
|
77
|
+
const reply = await request({ id: "h1", method: "GET", path: "/page", headers: { "accept-encoding": "gzip", "user-agent": "phone" } });
|
|
78
|
+
assert.equal(reply.status, 200);
|
|
79
|
+
const text = decode(reply);
|
|
80
|
+
assert.ok(text.includes('data-dsh-mobile-adapter'), "应注入 style/script");
|
|
81
|
+
const headIdx = text.toLowerCase().lastIndexOf("</head>");
|
|
82
|
+
assert.ok(text.indexOf("dsh-mobile-adapter-css") < headIdx, "注入块应位于 </head> 前");
|
|
83
|
+
// 原文其余部分保持(仅 splice)
|
|
84
|
+
const oi = OFFICIAL_HTML.toLowerCase().lastIndexOf("</head>");
|
|
85
|
+
assert.ok(text.startsWith(OFFICIAL_HTML.slice(0, oi)));
|
|
86
|
+
assert.ok(text.endsWith(OFFICIAL_HTML.slice(oi)));
|
|
87
|
+
} finally {
|
|
88
|
+
delete process.env.DSH_MIRROR_DIAG;
|
|
89
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("★ 集成:故障采集器**无条件**注入(不依赖 e2ee,也不依赖 mobile-adapter 开关)", async () => {
|
|
94
|
+
// 出问题的用户往往没开 E2EE、也可能被关了适配层 —— 采集器必须仍然在场,否则又变成"猜"。
|
|
95
|
+
process.env.DSH_MOBILE_ADAPTER = "0";
|
|
96
|
+
try {
|
|
97
|
+
const reply = await request({ id: "h1b", method: "GET", path: "/page", headers: { "user-agent": "phone" } });
|
|
98
|
+
const text = decode(reply);
|
|
99
|
+
assert.ok(text.includes("data-dsh-mirror-diag"), "mobile-adapter 关掉时采集器仍要注入");
|
|
100
|
+
assert.ok(text.indexOf("data-dsh-mirror-diag") < text.indexOf("__ModuleLoader__"), "必须在页面自身脚本之前");
|
|
101
|
+
assert.ok(!text.includes("data-dsh-mobile-adapter"), "适配层确实关着");
|
|
102
|
+
} finally {
|
|
103
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
104
|
+
}
|
|
105
|
+
// 逃生口:DSH_MIRROR_DIAG=0 → 一个字节都不注入,严格原样
|
|
106
|
+
process.env.DSH_MIRROR_DIAG = "0";
|
|
107
|
+
process.env.DSH_MOBILE_ADAPTER = "0"; // 适配层也要关,否则"原样"的基准不是 OFFICIAL_HTML
|
|
108
|
+
try {
|
|
109
|
+
const reply = await request({ id: "h1c", method: "GET", path: "/page", headers: { "user-agent": "phone" } });
|
|
110
|
+
const text = decode(reply);
|
|
111
|
+
assert.equal(text, OFFICIAL_HTML);
|
|
112
|
+
assert.ok(!text.includes("data-dsh-mirror-diag"));
|
|
113
|
+
} finally {
|
|
114
|
+
delete process.env.DSH_MIRROR_DIAG;
|
|
115
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
116
|
+
}
|
|
83
117
|
});
|
|
84
118
|
|
|
85
119
|
test("集成:注入在 gzip 前发生,回包仍可 gzip 解压且含注入块", async () => {
|
|
@@ -91,6 +125,7 @@ test("集成:注入在 gzip 前发生,回包仍可 gzip 解压且含注入块",
|
|
|
91
125
|
|
|
92
126
|
test("集成:DSH_MOBILE_ADAPTER=0 关闭注入,原样回传", async () => {
|
|
93
127
|
process.env.DSH_MOBILE_ADAPTER = "0";
|
|
128
|
+
process.env.DSH_MIRROR_DIAG = "0"; // 两个注入器都关,才叫"原样"
|
|
94
129
|
try {
|
|
95
130
|
const reply = await request({ id: "h3", method: "GET", path: "/page", headers: { "user-agent": "phone" } });
|
|
96
131
|
const text = decode(reply);
|
|
@@ -98,6 +133,7 @@ test("集成:DSH_MOBILE_ADAPTER=0 关闭注入,原样回传", async () => {
|
|
|
98
133
|
assert.equal(text, OFFICIAL_HTML);
|
|
99
134
|
} finally {
|
|
100
135
|
delete process.env.DSH_MOBILE_ADAPTER;
|
|
136
|
+
delete process.env.DSH_MIRROR_DIAG;
|
|
101
137
|
}
|
|
102
138
|
});
|
|
103
139
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 上游端口漂移自愈(2026-10-17)。
|
|
4
|
+
*
|
|
5
|
+
* 生产日志实证:
|
|
6
|
+
* WS 升级被拒: reason=upstream_ws_open_failed status=502 msg=connect ECONNREFUSED 127.0.0.1:19387
|
|
7
|
+
* 桌面端 App 重启会换本地端口,而 bridge 缓存了旧上游 → 镜像页 502/白屏。
|
|
8
|
+
* HTTP 失败路径早就会 refreshUpstream,**WS 失败路径之前漏了** —— 而镜像页常先开 WS。
|
|
9
|
+
*
|
|
10
|
+
* 这个用例守住:两条失败路径都必须触发重新发现(否则又回到"只能重启 bridge")。
|
|
11
|
+
*/
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
const SRC = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
|
|
17
|
+
|
|
18
|
+
test("★ WS 打开失败必须触发上游重新发现(不只是回 502)", () => {
|
|
19
|
+
// 找到 ws.on("error" ...) 的未建立分支
|
|
20
|
+
const i = SRC.indexOf('ws.on("error"');
|
|
21
|
+
assert.ok(i > 0, "必须有 ws error 处理");
|
|
22
|
+
const branch = SRC.slice(i, i + 1200);
|
|
23
|
+
assert.match(branch, /!session\.opened/, "只对未建立的会话做 502");
|
|
24
|
+
assert.match(branch, /refreshUpstream\(/, "★ 必须触发重新发现,否则端口变了会一直 502");
|
|
25
|
+
assert.match(branch, /code: 502/, "行为不变:本次仍然回 502");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("★ HTTP 失败路径也要有重新发现(既有能力不得回退)", () => {
|
|
29
|
+
const hits = SRC.match(/refreshUpstream\(/g) || [];
|
|
30
|
+
assert.ok(hits.length >= 3, `至少 HTTP/WS/启动 三处调用,实际 ${hits.length}`);
|
|
31
|
+
assert.match(SRC, /上游请求失败/, "HTTP 失败路径的理由文案应在");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("★ 重新发现必须有冷却(失败连成片时不能每个请求都 lsof/pgrep)", () => {
|
|
35
|
+
assert.match(SRC, /UPSTREAM_REFRESH_COOLDOWN_MS/);
|
|
36
|
+
const m = SRC.match(/UPSTREAM_REFRESH_COOLDOWN_MS\s*=\s*([\d_]+)/);
|
|
37
|
+
assert.ok(m, "必须定义冷却常量");
|
|
38
|
+
const ms = Number(m[1].replace(/_/g, ""));
|
|
39
|
+
assert.ok(ms >= 5000 && ms <= 60000, `冷却应在 5–60s(实际 ${ms}ms)`);
|
|
40
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mirror-diag.mjs —— 镜像页故障采集器的注入契约(防回归)。
|
|
4
|
+
*
|
|
5
|
+
* 这个文件守的是"下次故障能不能带证据回来",本身不修任何功能,所以更不能坏:
|
|
6
|
+
* ① 注入位置必须在 </head> 前(否则文档已开始执行,抓不到启动错误);
|
|
7
|
+
* ② 幂等:重复注入会把同一份脚本塞两遍(每次请求/每次重试都塞);
|
|
8
|
+
* ③ 门要准:只注入官方 dsh web 文档(text/html + 宿主特征),不能污染其它响应;
|
|
9
|
+
* ④ 开关必须真的关:DSH_MIRROR_DIAG=0 → 一个字节都不注入;
|
|
10
|
+
* ⑤ 注入体不得含 </script>(会把整页截断)。
|
|
11
|
+
*
|
|
12
|
+
* 用法:node --test test/mirror-diag.test.mjs
|
|
13
|
+
*/
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import {
|
|
17
|
+
injectMirrorDiag, maybeInjectMirrorDiag, shouldInjectMirrorDiag,
|
|
18
|
+
mirrorDiagEnabled, DIAG_ID, DIAG_SCRIPT_PATH
|
|
19
|
+
} from "../mirror-diag.mjs";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
|
|
22
|
+
const HTML = `<!doctype html><html><head><title>DeepSeek Harness</title>
|
|
23
|
+
<script type="module" src="./assets/index.js"></script></head><body><div id="root"></div></body></html>`;
|
|
24
|
+
|
|
25
|
+
test("★ 注入到 </head> 之前,且带唯一标记(幂等可识别)", () => {
|
|
26
|
+
const out = injectMirrorDiag(HTML);
|
|
27
|
+
assert.equal(out.injected, true);
|
|
28
|
+
const i = out.html.indexOf(`data-${DIAG_ID}`);
|
|
29
|
+
assert.ok(i > 0, "必须带 data- 标记");
|
|
30
|
+
assert.ok(i < out.html.indexOf("</head>"), "必须在 </head> 之前");
|
|
31
|
+
assert.ok(i < out.html.indexOf("./assets/index.js"), "必须早于页面自身脚本(才能抓到启动期错误)");
|
|
32
|
+
assert.ok(i < out.html.indexOf("<title>"), "必须紧跟 <head> 开头(越早越能抓到错误)");
|
|
33
|
+
// 原始内容一个不丢
|
|
34
|
+
assert.ok(out.html.includes('<div id="root"></div>'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("★ 幂等:已注入过就不再注入(防止重复执行/重复上报)", () => {
|
|
38
|
+
const once = injectMirrorDiag(HTML);
|
|
39
|
+
const twice = injectMirrorDiag(once.html);
|
|
40
|
+
assert.equal(twice.injected, false);
|
|
41
|
+
assert.equal(twice.html, once.html);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("★ 门要准:非 HTML / 非官方文档 / 已注入 → 一律不注入", () => {
|
|
45
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "application/javascript", html: HTML }), false, "JS 不注入");
|
|
46
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "text/html", html: "<html><head></head><body>hi</body></html>" }), false, "非官方文档不注入");
|
|
47
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "text/html", html: "<html><body>无 head</body></html>" }), false, "无 </head> 不注入");
|
|
48
|
+
const injected = injectMirrorDiag(HTML).html;
|
|
49
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "text/html", html: injected }), false, "已注入要跳过");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("★ 开关真能关:DSH_MIRROR_DIAG=0 → 不注入(逃生口)", () => {
|
|
53
|
+
const old = process.env.DSH_MIRROR_DIAG;
|
|
54
|
+
try {
|
|
55
|
+
process.env.DSH_MIRROR_DIAG = "0";
|
|
56
|
+
assert.equal(mirrorDiagEnabled(), false);
|
|
57
|
+
const r = maybeInjectMirrorDiag({ buf: Buffer.from(HTML), contentType: "text/html" });
|
|
58
|
+
assert.equal(r.injected, false);
|
|
59
|
+
} finally {
|
|
60
|
+
if (old === undefined) delete process.env.DSH_MIRROR_DIAG; else process.env.DSH_MIRROR_DIAG = old;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("★ Buffer 入口:注入后仍是合法 UTF-8,且字节数增加", () => {
|
|
65
|
+
const buf = Buffer.from(HTML);
|
|
66
|
+
const r = maybeInjectMirrorDiag({ buf, contentType: "text/html; charset=utf-8" });
|
|
67
|
+
assert.equal(r.injected, true);
|
|
68
|
+
assert.ok(r.buf.length > buf.length, "注入后应变大");
|
|
69
|
+
const text = r.buf.toString("utf8");
|
|
70
|
+
assert.ok(text.includes("</head>") && text.includes("dsh-mirror-diag"));
|
|
71
|
+
assert.ok(!text.includes("\ufffd"), "不得出现替换字符(编码破坏)");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("★ 采集器源码自身:不含 </script>、不写 DOM、只在失败时上报", () => {
|
|
75
|
+
const src = fs.readFileSync(DIAG_SCRIPT_PATH, "utf8");
|
|
76
|
+
assert.ok(src.length > 500, "采集器不能是空壳");
|
|
77
|
+
assert.ok(!/<\/script/i.test(src), "含 </script> 会把宿主文档截断");
|
|
78
|
+
assert.ok(!/document\.body\.appendChild|innerHTML\s*=/.test(src), "采集器不得改动页面 DOM");
|
|
79
|
+
assert.match(src, /mirror-diag/, "上报地址要对");
|
|
80
|
+
assert.match(src, /Failed to load plugins/, "必须识别首页启动失败文案");
|
|
81
|
+
assert.match(src, /mirror_diag|DSH_MIRROR_DIAG|pagehide/, "必须有无痕静默路径");
|
|
82
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.7.1",
|
|
3
|
+
"version": "0.7.2-beta.1",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.1",
|
|
3
|
+
"version": "0.7.2-beta.1",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -76,6 +76,7 @@ import { promisify } from "node:util";
|
|
|
76
76
|
import { gzip as gzipCb } from "node:zlib";
|
|
77
77
|
// 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
|
|
78
78
|
import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
|
|
79
|
+
import { maybeInjectMirrorDiag } from "./mirror-diag.mjs";
|
|
79
80
|
import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
|
|
80
81
|
// 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
|
|
81
82
|
import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
|
|
@@ -862,6 +863,15 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
862
863
|
buf = m.buf;
|
|
863
864
|
armMirrorLoadWatch(path);
|
|
864
865
|
}
|
|
866
|
+
// 镜像页加载故障采集器(2026-10-17,**与 e2ee 无关**):出问题的用户往往没开 E2EE,
|
|
867
|
+
// 所以无条件注入。只在失败时上报(见 mirror-diag-script.js),零 DOM 改动。
|
|
868
|
+
{
|
|
869
|
+
const d = maybeInjectMirrorDiag({ buf, contentType });
|
|
870
|
+
if (d.injected) {
|
|
871
|
+
console.log(`[bridge] mirror-diag 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(d.buf.length / 1024).toFixed(0)}KB`);
|
|
872
|
+
buf = d.buf;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
865
875
|
// E2EE 镜像页加密 shim(Phase-4):仅桥端 e2ee 启用时注入(e2ee.enabled=false → 零注入零行为)
|
|
866
876
|
if (e2ee.enabled) {
|
|
867
877
|
const s = maybeInjectE2eeShim({ buf, contentType });
|
|
@@ -1235,6 +1245,12 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1235
1245
|
console.log(`[bridge] ws-error ${path} (id=${id}): ${e.message || ""}`);
|
|
1236
1246
|
if (!session.opened) {
|
|
1237
1247
|
wsSessions.delete(id);
|
|
1248
|
+
// ★ 上游可能已经换端口(桌面端 App 重启会换本地端口,生产日志里抓到过
|
|
1249
|
+
// `upstream_ws_open_failed ... ECONNREFUSED 127.0.0.1:19387`)。
|
|
1250
|
+
// HTTP 失败路径早就会触发重新发现,**WS 失败路径之前漏了** —— 而镜像页常常
|
|
1251
|
+
// 先开 WS(控制通道/数据通道),于是端口变了以后一直 502、页面白屏,直到用户
|
|
1252
|
+
// 手动重开。这里补上:本次失败照旧回 502,但立即触发重新发现,下一次重试就命中新端口。
|
|
1253
|
+
void refreshUpstream("ws-open 上游连接失败");
|
|
1238
1254
|
send({ id, type: "ws-open", ok: false, code: 502, reason: String(e.message || "ws error") });
|
|
1239
1255
|
}
|
|
1240
1256
|
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mirror-diag — 镜像页「加载故障」采集器(由 bridge 注入到 text/html 的 </head> 前)。
|
|
3
|
+
*
|
|
4
|
+
* 由来(2026-10-17):用户反馈「装了 UI 插件后打不开界面 / Failed to load plugins」,
|
|
5
|
+
* 但本地与账号环境都复现不出来。生产日志里只看到 `/plugins/??…` 的 499 中断,
|
|
6
|
+
* 拿不到浏览器侧的真实失败项(哪个 URL、多少毫秒、什么错误)。这个采集器就是
|
|
7
|
+
* **让下一次故障自己带证据回来**。
|
|
8
|
+
*
|
|
9
|
+
* 设计约束(都是硬要求):
|
|
10
|
+
* 1. **绝不打扰用户**:不改 DOM、不加 UI、不阻塞渲染;任何异常都在内部吞掉。
|
|
11
|
+
* 2. **只在出错时上报**:没有失败资源/脚本错误 → 一个字节都不发(零流量)。
|
|
12
|
+
* 3. **不泄密**:只上报**路径**(剥掉 query/hash)、状态码、耗时、字节数、错误消息前 500 字;
|
|
13
|
+
* 不上报页面内容、请求体、cookie、token、用户信息。
|
|
14
|
+
* 4. **可关**:`?mirror_diag=0` 或 bridge 环境变量 `DSH_MIRROR_DIAG=0` → 完全不注入。
|
|
15
|
+
* 5. **慢请求也记**:`/plugins/`、`/assets/` 加载超过 8s 记一条(这正是"插件聚合包被
|
|
16
|
+
* 限速拖死 → 浏览器 499"的现场)。
|
|
17
|
+
*
|
|
18
|
+
* 上报地址:`/relay-api/api/mirror-diag`(企业端,需登录态;token 从 localStorage.dsh_token 取)。
|
|
19
|
+
*/
|
|
20
|
+
(function () {
|
|
21
|
+
"use strict";
|
|
22
|
+
var W = window, D = document;
|
|
23
|
+
var VERSION = 1;
|
|
24
|
+
var SLOW_MS = 8000;
|
|
25
|
+
var MAX_ITEMS = 20;
|
|
26
|
+
var FLUSH_DELAY = 2500;
|
|
27
|
+
var ENDPOINT = "/relay-api/api/mirror-diag";
|
|
28
|
+
var items = [];
|
|
29
|
+
var bootFailed = [];
|
|
30
|
+
var sent = false;
|
|
31
|
+
var timer = null;
|
|
32
|
+
|
|
33
|
+
function now() { return Date.now(); }
|
|
34
|
+
function clip(s, n) { s = String(s == null ? "" : s); return s.length > n ? s.slice(0, n) : s; }
|
|
35
|
+
/** 只保留同源路径(剥 query/hash),跨源整条丢弃 —— 避免把第三方 URL 带出去。 */
|
|
36
|
+
function pathOf(u) {
|
|
37
|
+
try {
|
|
38
|
+
var x = new URL(String(u || ""), W.location.href);
|
|
39
|
+
if (x.origin !== W.location.origin) return null;
|
|
40
|
+
return clip(x.pathname, 300);
|
|
41
|
+
} catch (e) { return null; }
|
|
42
|
+
}
|
|
43
|
+
function add(item) {
|
|
44
|
+
if (!item || items.length >= MAX_ITEMS || sent) return;
|
|
45
|
+
items.push(item);
|
|
46
|
+
if (timer === null) timer = setTimeout(flush, FLUSH_DELAY);
|
|
47
|
+
}
|
|
48
|
+
function interesting(p) {
|
|
49
|
+
return !!p && (p.indexOf("/plugins/") === 0 || p.indexOf("/assets/") === 0
|
|
50
|
+
|| p.indexOf("/remote/") === 0 || p.indexOf("/api/") === 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ① 资源加载失败(script/link/img/fetch 的 error 事件在捕获阶段拿到 target)
|
|
54
|
+
W.addEventListener("error", function (e) {
|
|
55
|
+
try {
|
|
56
|
+
var t = e && e.target;
|
|
57
|
+
if (t && t !== W && (t.src || t.href)) {
|
|
58
|
+
var p = pathOf(t.src || t.href);
|
|
59
|
+
if (interesting(p)) add({ t: "res", url: p, tag: String(t.tagName || "").toLowerCase() });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (e && e.message) add({ t: "err", msg: clip(e.message, 500), src: pathOf(e.filename) });
|
|
63
|
+
} catch (x) { /* 采集器自身绝不抛 */ }
|
|
64
|
+
}, true);
|
|
65
|
+
|
|
66
|
+
// ② 未处理异常 / Promise 拒绝
|
|
67
|
+
W.addEventListener("unhandledrejection", function (e) {
|
|
68
|
+
try {
|
|
69
|
+
var r = e && e.reason;
|
|
70
|
+
var msg = r && (r.message || r.stack) ? (r.message || "") + " " + (r.stack || "") : String(r);
|
|
71
|
+
add({ t: "rej", msg: clip(msg, 500) });
|
|
72
|
+
} catch (x) {}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ③ 慢资源(插件聚合包/前端主干)—— 用 Resource Timing,不额外发请求
|
|
76
|
+
try {
|
|
77
|
+
if (W.PerformanceObserver) {
|
|
78
|
+
var po = new W.PerformanceObserver(function (list) {
|
|
79
|
+
try {
|
|
80
|
+
list.getEntries().forEach(function (en) {
|
|
81
|
+
var p = pathOf(en.name);
|
|
82
|
+
if (!interesting(p)) return;
|
|
83
|
+
if (en.duration >= SLOW_MS) {
|
|
84
|
+
add({ t: "slow", url: p, ms: Math.round(en.duration), bytes: en.transferSize || 0 });
|
|
85
|
+
} else if (en.transferSize === 0 && en.duration > 0 && en.responseEnd > 0) {
|
|
86
|
+
// transferSize 为 0 且非缓存命中 → 常见于"请求被中断/取消"
|
|
87
|
+
add({ t: "zero", url: p, ms: Math.round(en.duration) });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
} catch (x) {}
|
|
91
|
+
});
|
|
92
|
+
po.observe({ type: "resource", buffered: true });
|
|
93
|
+
}
|
|
94
|
+
} catch (x) {}
|
|
95
|
+
|
|
96
|
+
// ④ 首页启动失败文案(`Failed to load plugins` / `did not activate`)—— 抓具体插件名
|
|
97
|
+
function scanBootFailure() {
|
|
98
|
+
try {
|
|
99
|
+
var txt = (D.body && D.body.innerText) || "";
|
|
100
|
+
if (txt.indexOf("Failed to load plugins") < 0 && txt.indexOf("did not activate") < 0) return;
|
|
101
|
+
var lines = txt.split("\n").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
102
|
+
for (var i = 0; i < lines.length; i++) {
|
|
103
|
+
if (/import failed|did not activate|pending \(waiting|: [a-z]+ failed/i.test(lines[i])) {
|
|
104
|
+
if (bootFailed.length < 10) bootFailed.push(clip(lines[i], 200));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (bootFailed.length === 0) bootFailed.push("Failed to load plugins(无明细文本)");
|
|
108
|
+
if (timer === null) timer = setTimeout(flush, 300);
|
|
109
|
+
} catch (x) {}
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
var mo = new W.MutationObserver(scanBootFailure);
|
|
113
|
+
if (D.documentElement) mo.observe(D.documentElement, { childList: true, subtree: true, characterData: true });
|
|
114
|
+
setTimeout(scanBootFailure, 6000);
|
|
115
|
+
setTimeout(scanBootFailure, 15000);
|
|
116
|
+
} catch (x) {}
|
|
117
|
+
|
|
118
|
+
function token() {
|
|
119
|
+
try {
|
|
120
|
+
var t = W.localStorage && W.localStorage.getItem("dsh_token");
|
|
121
|
+
if (t) return t;
|
|
122
|
+
var m = D.cookie.match(/(?:^|;\s*)dsh_token=([^;]+)/);
|
|
123
|
+
return m ? decodeURIComponent(m[1]) : "";
|
|
124
|
+
} catch (e) { return ""; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function flush() {
|
|
128
|
+
timer = null;
|
|
129
|
+
if (sent || (items.length === 0 && bootFailed.length === 0)) return;
|
|
130
|
+
sent = true;
|
|
131
|
+
var tk = token();
|
|
132
|
+
var payload = {
|
|
133
|
+
v: VERSION,
|
|
134
|
+
kind: bootFailed.length ? "boot" : "runtime",
|
|
135
|
+
page: clip(W.location.pathname, 200),
|
|
136
|
+
ua: clip(W.navigator && W.navigator.userAgent, 300),
|
|
137
|
+
items: items.slice(0, MAX_ITEMS),
|
|
138
|
+
boot_failed: bootFailed.slice(0, 10),
|
|
139
|
+
at: now()
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
fetch(ENDPOINT, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
credentials: "same-origin",
|
|
145
|
+
headers: { "content-type": "application/json", ...(tk ? { authorization: "Bearer " + tk } : {}) },
|
|
146
|
+
body: JSON.stringify(payload),
|
|
147
|
+
keepalive: true
|
|
148
|
+
}).catch(function () { /* 上报失败静默:绝不影响页面 */ });
|
|
149
|
+
} catch (e) { /* ignore */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 页面卸载前兜底发一次(keepalive 允许在卸载后继续)
|
|
153
|
+
W.addEventListener("pagehide", function () { try { if (!sent) flush(); } catch (x) {} });
|
|
154
|
+
})();
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirror-diag.mjs — bridge 侧注入的「镜像页加载故障采集器」。
|
|
3
|
+
*
|
|
4
|
+
* 注入目标:经隧道回传的**官方 dsh web 文档**(与 mobile-adapter / e2ee-shim 同一注入管线、
|
|
5
|
+
* 同一宿主特征门),在 `</head>` 前插入 `<script id="dsh-mirror-diag-js">`。
|
|
6
|
+
*
|
|
7
|
+
* 为什么需要它(2026-10-17):用户反馈「装了 UI 插件后打不开界面 / Failed to load plugins」,
|
|
8
|
+
* 本地复现不出来。生产日志只能看到 `/plugins/??…` 出现 499(客户端中断),拿不到浏览器侧
|
|
9
|
+
* 到底哪个资源失败、耗时多少、什么错误。采集器让下一次故障**自己带证据回来**。
|
|
10
|
+
*
|
|
11
|
+
* 与 e2ee 的关系:**刻意不依赖** e2ee.enabled —— 出问题的用户往往没开 E2EE,
|
|
12
|
+
* 只有无条件注入才能覆盖他们。脚本本身零 DOM 改动、只在失败时上报(见 mirror-diag-script.js)。
|
|
13
|
+
*
|
|
14
|
+
* 开关:
|
|
15
|
+
* - `DSH_MIRROR_DIAG=0` → 完全不注入(逃生口);
|
|
16
|
+
* - `?mirror_diag=0` → 页面侧由脚本自身判定(见 mirror-diag-script.js 的注释)。
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
export const DIAG_ID = "dsh-mirror-diag";
|
|
24
|
+
export const DIAG_SCRIPT_PATH = path.join(THIS_DIR, "mirror-diag-script.js");
|
|
25
|
+
|
|
26
|
+
/** 与 mobile-adapter / e2ee-shim 同源的「官方 dsh web 文档」特征门。 */
|
|
27
|
+
const HOST_FEATURE_RE =
|
|
28
|
+
/(__ModuleLoader__|@deepseek-ai\/dsh-client-modules|@deepseek-ai\/dsh-client-connection|<title>\s*DeepSeek Harness)/i;
|
|
29
|
+
|
|
30
|
+
/** 采集器源码:只在首次注入时读盘并缓存(读失败 → 永不注入,绝不影响主链路)。 */
|
|
31
|
+
let scriptCache = null;
|
|
32
|
+
function diagScriptText() {
|
|
33
|
+
if (scriptCache !== null) return scriptCache;
|
|
34
|
+
try {
|
|
35
|
+
scriptCache = fs.readFileSync(DIAG_SCRIPT_PATH, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
scriptCache = "";
|
|
38
|
+
}
|
|
39
|
+
return scriptCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `DSH_MIRROR_DIAG=0` 关闭注入(默认开启);测试里可用同样的环境变量。 */
|
|
43
|
+
export function mirrorDiagEnabled() {
|
|
44
|
+
const v = process.env.DSH_MIRROR_DIAG;
|
|
45
|
+
return !(v === "0" || v === "false" || v === "off");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 注入门:text/html + 宿主特征 + 未注入过。 */
|
|
49
|
+
export function shouldInjectMirrorDiag({ contentType, html }) {
|
|
50
|
+
const ct = String(contentType || "").toLowerCase();
|
|
51
|
+
if (!ct.includes("text/html")) return false;
|
|
52
|
+
const text = String(html || "");
|
|
53
|
+
if (text.length === 0) return false;
|
|
54
|
+
if (!text.toLowerCase().includes("</head>")) return false;
|
|
55
|
+
if (text.includes(`data-${DIAG_ID}`)) return false; // 幂等:重复注入直接跳过
|
|
56
|
+
return HOST_FEATURE_RE.test(text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 纯函数:把采集器注入到上游 HTML 的 `</head>` 之前。
|
|
61
|
+
* @param {string} html
|
|
62
|
+
* @returns {{html: string, injected: boolean}}
|
|
63
|
+
*/
|
|
64
|
+
export function injectMirrorDiag(html) {
|
|
65
|
+
const src = String(html);
|
|
66
|
+
if (src.includes(`data-${DIAG_ID}`)) return { html: src, injected: false };
|
|
67
|
+
// ★ 注入点必须是 <head> **开头**,不能是 </head> 前:
|
|
68
|
+
// 官方 dsh web 的插件/主干 <script> 就在 <head> 里,注入到 </head> 前会**晚于**
|
|
69
|
+
// 那些脚本执行 —— 启动期错误(本次要抓的正是这类)就漏了。
|
|
70
|
+
// 用字符扫描而不是正则,避免 HTML 里的属性引号/大小写差异把注入点算错。
|
|
71
|
+
const low = src.toLowerCase();
|
|
72
|
+
const headIdx = low.indexOf("<head");
|
|
73
|
+
let pos = -1;
|
|
74
|
+
if (headIdx !== -1) {
|
|
75
|
+
const gt = low.indexOf(">", headIdx);
|
|
76
|
+
pos = gt === -1 ? headIdx : gt + 1;
|
|
77
|
+
} else {
|
|
78
|
+
pos = low.indexOf("</head>"); // 兜底:没有 <head> 时退化为 </head> 前
|
|
79
|
+
}
|
|
80
|
+
if (pos === -1) return { html: src, injected: false };
|
|
81
|
+
const script = diagScriptText();
|
|
82
|
+
if (!script) return { html: src, injected: false };
|
|
83
|
+
// 注入体不能含 </script>(会把文档提前截断)—— 采集器源码里本来就没有,这里再守一道。
|
|
84
|
+
if (/<\/script/i.test(script)) {
|
|
85
|
+
console.warn(`[${DIAG_ID}] 采集器源码含 </script>,拒绝注入`);
|
|
86
|
+
return { html: src, injected: false };
|
|
87
|
+
}
|
|
88
|
+
const block = `<script id="${DIAG_ID}-js" data-${DIAG_ID}>${script}</script>`;
|
|
89
|
+
return { html: src.slice(0, pos) + block + src.slice(pos), injected: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {{buf: Buffer, contentType: string}} input
|
|
94
|
+
* @returns {{buf: Buffer, injected: boolean}}
|
|
95
|
+
*/
|
|
96
|
+
export function maybeInjectMirrorDiag({ buf, contentType }) {
|
|
97
|
+
if (!mirrorDiagEnabled() || !Buffer.isBuffer(buf) || buf.length === 0) {
|
|
98
|
+
return { buf, injected: false };
|
|
99
|
+
}
|
|
100
|
+
const text = buf.toString("utf8");
|
|
101
|
+
if (!shouldInjectMirrorDiag({ contentType, html: text })) return { buf, injected: false };
|
|
102
|
+
const out = injectMirrorDiag(text);
|
|
103
|
+
return { buf: Buffer.from(out.html, "utf8"), injected: out.injected };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default { injectMirrorDiag, maybeInjectMirrorDiag, shouldInjectMirrorDiag, mirrorDiagEnabled };
|