@wenbin_wb/dsh-bridge 2.10.1 → 2.10.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@
4
4
 
5
5
  ---
6
6
 
7
+ ## [v2.10.2] - 2026-09-01
8
+
9
+ ### ✨ 新功能
10
+
11
+ - **外部已部署隧道登记**(issue #25):自行部署 cloudflared(Docker 等)的用户可在面板登记公网地址,展示二维码与入口,插件不再重复下载管理
12
+
13
+ ### 🐞 修复
14
+
15
+ - **修复会话列表/历史加载问题**:自建隧道下 session.list 502 修复 + 会话历史载入提速(社区 PR #27)
16
+
17
+ ---
18
+
7
19
  ## [v2.10.1] - 2026-09-01
8
20
 
9
21
  ### 🧹 说明
package/client/client.js CHANGED
@@ -963,6 +963,7 @@ var BRIDGE_ENDPOINTS = {
963
963
  saveCloudflaredConfig: "saveCloudflaredConfig",
964
964
  setTunnelAutoStart: "setTunnelAutoStart",
965
965
  saveCustomTunnelConfig: "saveCustomTunnelConfig",
966
+ saveExternalTunnel: "saveExternalTunnel",
966
967
  setLanIp: "setLanIp",
967
968
  checkVersion: "checkVersion",
968
969
  upgradePlugin: "upgradePlugin",
@@ -1680,6 +1681,74 @@ var CloudflareConfigForm = React.memo(function CloudflareConfigForm2({ token, ho
1680
1681
  )
1681
1682
  );
1682
1683
  });
1684
+ var ExternalTunnelCard = React.memo(function ExternalTunnelCard2({ ext, onSave }) {
1685
+ const [urlVal, setUrlVal] = React.useState(ext?.url ?? "");
1686
+ const [saving, setSaving] = React.useState(false);
1687
+ const [msg, setMsg] = React.useState(null);
1688
+ React.useEffect(() => {
1689
+ setUrlVal(ext?.url ?? "");
1690
+ }, [ext?.url]);
1691
+ const handleSave = async (e) => {
1692
+ e.preventDefault();
1693
+ setSaving(true);
1694
+ setMsg(null);
1695
+ try {
1696
+ await onSave(urlVal.trim());
1697
+ setMsg({ ok: true, text: urlVal.trim() ? "\u2713 \u5916\u90E8\u96A7\u9053\u5730\u5740\u5DF2\u767B\u8BB0" : "\u2713 \u5DF2\u6E05\u9664\u767B\u8BB0" });
1698
+ } catch (err) {
1699
+ setMsg({ ok: false, text: err.message || "\u4FDD\u5B58\u5931\u8D25" });
1700
+ } finally {
1701
+ setSaving(false);
1702
+ }
1703
+ };
1704
+ return React.createElement(
1705
+ "div",
1706
+ { style: s.card },
1707
+ React.createElement(
1708
+ "div",
1709
+ { style: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10 } },
1710
+ React.createElement(
1711
+ "div",
1712
+ { style: { flex: "1 1 auto", minWidth: 0 } },
1713
+ React.createElement("div", { style: s.label }, "\u5916\u90E8\u5DF2\u90E8\u7F72\u96A7\u9053"),
1714
+ React.createElement(
1715
+ "div",
1716
+ { style: { ...s.muted, marginTop: 2 } },
1717
+ "\u5DF2\u5728 Docker / \u670D\u52A1\u5668\u4E0A\u81EA\u884C\u90E8\u7F72\u96A7\u9053\uFF08\u5982 cloudflared\uFF09\u65F6\uFF0C\u767B\u8BB0\u516C\u7F51\u5730\u5740\u5373\u53EF\u5728\u9762\u677F\u5C55\u793A\u5165\u53E3\u4E0E\u4E8C\u7EF4\u7801\uFF1B\u63D2\u4EF6\u4E0D\u4F1A\u91CD\u590D\u4E0B\u8F7D\u6216\u7BA1\u7406\u8BE5\u96A7\u9053\u3002"
1718
+ )
1719
+ ),
1720
+ React.createElement(StatusTag, { running: Boolean(ext?.configured) })
1721
+ ),
1722
+ ext?.configured && React.createElement(QrBlock, { url: ext?.url, qr: ext?.qr }),
1723
+ React.createElement(
1724
+ "form",
1725
+ { onSubmit: handleSave, style: { marginTop: 10, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" } },
1726
+ React.createElement("input", {
1727
+ style: { ...s.input, flex: "1 1 220px", minWidth: 0 },
1728
+ type: "text",
1729
+ placeholder: "https://tunnel.yourdomain.com \u6216 trycloudflare \u5730\u5740",
1730
+ value: urlVal,
1731
+ onChange: (e) => setUrlVal(e.target.value)
1732
+ }),
1733
+ React.createElement("button", {
1734
+ type: "submit",
1735
+ style: { ...s.btnPri, height: 28, fontSize: 12, padding: "0 12px" },
1736
+ disabled: saving
1737
+ }, saving ? "\u4FDD\u5B58\u4E2D\u2026" : "\u4FDD\u5B58"),
1738
+ ext?.configured && React.createElement("button", {
1739
+ type: "button",
1740
+ style: { ...s.btnGhost, height: 28, fontSize: 12, padding: "0 10px" },
1741
+ onClick: () => {
1742
+ setUrlVal("");
1743
+ onSave("");
1744
+ }
1745
+ }, "\u6E05\u9664"),
1746
+ msg && React.createElement("span", {
1747
+ style: { fontSize: 12, color: msg.ok ? "var(--dsw-alias-state-success-primary, #059669)" : "var(--dsw-alias-state-error-primary, #dc2626)" }
1748
+ }, msg.text)
1749
+ )
1750
+ );
1751
+ });
1683
1752
  var AccessAuthCard = React.memo(function AccessAuthCard2({ auth, rpcCall, onUpdate }) {
1684
1753
  const [enabled, setEnabled] = React.useState(auth?.enabled ?? false);
1685
1754
  const [mode, setMode] = React.useState(auth?.mode ?? "token_and_password");
@@ -3915,6 +3984,10 @@ function BridgePanel({ rpcCall }) {
3915
3984
  (serverUrl, accessToken) => act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken }),
3916
3985
  [act]
3917
3986
  );
3987
+ const saveExternalTunnel = React.useCallback(
3988
+ (url) => act(BRIDGE_ENDPOINTS.saveExternalTunnel, { url }),
3989
+ [act]
3990
+ );
3918
3991
  const navSecurity = React.useCallback(() => setActiveTab("security"), []);
3919
3992
  if (!status && !err) {
3920
3993
  return React.createElement("div", {
@@ -3948,6 +4021,7 @@ function BridgePanel({ rpcCall }) {
3948
4021
  })
3949
4022
  );
3950
4023
  } else if (activeTab === "tunnel") {
4024
+ const ext = status?.externalTunnel;
3951
4025
  tabContent = React.createElement(
3952
4026
  React.Fragment,
3953
4027
  null,
@@ -3976,6 +4050,10 @@ function BridgePanel({ rpcCall }) {
3976
4050
  onSave: saveCloudflaredConfig
3977
4051
  })
3978
4052
  ),
4053
+ React.createElement(ExternalTunnelCard, {
4054
+ ext,
4055
+ onSave: saveExternalTunnel
4056
+ }),
3979
4057
  React.createElement(
3980
4058
  TunnelCard,
3981
4059
  {
package/client/index.js CHANGED
@@ -649,6 +649,67 @@ const CloudflareConfigForm = React.memo(function CloudflareConfigForm({ token, h
649
649
  );
650
650
  });
651
651
 
652
+ // 外部已部署隧道登记卡片:用户自行部署(Docker cloudflared / 其他反向代理)时,
653
+ // 插件不下载不管理,仅登记公网地址用于面板展示二维码/URL 与放行 CORS。
654
+ const ExternalTunnelCard = React.memo(function ExternalTunnelCard({ ext, onSave }) {
655
+ const [urlVal, setUrlVal] = React.useState(ext?.url ?? '');
656
+ const [saving, setSaving] = React.useState(false);
657
+ const [msg, setMsg] = React.useState(null);
658
+
659
+ React.useEffect(() => {
660
+ setUrlVal(ext?.url ?? '');
661
+ }, [ext?.url]);
662
+
663
+ const handleSave = async (e) => {
664
+ e.preventDefault();
665
+ setSaving(true);
666
+ setMsg(null);
667
+ try {
668
+ await onSave(urlVal.trim());
669
+ setMsg({ ok: true, text: urlVal.trim() ? '✓ 外部隧道地址已登记' : '✓ 已清除登记' });
670
+ } catch (err) {
671
+ setMsg({ ok: false, text: err.message || '保存失败' });
672
+ } finally {
673
+ setSaving(false);
674
+ }
675
+ };
676
+
677
+ return React.createElement('div', { style: s.card },
678
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 } },
679
+ React.createElement('div', { style: { flex: '1 1 auto', minWidth: 0 } },
680
+ React.createElement('div', { style: s.label }, '外部已部署隧道'),
681
+ React.createElement('div', { style: { ...s.muted, marginTop: 2 } },
682
+ '已在 Docker / 服务器上自行部署隧道(如 cloudflared)时,登记公网地址即可在面板展示入口与二维码;插件不会重复下载或管理该隧道。'
683
+ ),
684
+ ),
685
+ React.createElement(StatusTag, { running: Boolean(ext?.configured) }),
686
+ ),
687
+ ext?.configured && React.createElement(QrBlock, { url: ext?.url, qr: ext?.qr }),
688
+ React.createElement('form', { onSubmit: handleSave, style: { marginTop: 10, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
689
+ React.createElement('input', {
690
+ style: { ...s.input, flex: '1 1 220px', minWidth: 0 },
691
+ type: 'text',
692
+ placeholder: 'https://tunnel.yourdomain.com 或 trycloudflare 地址',
693
+ value: urlVal,
694
+ onChange: (e) => setUrlVal(e.target.value),
695
+ }),
696
+ React.createElement('button', {
697
+ type: 'submit',
698
+ style: { ...s.btnPri, height: 28, fontSize: 12, padding: '0 12px' },
699
+ disabled: saving,
700
+ }, saving ? '保存中…' : '保存'),
701
+ ext?.configured && React.createElement('button', {
702
+ type: 'button',
703
+ style: { ...s.btnGhost, height: 28, fontSize: 12, padding: '0 10px' },
704
+ onClick: () => { setUrlVal(''); onSave(''); },
705
+ }, '清除'),
706
+ msg && React.createElement('span', {
707
+ style: { fontSize: 12, color: msg.ok ? 'var(--dsw-alias-state-success-primary, #059669)' : 'var(--dsw-alias-state-error-primary, #dc2626)' },
708
+ }, msg.text),
709
+ ),
710
+ );
711
+ });
712
+
652
713
  // ---- 访问安全认证卡片 ----
653
714
 
654
715
  const AccessAuthCard = React.memo(function AccessAuthCard({ auth, rpcCall, onUpdate }) {
@@ -2707,6 +2768,10 @@ function BridgePanel({ rpcCall }) {
2707
2768
  act(BRIDGE_ENDPOINTS.saveCustomTunnelConfig, { serverUrl, accessToken })
2708
2769
  , [act]);
2709
2770
 
2771
+ const saveExternalTunnel = React.useCallback((url) =>
2772
+ act(BRIDGE_ENDPOINTS.saveExternalTunnel, { url })
2773
+ , [act]);
2774
+
2710
2775
  const navSecurity = React.useCallback(() => setActiveTab('security'), []);
2711
2776
 
2712
2777
  if (!status && !err) {
@@ -2744,6 +2809,7 @@ function BridgePanel({ rpcCall }) {
2744
2809
  })
2745
2810
  );
2746
2811
  } else if (activeTab === 'tunnel') {
2812
+ const ext = status?.externalTunnel;
2747
2813
  tabContent = React.createElement(React.Fragment, null,
2748
2814
  React.createElement(TunnelCard, {
2749
2815
  title: 'Cloudflare 隧道',
@@ -2770,6 +2836,10 @@ function BridgePanel({ rpcCall }) {
2770
2836
  onSave: saveCloudflaredConfig,
2771
2837
  }),
2772
2838
  ),
2839
+ React.createElement(ExternalTunnelCard, {
2840
+ ext,
2841
+ onSave: saveExternalTunnel,
2842
+ }),
2773
2843
  React.createElement(TunnelCard, {
2774
2844
  title: '自建隧道',
2775
2845
  desc: '连接自己部署的隧道服务器,获得固定域名',
@@ -12,6 +12,7 @@ export const BRIDGE_ENDPOINTS = {
12
12
  saveCloudflaredConfig: 'saveCloudflaredConfig',
13
13
  setTunnelAutoStart: 'setTunnelAutoStart',
14
14
  saveCustomTunnelConfig: 'saveCustomTunnelConfig',
15
+ saveExternalTunnel: 'saveExternalTunnel',
15
16
  setLanIp: 'setLanIp',
16
17
  checkVersion: 'checkVersion',
17
18
  upgradePlugin: 'upgradePlugin',
package/lib/bridge-rpc.js CHANGED
@@ -173,6 +173,16 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
173
173
  return ok(status);
174
174
  }
175
175
 
176
+ if (endpoint === BRIDGE_ENDPOINTS.saveExternalTunnel) {
177
+ const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
178
+ if (adminErr) return adminErr;
179
+
180
+ const { url } = payload;
181
+ await service.saveExternalTunnel({ url });
182
+ const status = await service.getStatus();
183
+ return ok(status);
184
+ }
185
+
176
186
  if (endpoint === BRIDGE_ENDPOINTS.setTunnelAutoStart) {
177
187
  const adminErr = checkAdminAuth(authManager, payload, { requireConfigured: true });
178
188
  if (adminErr) return adminErr;
package/lib/index.js CHANGED
@@ -556,6 +556,7 @@ class BridgeService {
556
556
  this.home = home;
557
557
  this.cloudflaredConfig = cloudflaredConfig ?? { token: '', hostname: '', autoStart: false };
558
558
  this.customTunnelConfig = customTunnelConfig ?? null;
559
+ this.externalTunnelConfig = null; // 用户自行部署的外部隧道(Docker cloudflared 等),仅登记公网地址展示
559
560
  this.selectedLanIp = lanConfig?.selectedIp ?? null;
560
561
  this.authManager = authManager ?? null;
561
562
  this.onPersist = onPersist ?? null;
@@ -651,6 +652,7 @@ class BridgeService {
651
652
  if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
652
653
  if (this.cloudflared?.url) origins.push(new URL(this.cloudflared.url).origin);
653
654
  if (this.customTunnel?.publicUrl) origins.push(new URL(this.customTunnel.publicUrl).origin);
655
+ if (this.externalTunnelConfig?.url) origins.push(new URL(this.externalTunnelConfig.url).origin);
654
656
  } catch { /* 单项来源解析失败不影响其余 */ }
655
657
  return origins;
656
658
  },
@@ -739,6 +741,15 @@ class BridgeService {
739
741
  autoStart: Boolean(this.customTunnelConfig?.autoStart),
740
742
  },
741
743
 
744
+ // 外部已部署隧道(用户自行 Docker/二进制部署,插件仅登记展示)
745
+ externalTunnel: {
746
+ configured: !!this.externalTunnelConfig?.url,
747
+ url: this.externalTunnelConfig?.url ?? '',
748
+ qr: this.externalTunnelConfig?.url
749
+ ? await this.qrCache.get(this.externalTunnelConfig.url)
750
+ : null,
751
+ },
752
+
742
753
  // 宿主系统运行监控指标
743
754
  system: this.getSystemMetrics(),
744
755
  };
@@ -755,6 +766,27 @@ class BridgeService {
755
766
  await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
756
767
  }
757
768
 
769
+ // 登记用户自行部署的外部隧道(Docker cloudflared 等)。插件不下载/不管理该隧道,
770
+ // 仅保存公网地址用于面板展示二维码/URL 并放行 CORS。url 传空串则清除登记。
771
+ async saveExternalTunnel({ url } = {}) {
772
+ const trimmed = url ? String(url).trim() : '';
773
+ if (trimmed) {
774
+ // 仅接受 http/https 公网地址,防止注入任意协议或本地路径
775
+ let normalized;
776
+ try {
777
+ const u = new URL(trimmed);
778
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('仅支持 http/https');
779
+ normalized = u.toString().replace(/\/+$/, '');
780
+ } catch {
781
+ throw new Error('请输入合法的公网隧道地址(https://...)');
782
+ }
783
+ this.externalTunnelConfig = { url: normalized };
784
+ } else {
785
+ this.externalTunnelConfig = null;
786
+ }
787
+ await this.onPersist?.({ externalTunnel: this.externalTunnelConfig });
788
+ }
789
+
758
790
  async setTunnelAutoStart({ tunnel, autoStart }) {
759
791
  const isAuto = Boolean(autoStart);
760
792
  if (tunnel === 'cloudflared') {
@@ -1595,6 +1627,9 @@ function apply(ctx, config = {}) {
1595
1627
 
1596
1628
  // 启动时读取已保存的局域网网卡配置与公网隧道配置并按需自动拉起
1597
1629
  loadConfig().then(async (stored) => {
1630
+ if (stored?.externalTunnel) {
1631
+ service.externalTunnelConfig = stored.externalTunnel;
1632
+ }
1598
1633
  if (stored?.lan?.selectedIp) {
1599
1634
  service.selectedLanIp = stored.lan.selectedIp;
1600
1635
  logger.info('dsh-bridge: loaded saved lan config (selectedIp=%s)', service.selectedLanIp);
@@ -1,426 +1,440 @@
1
- // DSH Bridge - Custom Tunnel Client
2
- import { WebSocket } from 'ws';
3
- import { request as httpRequest } from 'node:http';
4
- import { connect as netConnect } from 'node:net';
5
- import { promisify } from 'node:util';
6
- import { gzip as gzipCallback } from 'node:zlib';
7
-
8
- const gzipAsync = promisify(gzipCallback);
9
-
10
- const HEARTBEAT_INTERVAL = 30000;
11
- const RECONNECT_DELAY = 5000;
12
- const MAX_RECONNECT_ATTEMPTS = 5;
13
-
14
- // 单响应内存上限:隧道把整个 body 缓冲进内存再 base64 传输,无上限会在大文件
15
- // 下载时把进程内存拖垮(一份 body 三份内存:chunks + Buffer + base64 字符串)
16
- const MAX_RESPONSE_BYTES = 32 * 1024 * 1024; // 32MB
17
-
18
- // 大响应 gzip 压缩阈值(超过此大小的可压缩响应将被 gzip)
19
- const GZIP_THRESHOLD = 102400; // 100KB
20
- // 可压缩的 content-type 前缀
21
- const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
22
-
23
- export class CustomTunnelClient {
24
- constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
25
- this.serverUrl = serverUrl;
26
- this.accessToken = accessToken;
27
- this.localPort = localPort;
28
- this.internalTunnelSecret = internalTunnelSecret;
29
- this.signal = signal;
30
- this.onStateChange = onStateChange;
31
- this.logger = logger;
32
- this.ws = null;
33
- this.publicUrl = null;
34
- this.connected = false;
35
- this.disconnecting = false;
36
- this.reconnectAttempts = 0;
37
- this.reconnectTimer = null;
38
- this.heartbeatTimer = null;
39
- this.localWsSockets = new Map(); // wsId -> net.Socket
40
- }
41
-
42
- async connect() {
43
- if (this.connected) return;
44
- this._setState('connecting', 'Connecting to tunnel server...');
45
- try {
46
- await this._connectWebSocket();
47
- this._startHeartbeat();
48
- this.reconnectAttempts = 0;
49
- this._setState('ready', 'Tunnel established');
50
- } catch (err) {
51
- this._setState('error', err.message);
52
- throw err;
53
- }
54
- }
55
-
56
- _connectWebSocket() {
57
- return new Promise((resolve, reject) => {
58
- if (this.signal?.aborted) return reject(new Error('Aborted'));
59
-
60
- const url = new URL(this.serverUrl);
61
- url.searchParams.set('token', this.accessToken);
62
-
63
- this.ws = new WebSocket(url.toString(), {
64
- handshakeTimeout: 10000,
65
- perMessageDeflate: {
66
- clientNoContextTakeover: true,
67
- serverNoContextTakeover: true,
68
- clientMaxWindowBits: 15,
69
- serverMaxWindowBits: 15,
70
- },
71
- });
72
-
73
- const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
74
- this.signal?.addEventListener('abort', onAbort);
75
-
76
- this.ws.on('open', () => {
77
- this.signal?.removeEventListener('abort', onAbort);
78
- this.logger?.info('Tunnel WebSocket connected');
79
- });
80
-
81
- this.ws.on('message', (data) => this._handleMessage(data));
82
-
83
- this.ws.on('close', (code, reason) => {
84
- this.connected = false;
85
- this._stopHeartbeat();
86
- this._cleanupLocalWs();
87
- if (!this.signal?.aborted) {
88
- this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
89
- this._scheduleReconnect();
90
- }
91
- });
92
-
93
- this.ws.on('error', (err) => {
94
- this.logger?.error('Tunnel WebSocket error: %s', err.message);
95
- if (!this.connected) {
96
- this.signal?.removeEventListener('abort', onAbort);
97
- reject(err);
98
- }
99
- });
100
-
101
- const readyHandler = (data) => {
102
- try {
103
- const msg = JSON.parse(data.toString());
104
- if (msg.type === 'ready' && msg.publicUrl) {
105
- this.publicUrl = msg.publicUrl;
106
- this.connected = true;
107
- this.ws.off('message', readyHandler);
108
- this.signal?.removeEventListener('abort', onAbort);
109
- this.logger?.info('Tunnel ready: %s', this.publicUrl);
110
- resolve();
111
- }
112
- } catch {}
113
- };
114
- this.ws.on('message', readyHandler);
115
-
116
- setTimeout(() => {
117
- if (!this.connected) {
118
- this.signal?.removeEventListener('abort', onAbort);
119
- this.ws?.terminate();
120
- reject(new Error('Connection timeout'));
121
- }
122
- }, 15000);
123
- });
124
- }
125
-
126
- _handleMessage(data) {
127
- try {
128
- const msg = JSON.parse(data.toString());
129
- if (msg.type === 'request') this._handleHttpRequest(msg);
130
- else if (msg.type === 'ws-open') this._handleWsOpen(msg);
131
- else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
132
- else if (msg.type === 'ws-close') this._handleWsClose(msg);
133
- // pong: ignore
134
- } catch (err) {
135
- this.logger?.error('Failed to parse tunnel message: %s', err.message);
136
- }
137
- }
138
-
139
- // ── HTTP 请求代理 ─────────────────────────────────────────────────────────
140
- _handleHttpRequest(msg) {
141
- const { requestId, method, path, headers } = msg;
142
- const SKIP = new Set(['transfer-encoding','connection','keep-alive',
143
- 'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
144
- const safeHeaders = Object.fromEntries(
145
- Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
146
- );
147
-
148
- const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
149
- if (this.internalTunnelSecret) {
150
- reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
151
- }
152
-
153
- const req = httpRequest({
154
- host: '127.0.0.1', port: this.localPort,
155
- method, path: path || '/',
156
- headers: reqHeaders,
157
- }, (res) => {
158
- const contentType = String(res.headers['content-type'] ?? '');
159
- const isSSE = contentType.includes('text/event-stream');
160
- const chunks = [];
161
-
162
- if (isSSE) {
163
- // SSE 流式响应:隧道协议不支持流式,收集初始数据后立即返回
164
- // 避免 SSE 永不 end 导致隧道服务器超时返回 504
165
- let sseSent = false;
166
- const sseTimer = setTimeout(() => {
167
- if (sseSent) return;
168
- sseSent = true;
169
- const respHeaders = Object.fromEntries(
170
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
171
- );
172
- this._sendMessage({
173
- type: 'response', requestId,
174
- statusCode: res.statusCode, headers: respHeaders,
175
- body: Buffer.concat(chunks).toString('base64'),
176
- });
177
- res.destroy();
178
- }, 500);
179
-
180
- res.on('data', (c) => {
181
- if (sseSent) return;
182
- chunks.push(c);
183
- // 收到初始数据后立即发送(不等超时)
184
- if (chunks.length >= 2) {
185
- clearTimeout(sseTimer);
186
- sseSent = true;
187
- const respHeaders = Object.fromEntries(
188
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
189
- );
190
- this._sendMessage({
191
- type: 'response', requestId,
192
- statusCode: res.statusCode, headers: respHeaders,
193
- body: Buffer.concat(chunks).toString('base64'),
194
- });
195
- res.destroy();
196
- }
197
- });
198
- res.on('error', () => {
199
- if (!sseSent) {
200
- clearTimeout(sseTimer);
201
- this._sendMessage({
202
- type: 'response', requestId, statusCode: 502,
203
- headers: { 'content-type': 'text/plain' },
204
- body: Buffer.from('Response Error').toString('base64'),
205
- });
206
- }
207
- });
208
- res.on('end', () => {
209
- if (!sseSent) {
210
- clearTimeout(sseTimer);
211
- sseSent = true;
212
- const respHeaders = Object.fromEntries(
213
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
214
- );
215
- this._sendMessage({
216
- type: 'response', requestId,
217
- statusCode: res.statusCode, headers: respHeaders,
218
- body: Buffer.concat(chunks).toString('base64'),
219
- });
220
- }
221
- });
222
- return;
223
- }
224
-
225
- let responded = false;
226
- const sendResponse = (statusCode, headers, body) => {
227
- if (responded) return;
228
- responded = true;
229
- this._sendMessage({
230
- type: 'response', requestId, statusCode, headers,
231
- body: Buffer.from(body).toString('base64'),
232
- });
233
- };
234
-
235
- let total = 0;
236
- res.on('data', (c) => {
237
- chunks.push(c);
238
- total += c.length;
239
- if (total > MAX_RESPONSE_BYTES) {
240
- sendResponse(502, { 'content-type': 'text/plain' },
241
- `Response too large for tunnel (>${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB cap)`);
242
- res.destroy();
243
- }
244
- });
245
- res.on('error', () => {
246
- sendResponse(502, { 'content-type': 'text/plain' }, 'Response Error');
247
- });
248
- res.on('end', () => {
249
- if (responded) return;
250
- const respHeaders = Object.fromEntries(
251
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
252
- );
253
- let bodyBuf = Buffer.concat(chunks);
254
-
255
- // session.list 响应可能极大(数百会话 × contextHeaders = 60MB+)
256
- // 剥离 contextHeaders 和 contextTimeline(侧边栏列表不需要,打开会话时通过 WebSocket 实时获取)
257
- const cleanPath = String(path || '').split('?')[0];
258
- if (cleanPath === '/api/session.list' && res.statusCode === 200) {
259
- try {
260
- const json = JSON.parse(bodyBuf.toString('utf8'));
261
- if (json?.result?.ok && json.result.value?.items) {
262
- for (const item of json.result.value.items) {
263
- const proj = item?.projections?.values;
264
- if (proj) {
265
- delete proj.contextHeaders;
266
- delete proj.contextTimeline;
267
- }
268
- }
269
- bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
270
- respHeaders['content-length'] = String(bodyBuf.length);
271
- }
272
- } catch {} // 解析失败则原样发送
273
- }
274
-
275
- // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
276
- const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
277
- const alreadyEncoded = String(res.headers['content-encoding'] ?? '').toLowerCase();
278
- const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
279
- if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
280
- gzipAsync(bodyBuf).then((zipped) => {
281
- respHeaders['content-encoding'] = 'gzip';
282
- respHeaders['content-length'] = String(zipped.length);
283
- sendResponse(res.statusCode, respHeaders, zipped);
284
- }).catch(() => {
285
- sendResponse(res.statusCode, respHeaders, bodyBuf);
286
- });
287
- return;
288
- }
289
-
290
- sendResponse(res.statusCode, respHeaders, bodyBuf);
291
- });
292
- });
293
- req.on('error', err => {
294
- this._sendMessage({ type: 'response', requestId, statusCode: 502,
295
- headers: { 'content-type': 'text/plain' },
296
- body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
297
- });
298
- if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
299
- req.end();
300
- }
301
-
302
- // ── WebSocket 升级代理 ────────────────────────────────────────────────────
303
- // 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
304
- _handleWsOpen(msg) {
305
- const { wsId, path, headers } = msg;
306
-
307
- const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
308
- this.localWsSockets.set(wsId, sock);
309
-
310
- // 构造 HTTP Upgrade 请求
311
- const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
312
- delete reqHeaders['proxy-connection'];
313
- delete reqHeaders['proxy-authorization'];
314
- if (this.internalTunnelSecret) {
315
- reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
316
- }
317
-
318
- const lines = [`GET ${path || '/'} HTTP/1.1`];
319
- for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
320
- lines.push('', '');
321
- sock.write(lines.join('\r\n'));
322
-
323
- let headerBuf = '';
324
- let upgraded = false;
325
-
326
- sock.on('data', (chunk) => {
327
- if (upgraded) {
328
- this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
329
- return;
330
- }
331
- headerBuf += chunk.toString('binary');
332
- const sep = headerBuf.indexOf('\r\n\r\n');
333
- if (sep === -1) return;
334
-
335
- upgraded = true;
336
- const replyHeaders = {};
337
- const headerLines = headerBuf.slice(0, sep).split('\r\n');
338
- for (let i = 1; i < headerLines.length; i++) {
339
- const ci = headerLines[i].indexOf(':');
340
- if (ci > 0) {
341
- replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
342
- headerLines[i].slice(ci + 1).trim();
343
- }
344
- }
345
- this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
346
-
347
- // 握手后紧跟的帧数据
348
- const rest = headerBuf.slice(sep + 4);
349
- if (rest.length > 0) {
350
- this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
351
- }
352
- });
353
-
354
- sock.on('close', () => {
355
- this._sendMessage({ type: 'ws-close', wsId });
356
- this.localWsSockets.delete(wsId);
357
- });
358
- sock.on('error', (err) => {
359
- this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
360
- this._sendMessage({ type: 'ws-close', wsId });
361
- this.localWsSockets.delete(wsId);
362
- });
363
- }
364
-
365
- _handleWsFrame(msg) {
366
- const sock = this.localWsSockets.get(msg.wsId);
367
- if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
368
- }
369
-
370
- _handleWsClose(msg) {
371
- const sock = this.localWsSockets.get(msg.wsId);
372
- if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
373
- }
374
-
375
- _cleanupLocalWs() {
376
- for (const [, sock] of this.localWsSockets) sock.destroy();
377
- this.localWsSockets.clear();
378
- }
379
-
380
- // ── 工具方法 ──────────────────────────────────────────────────────────────
381
- _sendMessage(msg) {
382
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
383
- }
384
-
385
- _startHeartbeat() {
386
- this._stopHeartbeat();
387
- this.heartbeatTimer = setInterval(() => {
388
- if (this.connected) this._sendMessage({ type: 'ping' });
389
- }, HEARTBEAT_INTERVAL);
390
- }
391
-
392
- _stopHeartbeat() {
393
- if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
394
- }
395
-
396
- _scheduleReconnect() {
397
- if (this.signal?.aborted || this.disconnecting) return;
398
- if (this.reconnectTimer) return;
399
- if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
400
- this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
401
- return;
402
- }
403
- this.reconnectAttempts++;
404
- const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
405
- this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
406
- this.reconnectTimer = setTimeout(() => {
407
- this.reconnectTimer = null;
408
- this.connect().catch(() => {});
409
- }, delay);
410
- }
411
-
412
- _setState(phase, detail) {
413
- if (this.onStateChange) this.onStateChange({ phase, detail });
414
- }
415
-
416
- disconnect() {
417
- this.disconnecting = true;
418
- this._stopHeartbeat();
419
- this._cleanupLocalWs();
420
- if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
421
- if (this.ws) { this.ws.close(); this.ws = null; }
422
- this.connected = false;
423
- this.publicUrl = null;
424
- this.logger?.info('Tunnel disconnected');
425
- }
426
- }
1
+ // DSH Bridge - Custom Tunnel Client
2
+ import { WebSocket } from 'ws';
3
+ import { request as httpRequest } from 'node:http';
4
+ import { connect as netConnect } from 'node:net';
5
+ import { promisify } from 'node:util';
6
+ import { gzip as gzipCallback } from 'node:zlib';
7
+
8
+ const gzipAsync = promisify(gzipCallback);
9
+
10
+ const HEARTBEAT_INTERVAL = 30000;
11
+ const RECONNECT_DELAY = 5000;
12
+ const MAX_RECONNECT_ATTEMPTS = 5;
13
+
14
+ // 单响应内存上限:隧道把整个 body 缓冲进内存再 base64 传输,无上限会在大文件
15
+ // 下载时把进程内存拖垮(一份 body 三份内存:chunks + Buffer + base64 字符串)
16
+ const MAX_RESPONSE_BYTES = 32 * 1024 * 1024; // 32MB
17
+
18
+ // 大响应 gzip 压缩阈值(超过此大小的可压缩响应将被 gzip)
19
+ const GZIP_THRESHOLD = 102400; // 100KB
20
+ // 可压缩的 content-type 前缀
21
+ const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
22
+
23
+ export class CustomTunnelClient {
24
+ constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
25
+ this.serverUrl = serverUrl;
26
+ this.accessToken = accessToken;
27
+ this.localPort = localPort;
28
+ this.internalTunnelSecret = internalTunnelSecret;
29
+ this.signal = signal;
30
+ this.onStateChange = onStateChange;
31
+ this.logger = logger;
32
+ this.ws = null;
33
+ this.publicUrl = null;
34
+ this.connected = false;
35
+ this.disconnecting = false;
36
+ this.reconnectAttempts = 0;
37
+ this.reconnectTimer = null;
38
+ this.heartbeatTimer = null;
39
+ this.localWsSockets = new Map(); // wsId -> net.Socket
40
+ }
41
+
42
+ async connect() {
43
+ if (this.connected) return;
44
+ this._setState('connecting', 'Connecting to tunnel server...');
45
+ try {
46
+ await this._connectWebSocket();
47
+ this._startHeartbeat();
48
+ this.reconnectAttempts = 0;
49
+ this._setState('ready', 'Tunnel established');
50
+ } catch (err) {
51
+ this._setState('error', err.message);
52
+ throw err;
53
+ }
54
+ }
55
+
56
+ _connectWebSocket() {
57
+ return new Promise((resolve, reject) => {
58
+ if (this.signal?.aborted) return reject(new Error('Aborted'));
59
+
60
+ const url = new URL(this.serverUrl);
61
+ url.searchParams.set('token', this.accessToken);
62
+
63
+ this.ws = new WebSocket(url.toString(), {
64
+ handshakeTimeout: 10000,
65
+ perMessageDeflate: {
66
+ clientNoContextTakeover: true,
67
+ serverNoContextTakeover: true,
68
+ clientMaxWindowBits: 15,
69
+ serverMaxWindowBits: 15,
70
+ },
71
+ });
72
+
73
+ const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
74
+ this.signal?.addEventListener('abort', onAbort);
75
+
76
+ this.ws.on('open', () => {
77
+ this.signal?.removeEventListener('abort', onAbort);
78
+ this.logger?.info('Tunnel WebSocket connected');
79
+ });
80
+
81
+ this.ws.on('message', (data) => this._handleMessage(data));
82
+
83
+ this.ws.on('close', (code, reason) => {
84
+ this.connected = false;
85
+ this._stopHeartbeat();
86
+ this._cleanupLocalWs();
87
+ if (!this.signal?.aborted) {
88
+ this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
89
+ this._scheduleReconnect();
90
+ }
91
+ });
92
+
93
+ this.ws.on('error', (err) => {
94
+ this.logger?.error('Tunnel WebSocket error: %s', err.message);
95
+ if (!this.connected) {
96
+ this.signal?.removeEventListener('abort', onAbort);
97
+ reject(err);
98
+ }
99
+ });
100
+
101
+ const readyHandler = (data) => {
102
+ try {
103
+ const msg = JSON.parse(data.toString());
104
+ if (msg.type === 'ready' && msg.publicUrl) {
105
+ this.publicUrl = msg.publicUrl;
106
+ this.connected = true;
107
+ this.ws.off('message', readyHandler);
108
+ this.signal?.removeEventListener('abort', onAbort);
109
+ this.logger?.info('Tunnel ready: %s', this.publicUrl);
110
+ resolve();
111
+ }
112
+ } catch {}
113
+ };
114
+ this.ws.on('message', readyHandler);
115
+
116
+ setTimeout(() => {
117
+ if (!this.connected) {
118
+ this.signal?.removeEventListener('abort', onAbort);
119
+ this.ws?.terminate();
120
+ reject(new Error('Connection timeout'));
121
+ }
122
+ }, 15000);
123
+ });
124
+ }
125
+
126
+ _handleMessage(data) {
127
+ try {
128
+ const msg = JSON.parse(data.toString());
129
+ if (msg.type === 'request') this._handleHttpRequest(msg);
130
+ else if (msg.type === 'ws-open') this._handleWsOpen(msg);
131
+ else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
132
+ else if (msg.type === 'ws-close') this._handleWsClose(msg);
133
+ // pong: ignore
134
+ } catch (err) {
135
+ this.logger?.error('Failed to parse tunnel message: %s', err.message);
136
+ }
137
+ }
138
+
139
+ // ── HTTP 请求代理 ─────────────────────────────────────────────────────────
140
+ _handleHttpRequest(msg) {
141
+ const { requestId, method, path, headers } = msg;
142
+ const SKIP = new Set(['transfer-encoding','connection','keep-alive',
143
+ 'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
144
+ const safeHeaders = Object.fromEntries(
145
+ Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
146
+ );
147
+
148
+ const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
149
+ if (this.internalTunnelSecret) {
150
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
151
+ }
152
+
153
+ const req = httpRequest({
154
+ host: '127.0.0.1', port: this.localPort,
155
+ method, path: path || '/',
156
+ headers: reqHeaders,
157
+ }, (res) => {
158
+ const contentType = String(res.headers['content-type'] ?? '');
159
+ const isSSE = contentType.includes('text/event-stream');
160
+ const chunks = [];
161
+
162
+ if (isSSE) {
163
+ // SSE 流式响应:隧道协议不支持流式,收集初始数据后立即返回
164
+ // 避免 SSE 永不 end 导致隧道服务器超时返回 504
165
+ let sseSent = false;
166
+ const sseTimer = setTimeout(() => {
167
+ if (sseSent) return;
168
+ sseSent = true;
169
+ const respHeaders = Object.fromEntries(
170
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
171
+ );
172
+ this._sendMessage({
173
+ type: 'response', requestId,
174
+ statusCode: res.statusCode, headers: respHeaders,
175
+ body: Buffer.concat(chunks).toString('base64'),
176
+ });
177
+ res.destroy();
178
+ }, 500);
179
+
180
+ res.on('data', (c) => {
181
+ if (sseSent) return;
182
+ chunks.push(c);
183
+ // 收到初始数据后立即发送(不等超时)
184
+ if (chunks.length >= 2) {
185
+ clearTimeout(sseTimer);
186
+ sseSent = true;
187
+ const respHeaders = Object.fromEntries(
188
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
189
+ );
190
+ this._sendMessage({
191
+ type: 'response', requestId,
192
+ statusCode: res.statusCode, headers: respHeaders,
193
+ body: Buffer.concat(chunks).toString('base64'),
194
+ });
195
+ res.destroy();
196
+ }
197
+ });
198
+ res.on('error', () => {
199
+ if (!sseSent) {
200
+ clearTimeout(sseTimer);
201
+ this._sendMessage({
202
+ type: 'response', requestId, statusCode: 502,
203
+ headers: { 'content-type': 'text/plain' },
204
+ body: Buffer.from('Response Error').toString('base64'),
205
+ });
206
+ }
207
+ });
208
+ res.on('end', () => {
209
+ if (!sseSent) {
210
+ clearTimeout(sseTimer);
211
+ sseSent = true;
212
+ const respHeaders = Object.fromEntries(
213
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
214
+ );
215
+ this._sendMessage({
216
+ type: 'response', requestId,
217
+ statusCode: res.statusCode, headers: respHeaders,
218
+ body: Buffer.concat(chunks).toString('base64'),
219
+ });
220
+ }
221
+ });
222
+ return;
223
+ }
224
+
225
+ let responded = false;
226
+ const sendResponse = (statusCode, headers, body) => {
227
+ if (responded) return;
228
+ responded = true;
229
+ this._sendMessage({
230
+ type: 'response', requestId, statusCode, headers,
231
+ body: Buffer.from(body).toString('base64'),
232
+ });
233
+ };
234
+
235
+ let total = 0;
236
+ const cleanPath = String(path || '').split('?')[0];
237
+ // session.list 会在 end 后剥离 contextHeaders 等大字段,跳过 data 阶段的大小检查
238
+ const skipSizeCheck = cleanPath === '/api/session.list';
239
+ res.on('data', (c) => {
240
+ chunks.push(c);
241
+ total += c.length;
242
+ if (!skipSizeCheck && total > MAX_RESPONSE_BYTES) {
243
+ sendResponse(502, { 'content-type': 'text/plain' },
244
+ `Response too large for tunnel (>${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB cap)`);
245
+ res.destroy();
246
+ }
247
+ });
248
+ res.on('error', () => {
249
+ sendResponse(502, { 'content-type': 'text/plain' }, 'Response Error');
250
+ });
251
+ res.on('end', () => {
252
+ if (responded) return;
253
+ const respHeaders = Object.fromEntries(
254
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
255
+ );
256
+ let bodyBuf = Buffer.concat(chunks);
257
+
258
+ // 剥离 contextHeaders contextTimeline 这两个投影字段包含每轮完整
259
+ // 系统提示+工具定义(4.8MB+),但没有任何客户端 UI 读取它们。
260
+ // session.list: 数百会话 × contextHeaders = 60MB+
261
+ // session.history: 单会话 contextHeaders = 4.8MB,占响应 80%+
262
+ // 剥离后 session.history 1.2MB gzip 降到 ~150KB(8x 减少)
263
+ if ((cleanPath === '/api/session.list' || cleanPath === '/api/session.history') && res.statusCode === 200) {
264
+ try {
265
+ const json = JSON.parse(bodyBuf.toString('utf8'));
266
+ if (json?.result?.ok) {
267
+ const v = json.result.value;
268
+ // session.list: items[].projections.values
269
+ if (v?.items) {
270
+ for (const item of v.items) {
271
+ const proj = item?.projections?.values;
272
+ if (proj) {
273
+ delete proj.contextHeaders;
274
+ delete proj.contextTimeline;
275
+ }
276
+ }
277
+ }
278
+ // session.history: projections.values
279
+ if (v?.projections?.values) {
280
+ delete v.projections.values.contextHeaders;
281
+ delete v.projections.values.contextTimeline;
282
+ }
283
+ bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
284
+ respHeaders['content-length'] = String(bodyBuf.length);
285
+ }
286
+ } catch {} // 解析失败则原样发送
287
+ }
288
+
289
+ // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
290
+ const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
291
+ const alreadyEncoded = String(res.headers['content-encoding'] ?? '').toLowerCase();
292
+ const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
293
+ if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
294
+ gzipAsync(bodyBuf).then((zipped) => {
295
+ respHeaders['content-encoding'] = 'gzip';
296
+ respHeaders['content-length'] = String(zipped.length);
297
+ sendResponse(res.statusCode, respHeaders, zipped);
298
+ }).catch(() => {
299
+ sendResponse(res.statusCode, respHeaders, bodyBuf);
300
+ });
301
+ return;
302
+ }
303
+
304
+ sendResponse(res.statusCode, respHeaders, bodyBuf);
305
+ });
306
+ });
307
+ req.on('error', err => {
308
+ this._sendMessage({ type: 'response', requestId, statusCode: 502,
309
+ headers: { 'content-type': 'text/plain' },
310
+ body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
311
+ });
312
+ if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
313
+ req.end();
314
+ }
315
+
316
+ // ── WebSocket 升级代理 ────────────────────────────────────────────────────
317
+ // 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
318
+ _handleWsOpen(msg) {
319
+ const { wsId, path, headers } = msg;
320
+
321
+ const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
322
+ this.localWsSockets.set(wsId, sock);
323
+
324
+ // 构造 HTTP Upgrade 请求
325
+ const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
326
+ delete reqHeaders['proxy-connection'];
327
+ delete reqHeaders['proxy-authorization'];
328
+ if (this.internalTunnelSecret) {
329
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
330
+ }
331
+
332
+ const lines = [`GET ${path || '/'} HTTP/1.1`];
333
+ for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
334
+ lines.push('', '');
335
+ sock.write(lines.join('\r\n'));
336
+
337
+ let headerBuf = '';
338
+ let upgraded = false;
339
+
340
+ sock.on('data', (chunk) => {
341
+ if (upgraded) {
342
+ this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
343
+ return;
344
+ }
345
+ headerBuf += chunk.toString('binary');
346
+ const sep = headerBuf.indexOf('\r\n\r\n');
347
+ if (sep === -1) return;
348
+
349
+ upgraded = true;
350
+ const replyHeaders = {};
351
+ const headerLines = headerBuf.slice(0, sep).split('\r\n');
352
+ for (let i = 1; i < headerLines.length; i++) {
353
+ const ci = headerLines[i].indexOf(':');
354
+ if (ci > 0) {
355
+ replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
356
+ headerLines[i].slice(ci + 1).trim();
357
+ }
358
+ }
359
+ this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
360
+
361
+ // 握手后紧跟的帧数据
362
+ const rest = headerBuf.slice(sep + 4);
363
+ if (rest.length > 0) {
364
+ this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
365
+ }
366
+ });
367
+
368
+ sock.on('close', () => {
369
+ this._sendMessage({ type: 'ws-close', wsId });
370
+ this.localWsSockets.delete(wsId);
371
+ });
372
+ sock.on('error', (err) => {
373
+ this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
374
+ this._sendMessage({ type: 'ws-close', wsId });
375
+ this.localWsSockets.delete(wsId);
376
+ });
377
+ }
378
+
379
+ _handleWsFrame(msg) {
380
+ const sock = this.localWsSockets.get(msg.wsId);
381
+ if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
382
+ }
383
+
384
+ _handleWsClose(msg) {
385
+ const sock = this.localWsSockets.get(msg.wsId);
386
+ if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
387
+ }
388
+
389
+ _cleanupLocalWs() {
390
+ for (const [, sock] of this.localWsSockets) sock.destroy();
391
+ this.localWsSockets.clear();
392
+ }
393
+
394
+ // ── 工具方法 ──────────────────────────────────────────────────────────────
395
+ _sendMessage(msg) {
396
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
397
+ }
398
+
399
+ _startHeartbeat() {
400
+ this._stopHeartbeat();
401
+ this.heartbeatTimer = setInterval(() => {
402
+ if (this.connected) this._sendMessage({ type: 'ping' });
403
+ }, HEARTBEAT_INTERVAL);
404
+ }
405
+
406
+ _stopHeartbeat() {
407
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
408
+ }
409
+
410
+ _scheduleReconnect() {
411
+ if (this.signal?.aborted || this.disconnecting) return;
412
+ if (this.reconnectTimer) return;
413
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
414
+ this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
415
+ return;
416
+ }
417
+ this.reconnectAttempts++;
418
+ const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
419
+ this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
420
+ this.reconnectTimer = setTimeout(() => {
421
+ this.reconnectTimer = null;
422
+ this.connect().catch(() => {});
423
+ }, delay);
424
+ }
425
+
426
+ _setState(phase, detail) {
427
+ if (this.onStateChange) this.onStateChange({ phase, detail });
428
+ }
429
+
430
+ disconnect() {
431
+ this.disconnecting = true;
432
+ this._stopHeartbeat();
433
+ this._cleanupLocalWs();
434
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
435
+ if (this.ws) { this.ws.close(); this.ws = null; }
436
+ this.connected = false;
437
+ this.publicUrl = null;
438
+ this.logger?.info('Tunnel disconnected');
439
+ }
440
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.10.1",
3
+ "version": "2.10.2",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.10.1】\n• 🧹 v2.10.0 代码一致:修正升级提示措辞,与发布说明对齐\n\n【v2.10.0】\n• 🆕 新增「管理保护」独立开关:与访问认证解耦,远程设备管理权限始终受管理保护约束,仅凭管理密码可解锁\n• 🆕 面板版本条新增 DSH 宿主版本显示\n• 🐞 修复远程锁屏/解锁流程多处问题(会话失效后自动回登录页、锁屏可正常解锁、关闭访问认证后锁屏仍生效、首次进入即显示锁屏等)\n• 🧹 内部:统一 adminToken 缓存,清理死代码与重复实现",
5
+ "releaseNotes": "【v2.10.2】\n• 🆕 外部已部署隧道登记(issue #25):自行部署 cloudflared(Docker 等)可在面板登记公网地址,展示二维码与入口,插件不再重复下载管理\n• 🐞 修复会话列表/历史加载问题:自建隧道下 session.list 502 修复 + 会话历史载入提速(社区 PR #27)",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {