@mrrisega/dsh-remote 0.7.6-beta.1 → 0.7.6-beta.3

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/dsh-bridge.mjs +641 -49
  2. package/clients/dsh-remote/e2ee-client.mjs +256 -0
  3. package/clients/dsh-remote/mobile-adapter.mjs +261 -0
  4. package/clients/dsh-remote/test/bridge-control-priority.test.mjs +206 -0
  5. package/clients/dsh-remote/test/bridge-heartbeat-adaptive.test.mjs +110 -0
  6. package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +26 -8
  7. package/clients/dsh-remote/test/bridge-ws-open-fast-ack.test.mjs +213 -0
  8. package/clients/dsh-remote/test/e2ee-session-store.test.mjs +251 -0
  9. package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +328 -0
  10. package/clients/dsh-remote/test/pending-plugin-update.test.mjs +96 -0
  11. package/dsh-setup.mjs +51 -6
  12. package/package.json +1 -1
  13. package/packages/dsh-remote-web/lib/client.js +137 -6
  14. package/packages/dsh-remote-web/lib/index.js +172 -2
  15. package/packages/dsh-remote-web/package.json +1 -1
  16. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +641 -49
  17. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +256 -0
  18. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +261 -0
  19. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +51 -6
  20. package/packages/dsh-remote-web/test/community-invite-proxy.test.mjs +343 -0
  21. package/packages/dsh-remote-web/test/community-invite-ui.test.mjs +375 -0
  22. package/packages/dsh-remote-web/test/desktop-compat.test.mjs +7 -5
  23. package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +198 -0
  24. package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +336 -0
  25. package/packages/dsh-remote-web/test/setup-output.test.mjs +4 -1
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 隧道发送侧「控制帧快速通道」回归(2026-10-23)。
4
+ *
5
+ * 生产事故(手机连不上/断线主因链 #1):
6
+ * 手机首屏的 /plugins/?? 聚合包最大 16.33MB。旧发送器把它切成 200KB 分块后在**同一个
7
+ * tick 里连续 ws.send**:内核发送缓冲一满,剩余分块全堆进 ws 的内存队列(弱网下十几秒
8
+ * 的量)。这期间 router 发来的 ws-open,bridge 只需几毫秒就能回,但**回包被排在 16MB
9
+ * 分块后面** → router 20s 超时(ws_open_timeout),页面一直转圈。
10
+ *
11
+ * 本文件用假 socket + 可控调度器锁三条契约:
12
+ * ① 大帧传输中插入的 ws-open 回包,在**下一轮**就被发出(不会排到整帧传完之后);
13
+ * ② 发送缓冲高水位以上时停手,控制帧前面最多只有「高水位 + 一片」待发字节;
14
+ * ③ 分块信封仍能被接收侧 makeFrameReceiver 完整还原(协议未被改坏)。
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import test from "node:test";
18
+
19
+ import {
20
+ CONTROL_FRAME_TYPES,
21
+ TUNNEL_BULK_HWM_DEFAULT,
22
+ isControlFrame,
23
+ makeFrameReceiver,
24
+ makePrioritySender,
25
+ tunnelBulkOptionsFromEnv
26
+ } from "../dsh-bridge.mjs";
27
+
28
+ /** 假 socket:记录每一帧,可选"慢速排空"模型(bufferedAmount 缓慢下降)。 */
29
+ function fakeSocket({ drainPerTick = Infinity } = {}) {
30
+ const frames = [];
31
+ let pending = 0;
32
+ return {
33
+ frames,
34
+ rawSend(s) {
35
+ frames.push(s);
36
+ pending += s.length;
37
+ },
38
+ bufferedAmount: () => pending,
39
+ /** 模拟一次内核把缓冲排掉一些。 */
40
+ drain(bytes = drainPerTick) {
41
+ pending = Math.max(0, pending - bytes);
42
+ return pending;
43
+ },
44
+ get pending() { return pending; }
45
+ };
46
+ }
47
+
48
+ function makeScheduler() {
49
+ const queue = [];
50
+ return {
51
+ schedule: (fn) => { queue.push({ fn, kind: "immediate" }); },
52
+ setTimer: (fn) => { queue.push({ fn, kind: "timer" }); },
53
+ /** 跑一个排队的回调(模拟一个事件循环轮次)。 */
54
+ tick() {
55
+ const job = queue.shift();
56
+ if (!job) return false;
57
+ job.fn();
58
+ return true;
59
+ },
60
+ runAll(limit = 5000) {
61
+ let n = 0;
62
+ while (n < limit && this.tick()) n += 1;
63
+ return n;
64
+ },
65
+ pending: () => queue.length
66
+ };
67
+ }
68
+
69
+ /** 从已发帧里还原出每个分块信封所属的逻辑帧(用真实接收侧实现,顺便守住协议)。 */
70
+ function reassemble(frames) {
71
+ const out = [];
72
+ const recv = makeFrameReceiver();
73
+ for (const raw of frames) recv(raw, (frame) => out.push(frame));
74
+ return out;
75
+ }
76
+
77
+ test("契约①:大响应传输中插入的 ws-open 回包在下一轮就发出(不排到整帧之后)", () => {
78
+ const sock = fakeSocket();
79
+ const sched = makeScheduler();
80
+ const sender = makePrioritySender({
81
+ rawSend: sock.rawSend,
82
+ bufferedAmount: sock.bufferedAmount,
83
+ bulkHighWater: TUNNEL_BULK_HWM_DEFAULT,
84
+ chunkSize: 64 * 1024,
85
+ schedule: sched.schedule,
86
+ setTimer: sched.setTimer
87
+ });
88
+
89
+ /** 快速链路:每轮把内核缓冲排空(同时证明"高水位以上才停手"不影响正常链路)。 */
90
+ const tickDrain = () => { sock.drain(2 * 1024 * 1024); return sched.tick(); };
91
+
92
+ // 一条 4MB 的大响应(≈64 个分块)
93
+ const big = { id: "big", type: "http", status: 200, body: "A".repeat(4 * 1024 * 1024), bodyBase64: true };
94
+ assert.equal(sender.send(big), true);
95
+ assert.equal(sock.frames.length, 1, "第一片当场发出");
96
+ const chunksTotal = Math.ceil(JSON.stringify(big).length / (64 * 1024));
97
+ assert.ok(chunksTotal >= 60, `应被切成很多片(实际 ${chunksTotal})`);
98
+
99
+ // 让事件循环转几轮(还没传完)
100
+ for (let i = 0; i < 5; i += 1) assert.equal(tickDrain(), true);
101
+ const sentBefore = sock.frames.length;
102
+ assert.ok(sentBefore >= 4 && sentBefore < chunksTotal, `此时应只发了一小部分(${sentBefore}/${chunksTotal})`);
103
+
104
+ // router 的 ws-open 到达 → bridge 处理 → 回包入控制车道
105
+ sender.send({ id: "w1", type: "ws-open", ok: true, fast: true });
106
+ const sentAfterControl = sock.frames.length;
107
+ const pos = sock.frames.findIndex((s) => s.includes('"id":"w1"') && s.includes('"ok":true'));
108
+ assert.ok(pos >= 0, "控制帧必须已经发出");
109
+ assert.equal(pos, sentAfterControl - 1, "控制帧应当**立刻**发出(不等当前轮次的剩余分块)");
110
+ assert.ok(pos < sentBefore + 3, `控制帧位置 ${pos} 必须远早于大帧剩余分块(已发 ${sentBefore})`);
111
+
112
+ // 继续跑完
113
+ for (let i = 0; i < 5000 && sched.pending() > 0; i += 1) tickDrain();
114
+ assert.equal(sock.frames.length, chunksTotal + 1);
115
+ const frames = reassemble(sock.frames);
116
+ assert.equal(frames.length, 2, "大帧 + ws-open 两帧都应能被接收侧还原");
117
+ const bigFrame = frames.find((f) => f.id === "big");
118
+ const ctrlFrame = frames.find((f) => f.id === "w1");
119
+ assert.ok(bigFrame && ctrlFrame, "两条逻辑帧都要还原出来");
120
+ assert.equal(ctrlFrame.ok, true);
121
+ assert.equal(bigFrame.body.length, big.body.length, "分块重组后正文逐字节一致");
122
+ });
123
+
124
+ test("契约②:发送缓冲高水位以上时停手(控制帧不会被埋进几十秒的队列)", () => {
125
+ // 慢链路:每发一片,缓冲就积压 100KB(> 64KB 高水位)
126
+ const sock = fakeSocket();
127
+ const sched = makeScheduler();
128
+ const sender = makePrioritySender({
129
+ rawSend: sock.rawSend,
130
+ bufferedAmount: sock.bufferedAmount,
131
+ bulkHighWater: 64 * 1024,
132
+ chunkSize: 64 * 1024,
133
+ schedule: sched.schedule,
134
+ setTimer: sched.setTimer
135
+ });
136
+
137
+ const big = { id: "big2", type: "http", body: "B".repeat(1024 * 1024) };
138
+ sender.send(big);
139
+ // 第一片发出后 bufferedAmount = 片长(>64KB) → 下一轮必须停手等排空
140
+ sched.tick();
141
+ const afterFirst = sock.frames.length;
142
+ sched.tick();
143
+ assert.equal(sock.frames.length, afterFirst, "超过高水位时不得继续灌分块(只能等排空)");
144
+ assert.ok(sender.stats().waits >= 1, "应记录过背压等待");
145
+
146
+ // 此时控制帧到达:它仍然必须立刻出去(控制车道不受批量车道背压影响)
147
+ sock.drain(10 * 1024 * 1024); // 缓冲排空
148
+ const beforeControl = sock.frames.length;
149
+ sender.send({ id: "w2", type: "ws-open", ok: false, code: 1008, reason: "test" });
150
+ const pos = sock.frames.findIndex((s) => s.includes('"id":"w2"'));
151
+ assert.equal(pos, beforeControl, "排空后第一时间发控制帧(之前的背压不能把它排到分块后面)");
152
+ });
153
+
154
+ test("契约③:大帧传输中小帧优先(交互不被整帧传输拖住);close() 丢弃未发内容", () => {
155
+ const sock = fakeSocket();
156
+ const sched = makeScheduler();
157
+ const sender = makePrioritySender({
158
+ rawSend: sock.rawSend,
159
+ bufferedAmount: sock.bufferedAmount,
160
+ schedule: sched.schedule,
161
+ setTimer: sched.setTimer
162
+ });
163
+ // 大帧刚发出第一片(之后每轮只发一片)
164
+ sender.send({ id: "big3", type: "http", body: "C".repeat(512 * 1024) });
165
+ // 传输中插入两条小帧(交互消息 / SSE 块):必须插在大帧的下一片之前,而不是排在整帧之后
166
+ sender.send({ id: "i1", type: "ws-msg", data: "hello" });
167
+ sender.send({ id: "i2", type: "http-chunk", body: "chunk" });
168
+ const ids = sock.frames.map((s) => {
169
+ const o = JSON.parse(s);
170
+ return o.__chunk ? "__chunk" : o.id;
171
+ });
172
+ assert.deepEqual(ids.slice(0, 3), ["__chunk", "i1", "i2"], "小帧必须插在大帧的下一片之前");
173
+ assert.equal(sender.stats().bigChunks, 1, "此时大帧只发了 1 片");
174
+
175
+ // close 后:未发内容丢弃,后续 send 返回 false(重连后不能把旧分片发到新连接上)
176
+ const sock2 = fakeSocket();
177
+ const sched2 = makeScheduler();
178
+ const sender2 = makePrioritySender({
179
+ rawSend: sock2.rawSend,
180
+ bufferedAmount: sock2.bufferedAmount,
181
+ schedule: sched2.schedule,
182
+ setTimer: sched2.setTimer
183
+ });
184
+ sender2.send({ id: "big4", type: "http", body: "D".repeat(512 * 1024) });
185
+ const before = sock2.frames.length;
186
+ sender2.close();
187
+ assert.equal(sender2.send({ id: "after", type: "ws-msg", data: "x" }), false, "close 后不再受理");
188
+ sched2.runAll();
189
+ assert.equal(sock2.frames.length, before, "close 后不得再发剩余分片");
190
+ });
191
+
192
+ test("控制帧类型/环境变量解析(纯函数)", () => {
193
+ for (const t of ["ws-open", "ws-close", "http-abort", "http-end"]) {
194
+ assert.equal(isControlFrame({ id: 1, type: t }), true, `${t} 必须走控制车道`);
195
+ }
196
+ for (const t of ["http", "ws-msg", "http-chunk"]) {
197
+ assert.equal(isControlFrame({ id: 1, type: t }), false, `${t} 不该抢控制车道`);
198
+ }
199
+ assert.equal(isControlFrame(null), false);
200
+ assert.deepEqual(CONTROL_FRAME_TYPES.slice(0, 2), ["ws-open", "ws-close"]);
201
+
202
+ assert.equal(tunnelBulkOptionsFromEnv({}).bulkHighWater, TUNNEL_BULK_HWM_DEFAULT);
203
+ assert.equal(tunnelBulkOptionsFromEnv({ DSH_BRIDGE_TUNNEL_BULK_HWM_KB: "32" }).bulkHighWater, 32 * 1024);
204
+ assert.equal(tunnelBulkOptionsFromEnv({ DSH_BRIDGE_TUNNEL_CHUNK_KB: "16" }).chunkSize, 16 * 1024);
205
+ assert.equal(tunnelBulkOptionsFromEnv({ DSH_BRIDGE_TUNNEL_CHUNK_KB: "abc" }).chunkSize, 64 * 1024, "非法值回默认");
206
+ });
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 心跳自适应回归(2026-10-23)。
4
+ *
5
+ * 生产事故(手机频繁断线主因链 #3):
6
+ * 固定 20s 心跳 + 固定"丢 2 个 pong 就重连"。DSH 主线程被大任务/大响应占住时,pong 在
7
+ * socket 事件里排不上队 → 连续两拍判死 → 重建隧道 → 手机侧全部在途请求断开、页面重连。
8
+ * 全站 13706 次/天 bridge 断开里有一大批是这么来的。
9
+ *
10
+ * 本文件锁「纯函数」契约(真机/集成行为由 bridge 日志与 test:bridge 全量保证):
11
+ * ① 健康链路完全保留旧契约(丢 2 拍才断);
12
+ * ② 本机事件循环延迟高 / RTT 慢 / 已在中断中 → 判定窗口放宽;
13
+ * ③ 放宽有上限(默认 4 拍 / 环境变量可调),不会退化成永不重连;
14
+ * ④ DSH_BRIDGE_HEARTBEAT_ADAPT=0 可退回旧契约。
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import test from "node:test";
18
+
19
+ import {
20
+ HEARTBEAT_LAG_BUSY_MS,
21
+ HEARTBEAT_MAX_MISSED_CAP_DEFAULT,
22
+ HEARTBEAT_MAX_MISSED_PONGS,
23
+ HEARTBEAT_RTT_SLOW_MS,
24
+ TUNNEL_HEARTBEAT_DEFAULT_MS,
25
+ __setEventLoopLagForTest,
26
+ adaptiveHeartbeat,
27
+ currentEventLoopLagMs,
28
+ heartbeatStep
29
+ } from "../dsh-bridge.mjs";
30
+
31
+ const BASE = TUNNEL_HEARTBEAT_DEFAULT_MS;
32
+
33
+ test("① 健康链路 = 旧契约:收到 pong 就归零,连续 2 拍没 pong 才断", () => {
34
+ const pong = adaptiveHeartbeat({ pongReceived: true, missedPongs: 5, rttMs: 40, eventLoopLagMs: 1 });
35
+ assert.equal(pong.missedPongs, 0);
36
+ assert.equal(pong.terminate, false);
37
+ assert.equal(pong.intervalMs, BASE, "健康链路 ping 间隔不变");
38
+ assert.equal(pong.maxMissed, HEARTBEAT_MAX_MISSED_PONGS);
39
+
40
+ const one = adaptiveHeartbeat({ pongReceived: false, missedPongs: 0, rttMs: 40, eventLoopLagMs: 1 });
41
+ assert.equal(one.missedPongs, 1);
42
+ assert.equal(one.terminate, false, "丢 1 拍不断(旧行为是丢 1 就断)");
43
+
44
+ const two = adaptiveHeartbeat({ pongReceived: false, missedPongs: 1, rttMs: 40, eventLoopLagMs: 1 });
45
+ assert.equal(two.missedPongs, 2);
46
+ assert.equal(two.terminate, true, "健康链路下连续 2 拍没 pong 仍按旧契约判死");
47
+
48
+ // 与旧纯函数 heartbeatStep 在健康链路下结论一致
49
+ for (const missed of [0, 1, 2, 3]) {
50
+ const a = adaptiveHeartbeat({ pongReceived: false, missedPongs: missed, rttMs: 40, eventLoopLagMs: 0 });
51
+ const b = heartbeatStep({ pongReceived: false, missedPongs: missed });
52
+ assert.equal(a.terminate, b.terminate, `健康链路 missed=${missed} 的判定必须与旧契约一致`);
53
+ }
54
+ });
55
+
56
+ test("② 本机繁忙(事件循环延迟 ≥500ms)→ 多给一拍,瞬时卡顿不再判死", () => {
57
+ const plan = adaptiveHeartbeat({ pongReceived: false, missedPongs: 0, rttMs: 40, eventLoopLagMs: HEARTBEAT_LAG_BUSY_MS });
58
+ assert.equal(plan.maxMissed, HEARTBEAT_MAX_MISSED_PONGS + 1);
59
+ assert.ok(plan.reasons.includes("event_loop_busy"));
60
+ assert.equal(plan.terminate, false);
61
+ // 第 2 拍仍不断(这正是生产里 DSH 主线程卡 3~5 秒的场景)
62
+ const second = adaptiveHeartbeat({ pongReceived: false, missedPongs: 1, rttMs: 40, eventLoopLagMs: HEARTBEAT_LAG_BUSY_MS });
63
+ assert.equal(second.terminate, false, "繁忙时第 2 拍不得判死");
64
+ // 第 3 拍才断
65
+ const third = adaptiveHeartbeat({ pongReceived: false, missedPongs: 2, rttMs: 40, eventLoopLagMs: HEARTBEAT_LAG_BUSY_MS });
66
+ assert.equal(third.terminate, true);
67
+ });
68
+
69
+ test("③ 慢链路(RTT ≥1s)→ 间隔拉长 + 多给一拍;两者叠加有上限", () => {
70
+ const slow = adaptiveHeartbeat({ pongReceived: false, missedPongs: 0, rttMs: HEARTBEAT_RTT_SLOW_MS, eventLoopLagMs: 0 });
71
+ assert.ok(slow.maxMissed > HEARTBEAT_MAX_MISSED_PONGS);
72
+ assert.ok(slow.intervalMs > BASE, "慢链路 ping 间隔应拉长(减少额外负载)");
73
+ assert.ok(slow.intervalMs <= 60_000);
74
+ assert.ok(slow.reasons.includes("rtt_slow"));
75
+
76
+ // 繁忙 + 慢链路 + 已在中断中 → 上限封顶,不会无限放宽
77
+ const worst = adaptiveHeartbeat({
78
+ pongReceived: false,
79
+ missedPongs: 1,
80
+ rttMs: 5000,
81
+ eventLoopLagMs: 3000,
82
+ maxMissedCap: HEARTBEAT_MAX_MISSED_CAP_DEFAULT
83
+ });
84
+ assert.equal(worst.maxMissed, HEARTBEAT_MAX_MISSED_CAP_DEFAULT);
85
+ assert.equal(worst.terminate, false);
86
+ const capped = adaptiveHeartbeat({ pongReceived: false, missedPongs: 3, rttMs: 5000, eventLoopLagMs: 3000 });
87
+ assert.equal(capped.maxMissed, HEARTBEAT_MAX_MISSED_CAP_DEFAULT);
88
+ assert.equal(capped.terminate, true, "到了上限那一拍必须判死(不能永不重连)");
89
+ });
90
+
91
+ test("④ DSH_BRIDGE_HEARTBEAT_ADAPT=0 等价语义:完全退回固定窗口", () => {
92
+ const plan = adaptiveHeartbeat({
93
+ pongReceived: false,
94
+ missedPongs: 1,
95
+ rttMs: 5000,
96
+ eventLoopLagMs: 3000,
97
+ adaptive: false
98
+ });
99
+ assert.equal(plan.maxMissed, HEARTBEAT_MAX_MISSED_PONGS);
100
+ assert.equal(plan.intervalMs, BASE);
101
+ assert.equal(plan.terminate, true, "退回旧契约:第 2 拍即判死");
102
+ });
103
+
104
+ test("⑤ 事件循环延迟探针(测试钩子)", () => {
105
+ assert.equal(currentEventLoopLagMs(), 0);
106
+ __setEventLoopLagForTest(800);
107
+ assert.equal(currentEventLoopLagMs(), 800);
108
+ __setEventLoopLagForTest(-5);
109
+ assert.equal(currentEventLoopLagMs(), 0);
110
+ });
@@ -8,6 +8,10 @@
8
8
  * HTTP 失败路径早就会 refreshUpstream,**WS 失败路径之前漏了** —— 而镜像页常先开 WS。
9
9
  *
10
10
  * 这个用例守住:两条失败路径都必须触发重新发现(否则又回到"只能重启 bridge")。
11
+ *
12
+ * 【2026-10-23 契约变更】ws-open 改为「先快速 ack、再连上游」后,上游建立失败的收尾从
13
+ * `{type:"ws-open", ok:false, code:502}` 变成 `{type:"ws-close", code:1011, retryable:true}`
14
+ * —— 升级早已完成,再回 ws-open 失败帧没有意义。自愈要求不变:仍必须 refreshUpstream。
11
15
  */
12
16
  import test from "node:test";
13
17
  import assert from "node:assert/strict";
@@ -15,14 +19,28 @@ import { readFileSync } from "node:fs";
15
19
 
16
20
  const SRC = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
17
21
 
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");
22
+ /** 取某个锚点到下一处 `\n}` 的函数体(够做源代码契约断言,不追求通用解析)。 */
23
+ function bodyAfter(anchor, maxLen = 2000) {
24
+ const i = SRC.indexOf(anchor);
25
+ assert.ok(i > 0, `找不到锚点:${anchor}`);
26
+ return SRC.slice(i, i + maxLen);
27
+ }
28
+
29
+ test("★ WS 上游建立失败必须触发上游重新发现(快速应答后走 ws-close 1011)", () => {
30
+ // ws.on("error") 的未建立分支 → 统一交给 failUpstream 收尾
31
+ const errBranch = bodyAfter('ws.on("error"', 900);
32
+ assert.match(errBranch, /!session\.opened/, "只对未建立的会话做失败收尾");
33
+ assert.match(errBranch, /failUpstream\(/, "未建立失败必须走统一收尾(不要各写一份)");
34
+ // failUpstream 内部:重发现 + ws-close(升级已 ack,不能再回 ws-open)
35
+ const failFn = bodyAfter("const failUpstream =", 2200);
36
+ assert.match(failFn, /refreshUpstream\(/, "★ 必须触发重新发现,否则端口变了会一直失败");
37
+ assert.match(failFn, /failAckedWs\(/, "快速应答之后只能 ws-close 收尾");
38
+ const ackFn = bodyAfter("function failAckedWs(", 600);
39
+ assert.match(ackFn, /type: "ws-close"/, "收尾帧必须是 ws-close");
40
+ assert.match(ackFn, /code: 1011/, "ws-close 用 1011(服务端内部错误)");
41
+ // close 路径也要覆盖「还没 open 就断」:不能只靠 error 事件
42
+ const closeBranch = bodyAfter('ws.on("close"', 900);
43
+ assert.match(closeBranch, /failUpstream\(/, "上游没建立就 close 也要触发重发现");
26
44
  });
27
45
 
28
46
  test("★ HTTP 失败路径也要有重新发现(既有能力不得回退)", () => {
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ws-open 快速应答 / 上游建立期间消息不丢 / 上游失败用 ws-close 收尾(2026-10-23)。
4
+ *
5
+ * 旧契约:收到 ws-open → 连上游 WebSocket → **等上游 open** 才回 ok。
6
+ * 上游连不上(桌面端换端口 / 宿主重启)时,手机要等 router 的 20s 超时;
7
+ * bridge 发送队列被大响应占住时(见 bridge-control-priority),连"回 ok"都排不上。
8
+ *
9
+ * 新契约(本文件锁住):
10
+ * ① 校验通过立刻回 `{ok:true, fast:true}`(同步、<100ms),再连上游;
11
+ * ② 上游建立期间手机发来的 ws-msg 进 pending 队列,open 后按序补发(不丢 mux 首帧);
12
+ * ③ 上游最终失败 → 回 `ws-close`(1011, retryable) 而不是干等 20s(升级早已 ack);
13
+ * ④ E2EE 语义不变:带 &e2ee= 的流先校验会话;未知/未验证 → 1008 +
14
+ * `retryable:true,action:"rehandshake"`(手机可自动重握手,不用让用户重新解锁)。
15
+ */
16
+ import assert from "node:assert/strict";
17
+ import http from "node:http";
18
+ import { mkdtempSync, rmSync } from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import test from "node:test";
22
+ import { setTimeout as delay } from "node:timers/promises";
23
+
24
+ import { WebSocketServer } from "ws";
25
+
26
+ // ── 上游假 dsh web:三种升级行为 ───────────────────────────────────────────────
27
+ const upstream = http.createServer((req, res) => { res.writeHead(404); res.end(); });
28
+ const wss = new WebSocketServer({ noServer: true });
29
+ wss.on("connection", (ws) => {
30
+ ws.on("message", (data, isBinary) => {
31
+ try { ws.send(data, { binary: isBinary }); } catch { /* 手机侧已断 */ }
32
+ });
33
+ });
34
+ let upPort = 0;
35
+ await new Promise((resolve) => {
36
+ upstream.listen(0, "127.0.0.1", () => {
37
+ upPort = upstream.address().port;
38
+ resolve();
39
+ });
40
+ });
41
+ upstream.on("upgrade", (req, socket, head) => {
42
+ let pathname = "";
43
+ try { pathname = new URL(req.url || "/", "http://x").pathname; } catch { pathname = ""; }
44
+ if (pathname === "/ws-slow") {
45
+ // 上游建立要 400ms:足够断言"ok 已经先回来了"
46
+ setTimeout(() => {
47
+ try { wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); } catch { /* 手机已断 */ }
48
+ }, 400);
49
+ return;
50
+ }
51
+ if (pathname === "/ws-reject") {
52
+ try { socket.destroy(); } catch { /* 忽略 */ }
53
+ return;
54
+ }
55
+ try { wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); } catch { /* 忽略 */ }
56
+ });
57
+
58
+ // ── 环境必须在 import dsh-bridge 之前定好(模块级常量在 import 时求值)────────────
59
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "dsh-wsopen-"));
60
+ process.env.DSH_BRIDGE_CONFIG = path.join(tmpDir, ".dsh-config.json");
61
+ process.env.DSH_BRIDGE_UPSTREAM = `http://127.0.0.1:${upPort}`;
62
+ process.env.DSH_BRIDGE_TUNNEL_URL = "ws://127.0.0.1:1"; // 本用例不连隧道
63
+ const bridge = await import("../dsh-bridge.mjs");
64
+ const {
65
+ __setE2eeForTest,
66
+ __wsSessionState,
67
+ handleWsClose,
68
+ handleWsMessage,
69
+ handleWsOpen
70
+ } = bridge;
71
+ const e2eeMod = await import("../e2ee-client.mjs");
72
+
73
+ test.after(() => {
74
+ try { wss.close(); } catch { /* 忽略 */ }
75
+ try { upstream.close(); } catch { /* 忽略 */ }
76
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* 忽略 */ }
77
+ });
78
+
79
+ /** 记录 bridge → router 的帧(等价于隧道 send)。 */
80
+ function spy() {
81
+ const frames = [];
82
+ const send = (obj) => {
83
+ frames.push({ ...obj, __at: Date.now() });
84
+ return true;
85
+ };
86
+ return { frames, send };
87
+ }
88
+
89
+ async function waitFor(label, pred, timeoutMs = 4000) {
90
+ const deadline = Date.now() + timeoutMs;
91
+ for (;;) {
92
+ const v = pred();
93
+ if (v) return v;
94
+ if (Date.now() > deadline) throw new Error(`等待超时:${label}`);
95
+ await delay(10);
96
+ }
97
+ }
98
+
99
+ test("① 先把 ok 回给手机(<100ms),再连上游;ok 不等于上游已建立", async () => {
100
+ const { frames, send } = spy();
101
+ const t0 = Date.now();
102
+ await handleWsOpen(send, { id: "fast1", type: "ws-open", path: "/ws-slow", headers: {} });
103
+ const elapsed = Date.now() - t0;
104
+ assert.ok(elapsed < 100, `ok 必须立刻回(实测 ${elapsed}ms)`);
105
+ assert.equal(frames.length, 1, "此刻只有 ws-open 回包,没有上游消息");
106
+ assert.equal(frames[0].ok, true);
107
+ assert.equal(frames[0].fast, true, "回包应带 fast:true(可观测:这是快速应答)");
108
+ // 上游还没建立(400ms 后才 open)
109
+ await delay(100);
110
+ assert.equal(__wsSessionState("fast1").opened, false, "100ms 时上游仍未建立");
111
+ await waitFor("上游 open", () => __wsSessionState("fast1")?.opened === true);
112
+ handleWsClose(send, { id: "fast1", code: 1000 });
113
+ });
114
+
115
+ test("② 上游建立期间手机发来的 ws-msg 不丢:排队 → open 后按序补发", async () => {
116
+ const { frames, send } = spy();
117
+ await handleWsOpen(send, { id: "fast2", type: "ws-open", path: "/ws-slow", headers: {} });
118
+ assert.equal(frames[0].ok, true);
119
+ // 手机拿到 ok 后立刻发(真实 mux 首帧就是这样):此时上游还没 open
120
+ handleWsMessage(send, { id: "fast2", data: "first-frame", binary: false });
121
+ handleWsMessage(send, { id: "fast2", data: "second-frame", binary: false });
122
+ assert.equal(__wsSessionState("fast2").pending, 2, "两条消息应排队等上游");
123
+ // 上游 open 后:消息被补发 → echo 回来(顺序必须与发出顺序一致)
124
+ await waitFor("两条 echo", () => frames.filter((f) => f.type === "ws-msg").length >= 2, 5000);
125
+ const echoed = frames.filter((f) => f.type === "ws-msg").map((f) => String(f.data));
126
+ assert.deepEqual(echoed, ["first-frame", "second-frame"], "补发顺序必须与手机发出顺序一致");
127
+ handleWsClose(send, { id: "fast2", code: 1000 });
128
+ });
129
+
130
+ test("③ 上游建立失败 → ws-close(1011, retryable),不再让手机干等 20s", async () => {
131
+ const { frames, send } = spy();
132
+ await handleWsOpen(send, { id: "fast3", type: "ws-open", path: "/ws-reject", headers: {} });
133
+ assert.equal(frames[0].ok, true, "先快速 ok");
134
+ const close = await waitFor("失败收尾帧", () => frames.find((f) => f.type === "ws-close"), 4000);
135
+ assert.equal(close.code, 1011);
136
+ assert.equal(close.retryable, true);
137
+ assert.match(String(close.reason || ""), /upstream_ws_open_failed/);
138
+ assert.equal(__wsSessionState("fast3"), null, "失败后会话表应清掉");
139
+ });
140
+
141
+ test("④ 非法路径仍同步拒绝(400),不做快速 ok", async () => {
142
+ const { frames, send } = spy();
143
+ await handleWsOpen(send, { id: "bad1", type: "ws-open", path: "//evil.example/x", headers: {} });
144
+ assert.equal(frames.length, 1);
145
+ assert.equal(frames[0].ok, false);
146
+ assert.equal(frames[0].code, 400);
147
+ });
148
+
149
+ test("⑤ E2EE:未知会话 → 1008 + action=rehandshake(手机可自动重握手)", async () => {
150
+ const mk = Buffer.alloc(32, 7);
151
+ const svc = new e2eeMod.E2eeService({ enabled: true, mk, profile: "pbkdf2-sha256-600k", salt: "salt-abc", epoch: 0 });
152
+ __setE2eeForTest(svc);
153
+ try {
154
+ const { frames, send } = spy();
155
+ const sessId = e2eeMod.newSessId();
156
+ await handleWsOpen(send, {
157
+ id: "e2ee-locked",
158
+ type: "ws-open",
159
+ path: `/ws-echo?e2ee=${sessId}&w=0123456789abcdef`,
160
+ headers: {}
161
+ });
162
+ assert.equal(frames.length, 1);
163
+ assert.equal(frames[0].ok, false);
164
+ assert.equal(frames[0].code, 1008);
165
+ assert.equal(frames[0].retryable, true, "★ 必须显式告诉手机这是可自动重试的");
166
+ assert.equal(frames[0].action, "rehandshake", "★ 让手机自动重握手,而不是弹「请重新解锁」");
167
+ assert.match(String(frames[0].reason || ""), /e2ee_session_locked/);
168
+ } finally {
169
+ __setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
170
+ }
171
+ });
172
+
173
+ test("⑥ E2EE 语义保持:已验证会话 → 快速 ok;加密消息解封→上游 echo→重新封装一致", async () => {
174
+ const mk = Buffer.alloc(32, 7);
175
+ const salt = "salt-abc";
176
+ const profile = "pbkdf2-sha256-600k";
177
+ const svc = new e2eeMod.E2eeService({ enabled: true, mk, profile, salt, epoch: 0 });
178
+ __setE2eeForTest(svc);
179
+ try {
180
+ // 跑 §5.2 握手(与生产同一条路径,只是"手机"在我们进程里)
181
+ const sessId = e2eeMod.newSessId();
182
+ const a = e2eeMod.randomB64(32);
183
+ const { session, ack } = svc.handleHello({ v: 2, type: "e2ee-hello", role: "phone", s: sessId, a: a, salt, profile });
184
+ const { shk, saltH } = e2eeMod.deriveShk(mk, a, ack.b);
185
+ const client = new e2eeMod.E2eeSession({ sessId, shk, saltH, profile, epoch: 0 });
186
+ const probe = client.seal({ kind: "ctrl", dir: "p2b", counter: 0, data: JSON.stringify({ p: "dsh-e2ee-probe-v1", t: 1, c: 0 }) });
187
+ svc.handleProbe(sessId, probe);
188
+ assert.equal(session.verified, true);
189
+
190
+ const w = "0123456789abcdef";
191
+ const wsLabel = `/ws-echo?w=${w}`;
192
+ const { frames, send } = spy();
193
+ await handleWsOpen(send, {
194
+ id: "e2ee-ok",
195
+ type: "ws-open",
196
+ path: `/ws-echo?e2ee=${sessId}&w=${w}`,
197
+ headers: {}
198
+ });
199
+ assert.equal(frames[0].ok, true);
200
+ assert.equal(frames[0].fast, true);
201
+ await waitFor("上游 open", () => __wsSessionState("e2ee-ok")?.opened === true);
202
+
203
+ const envP2b = client.seal({ kind: "w", dir: "p2b", counter: 0, data: "ping-加密-你好", wsLabel });
204
+ handleWsMessage(send, { id: "e2ee-ok", data: JSON.stringify(envP2b), binary: false });
205
+ const back = await waitFor("b2p 信封", () => frames.find((f) => f.type === "ws-msg" && String(f.data).includes('"k":"w"')), 5000);
206
+ const envB2p = JSON.parse(String(back.data));
207
+ const opened = client.open({ kind: "w", dir: "b2p", env: envB2p, counter: envB2p.c, wsLabel });
208
+ assert.equal(opened.data.toString("utf8"), "ping-加密-你好", "端到端加解密后逐字节一致");
209
+ handleWsClose(send, { id: "e2ee-ok", code: 1000 });
210
+ } finally {
211
+ __setE2eeForTest(new e2eeMod.E2eeService({ enabled: false }));
212
+ }
213
+ });