@mrrisega/dsh-remote 0.7.12-beta.1 → 0.7.12-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
  2. package/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
  3. package/clients/dsh-remote/dsh-bridge.mjs +398 -86
  4. package/clients/dsh-remote/feishu-bot.mjs +1668 -0
  5. package/clients/dsh-remote/mirror-diag-script.js +53 -14
  6. package/clients/dsh-remote/mobile-adapter.mjs +355 -40
  7. package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
  8. package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
  9. package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
  10. package/clients/dsh-remote/test/dingtalk-bot.test.mjs +550 -0
  11. package/clients/dsh-remote/test/feishu-bot.test.mjs +839 -0
  12. package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
  13. package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
  14. package/dsh-setup.mjs +1 -1
  15. package/package.json +2 -2
  16. package/packages/dsh-remote-web/lib/client.js +332 -4
  17. package/packages/dsh-remote-web/lib/index.js +367 -21
  18. package/packages/dsh-remote-web/package.json +1 -1
  19. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +7 -1
  20. package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
  21. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
  23. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +77 -6
  24. package/packages/dsh-remote-web/test/im-bots.test.mjs +224 -2
  25. package/packages/dsh-remote-web/test/panel-ia.test.mjs +197 -32
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * B2 取消死请求(2026-11-08)。
4
+ *
5
+ * 生产问题:手机放弃(nginx 499 / 切页 / 锁屏)后,普通大响应仍会被 bridge 读完、gzip 完、
6
+ * **继续灌进隧道**,占住数据队列队头把同设备后续请求堵在后面(2026-11-07 事故里 77 次 499
7
+ * 每次都这么放大,最坏让 0 字节请求陪跑 97.6s)。
8
+ *
9
+ * 修复两端配合:
10
+ * · 中继:`res.on("close")` 时**普通响应也**发 `http-abort`(可 `DSH_ROUTER_ABORT_DEAD=0` 回滚);
11
+ * · 桥:`http-abort` → abort 上游 fetch + `sender.drop(id)` 丢掉隧道里还没发的分片。
12
+ *
13
+ * 本文件锁四条:
14
+ * ① `drop(id)` 之后该响应的剩余分片不再发出,且**其余条目的相对顺序不变**(严格 FIFO 不破);
15
+ * ② 被取消的条目不会回调 `onFlushed`(不许拿"取消"当"发完"去记耗时);
16
+ * ③ `http-abort` 到桥 → 上游 fetch 被 abort、`drop` 被调用、**不回 502**、inflight 归零;
17
+ * ④ `DSH_BRIDGE_ABORT_DEAD=0` / 非法值解析(纯函数)。
18
+ */
19
+ import assert from "node:assert/strict";
20
+ import http from "node:http";
21
+ import { after, test } from "node:test";
22
+
23
+ // ⚠️ 必须在 import dsh-bridge.mjs **之前**把上游指到一个本地假服务:
24
+ // UPSTREAM 是模块加载时读 env 的;测试 ③ 需要一个"永不回完 body"的上游,才能证明
25
+ // `http-abort` 真的中止了 fetch(而不是等 120s 超时)。
26
+ const slowUpstream = http.createServer((req, res) => {
27
+ res.writeHead(200, { "content-type": "application/json" });
28
+ res.write('{"partial":'); // 不 end:模拟大响应正在产出的中途
29
+ });
30
+ await new Promise((r) => slowUpstream.listen(0, "127.0.0.1", r));
31
+ process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${slowUpstream.address().port}`;
32
+ after(() => {
33
+ try { slowUpstream.closeAllConnections?.(); } catch { /* ignore */ }
34
+ try { slowUpstream.close(); } catch { /* ignore */ }
35
+ });
36
+
37
+ const { abortDeadEnabled, makePrioritySender, __httpInflightForTest, handleFrame, handleHttpFrame } =
38
+ await import("../dsh-bridge.mjs");
39
+
40
+ function fakeSocket() {
41
+ const frames = [];
42
+ let pending = 0;
43
+ return {
44
+ frames,
45
+ rawSend(s) { frames.push(s); pending += s.length; },
46
+ bufferedAmount: () => pending,
47
+ drain(bytes = Infinity) { pending = Math.max(0, pending - bytes); return pending; }
48
+ };
49
+ }
50
+
51
+ function makeScheduler() {
52
+ const queue = [];
53
+ return {
54
+ schedule: (fn) => queue.push(fn),
55
+ setTimer: (fn) => queue.push(fn),
56
+ tick() { const fn = queue.shift(); if (!fn) return false; fn(); return true; },
57
+ pending: () => queue.length
58
+ };
59
+ }
60
+
61
+ function newSender() {
62
+ const sock = fakeSocket();
63
+ const sched = makeScheduler();
64
+ const sender = makePrioritySender({
65
+ rawSend: sock.rawSend,
66
+ bufferedAmount: sock.bufferedAmount,
67
+ schedule: sched.schedule,
68
+ setTimer: sched.setTimer
69
+ });
70
+ return { sock, sched, sender };
71
+ }
72
+
73
+ test("B2①:drop(id) 丢掉剩余分片;其余条目顺序不变;不回调 onFlushed", () => {
74
+ const { sock, sched, sender } = newSender();
75
+ let flushedBig = 0;
76
+
77
+ // 一条 400KB 的大响应(多片)先入队,之后才入队一个小响应
78
+ sender.send({ id: "big", type: "http", body: "A".repeat(400 * 1024) }, {
79
+ id: "big",
80
+ onFlushed: () => { flushedBig += 1; }
81
+ });
82
+ sender.send({ id: "small", type: "http", body: "ok" }, { id: "small" });
83
+ // 让大帧只发出前几片就停手(模拟"隧道很慢")
84
+ sched.tick();
85
+ const beforeDrop = sock.frames.length;
86
+
87
+ sender.drop("big");
88
+ // drop 之后:大帧剩余分片一片都不许再发
89
+ for (let i = 0; i < 5000 && sched.pending() > 0; i += 1) { sock.drain(0); sched.tick(); }
90
+ const after = sock.frames.slice(beforeDrop).map((s) => JSON.parse(s));
91
+ assert.ok(after.every((f) => f.__chunk === undefined), `取消后不得再发大帧分片:${JSON.stringify(after).slice(0, 200)}`);
92
+ const small = after.find((f) => f.id === "small");
93
+ assert.ok(small, "取消大帧后,后面排队的小响应必须被发出(队头被腾空)");
94
+ assert.equal(flushedBig, 0, "被取消的条目不得回调 onFlushed(否则会把取消记成发完)");
95
+ });
96
+
97
+ test("B2②:drop 只删指定 id,其它条目的相对顺序原样保留(FIFO 不破)", () => {
98
+ const { sock, sched, sender } = newSender();
99
+ sender.send({ id: "a", type: "http", body: "A".repeat(300 * 1024) }, { id: "a" });
100
+ sender.send({ id: "b", type: "http", body: "b1" }, { id: "b" });
101
+ sender.send({ id: "c", type: "http", body: "c1" }, { id: "c" });
102
+ sched.tick(); // 大帧发出第 1 片
103
+
104
+ sender.drop("a"); // 取消正在传的 a
105
+ for (let i = 0; i < 5000 && sched.pending() > 0; i += 1) { sock.drain(4 * 1024 * 1024); sched.tick(); }
106
+
107
+ const ids = sock.frames.map((s) => { const o = JSON.parse(s); return o.__chunk ? "__chunk" : o.id; });
108
+ const firstB = ids.indexOf("b");
109
+ const firstC = ids.indexOf("c");
110
+ assert.ok(firstB >= 0 && firstC >= 0, "b / c 都必须发出");
111
+ assert.ok(firstB < firstC, `b 必须仍在 c 之前(入队顺序不因取消而变):${ids.join(",")}`);
112
+ // a 的条目被整条删除:不应有 a 的**整帧**
113
+ assert.ok(!ids.includes("a"), "被取消的 a 不得再出现");
114
+ });
115
+
116
+ test("B2③:http-abort 到桥 → abort 上游 fetch + drop(id) + 不回 502(端到端到 fetch)", async () => {
117
+ // 上游由文件顶部的 slowUpstream 提供(永不回完 body),才能验证 abort 真的生效
118
+ const sent = [];
119
+ const drops = [];
120
+ const sendFn = Object.assign(
121
+ (obj) => { sent.push(obj); return true; },
122
+ { drop: (id) => { drops.push(id); return 1; }, stats: () => ({ queued: 0, queuedBytes: 0, controlQueued: 0 }) }
123
+ );
124
+
125
+ const p = handleHttpFrame(sendFn, { id: "ab1", type: "http", method: "GET", path: "/api/slow" });
126
+ // 等到 controller 登记(= 已经进入 fetch 之前的那一步)
127
+ const t0 = Date.now();
128
+ while (__httpInflightForTest() < 1 && Date.now() - t0 < 3000) await new Promise((r) => setTimeout(r, 10));
129
+ assert.equal(__httpInflightForTest(), 1, "在飞请求必须被登记,才能被取消");
130
+
131
+ await handleFrame(sendFn, { id: "ab1", type: "http-abort" });
132
+ const t1 = Date.now();
133
+ await p; // 取消后必须快速 resolve,而不是等 120s 超时
134
+ assert.ok(Date.now() - t1 < 2000, "取消后必须立刻结束");
135
+ assert.deepEqual(drops, ["ab1"], "必须丢掉该 id 在隧道里还没发的分片");
136
+ assert.ok(!sent.some((f) => f.type === "http" && f.status === 502), `取消不得回 502 帧:${JSON.stringify(sent)}`);
137
+ assert.equal(__httpInflightForTest(), 0, "取消后 inflight 必须归零(不能泄漏 controller)");
138
+ });
139
+
140
+ test("B2④:abortDeadEnabled 解析(0/false/off 才关;非法值当开)", () => {
141
+ for (const v of ["0", "false", "OFF", "no", "disabled"]) assert.equal(abortDeadEnabled({ DSH_BRIDGE_ABORT_DEAD: v }), false, v);
142
+ for (const v of ["1", "true", "on", "yes", "anything"]) assert.equal(abortDeadEnabled({ DSH_BRIDGE_ABORT_DEAD: v }), true, v);
143
+ });
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * B0 观测(2026-11-08):慢响应计时口径。
4
+ *
5
+ * 生产问题:首屏资源从 PC 出来只有 3–16KB/s,但原日志只有 "→ 200 (3200ms, 1700KB)",
6
+ * 分不清慢在宿主首包 / 读体 / gzip / 注入 / **排队等隧道**(后者是本轮最要紧的一段)。
7
+ *
8
+ * 锁三条:
9
+ * ① 发送器的 `onFlushed` 在整条数据帧**所有分片都 rawSend 完**之后才回调(否则计时会偏小);
10
+ * ② 慢响应行格式包含五段耗时、且**不把查询串写进日志**(与埋点/中继日志口径一致);
11
+ * ③ timings 用不可枚举属性挂回回包对象 —— `JSON.stringify(reply)` 不带它,隧道帧字节不变。
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import test from "node:test";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { formatSlowResponse, makePrioritySender, slowRespLogOptions } from "../dsh-bridge.mjs";
20
+
21
+ const BRIDGE_SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dsh-bridge.mjs");
22
+
23
+ function fakeSocket() {
24
+ const frames = [];
25
+ let pending = 0;
26
+ return {
27
+ frames,
28
+ rawSend(s) { frames.push(s); pending += s.length; },
29
+ bufferedAmount: () => pending,
30
+ drain(bytes = Infinity) { pending = Math.max(0, pending - bytes); return pending; }
31
+ };
32
+ }
33
+
34
+ function makeScheduler() {
35
+ const queue = [];
36
+ return {
37
+ schedule: (fn) => queue.push(fn),
38
+ setTimer: (fn) => queue.push(fn),
39
+ tick() { const fn = queue.shift(); if (!fn) return false; fn(); return true; },
40
+ pending: () => queue.length
41
+ };
42
+ }
43
+
44
+ test("B0①:onFlushed 在**全部**分片发完后才回调(且只回调一次)", () => {
45
+ const sock = fakeSocket();
46
+ const sched = makeScheduler();
47
+ const sender = makePrioritySender({
48
+ rawSend: sock.rawSend,
49
+ bufferedAmount: sock.bufferedAmount,
50
+ schedule: sched.schedule,
51
+ setTimer: sched.setTimer
52
+ });
53
+
54
+ const big = { id: "r1", type: "http", body: "A".repeat(300 * 1024) };
55
+ const total = JSON.stringify(big).length;
56
+ let flushAt = -1;
57
+ let flushCount = 0;
58
+ sender.send(big, { id: "r1", onFlushed: () => { flushAt = sock.frames.length; flushCount += 1; } });
59
+ assert.ok(sock.frames.length >= 1, "第一片当场发出");
60
+ // 大帧不会当轮发完
61
+ for (let i = 0; i < 5000 && sched.pending() > 0; i += 1) { sock.drain(4 * 1024 * 1024); sched.tick(); }
62
+ assert.equal(flushCount, 1, "必须只回调一次");
63
+ const chunks = Math.ceil(total / (64 * 1024));
64
+ assert.equal(flushAt, chunks, `回调时所有分片都应已发出(回调于第 ${flushAt} 条,应为 ${chunks})`);
65
+ });
66
+
67
+ test("B0②:慢响应行五段齐全、去查询串、队列厚度可见", () => {
68
+ const line = formatSlowResponse({
69
+ method: "GET",
70
+ path: "/plugins/??@a/x.js,@b/y.js&rev=deadbeef",
71
+ status: 200,
72
+ totalMs: 3200,
73
+ timings: { ttfbMs: 120, readMs: 80, injectMs: 0, gzipMs: 40 },
74
+ queueWaitMs: 2900,
75
+ queue: { queued: 3, queuedBytes: 2270000 },
76
+ outBytes: 1740800
77
+ });
78
+ assert.match(line, /^\[bridge\] 慢响应: GET \/plugins\/ /, "必须以固定前缀开头、路径去查询串");
79
+ assert.ok(!line.includes("rev="), `日志不得含查询串:${line}`);
80
+ assert.match(line, /总 3200ms/);
81
+ assert.match(line, /上游首包 120ms/);
82
+ assert.match(line, /读体 80ms/);
83
+ assert.match(line, /注入 0ms/);
84
+ assert.match(line, /gzip 40ms/);
85
+ assert.match(line, /入队→发完 2900ms/);
86
+ assert.match(line, /队列 3条\/2270000B/);
87
+ assert.match(line, /下行 1700KB/);
88
+ });
89
+
90
+ test("B0③:slowRespLogOptions 默认值与 env 解析(非法值回默认)", () => {
91
+ assert.deepEqual(slowRespLogOptions({}), { thresholdMs: 2000, windowMs: 3000 });
92
+ assert.deepEqual(slowRespLogOptions({ DSH_BRIDGE_SLOW_RESP_MS: "500", DSH_BRIDGE_SLOW_LOG_MS: "0" }), { thresholdMs: 500, windowMs: 0 });
93
+ assert.deepEqual(slowRespLogOptions({ DSH_BRIDGE_SLOW_RESP_MS: "abc" }), { thresholdMs: 2000, windowMs: 3000 });
94
+ assert.deepEqual(slowRespLogOptions({ DSH_BRIDGE_SLOW_RESP_MS: "-5" }), { thresholdMs: 2000, windowMs: 3000 });
95
+ });
96
+
97
+ test("B0④:timings 不可枚举 —— 不会混进隧道帧(协议字节不变)", () => {
98
+ // 源码契约:必须用 defineProperty(..., enumerable:false);不允许把 timings 塞进可枚举字段
99
+ const src = fs.readFileSync(BRIDGE_SRC, "utf8");
100
+ assert.match(src, /Object\.defineProperty\(reply, "timings", \{ value: timings, enumerable: false \}\)/);
101
+ // 行为验证:不可枚举属性不会出现在 JSON(= 隧道帧的序列化方式)里
102
+ const reply = { status: 200, headers: {}, body: "AA==", bodyBase64: true };
103
+ Object.defineProperty(reply, "timings", { value: { ttfbMs: 1, readMs: 2, injectMs: 3, gzipMs: 4 }, enumerable: false });
104
+ const wire = JSON.stringify({ id: "r1", type: "http", ...reply });
105
+ assert.ok(!wire.includes("timings"), `隧道帧不得带 timings:${wire}`);
106
+ assert.ok(wire.includes('"body":"AA=="'));
107
+ });
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 隧道入站分片重组(P0-1,2026-11-08)。
4
+ *
5
+ * 生产缺陷:`connectTunnel` 的 `ws.on("message")` 处理器里**每条消息**都新建一个
6
+ * `makeFrameReceiver()`。而 `__chunk` 的 n 片是 n 条独立 WS 消息 ⇒ 攒片状态(bufs)
7
+ * 随消息一起丢 ⇒ 跨消息分片永远拼不齐,中继发来的大帧(大请求体等)在桥侧被静默丢弃。
8
+ * 中继侧一直是**连接级**单实例(index.mjs 在 connection 回调里建一次)。
9
+ *
10
+ * 本文件锁三条:
11
+ * ① 一个连接级 dispatcher 喂多条消息 → 能还原一条完整帧(跨消息重组);
12
+ * ② 多条分块序列交错到达 → 各自独立还原(状态是 Map,不是单槽);
13
+ * ③ 源码契约:`connectTunnel` 里只允许建一次接收器(且不得再出现 makeFrameReceiver())。
14
+ */
15
+ import assert from "node:assert/strict";
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import test from "node:test";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ import { createTunnelFrameDispatcher, makeFrameReceiver } from "../dsh-bridge.mjs";
22
+
23
+ const BRIDGE_SRC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "dsh-bridge.mjs");
24
+
25
+ /** 把一条大帧按 chunkSize 切成 `__chunk` 消息(与 makePrioritySender.enqueueData 同款信封)。 */
26
+ function splitFrame(obj, chunkSize, chunkId = "cx") {
27
+ const text = JSON.stringify(obj);
28
+ if (text.length <= chunkSize) return [text];
29
+ const n = Math.ceil(text.length / chunkSize);
30
+ const out = [];
31
+ for (let i = 0; i < n; i += 1) {
32
+ out.push(JSON.stringify({ __chunk: { id: chunkId, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } }));
33
+ }
34
+ return out;
35
+ }
36
+
37
+ test("P0-1①:跨 WS 消息的分片,用**同一个**连接级 dispatcher 能完整重组", () => {
38
+ const got = [];
39
+ const dispatch = createTunnelFrameDispatcher((f) => got.push(f));
40
+
41
+ const big = { id: "r1", type: "http", method: "POST", path: "/api/upload", body: "A".repeat(5000), bodyBase64: true };
42
+ const parts = splitFrame(big, 1000);
43
+ assert.ok(parts.length >= 5, `应切成多片(实际 ${parts.length})`);
44
+
45
+ // 一条消息一片地喂(生产就是这种情况:每片是一次独立 ws.send)
46
+ for (const p of parts) {
47
+ assert.equal(got.length, 0, "喂齐之前不应回调");
48
+ dispatch(p);
49
+ }
50
+ assert.equal(got.length, 1, "跨消息必须能还原成一帧");
51
+ assert.equal(got[0].id, "r1");
52
+ assert.equal(got[0].path, "/api/upload");
53
+ assert.equal(got[0].body.length, big.body.length, "重组后正文逐字节一致");
54
+ assert.equal(got[0].body, big.body);
55
+ });
56
+
57
+ test("P0-1②:两条分块序列交错到达,各自独立还原(状态是 Map 而非单槽)", () => {
58
+ const got = [];
59
+ const dispatch = createTunnelFrameDispatcher((f) => got.push(f));
60
+
61
+ const a = { id: "a", type: "http", body: "a".repeat(2500) };
62
+ const b = { id: "b", type: "http", body: "b".repeat(2500) };
63
+ const pa = splitFrame(a, 700, "ca");
64
+ const pb = splitFrame(b, 700, "cb");
65
+
66
+ const max = Math.max(pa.length, pb.length);
67
+ for (let i = 0; i < max; i += 1) {
68
+ if (pa[i]) dispatch(pa[i]);
69
+ if (pb[i]) dispatch(pb[i]);
70
+ }
71
+ assert.equal(got.length, 2, "两条序列都应还原");
72
+ const fa = got.find((f) => f.id === "a");
73
+ const fb = got.find((f) => f.id === "b");
74
+ assert.equal(fa.body, a.body);
75
+ assert.equal(fb.body, b.body);
76
+ });
77
+
78
+ test("P0-1③:未分片的普通帧直通(行为不变)", () => {
79
+ const got = [];
80
+ const dispatch = createTunnelFrameDispatcher((f) => got.push(f));
81
+ dispatch(JSON.stringify({ id: "w1", type: "ws-open", ok: true }));
82
+ assert.deepEqual(got, [{ id: "w1", type: "ws-open", ok: true }]);
83
+ });
84
+
85
+ test("P0-1④:非 JSON / 损坏分块不得抛(连接不能因为坏帧整个断掉)", () => {
86
+ const got = [];
87
+ const dispatch = createTunnelFrameDispatcher((f) => got.push(f));
88
+ dispatch("not-json{");
89
+ dispatch(JSON.stringify({ __chunk: { id: "bad", n: 2, i: 0, data: "{" } }));
90
+ assert.equal(got.length, 0);
91
+ dispatch(JSON.stringify({ id: "ok", type: "http-chunk" }));
92
+ assert.equal(got.length, 1, "坏帧之后仍要继续工作");
93
+ });
94
+
95
+ /** 从 `function foo(` 起始位置取出到匹配右花括号为止的函数体(朴素花括号计数,避开字符串里的括号够用)。 */
96
+ function extractFunction(src, signature) {
97
+ const start = src.indexOf(signature);
98
+ assert.ok(start > 0, `必须能找到 ${signature}`);
99
+ const open = src.indexOf("{", start);
100
+ assert.ok(open > start, `${signature} 必须有大括号`);
101
+ let depth = 0;
102
+ for (let i = open; i < src.length; i += 1) {
103
+ const ch = src[i];
104
+ if (ch === "{") depth += 1;
105
+ else if (ch === "}") {
106
+ depth -= 1;
107
+ if (depth === 0) return src.slice(start, i + 1);
108
+ }
109
+ }
110
+ throw new Error(`${signature} 花括号不闭合`);
111
+ }
112
+
113
+ test("P0-1⑤:源码契约 —— connectTunnel 里接收器只能建一次,且不得在 message 回调里 new", () => {
114
+ const src = fs.readFileSync(BRIDGE_SRC, "utf8");
115
+ const body = extractFunction(src, "function connectTunnel(token)");
116
+ // 只看代码:注释里会引用这个函数名(解释历史缺陷),不能算违规
117
+ const code = body.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
118
+
119
+ assert.match(code, /createTunnelFrameDispatcher\(/, "必须用连接级 dispatcher");
120
+ assert.doesNotMatch(
121
+ code,
122
+ /makeFrameReceiver\s*\(/,
123
+ "connectTunnel 内不得直接 new makeFrameReceiver()(每条消息新建会让跨消息 __chunk 永远拼不齐 —— 2026-11-08 P0-1)"
124
+ );
125
+ // 且 dispatcher 只建一次(在 ws.on("message") 之前)
126
+ const messageHandler = body.indexOf('ws.on("message"');
127
+ assert.ok(messageHandler > 0, '必须找到 ws.on("message")');
128
+ assert.ok(
129
+ body.lastIndexOf("createTunnelFrameDispatcher(", messageHandler) > 0,
130
+ 'dispatcher 必须在 ws.on("message") 之前创建(连接级单实例)'
131
+ );
132
+ // makeFrameReceiver 本身不能改变协议:仍是 (raw,onFrame) 形状,供 dispatcher/测试共用
133
+ const recv = makeFrameReceiver();
134
+ assert.equal(typeof recv, "function");
135
+ });