@wenbin_wb/dsh-bridge 2.10.0 → 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 +23 -11
- package/client/client.js +78 -0
- package/client/index.js +70 -0
- package/docs/release-process.md +40 -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,25 +4,37 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## [v2.10.
|
|
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
|
+
|
|
19
|
+
## [v2.10.1] - 2026-09-01
|
|
8
20
|
|
|
9
|
-
|
|
21
|
+
### 🧹 说明
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
- 无代码变更:修正 npm 包内升级提示(releaseNotes)措辞,与 v2.10.0 发布说明保持一致(仅突出新增功能,不再罗列内部修复细节)。
|
|
12
24
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
- **修复关闭「访问认证」后仍要求密码**:`enabled` 开关豁免管理 gate(否则"关闭访问认证"这个动作本身会被管理保护锁死)。
|
|
17
|
-
- **修复远程首次进入设置页不显示锁屏**:现在远程设备首次进入即显示完整全屏锁屏(输入管理密码后方可操作)。
|
|
18
|
-
- **修复远程目录选择器卡死**:目录浏览被鉴权拦截时内嵌解锁入口,解锁后自动继续;`local_only` 策略下明确提示远程不可解锁。
|
|
19
|
-
- **修复解锁成功后操作丢失**:被拦截的操作暂存队列,解锁成功后自动重放,无需重新点击。
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## [v2.10.0] - 2026-09-01
|
|
20
28
|
|
|
21
29
|
### ✨ 新功能
|
|
22
30
|
|
|
23
|
-
-
|
|
31
|
+
- **新增「管理保护」独立开关**:与访问认证解耦的独立安全开关(安全认证页可切换)。此前远程锁屏依附于访问认证开关,关闭访问认证后管理保护随之失效;现在两者完全独立,远程设备的管理权限始终受「管理保护」约束,仅凭管理密码可解锁。
|
|
24
32
|
- **面板显示 DSH 宿主版本**:版本条新增灰色 `DSH vX.Y.Z` 标签(服务端惰性探测 `dsh --version` 并缓存,零轮询开销),与插件版本并排展示。
|
|
25
33
|
|
|
34
|
+
### 🐞 修复
|
|
35
|
+
|
|
36
|
+
- 修复远程锁屏/解锁流程的多处问题:会话失效(DSH 重启/过期)后自动跳回登录页重新登录,不再困在锁屏;锁屏状态下可正常输入管理密码解锁;关闭访问认证后锁屏仍生效、且不再误要求密码;远程首次进入设置页即显示全屏锁屏;目录选择器解锁不再卡死。
|
|
37
|
+
|
|
26
38
|
### 🧹 内部优化
|
|
27
39
|
|
|
28
40
|
- 统一 adminToken 缓存为单一来源,删除死代码与重复实现(unlock-manager 未接入 API、组件内冗余 state、重复的 loopback-token 逻辑等)。
|
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: '连接自己部署的隧道服务器,获得固定域名',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 发布流程规则(RELEASE PROCESS)
|
|
2
|
+
|
|
3
|
+
> 本文件是**发布操作的红线规则**,任何一次对外发布(npm / GitHub Release)都必须遵守。
|
|
4
|
+
|
|
5
|
+
## 铁律:发布前必须人工确认
|
|
6
|
+
|
|
7
|
+
**任何版本(正式版 / 补丁版)在推 tag、npm publish、创建 GitHub Release 之前,必须先向仓库所有者(wenbin-wb)确认,得到明确同意后才可执行。**
|
|
8
|
+
|
|
9
|
+
原因(2026-09-01 教训):v2.10.0 发布时未先确认就自行完成了 合并 → 打 tag → 推送 → npm publish 全流程,随后发现发布说明措辞需要修订,而 npm 不允许覆盖已发布版本,只能再补 v2.10.1 修正,造成不必要的版本噪音。
|
|
10
|
+
|
|
11
|
+
### 确认清单(发布前发给所有者)
|
|
12
|
+
|
|
13
|
+
1. **版本号**:下一个版本号(如 `v2.10.1`)是否认可?
|
|
14
|
+
2. **发布说明(releaseNotes / CHANGELOG)**:内容是否准确?是否只包含用户可感知的变更(新功能、真实修复),不包含开发过程细节?
|
|
15
|
+
3. **发布范围**:npm 发布 + GitHub Release + tag,是否全部执行?
|
|
16
|
+
|
|
17
|
+
得到「可以发布」的明确回复后,才允许执行发布动作。
|
|
18
|
+
|
|
19
|
+
## 发布流程
|
|
20
|
+
|
|
21
|
+
1. **改版本**:`package.json` 的 `version` + `releaseNotes`
|
|
22
|
+
2. **写 CHANGELOG**:`CHANGELOG.md` 顶部新增对应版本条目
|
|
23
|
+
3. **构建与测试**:`npm run build:lark` + `npm run build:client` + `npm run build:banner` + `npm test`(全量 166 项)
|
|
24
|
+
4. **提交**:合并到 `main`(fast-forward),提交信息 `release: vX.Y.Z — ...`
|
|
25
|
+
5. **打 tag**:`git tag vX.Y.Z && git push origin vX.Y.Z`(触发 GitHub Actions 自动创建 Release)
|
|
26
|
+
6. **npm 发布**:`npm publish`(prepack 自动构建产物)
|
|
27
|
+
7. **验证**:npm 线上版本 + GitHub Release body 是否正确
|
|
28
|
+
|
|
29
|
+
## 发布说明写作规范
|
|
30
|
+
|
|
31
|
+
- ✅ 只写**用户可感知**的变更:新功能、真实修复(用户报告的问题)
|
|
32
|
+
- ❌ 不写开发过程中自己引入又修复的内部 bug
|
|
33
|
+
- ❌ 不罗列内部重构/清理细节(一句话概括即可)
|
|
34
|
+
- ✅ 修复类条目建议**合并成一条概括**,不逐条展开技术实现
|
|
35
|
+
|
|
36
|
+
## 注意事项
|
|
37
|
+
|
|
38
|
+
- npm **不允许覆盖已发布版本**(403)。发布后发现说明要改,只能 bump 补丁版本——所以发布前确认说明尤其重要。
|
|
39
|
+
- `release.yml` workflow 只创建 GitHub Release,npm 发布是手动步骤,不要漏。
|
|
40
|
+
- 发布后如发现问题,走补丁版本(`vX.Y.Z+1`),不要尝试覆盖。
|
|
@@ -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": {
|