@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 +12 -0
- package/client/client.js +78 -0
- package/client/index.js +70 -0
- package/lib/bridge-rpc-constants.js +1 -0
- package/lib/bridge-rpc.js +10 -0
- package/lib/index.js +35 -0
- package/lib/tunnel-client.mjs +440 -426
- package/package.json +2 -2
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);
|
package/lib/tunnel-client.mjs
CHANGED
|
@@ -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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
});
|
|
248
|
-
res.on('
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
sock
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
this.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
this.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (this.
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
this.
|
|
418
|
-
this.
|
|
419
|
-
this.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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.
|
|
3
|
+
"version": "2.10.2",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.10.
|
|
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": {
|