@mrrisega/dsh-remote 0.7.5-beta.1 → 0.7.6-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 +49 -540
- package/clients/dsh-remote/e2ee-client.mjs +0 -251
- package/clients/dsh-remote/mobile-adapter.mjs +0 -261
- package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +8 -26
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +0 -195
- package/packages/dsh-remote-web/lib/index.js +0 -145
- package/packages/dsh-remote-web/package.json +1 -1
- package/clients/dsh-remote/test/bridge-control-priority.test.mjs +0 -206
- package/clients/dsh-remote/test/bridge-heartbeat-adaptive.test.mjs +0 -110
- package/clients/dsh-remote/test/bridge-ws-open-fast-ack.test.mjs +0 -213
- package/clients/dsh-remote/test/e2ee-session-store.test.mjs +0 -254
- package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +0 -328
- package/packages/dsh-remote-web/test/community-invite-proxy.test.mjs +0 -343
- package/packages/dsh-remote-web/test/community-invite-ui.test.mjs +0 -375
- package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +0 -198
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +0 -323
|
@@ -29,16 +29,10 @@
|
|
|
29
29
|
* REMOTE_STREAM_MUX_PATH)。线上未出问题是因为本文件对路径**透明透传**、由浏览器决定
|
|
30
30
|
* 请求哪个路径;但错的注释会把后来者带沟里(微信通道订阅事件流时会照着它写)。
|
|
31
31
|
* → { "id", "type":"ws-open", "path", "headers":{...} } ← { "id","type":"ws-open","ok":true|false,"code"?,"reason"? }
|
|
32
|
-
* 【2026-10-23 快速应答】校验通过立刻回 {ok:true,fast:true},随后才连上游;上游建立期间手机发来的
|
|
33
|
-
* ws-msg 先排队、open 后按序补发。上游最终失败 → 回 {type:"ws-close",code:1011,retryable:true}
|
|
34
|
-
* (此时 router 已把手机 WS 交接给上层,不能再回 ws-open 失败帧)。E2EE 会话缺失/未验证仍先拒绝:
|
|
35
|
-
* {ok:false,code:1008,retryable:true,action:"rehandshake"}(手机自动重握手,不逼用户重新解锁)。
|
|
36
32
|
* → { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
37
33
|
* ← { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
38
34
|
* → { "id", "type":"ws-close", "code"?, "reason"? } ← { "id","type":"ws-close","code"?,"reason"? }
|
|
39
35
|
* ws 会话以帧 id 为 key;隧道断开时全部关闭。
|
|
40
|
-
* 发送侧有「控制帧快速通道」(ws-open/ws-close/http-end 等可在大响应分片之间插队,
|
|
41
|
-
* 发送缓冲超过高水位时暂停灌分块) —— 见 makePrioritySender 上方长注释。
|
|
42
36
|
*
|
|
43
37
|
* 环境:
|
|
44
38
|
* DSH_BRIDGE_TUNNEL_URL 必填:relay-router 地址(如 ws://127.0.0.1:13444 或 wss://relay.example.com)
|
|
@@ -50,11 +44,7 @@
|
|
|
50
44
|
* DSH_BRIDGE_TOKEN JWT(直接给 token;优先级高于 手机号/邮箱+密码)
|
|
51
45
|
* DSH_BRIDGE_API 账号 API 地址(默认云端服务地址;自建模式无需设置)
|
|
52
46
|
* DSH_BRIDGE_LOCAL_KEY 开源自部署:访问密钥(设后经 router POST /_login 换本地 JWT,免账号体系)
|
|
53
|
-
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认
|
|
54
|
-
* DSH_BRIDGE_HEARTBEAT_ADAPT=0 关闭心跳自适应(退回"固定窗口 2 拍"的旧契约)
|
|
55
|
-
* DSH_BRIDGE_HEARTBEAT_MAX_MISSED 判定窗口上限拍数(默认 4,范围 1-8)
|
|
56
|
-
* DSH_BRIDGE_TUNNEL_BULK_HWM_KB 发送缓冲高水位(默认 64KB;弱网可调低)
|
|
57
|
-
* DSH_BRIDGE_TUNNEL_CHUNK_KB 大帧分片大小(默认 64KB)
|
|
47
|
+
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认 15000ms)
|
|
58
48
|
* DSH_MOBILE_ADAPTER 经隧道访问的官方 dsh web 移动端适配注入开关:0 关闭(默认开启,仅 ≤820px 生效)
|
|
59
49
|
*
|
|
60
50
|
* E2EE(Phase-2,见 docs/e2ee-protocol.md):
|
|
@@ -62,9 +52,6 @@
|
|
|
62
52
|
* 不落盘);服务端 /api/e2ee-params 返回 enabled=false / 401 / 无密码 / 派生失败 → 明文 v1 回退。
|
|
63
53
|
* - 开关与状态写入 <relayDir>/.e2ee-state.json {enabled, reason, profile, epoch, caps};
|
|
64
54
|
* DSH_BRIDGE_E2EE=0 或配置 e2ee:false 可本地关闭;DSH_BRIDGE_E2EE_STATE 可覆盖状态文件路径。
|
|
65
|
-
* - 【2026-10-23】会话材料加密落盘 <relayDir>/.e2ee-sessions.enc(storeKey=HKDF(MK)、AES-256-GCM、
|
|
66
|
-
* 0600、原子写),bridge 重启后恢复(24h 滑动 TTL),手机旧票不再变死票;DSH_BRIDGE_E2EE_SESSIONS=0
|
|
67
|
-
* 可关闭持久化,DSH_BRIDGE_E2EE_STORE_DIR 可覆盖目录。方案/信任边界见 e2ee-client.mjs 同段注释。
|
|
68
55
|
* - 帧体带信封标记(content-type: application/vnd.dsh.e2ee-v2 或 x-dsh-e2ee)= 已加密:
|
|
69
56
|
* HTTP 解封→转发上游→响应封回;WS 逐消息封/解。任何解封失败显式
|
|
70
57
|
* 502 + x-dsh-e2ee-error / ws-close 1008,绝不静默降级。
|
|
@@ -123,10 +110,8 @@ for (const k of ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_
|
|
|
123
110
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
124
111
|
const ROOT = path.join(THIS_DIR, "..", "..");
|
|
125
112
|
// 配置路径:dsh-setup 总会显式传入 DSH_BRIDGE_CONFIG;npm 安装时默认落到 ~/.dsh-remote
|
|
126
|
-
/** 是否是「受管安装」(npm 装到 node_modules / 安装器显式给配置):决定状态/会话库是否落盘。 */
|
|
127
|
-
const IS_MANAGED_INSTALL = ROOT.includes(`${path.sep}node_modules${path.sep}`);
|
|
128
113
|
const DEFAULT_CONFIG = path.join(
|
|
129
|
-
|
|
114
|
+
ROOT.includes(`${path.sep}node_modules${path.sep}`) ? path.join(os.homedir(), ".dsh-remote") : ROOT,
|
|
130
115
|
".dsh-config.json"
|
|
131
116
|
);
|
|
132
117
|
const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
|
|
@@ -138,8 +123,8 @@ const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "")
|
|
|
138
123
|
* 为什么从 15s 调到 20s:生产上"30 秒级断连、75% 的隧道会话活不过 60s"(诊断报告 R2),
|
|
139
124
|
* 15s 的心跳 + "丢一次 pong 就断" 让弱网(丢包 5%)下的正常抖动被当成故障,
|
|
140
125
|
* 每次误断都会重连重建隧道 + 打断在途请求。20s 与中继/nginx 的 3600s read timeout 无关,
|
|
141
|
-
* 只影响"多久发现半开连接"
|
|
142
|
-
*
|
|
126
|
+
* 只影响"多久发现半开连接":最坏 3 个心跳周期(≈60s)发现真死的连接,仍远小于移动网络
|
|
127
|
+
* 常见的 NAT 空闲超时(通常 2~5 分钟)。
|
|
143
128
|
*/
|
|
144
129
|
export const TUNNEL_HEARTBEAT_DEFAULT_MS = 20_000;
|
|
145
130
|
/** 连续丢多少个 pong 才判定隧道已死(C5:旧实现丢 1 次就 terminate,弱网误判率过高)。 */
|
|
@@ -615,177 +600,6 @@ export function dcSend(dc, obj) {
|
|
|
615
600
|
return makeChunkedSender((s) => dc.send(s))(obj);
|
|
616
601
|
}
|
|
617
602
|
|
|
618
|
-
// ============================================================
|
|
619
|
-
// 发送侧:控制帧快速通道(2026-10-23,生产「手机连不上」主因链 #1)
|
|
620
|
-
// ============================================================
|
|
621
|
-
/* 【事故】手机端首屏要的 /plugins/?? 聚合包最大 16.33MB。旧实现把整包切成 82 个 200KB
|
|
622
|
-
* 分块后**在同一个 tick 里连续 ws.send**:内核发送缓冲一满,剩下的块全部堆进 ws 的
|
|
623
|
-
* 内存队列(手机弱网下这是十几秒的量)。router 在此期间发来的 `ws-open`,bridge 处理和
|
|
624
|
-
* 回包只需几毫秒,但**回包帧被排在 16MB 分块后面** → router 20s 超时
|
|
625
|
-
* (日志 `bridge ws-open 应答超时` / `ws_open_timeout`),页面表现为「一直转圈/连不上」。
|
|
626
|
-
*
|
|
627
|
-
* 【WS 帧有序,无法真的插队到已入队分片之前】所以换思路:**不让队列堆起来**。
|
|
628
|
-
* · 大帧分块进「批量车道」,一片一片发,每片之后让出事件循环(入站 ws-open 因此能被及时
|
|
629
|
-
* 处理,回包立刻进「控制车道」);
|
|
630
|
-
* · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
|
|
631
|
-
* 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
|
|
632
|
-
* 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
|
|
633
|
-
* · 小帧(交互消息 / SSE 块)单独一条车道,优先于大帧的下一片(交互不被整帧传输拖住)。
|
|
634
|
-
* · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
|
|
635
|
-
*/
|
|
636
|
-
export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
|
|
637
|
-
export const TUNNEL_BULK_CHUNK_DEFAULT = 64 * 1024; // 大帧分片大小(字节)
|
|
638
|
-
export const TUNNEL_BULK_POLL_MS = 10; // 高水位以上时的轮询间隔
|
|
639
|
-
/** 必须能插队的控制帧类型(ws-open 回包是这次事故的正主;ws-close 决定页面多久知道断了)。 */
|
|
640
|
-
export const CONTROL_FRAME_TYPES = Object.freeze(["ws-open", "ws-close", "http-abort", "http-end", "tunnel-register"]);
|
|
641
|
-
const CONTROL_FRAME_SET = new Set(CONTROL_FRAME_TYPES);
|
|
642
|
-
|
|
643
|
-
/** 该帧是否走控制车道(纯函数,发送器与用例共用)。 */
|
|
644
|
-
export function isControlFrame(obj) {
|
|
645
|
-
return Boolean(obj && typeof obj === "object" && CONTROL_FRAME_SET.has(obj.type));
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/** 从环境变量读批量车道参数(运维可调;非法值回默认)。 */
|
|
649
|
-
export function tunnelBulkOptionsFromEnv(env = process.env) {
|
|
650
|
-
const kb = (name, dflt) => {
|
|
651
|
-
const n = Number(env?.[name]);
|
|
652
|
-
return Number.isFinite(n) && n > 0 ? n * 1024 : dflt;
|
|
653
|
-
};
|
|
654
|
-
return {
|
|
655
|
-
bulkHighWater: Math.round(kb("DSH_BRIDGE_TUNNEL_BULK_HWM_KB", TUNNEL_BULK_HWM_DEFAULT)),
|
|
656
|
-
chunkSize: Math.round(kb("DSH_BRIDGE_TUNNEL_CHUNK_KB", TUNNEL_BULK_CHUNK_DEFAULT))
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* 构造「控制帧优先」的隧道发送器。返回 { send, close, stats, hasBacklog }。
|
|
662
|
-
*
|
|
663
|
-
* send(obj) → boolean:控制帧立即入控制车道并尝试发;其余帧序列化后入批量车道。
|
|
664
|
-
* `true` 表示已受理(与 makeChunkedSender 语义一致:只表达"入队阶段没失败")。
|
|
665
|
-
* 依赖可注入(rawSend / bufferedAmount / schedule / setTimer),便于用假 socket + 可控调度
|
|
666
|
-
* 断言「大响应传输中 ws-open 回包能插队」这条契约(test/bridge-control-priority.test.mjs)。
|
|
667
|
-
*/
|
|
668
|
-
export function makePrioritySender({
|
|
669
|
-
rawSend,
|
|
670
|
-
bufferedAmount = () => 0,
|
|
671
|
-
bulkHighWater = TUNNEL_BULK_HWM_DEFAULT,
|
|
672
|
-
chunkSize = TUNNEL_BULK_CHUNK_DEFAULT,
|
|
673
|
-
pollMs = TUNNEL_BULK_POLL_MS,
|
|
674
|
-
schedule = (fn) => setImmediate(fn),
|
|
675
|
-
setTimer = (fn, ms) => setTimeout(fn, ms)
|
|
676
|
-
} = {}) {
|
|
677
|
-
const controlQ = [];
|
|
678
|
-
let closed = false;
|
|
679
|
-
let pumping = false;
|
|
680
|
-
let scheduled = false;
|
|
681
|
-
let timerPending = false;
|
|
682
|
-
const smallQ = []; // 小帧(可整帧直发):交互消息 / SSE 块 —— 优先于大帧的下一片
|
|
683
|
-
const bigQ = []; // 大帧:{ frames: string[], i }
|
|
684
|
-
const stats = { controlSent: 0, smallSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
|
|
685
|
-
|
|
686
|
-
function enqueueBulk(text) {
|
|
687
|
-
if (text.length <= chunkSize) {
|
|
688
|
-
smallQ.push(text);
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
const cid = `c${++chunkSeq}`;
|
|
692
|
-
const n = Math.ceil(text.length / chunkSize);
|
|
693
|
-
const frames = new Array(n);
|
|
694
|
-
for (let i = 0; i < n; i += 1) {
|
|
695
|
-
frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
|
|
696
|
-
}
|
|
697
|
-
stats.bigFrames += 1;
|
|
698
|
-
bigQ.push({ frames, i: 0 });
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function scheduleNext(waitMs) {
|
|
702
|
-
if (closed) return;
|
|
703
|
-
if (waitMs > 0) {
|
|
704
|
-
if (timerPending) return;
|
|
705
|
-
timerPending = true;
|
|
706
|
-
setTimer(() => { timerPending = false; pump(); }, waitMs);
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
if (scheduled) return;
|
|
710
|
-
scheduled = true;
|
|
711
|
-
schedule(() => { scheduled = false; pump(); });
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
function pump() {
|
|
715
|
-
if (pumping || closed) return;
|
|
716
|
-
pumping = true;
|
|
717
|
-
let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
|
|
718
|
-
let budget = 32; // 一轮最多发多少条小帧
|
|
719
|
-
try {
|
|
720
|
-
for (;;) {
|
|
721
|
-
// ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
|
|
722
|
-
while (controlQ.length > 0) {
|
|
723
|
-
const frame = controlQ.shift();
|
|
724
|
-
try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
|
|
725
|
-
}
|
|
726
|
-
if (closed) { yieldMode = ""; break; }
|
|
727
|
-
// ② 小帧优先于大帧的下一片:大包传输期间,交互消息 / SSE 块不该排在它后面。
|
|
728
|
-
if (smallQ.length > 0 && budget > 0) {
|
|
729
|
-
try { rawSend(smallQ.shift()); stats.smallSent += 1; } catch { closed = true; break; }
|
|
730
|
-
budget -= 1;
|
|
731
|
-
continue; // 回环顶先查控制车道
|
|
732
|
-
}
|
|
733
|
-
if (smallQ.length > 0) { yieldMode = "schedule"; break; } // 一轮预算用完
|
|
734
|
-
if (bigQ.length === 0) { yieldMode = ""; break; }
|
|
735
|
-
// ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
|
|
736
|
-
let pending = 0;
|
|
737
|
-
try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
|
|
738
|
-
if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
|
|
739
|
-
const item = bigQ[0];
|
|
740
|
-
try { rawSend(item.frames[item.i]); stats.bigChunks += 1; } catch { closed = true; break; }
|
|
741
|
-
item.i += 1;
|
|
742
|
-
if (item.i >= item.frames.length) bigQ.shift();
|
|
743
|
-
yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
|
|
744
|
-
break;
|
|
745
|
-
}
|
|
746
|
-
} finally {
|
|
747
|
-
pumping = false;
|
|
748
|
-
if (closed) {
|
|
749
|
-
controlQ.length = 0;
|
|
750
|
-
smallQ.length = 0;
|
|
751
|
-
bigQ.length = 0;
|
|
752
|
-
} else if (yieldMode) {
|
|
753
|
-
scheduleNext(yieldMode === "poll" ? pollMs : 0);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
function send(obj) {
|
|
759
|
-
let text;
|
|
760
|
-
try { text = JSON.stringify(obj); } catch { return false; }
|
|
761
|
-
if (closed) { stats.dropped += 1; return false; }
|
|
762
|
-
if (isControlFrame(obj)) controlQ.push(text);
|
|
763
|
-
else enqueueBulk(text);
|
|
764
|
-
pump();
|
|
765
|
-
return true;
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/** 隧道断开:丢掉未发内容并停止调度(否则重连后旧分片会发到新连接上)。 */
|
|
769
|
-
function close() {
|
|
770
|
-
closed = true;
|
|
771
|
-
controlQ.length = 0;
|
|
772
|
-
smallQ.length = 0;
|
|
773
|
-
bigQ.length = 0;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
return {
|
|
777
|
-
send,
|
|
778
|
-
close,
|
|
779
|
-
stats: () => ({
|
|
780
|
-
...stats,
|
|
781
|
-
queued: smallQ.length + bigQ.length,
|
|
782
|
-
controlQueued: controlQ.length,
|
|
783
|
-
closed
|
|
784
|
-
}),
|
|
785
|
-
hasBacklog: () => smallQ.length > 0 || bigQ.length > 0 || controlQ.length > 0
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
|
-
|
|
789
603
|
/** 兼容两种调用:handleXxx(dch, frame)(WebRTC) 或 handleXxx(sendFn, frame)(隧道)。 */
|
|
790
604
|
function toSender(dchOrSend) {
|
|
791
605
|
return typeof dchOrSend === "function" ? dchOrSend : (obj) => dcSend(dchOrSend, obj);
|
|
@@ -1570,68 +1384,6 @@ function handleCtrlMessage(send, id, rawText) {
|
|
|
1570
1384
|
// 其余控制消息忽略
|
|
1571
1385
|
}
|
|
1572
1386
|
|
|
1573
|
-
/* ── ws-open 快速应答(2026-10-23,生产「手机连不上」主因链 #1 的另一半)────────────
|
|
1574
|
-
*
|
|
1575
|
-
* 旧契约:收到 ws-open → 连上游 WebSocket → **等上游 open** 才回 ok。
|
|
1576
|
-
* 上游连不上时(桌面端换端口/宿主重启),手机要等到 router 的 20s 超时才看到失败;
|
|
1577
|
-
* 而 bridge 发送队列被大响应占住时(另见 makePrioritySender),连"回 ok"都排不上。
|
|
1578
|
-
*
|
|
1579
|
-
* 新契约:校验通过 → **立刻回 ok** → 再连上游。
|
|
1580
|
-
* · 手机拿到 ok 后立刻发的消息(mux 订阅/首帧)进 pending 队列,上游 open 后按序补发,
|
|
1581
|
-
* 上限 WS_OPEN_PENDING_MAX_FRAMES / BYTES(只是上游建立期间的过渡,正常是几十 ms)。
|
|
1582
|
-
* · 上游最终失败/超时 → 回 ``ws-close``(1011, retryable) —— 此时升级早已完成,
|
|
1583
|
-
* 再回 ws-open 失败帧没有意义(router 已把手机 WS 交给上层)。
|
|
1584
|
-
* · 失败路径照旧触发 refreshUpstream(端口漂移自愈,见 test/bridge-upstream-refresh)。
|
|
1585
|
-
* · E2EE 语义不变:带 &e2ee= 的流仍然**先**校验会话(未验证/不存在 → 1008 + 可自动重握手
|
|
1586
|
-
* 信号),校验通过才 ack,绝不在未验证的会话上转发任何数据(§4.6/§5.2)。
|
|
1587
|
-
*/
|
|
1588
|
-
export const WS_OPEN_UPSTREAM_TIMEOUT_MS = 10_000;
|
|
1589
|
-
export const WS_OPEN_PENDING_MAX_FRAMES = 512;
|
|
1590
|
-
export const WS_OPEN_PENDING_MAX_BYTES = 2 * 1024 * 1024;
|
|
1591
|
-
|
|
1592
|
-
/** ws-open 拒绝帧;extra 里放机器可读信号(手机据此自动重握手,而不是让用户去解锁)。 */
|
|
1593
|
-
function sendWsOpenReject(send, id, code, reason, extra = {}) {
|
|
1594
|
-
send({ id, type: "ws-open", ok: false, code, reason, at: Date.now(), ...extra });
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
/** 已 ack 之后的上游失败:只能换成 ws-close 收尾(升级已完成,不能再回 ws-open 失败帧)。 */
|
|
1598
|
-
function failAckedWs(send, id, phase, reason, extra = {}) {
|
|
1599
|
-
send({ id, type: "ws-close", code: 1011, reason: `${phase}: ${reason}`, phase, at: Date.now(), ...extra });
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
/** 上游 open 前把消息排队;open 后按序补发。overflow 返回 false(调用方关流)。 */
|
|
1603
|
-
function enqueuePendingWsMsg(session, payload, binary) {
|
|
1604
|
-
const bytes = Buffer.isBuffer(payload) ? payload.length : String(payload).length;
|
|
1605
|
-
if (session.pending.length >= WS_OPEN_PENDING_MAX_FRAMES || session.pendingBytes + bytes > WS_OPEN_PENDING_MAX_BYTES) {
|
|
1606
|
-
return false;
|
|
1607
|
-
}
|
|
1608
|
-
session.pending.push({ data: payload, binary });
|
|
1609
|
-
session.pendingBytes += bytes;
|
|
1610
|
-
return true;
|
|
1611
|
-
}
|
|
1612
|
-
|
|
1613
|
-
/** 把一条消息发到上游;上游还没 open 就排队(见上方 ws-open 快速应答说明)。 */
|
|
1614
|
-
function forwardToUpstream(send, id, session, payload, binary) {
|
|
1615
|
-
const ws = session.ws;
|
|
1616
|
-
if (session.opened && ws && ws.readyState === WebSocket.OPEN) {
|
|
1617
|
-
try {
|
|
1618
|
-
if (binary) ws.send(payload);
|
|
1619
|
-
else ws.send(String(payload));
|
|
1620
|
-
} catch (e) {
|
|
1621
|
-
console.log(`[bridge] ws-send err: ${e.message}`);
|
|
1622
|
-
}
|
|
1623
|
-
return true;
|
|
1624
|
-
}
|
|
1625
|
-
if (session.cancelled || session.failed) return false;
|
|
1626
|
-
if (enqueuePendingWsMsg(session, payload, binary)) return true;
|
|
1627
|
-
console.error(`[bridge] ws-open 待发队列溢出(id=${id}),关闭该流(上游建立过慢)`);
|
|
1628
|
-
session.cancelled = true;
|
|
1629
|
-
try { session.ws?.terminate?.(); } catch { /* 忽略 */ }
|
|
1630
|
-
wsSessions.delete(id);
|
|
1631
|
-
try { failAckedWs(send, id, "upstream_ws_open_slow", "上游连接建立过慢,待发消息超限"); } catch { /* 忽略 */ }
|
|
1632
|
-
return false;
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
1387
|
export async function handleWsOpen(dchOrSend, frame) {
|
|
1636
1388
|
const send = toSender(dchOrSend);
|
|
1637
1389
|
const { id, path = "/", headers = {} } = frame;
|
|
@@ -1643,91 +1395,32 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1643
1395
|
const tagged = parseWsE2eeParams(path);
|
|
1644
1396
|
if (tagged) {
|
|
1645
1397
|
if (!e2ee.enabled) {
|
|
1646
|
-
|
|
1398
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_disabled:bridge 未启用端到端加密(请用明文连接)" });
|
|
1647
1399
|
return;
|
|
1648
1400
|
}
|
|
1649
|
-
const
|
|
1650
|
-
if (!
|
|
1651
|
-
// 会话可能刚被重启/被 LRU 挤掉:把「可自动重握手」写成机器可读信号,
|
|
1652
|
-
// 手机侧据此自动重建会话,而不是弹「请重新解锁」让用户输密码(生产 3964 次/设备)。
|
|
1401
|
+
const session = e2ee.sessionOf(tagged.sessId);
|
|
1402
|
+
if (!session || !session.verified) {
|
|
1653
1403
|
console.error(`[bridge] e2ee ws 拒绝(会话未解锁/未验证): ${path}`);
|
|
1654
|
-
|
|
1655
|
-
retryable: true,
|
|
1656
|
-
action: "rehandshake"
|
|
1657
|
-
});
|
|
1404
|
+
send({ id, type: "ws-open", ok: false, code: 1008, reason: "e2ee_session_locked:请先解锁完成握手(检查密码)" });
|
|
1658
1405
|
return;
|
|
1659
1406
|
}
|
|
1660
1407
|
// 探测前不转发任何数据(§4.6/§5.2:探测通过后数据流才可建);此时已验证 → 安全
|
|
1661
1408
|
}
|
|
1662
|
-
|
|
1663
|
-
if (previous) {
|
|
1664
|
-
previous.cancelled = true;
|
|
1665
|
-
try { previous.ws?.terminate?.(); } catch { /* 忽略 */ }
|
|
1666
|
-
wsSessions.delete(id);
|
|
1667
|
-
}
|
|
1409
|
+
if (wsSessions.has(id)) { try { wsSessions.get(id).ws.terminate(); } catch {} wsSessions.delete(id); }
|
|
1668
1410
|
const safe = safePath(tagged ? tagged.upstreamPath : path);
|
|
1669
|
-
if (safe === null) {
|
|
1670
|
-
|
|
1671
|
-
const
|
|
1411
|
+
if (safe === null) { send({ id, type: "ws-open", ok: false, code: 400, reason: "非法路径" }); return; }
|
|
1412
|
+
const url = `${UPSTREAM.replace(/^http/, "ws")}${safe}`;
|
|
1413
|
+
const ws = new WebSocket(url, { headers: buildWsHeaders(headers), followRedirects: false });
|
|
1414
|
+
const session = { ws, opened: false };
|
|
1672
1415
|
if (tagged && e2ee.enabled) {
|
|
1673
1416
|
// 会话必须已 verified(上面已校验),给该流独立的收发密钥与方向计数
|
|
1674
1417
|
session.e2ee = { s: e2ee.sessionOf(tagged.sessId), wsLabel: tagged.wsLabel, p2bLast: -1, b2pLast: -1 };
|
|
1675
1418
|
}
|
|
1676
1419
|
wsSessions.set(id, session);
|
|
1677
|
-
// ★ 快速应答:先回 ok,再连上游(理由见上方长注释)
|
|
1678
|
-
const acked = send({ id, type: "ws-open", ok: true, fast: true });
|
|
1679
|
-
if (acked === false) { wsSessions.delete(id); return; }
|
|
1680
|
-
|
|
1681
|
-
const url = `${UPSTREAM.replace(/^http/, "ws")}${safe}`;
|
|
1682
|
-
let ws;
|
|
1683
|
-
try {
|
|
1684
|
-
ws = new WebSocket(url, { headers: buildWsHeaders(headers), followRedirects: false });
|
|
1685
|
-
} catch (e) {
|
|
1686
|
-
wsSessions.delete(id);
|
|
1687
|
-
session.failed = true;
|
|
1688
|
-
void refreshUpstream("ws-open 上游连接失败");
|
|
1689
|
-
failAckedWs(send, id, "upstream_ws_open_failed", String(e?.message || e), { retryable: true });
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
session.ws = ws;
|
|
1693
|
-
/** 上游没建立就失败:统一收尾(只发一次)。 */
|
|
1694
|
-
const failUpstream = (phase, reason) => {
|
|
1695
|
-
if (session.opened || session.failed || session.cancelled) return;
|
|
1696
|
-
session.failed = true;
|
|
1697
|
-
try { clearTimeout(openTimer); } catch { /* 忽略 */ }
|
|
1698
|
-
session.pending.length = 0;
|
|
1699
|
-
session.pendingBytes = 0;
|
|
1700
|
-
wsSessions.delete(id);
|
|
1701
|
-
// ★ 上游可能已经换端口(桌面端 App 重启会换本地端口,生产日志里抓到过
|
|
1702
|
-
// `upstream_ws_open_failed ... ECONNREFUSED 127.0.0.1:19387`)。
|
|
1703
|
-
// HTTP 失败路径早就会触发重新发现,**WS 失败路径之前漏了** —— 而镜像页常常
|
|
1704
|
-
// 先开 WS(控制通道/数据通道),于是端口变了以后一直失败、页面白屏,直到用户
|
|
1705
|
-
// 手动重开。这里补上:本次失败照旧通知手机,但立即触发重新发现,下一次重试命中新端口。
|
|
1706
|
-
void refreshUpstream("ws-open 上游连接失败");
|
|
1707
|
-
// 已 ack → 用 ws-close 收尾(1011 + retryable),手机侧走正常重连,不必干等 20s 超时。
|
|
1708
|
-
failAckedWs(send, id, phase, reason, { retryable: true });
|
|
1709
|
-
};
|
|
1710
|
-
let openTimer = null;
|
|
1711
|
-
openTimer = setTimeout(() => {
|
|
1712
|
-
failUpstream("upstream_ws_open_timeout", `上游 WebSocket ${Math.round(WS_OPEN_UPSTREAM_TIMEOUT_MS / 1000)}s 未建立`);
|
|
1713
|
-
}, WS_OPEN_UPSTREAM_TIMEOUT_MS);
|
|
1714
|
-
openTimer.unref?.();
|
|
1715
1420
|
ws.on("open", () => {
|
|
1716
|
-
if (session.cancelled || session.failed) { try { ws.terminate(); } catch { /* 忽略 */ } return; }
|
|
1717
|
-
try { clearTimeout(openTimer); } catch { /* 忽略 */ }
|
|
1718
1421
|
session.opened = true;
|
|
1719
|
-
console.log(`[bridge] ws-open ${session.e2ee ? "(e2ee) " : ""}${safe} (id=${id}
|
|
1720
|
-
|
|
1721
|
-
const pending = session.pending;
|
|
1722
|
-
session.pending = [];
|
|
1723
|
-
session.pendingBytes = 0;
|
|
1724
|
-
for (const item of pending) {
|
|
1725
|
-
try {
|
|
1726
|
-
if (item.binary) ws.send(item.data);
|
|
1727
|
-
else ws.send(String(item.data));
|
|
1728
|
-
} catch { /* 上游立刻又出错 → close 路径收尾 */ }
|
|
1729
|
-
}
|
|
1730
|
-
if (pending.length > 0) console.log(`[bridge] ws-open 补发 ${pending.length} 条上游建立期间的消息(id=${id})`);
|
|
1422
|
+
console.log(`[bridge] ws-open ${session.e2ee ? "(e2ee) " : ""}${safe} (id=${id})`);
|
|
1423
|
+
send({ id, type: "ws-open", ok: true });
|
|
1731
1424
|
});
|
|
1732
1425
|
ws.on("message", (data, isBinary) => {
|
|
1733
1426
|
// 上游 → 手机:加密流逐条封信封(b2p 独立计数);明文流原样透传
|
|
@@ -1754,12 +1447,7 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1754
1447
|
send({ id, type: "ws-msg", data: payload, binary: isBinary });
|
|
1755
1448
|
});
|
|
1756
1449
|
ws.on("close", (code, reason) => {
|
|
1757
|
-
if (session.
|
|
1758
|
-
if (!session.opened) {
|
|
1759
|
-
// 快速应答之后才断:走统一失败收尾(ws-close 1011 + 触发上游重发现)
|
|
1760
|
-
failUpstream("upstream_ws_open_failed", `上游 WebSocket 未建立就关闭(code=${code}${reason ? ", " + reason : ""})`);
|
|
1761
|
-
return;
|
|
1762
|
-
}
|
|
1450
|
+
if (!session.opened) return; // 未建立成功的会话由 error 路径收尾
|
|
1763
1451
|
console.log(`[bridge] ws-close ${path} (id=${id}, code=${code})`);
|
|
1764
1452
|
wsSessions.delete(id);
|
|
1765
1453
|
send({ id, type: "ws-close", code: code ?? 1006, reason: reason?.toString() ?? "" });
|
|
@@ -1767,8 +1455,14 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1767
1455
|
ws.on("error", (e) => {
|
|
1768
1456
|
console.log(`[bridge] ws-error ${path} (id=${id}): ${e.message || ""}`);
|
|
1769
1457
|
if (!session.opened) {
|
|
1770
|
-
|
|
1771
|
-
|
|
1458
|
+
wsSessions.delete(id);
|
|
1459
|
+
// ★ 上游可能已经换端口(桌面端 App 重启会换本地端口,生产日志里抓到过
|
|
1460
|
+
// `upstream_ws_open_failed ... ECONNREFUSED 127.0.0.1:19387`)。
|
|
1461
|
+
// HTTP 失败路径早就会触发重新发现,**WS 失败路径之前漏了** —— 而镜像页常常
|
|
1462
|
+
// 先开 WS(控制通道/数据通道),于是端口变了以后一直 502、页面白屏,直到用户
|
|
1463
|
+
// 手动重开。这里补上:本次失败照旧回 502,但立即触发重新发现,下一次重试就命中新端口。
|
|
1464
|
+
void refreshUpstream("ws-open 上游连接失败");
|
|
1465
|
+
send({ id, type: "ws-open", ok: false, code: 502, reason: String(e.message || "ws error") });
|
|
1772
1466
|
}
|
|
1773
1467
|
});
|
|
1774
1468
|
}
|
|
@@ -1786,8 +1480,7 @@ export function handleWsMessage(_dchOrSend, frame) {
|
|
|
1786
1480
|
return;
|
|
1787
1481
|
}
|
|
1788
1482
|
const session = wsSessions.get(id);
|
|
1789
|
-
if (!session) return;
|
|
1790
|
-
const send = toSender(_dchOrSend);
|
|
1483
|
+
if (!session || !session.opened || session.ws.readyState !== WebSocket.OPEN) return;
|
|
1791
1484
|
if (session.e2ee) {
|
|
1792
1485
|
// 加密流:逐条解封 → 原文转发上游;AEAD 失败 → 1008 关闭并回告(不静默降级)
|
|
1793
1486
|
try {
|
|
@@ -1797,18 +1490,20 @@ export function handleWsMessage(_dchOrSend, frame) {
|
|
|
1797
1490
|
if (!Number.isInteger(env.c) || env.c <= e2.p2bLast) throw new E2eeError("replay", `e2ee ws 计数重放/乱序 ${env.c}`);
|
|
1798
1491
|
const opened = e2.s.open({ kind: "w", dir: "p2b", env, counter: env.c, wsLabel: e2.wsLabel });
|
|
1799
1492
|
e2.p2bLast = env.c;
|
|
1800
|
-
|
|
1801
|
-
|
|
1493
|
+
if (opened.t === 1) session.ws.send(opened.data);
|
|
1494
|
+
else session.ws.send(opened.data.toString("utf8"));
|
|
1802
1495
|
} catch (e) {
|
|
1803
1496
|
console.error(`[bridge] e2ee ws 解封失败(id=${id}): ${e.message || e}(不回退明文,1008 关闭)`);
|
|
1804
|
-
|
|
1805
|
-
try { ws?.close?.(1008, "⚠ e2ee 消息无法解密"); } catch { /* 忽略 */ }
|
|
1497
|
+
try { session.ws.close(1008, "⚠ e2ee 消息无法解密"); } catch {}
|
|
1806
1498
|
wsSessions.delete(id);
|
|
1807
|
-
try {
|
|
1499
|
+
try { toSender(_dchOrSend)({ id, type: "ws-close", code: 1008, reason: "⚠ e2ee 消息无法解密" }); } catch {}
|
|
1808
1500
|
}
|
|
1809
1501
|
return;
|
|
1810
1502
|
}
|
|
1811
|
-
|
|
1503
|
+
try {
|
|
1504
|
+
if (binary) session.ws.send(Buffer.from(data, "base64"));
|
|
1505
|
+
else session.ws.send(String(data));
|
|
1506
|
+
} catch (e) { console.log(`[bridge] ws-send err: ${e.message}`); }
|
|
1812
1507
|
}
|
|
1813
1508
|
|
|
1814
1509
|
export function handleWsClose(_dchOrSend, frame) {
|
|
@@ -1820,16 +1515,13 @@ export function handleWsClose(_dchOrSend, frame) {
|
|
|
1820
1515
|
}
|
|
1821
1516
|
const session = wsSessions.get(id);
|
|
1822
1517
|
if (!session) return;
|
|
1823
|
-
session.
|
|
1824
|
-
wsSessions.delete(id);
|
|
1825
|
-
try { session.ws?.close?.(code && typeof code === "number" ? code : 1000, reason || ""); } catch { /* 忽略 */ }
|
|
1518
|
+
try { session.ws.close(code && typeof code === "number" ? code : 1000, reason || ""); } catch {}
|
|
1826
1519
|
}
|
|
1827
1520
|
|
|
1828
1521
|
/** DataChannel 断开:关闭所有 ws 会话与控制通道。 */
|
|
1829
1522
|
export function closeAllWsSessions() {
|
|
1830
1523
|
for (const session of wsSessions.values()) {
|
|
1831
|
-
session.
|
|
1832
|
-
try { session.ws?.terminate?.(); } catch { /* 忽略 */ }
|
|
1524
|
+
try { session.ws.terminate(); } catch {}
|
|
1833
1525
|
}
|
|
1834
1526
|
wsSessions.clear();
|
|
1835
1527
|
ctrlConns.clear();
|
|
@@ -1977,116 +1669,6 @@ export function heartbeatStep({ pongReceived, missedPongs = 0 }) {
|
|
|
1977
1669
|
return { missedPongs: missed, terminate, ping: !terminate };
|
|
1978
1670
|
}
|
|
1979
1671
|
|
|
1980
|
-
/* ── 心跳自适应(2026-10-23,生产「DSH 主线程卡顿 → 自杀式重连」)──────────────
|
|
1981
|
-
*
|
|
1982
|
-
* 【问题】固定 20s 心跳 + 固定"丢 2 个 pong 就断":DSH 主线程被大任务/大响应占住时
|
|
1983
|
-
* (pong 由 ws 库在 socket 事件里回,但事件循环被占就排不上队),连续两拍收不到 pong
|
|
1984
|
-
* 就 terminate → 重建隧道 → 手机侧所有在途请求断掉、页面重连,而卡顿本来几十 ms~几秒
|
|
1985
|
-
* 就过去了。生产 13706 次/天 bridge 断开里有一大批是这么来的。
|
|
1986
|
-
*
|
|
1987
|
-
* 【做法】判定窗口按「链路 RTT + 本地事件循环延迟」动态放宽,ping 间隔按 RTT 略微拉长:
|
|
1988
|
-
* · rtt ≥ 1s → 判定窗口 +1 拍(慢链路多给一拍),ping 间隔同时拉长到 1.5x(≤60s)
|
|
1989
|
-
* · 事件循环延迟 ≥500ms → 判定窗口 +1 拍(本机忙,pong 不是"没回来"而是"没轮到处理")
|
|
1990
|
-
* 健康链路(rtt 低、不忙)下窗口仍是 2 拍 —— 与旧契约完全一致,不快也不慢。
|
|
1991
|
-
* 上限 4 拍可配(DSH_BRIDGE_HEARTBEAT_MAX_MISSED),DSH_BRIDGE_HEARTBEAT_ADAPT=0 可退回旧契约。
|
|
1992
|
-
* 最坏发现"真死"的耗时 = 4 拍 ≈ 80s,仍远小于移动网络 NAT 空闲超时(2~5 分钟)。
|
|
1993
|
-
*
|
|
1994
|
-
* 纯函数,便于单测(test/bridge-heartbeat-adaptive.test.mjs)。
|
|
1995
|
-
*/
|
|
1996
|
-
export const HEARTBEAT_RTT_SLOW_MS = 1000; // RTT 超过它视为慢链路
|
|
1997
|
-
export const HEARTBEAT_LAG_BUSY_MS = 500; // 事件循环延迟超过它视为本机繁忙
|
|
1998
|
-
export const HEARTBEAT_MAX_MISSED_CAP_DEFAULT = 4;
|
|
1999
|
-
export const HEARTBEAT_MAX_INTERVAL_MS = 60_000;
|
|
2000
|
-
/** 自适应开关/上限(环境变量;测试与运维可覆盖)。 */
|
|
2001
|
-
export const HEARTBEAT_ADAPTIVE = process.env.DSH_BRIDGE_HEARTBEAT_ADAPT !== "0";
|
|
2002
|
-
export const HEARTBEAT_MAX_MISSED_CAP = (() => {
|
|
2003
|
-
const n = Number(process.env.DSH_BRIDGE_HEARTBEAT_MAX_MISSED);
|
|
2004
|
-
return Number.isFinite(n) && n >= 1 ? Math.min(8, Math.floor(n)) : HEARTBEAT_MAX_MISSED_CAP_DEFAULT;
|
|
2005
|
-
})();
|
|
2006
|
-
|
|
2007
|
-
/**
|
|
2008
|
-
* 心跳一拍(自适应版)。
|
|
2009
|
-
* @param {object} o
|
|
2010
|
-
* @param {boolean} [o.pongReceived] 上一拍发出后是否收到 pong
|
|
2011
|
-
* @param {number} [o.missedPongs] 此前连续丢了几拍
|
|
2012
|
-
* @param {number|null} [o.rttMs] 最近 RTT(null=还没测到)
|
|
2013
|
-
* @param {number} [o.eventLoopLagMs] 本机事件循环延迟
|
|
2014
|
-
* @param {number} [o.baseIntervalMs] ping 间隔基准
|
|
2015
|
-
* @param {number} [o.baseMaxMissed] 判定窗口基准(旧契约 2)
|
|
2016
|
-
* @param {number} [o.maxMissedCap] 判定窗口上限
|
|
2017
|
-
* @param {boolean} [o.adaptive] false = 完全退回旧契约(interval 不变、窗口=baseMaxMissed)
|
|
2018
|
-
* @returns {{intervalMs:number, maxMissed:number, missedPongs:number, terminate:boolean, ping:boolean, degraded:boolean, reasons:string[]}}
|
|
2019
|
-
*/
|
|
2020
|
-
export function adaptiveHeartbeat({
|
|
2021
|
-
pongReceived = false,
|
|
2022
|
-
missedPongs = 0,
|
|
2023
|
-
rttMs = null,
|
|
2024
|
-
eventLoopLagMs = 0,
|
|
2025
|
-
baseIntervalMs = TUNNEL_HEARTBEAT_DEFAULT_MS,
|
|
2026
|
-
baseMaxMissed = HEARTBEAT_MAX_MISSED_PONGS,
|
|
2027
|
-
maxMissedCap = HEARTBEAT_MAX_MISSED_CAP_DEFAULT,
|
|
2028
|
-
adaptive = true
|
|
2029
|
-
} = {}) {
|
|
2030
|
-
const base = Math.max(100, Number(baseIntervalMs) || TUNNEL_HEARTBEAT_DEFAULT_MS);
|
|
2031
|
-
const baseMax = Math.max(1, Number(baseMaxMissed) || HEARTBEAT_MAX_MISSED_PONGS);
|
|
2032
|
-
const cap = Math.max(baseMax, Math.min(8, Number(maxMissedCap) || HEARTBEAT_MAX_MISSED_CAP_DEFAULT));
|
|
2033
|
-
const missed = pongReceived ? 0 : Math.max(0, missedPongs) + 1;
|
|
2034
|
-
if (!adaptive) {
|
|
2035
|
-
return {
|
|
2036
|
-
intervalMs: base,
|
|
2037
|
-
maxMissed: baseMax,
|
|
2038
|
-
missedPongs: missed,
|
|
2039
|
-
terminate: !pongReceived && missed >= baseMax,
|
|
2040
|
-
ping: !(!pongReceived && missed >= baseMax),
|
|
2041
|
-
degraded: false,
|
|
2042
|
-
reasons: []
|
|
2043
|
-
};
|
|
2044
|
-
}
|
|
2045
|
-
const reasons = [];
|
|
2046
|
-
let maxMissed = baseMax;
|
|
2047
|
-
let intervalMs = base;
|
|
2048
|
-
const lag = Math.max(0, Number(eventLoopLagMs) || 0);
|
|
2049
|
-
const rtt = rttMs === null || rttMs === undefined ? null : Math.max(0, Number(rttMs) || 0);
|
|
2050
|
-
if (lag >= HEARTBEAT_LAG_BUSY_MS) {
|
|
2051
|
-
maxMissed += 1;
|
|
2052
|
-
reasons.push("event_loop_busy");
|
|
2053
|
-
}
|
|
2054
|
-
if (rtt !== null && rtt >= HEARTBEAT_RTT_SLOW_MS) {
|
|
2055
|
-
maxMissed += 1;
|
|
2056
|
-
intervalMs = Math.min(HEARTBEAT_MAX_INTERVAL_MS, Math.round(base * 1.5));
|
|
2057
|
-
reasons.push("rtt_slow");
|
|
2058
|
-
}
|
|
2059
|
-
maxMissed = Math.min(cap, Math.max(1, maxMissed));
|
|
2060
|
-
const terminate = !pongReceived && missed >= maxMissed;
|
|
2061
|
-
return { intervalMs, maxMissed, missedPongs: missed, terminate, ping: !terminate, degraded: reasons.length > 0, reasons };
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
/**
|
|
2065
|
-
* 事件循环延迟探针(本进程)。1s 一拍,记"实际触发时刻比预期晚多少"。
|
|
2066
|
-
* 只是给心跳判定的输入,不做任何业务动作;unref 不阻塞退出。
|
|
2067
|
-
*/
|
|
2068
|
-
let eventLoopLagMs = 0;
|
|
2069
|
-
let eventLoopLagTimer = null;
|
|
2070
|
-
export function startEventLoopLagMonitor({ intervalMs = 1000 } = {}) {
|
|
2071
|
-
if (eventLoopLagTimer) return () => {};
|
|
2072
|
-
const tick = Math.max(100, Number(intervalMs) || 1000);
|
|
2073
|
-
let expected = Date.now() + tick;
|
|
2074
|
-
eventLoopLagTimer = setInterval(() => {
|
|
2075
|
-
const now = Date.now();
|
|
2076
|
-
eventLoopLagMs = Math.max(0, now - expected);
|
|
2077
|
-
expected = now + tick;
|
|
2078
|
-
}, tick);
|
|
2079
|
-
eventLoopLagTimer.unref?.();
|
|
2080
|
-
return () => {
|
|
2081
|
-
if (eventLoopLagTimer) { try { clearInterval(eventLoopLagTimer); } catch { /* 忽略 */ } }
|
|
2082
|
-
eventLoopLagTimer = null;
|
|
2083
|
-
eventLoopLagMs = 0;
|
|
2084
|
-
};
|
|
2085
|
-
}
|
|
2086
|
-
/** 当前事件循环延迟(毫秒;未启用探针时为 0)。 */
|
|
2087
|
-
export function currentEventLoopLagMs() { return eventLoopLagMs; }
|
|
2088
|
-
/** 测试钩子:直接设定延迟读数。 */
|
|
2089
|
-
export function __setEventLoopLagForTest(ms) { eventLoopLagMs = Math.max(0, Number(ms) || 0); }
|
|
2090
1672
|
/** 把 DSH_BRIDGE_TUNNEL_URL 归一化为 router 注册端点(缺省补 / _bridge)。 */
|
|
2091
1673
|
function tunnelEndpoint(raw) {
|
|
2092
1674
|
try {
|
|
@@ -2156,20 +1738,10 @@ function connectTunnel(token) {
|
|
|
2156
1738
|
// maxPayload 必须显式给:ws 客户端默认 100MiB,而 router→bridge 的单帧不分块,
|
|
2157
1739
|
// 大文件上传的 base64 请求体可以顶到 89.5MB(见 TUNNEL_WS_MAX_PAYLOAD 注释)。
|
|
2158
1740
|
const ws = new WebSocket(endpoint, TUNNEL_WS_OPTIONS);
|
|
2159
|
-
|
|
2160
|
-
// 全灌进 ws 队列,ws-open 回包被埋在后面 → router 20s 超时。新发送器给控制帧留快车道,
|
|
2161
|
-
// 并用 bufferedAmount 高水位限制"已入队未发送"的字节量。
|
|
2162
|
-
const sender = makePrioritySender({
|
|
2163
|
-
rawSend: (s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); },
|
|
2164
|
-
bufferedAmount: () => ws.bufferedAmount,
|
|
2165
|
-
...tunnelBulkOptionsFromEnv()
|
|
2166
|
-
});
|
|
2167
|
-
const send = sender.send;
|
|
1741
|
+
const send = makeChunkedSender((s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); });
|
|
2168
1742
|
let heartbeat;
|
|
2169
1743
|
let pongReceived = true;
|
|
2170
1744
|
let missedPongs = 0;
|
|
2171
|
-
let pingSentAt = 0;
|
|
2172
|
-
let rttEma = null;
|
|
2173
1745
|
|
|
2174
1746
|
ws.on("open", () => {
|
|
2175
1747
|
tunnelRetry = 0;
|
|
@@ -2179,45 +1751,22 @@ function connectTunnel(token) {
|
|
|
2179
1751
|
// caps:bridge E2EE 能力上报(§6.2/§7.1;router 纯透传)。enabled=false → 空数组(明文回退)
|
|
2180
1752
|
ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge", caps: e2ee.caps() }));
|
|
2181
1753
|
} catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
pongReceived,
|
|
2188
|
-
missedPongs,
|
|
2189
|
-
rttMs: rttEma,
|
|
2190
|
-
eventLoopLagMs: currentEventLoopLagMs(),
|
|
2191
|
-
baseIntervalMs: TUNNEL_HEARTBEAT_MS,
|
|
2192
|
-
baseMaxMissed: HEARTBEAT_MAX_MISSED_PONGS,
|
|
2193
|
-
maxMissedCap: HEARTBEAT_MAX_MISSED_CAP,
|
|
2194
|
-
adaptive: HEARTBEAT_ADAPTIVE
|
|
2195
|
-
});
|
|
2196
|
-
missedPongs = plan.missedPongs;
|
|
1754
|
+
heartbeat = setInterval(() => {
|
|
1755
|
+
// C5:连续 2 次没收到 pong 才判定半开(旧实现丢 1 次就断 → 弱网下正常的单次丢包
|
|
1756
|
+
// 也会被当成故障重连,直接贡献了"75% 的隧道会话活不过 60s")。
|
|
1757
|
+
const step = heartbeatStep({ pongReceived, missedPongs });
|
|
1758
|
+
missedPongs = step.missedPongs;
|
|
2197
1759
|
pongReceived = false;
|
|
2198
|
-
if (
|
|
2199
|
-
console.log(`[bridge] 隧道连续 ${missedPongs}
|
|
1760
|
+
if (step.terminate) {
|
|
1761
|
+
console.log(`[bridge] 隧道连续 ${missedPongs} 次心跳无响应,主动重连...`);
|
|
2200
1762
|
try { ws.terminate(); } catch { /* 已关闭 */ }
|
|
2201
1763
|
return;
|
|
2202
1764
|
}
|
|
2203
|
-
if (missedPongs > 0) {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
pingSentAt = Date.now();
|
|
2207
|
-
try { ws.ping(); } catch { try { ws.terminate(); } catch { /* 已关闭 */ } return; }
|
|
2208
|
-
heartbeat = setTimeout(beat, plan.intervalMs);
|
|
2209
|
-
};
|
|
2210
|
-
heartbeat = setTimeout(beat, TUNNEL_HEARTBEAT_MS);
|
|
2211
|
-
});
|
|
2212
|
-
ws.on("pong", () => {
|
|
2213
|
-
pongReceived = true;
|
|
2214
|
-
missedPongs = 0;
|
|
2215
|
-
if (pingSentAt > 0) {
|
|
2216
|
-
const rtt = Date.now() - pingSentAt;
|
|
2217
|
-
pingSentAt = 0;
|
|
2218
|
-
rttEma = rttEma === null ? rtt : Math.round(rttEma * 0.7 + rtt * 0.3);
|
|
2219
|
-
}
|
|
1765
|
+
if (missedPongs > 0) console.log(`[bridge] 隧道心跳无响应(${missedPongs}/${HEARTBEAT_MAX_MISSED_PONGS}),继续探测...`);
|
|
1766
|
+
try { ws.ping(); } catch { ws.terminate(); }
|
|
1767
|
+
}, TUNNEL_HEARTBEAT_MS);
|
|
2220
1768
|
});
|
|
1769
|
+
ws.on("pong", () => { pongReceived = true; missedPongs = 0; });
|
|
2221
1770
|
ws.on("message", (raw) => {
|
|
2222
1771
|
const receive = makeFrameReceiver();
|
|
2223
1772
|
try {
|
|
@@ -2242,9 +1791,7 @@ function connectTunnel(token) {
|
|
|
2242
1791
|
});
|
|
2243
1792
|
ws.on("close", (code, reason) => {
|
|
2244
1793
|
routerCaps.clear(); // 换中继/断线:能力重新协商,期间一律退回保守行为
|
|
2245
|
-
|
|
2246
|
-
heartbeat = null;
|
|
2247
|
-
sender.close(); // 丢掉未发分片,别让重连后的新连接接到旧响应的剩余部分
|
|
1794
|
+
clearInterval(heartbeat);
|
|
2248
1795
|
console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
|
|
2249
1796
|
closeAllWsSessions();
|
|
2250
1797
|
// C2 + C5:4000(被同 deviceId 的新连接顶掉)走长退避/退出;其余走 2/4/8/10s + 抖动。
|
|
@@ -2290,30 +1837,7 @@ async function initE2ee(token) {
|
|
|
2290
1837
|
writeE2eeStateFile(stateFileDir, { enabled: st.enabled, reason: st.reason, profile: st.profile, epoch: st.epoch, caps: e2ee.caps() });
|
|
2291
1838
|
}
|
|
2292
1839
|
if (st.enabled) {
|
|
2293
|
-
|
|
2294
|
-
// 生产事故:bridge 重启/崩一次,手机端保存的 sessId 就全成死票(每个请求 502
|
|
2295
|
-
// unknown_session、每条数据 WS 1008),用户必须回设备列表重新解锁(实测某设备 3964 次)。
|
|
2296
|
-
// 方案:把会话材料(SHK/saltH/verified/ctrl 计数)用 MK 派生的 storeKey 做 AES-256-GCM
|
|
2297
|
-
// 加密后落 relay 数据目录 .e2ee-sessions.enc(0600,原子写),启动时解密恢复 ——
|
|
2298
|
-
// 文件里没有 MK/密码/密钥,明文不存在于磁盘。详见 e2ee-client.mjs「会话持久化」段。
|
|
2299
|
-
// 落盘条件与状态文件同源:受管安装或显式给了配置目录(仓库里直接跑不写工作区)。
|
|
2300
|
-
const storeDir = process.env.DSH_BRIDGE_E2EE_STORE_DIR
|
|
2301
|
-
|| ((process.env.DSH_BRIDGE_CONFIG || process.env.DSH_BRIDGE_E2EE_STATE || IS_MANAGED_INSTALL) ? stateFileDir : "");
|
|
2302
|
-
const storeEnabled = process.env.DSH_BRIDGE_E2EE_SESSIONS !== "0";
|
|
2303
|
-
const store = e2ee.attachStore(storeDir, { enabled: storeEnabled });
|
|
2304
|
-
if (store.restored > 0) {
|
|
2305
|
-
console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(加密落盘;手机重启/桥重启后免重新解锁)`);
|
|
2306
|
-
}
|
|
2307
|
-
if (store.discarded) {
|
|
2308
|
-
// 丢弃 = 手机手里的旧票必然对不上:后续 ws-open/HTTP 会带 retryable 的 rehandshake 信号,
|
|
2309
|
-
// 手机自动重建会话而不是让用户去输密码。
|
|
2310
|
-
console.log(`[bridge] E2EE 会话存储已作废(${store.discarded}),手机端将自动重新握手`);
|
|
2311
|
-
}
|
|
2312
|
-
if (storeDir && storeEnabled) {
|
|
2313
|
-
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存,会话库加密落盘`);
|
|
2314
|
-
} else {
|
|
2315
|
-
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存(会话库未落盘)`);
|
|
2316
|
-
}
|
|
1840
|
+
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存`);
|
|
2317
1841
|
} else {
|
|
2318
1842
|
console.log(`[bridge] e2ee 未启用(${st.reason}) → 明文 v1 路径照常(能力不上报)`);
|
|
2319
1843
|
}
|
|
@@ -2691,18 +2215,11 @@ async function main() {
|
|
|
2691
2215
|
if (shuttingDown) return;
|
|
2692
2216
|
shuttingDown = true;
|
|
2693
2217
|
console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
|
|
2694
|
-
// 退出前把 E2EE 会话库刷一次(滑动 TTL 的 usedAt 要落盘;崩溃也能恢复到最多 60s 前)
|
|
2695
|
-
try { e2ee.flushStore(); } catch { /* 已内部兜底 */ }
|
|
2696
2218
|
await stopWeChat();
|
|
2697
2219
|
process.exit(0);
|
|
2698
2220
|
};
|
|
2699
2221
|
process.on("SIGTERM", () => { shutdown("SIGTERM"); });
|
|
2700
2222
|
process.on("SIGINT", () => { shutdown("SIGINT"); });
|
|
2701
|
-
// Windows 的 TerminateProcess 不会跑上面的信号处理,但 exit 钩子可以(同步操作)——
|
|
2702
|
-
// 会话库是同步写,放在这里能在被强杀前尽量落一次盘。
|
|
2703
|
-
process.on("exit", () => { try { e2ee.flushStore(); e2ee.disposeStore(); } catch { /* 忽略 */ } });
|
|
2704
|
-
// 事件循环延迟探针:心跳自适应要知道"本机是不是忙"(见 adaptiveHeartbeat 长注释)。
|
|
2705
|
-
startEventLoopLagMonitor();
|
|
2706
2223
|
// 【Windows 的关键一条】Windows 上 process.kill() 是**无条件 TerminateProcess**:
|
|
2707
2224
|
// 上面的 SIGTERM/SIGINT 处理函数在 Windows 上**永远不会被执行**,
|
|
2708
2225
|
// 于是 notifystop(微信通道下线通知)永远发不出去,腾讯侧一直以为通道还在线。
|
|
@@ -2729,14 +2246,6 @@ async function main() {
|
|
|
2729
2246
|
// test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
|
|
2730
2247
|
export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
|
|
2731
2248
|
export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
|
|
2732
|
-
// test hooks:注入 E2EE 服务(生产由 initE2ee 设置;ws-open 会话校验/快速应答用例用)
|
|
2733
|
-
export function __setE2eeForTest(svc) { e2ee = svc; return e2ee; }
|
|
2734
|
-
// test hooks:当前 ws 会话数与某条会话的内部状态(快速应答/待发队列用例用)
|
|
2735
|
-
export function __wsSessionState(id) {
|
|
2736
|
-
const s = wsSessions.get(id);
|
|
2737
|
-
if (!s) return null;
|
|
2738
|
-
return { opened: s.opened === true, pending: s.pending ? s.pending.length : 0, cancelled: s.cancelled === true, failed: s.failed === true };
|
|
2739
|
-
}
|
|
2740
2249
|
|
|
2741
2250
|
// 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
|
|
2742
2251
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|