@wenbin_wb/dsh-bridge 1.0.5 → 1.0.7

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/README.md CHANGED
@@ -2,132 +2,178 @@
2
2
 
3
3
  ![dsh-bridge banner](docs/banner.jpg)
4
4
 
5
+ > Multi-channel remote access plugin for DeepSeek Harness
5
6
  > DeepSeek Harness 多通道远程访问插件
6
7
 
8
+ Seamlessly extend your local DeepSeek Harness to mobile phones, tablets, public networks, and even IM chat apps. Access your AI assistant anytime, anywhere via QR code scanning, web browsers, or future integrations with WeChat/QQ/Feishu.
9
+
7
10
  把你本地的 DeepSeek Harness 无缝延伸到手机、平板、公网、甚至 IM 聊天软件。无论你在哪,都能通过扫码、网页或未来的微信/QQ/飞书,随时调用你的 AI 助手。
8
11
 
9
- 在 DSH 设置页新增「远程访问」面板,开箱即用地获得:
12
+ ---
13
+
14
+ ## Features / 功能特性
15
+
16
+ ### English
17
+
18
+ - **LAN Access**: Scan QR code with your smartphone/tablet, direct access on the same Wi-Fi
19
+ - **Cloudflare Tunnel**: One-click public internet exposure, connect from anywhere
20
+ - **Custom Tunnel**: Connect to your own tunnel server with a fixed domain ([Setup Guide](docs/custom-tunnel.md))
21
+ - **IM Integration (Planned)**: WeChat / QQ / Feishu / OpenClaw direct chat integration
22
+ - **Security Alerts**: URLs and QR codes with access warnings to prevent accidental sharing
23
+ - **Auto Version Check**: Automatic update detection when entering the panel
24
+
25
+ ### 中文
10
26
 
11
- - **局域网访问**:手机扫码,同一 Wi-Fi 直接访问
27
+ - **局域网访问**:手机/平板扫码,同一 Wi-Fi 直接访问
12
28
  - **Cloudflare 隧道**:一键暴露公网地址,随时随地连接
13
- - **自建隧道**:连接自己的隧道服务器,获得固定域名
29
+ - **自建隧道**:连接自己的隧道服务器,获得固定域名([搭建教程](docs/custom-tunnel.md))
14
30
  - **IM 集成(规划中)**:微信 / QQ / 飞书 / OpenClaw,直接在聊天软件里呼唤你的 Agent
31
+ - **安全提示**:URL 和二维码带访问警告,防止误分享
32
+ - **版本检查**:进入面板自动检测是否有新版本
15
33
 
16
34
  ![npm](https://img.shields.io/npm/v/@wenbin_wb/dsh-bridge?label=npm)
17
35
  ![npm downloads](https://img.shields.io/npm/dt/@wenbin_wb/dsh-bridge?label=downloads)
18
36
  ![npm license](https://img.shields.io/npm/l/@wenbin_wb/dsh-bridge?label=license)
19
37
  ![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen)
20
38
 
21
- ## 功能
22
-
23
- - **局域网访问**:自动检测本机 IP,生成二维码,同一 Wi-Fi 下的设备直接扫码访问
24
- - **Cloudflare 隧道**:一键获取公网地址,自动下载 cloudflared(无需手动安装),重启后 URL 会变化
25
- - **自建隧道**:WebSocket 反向隧道,连接自己部署的隧道服务器,可获得固定域名
26
- - **安全提示**:URL 和二维码带访问警告,防止误分享
27
- - **版本检查**:进入面板自动检测是否有新版本
39
+ ---
28
40
 
29
41
  ## Roadmap
30
42
 
31
- 除了现有的三种访问方式,正在规划:
43
+ | Target / 目标 | Description / 说明 | Status / 状态 |
44
+ |---------------|-------------------|--------------|
45
+ | **WeChat / 微信** | Chat with your Agent directly in WeChat / 在微信里直接与你的 Agent 对话 | Planned / 规划中 |
46
+ | **QQ Bot** | QQ bot integration for group/private chat / 接入 QQ 机器人,群聊/私聊唤起 Agent | Planned / 规划中 |
47
+ | **Feishu / 飞书** | Feishu message/bot integration for workplace scenarios / 飞书消息/机器人集成,办公场景直接调用 | Planned / 规划中 |
48
+ | **OpenClaw** | Integration with OpenClaw ecosystem / 与 OpenClaw 生态打通 | Planned / 规划中 |
49
+ | **Telegram** | Self-hosted IM channel / 适合自托管的 IM 渠道 | Planned / 规划中 |
32
50
 
33
- | 目标 | 说明 | 状态 |
34
- |------|------|------|
35
- | **微信** | 在微信里直接与你的 Agent 对话 | 规划中 |
36
- | **QQ Bot** | 接入 QQ 机器人,群聊/私聊唤起 Agent | 规划中 |
37
- | **飞书** | 飞书消息/机器人集成,办公场景直接调用 | 规划中 |
38
- | **OpenClaw** | 与 OpenClaw 生态打通 | 规划中 |
39
- | **Telegram** | 适合自托管的 IM 渠道 | 规划中 |
51
+ ---
40
52
 
41
- ## 环境要求
53
+ ## Requirements / 环境要求
42
54
 
55
+ Before installing the plugin, ensure:
43
56
  安装插件前,请先确保:
44
57
 
45
- 1. **Node.js ≥ 22**(DSH 要求 `^22.19.0` `≥ 24.0.0`)
46
- 2. **dsh CLI 可用**——即能在终端直接运行 `dsh` 命令
58
+ 1. **Node.js ≥ 22** (DSH requires `^22.19.0` or `≥ 24.0.0` / DSH 要求)
59
+ 2. **dsh CLI available** — you can run `dsh` command in terminal / 能在终端直接运行 `dsh` 命令
47
60
 
48
61
  ```bash
49
- # 检查 Node 版本
50
- node -v # 应显示 v22.19+ v24+
62
+ # Check Node version / 检查 Node 版本
63
+ node -v # Should show v22.19+ or v24+ / 应显示
51
64
 
52
- # 检查 dsh 是否可用
65
+ # Check dsh availability / 检查 dsh 是否可用
53
66
  dsh --version
54
67
  ```
55
68
 
56
- 如果 `dsh` 命令提示"无法识别/找不到",说明它没有全局安装,先安装 DSH
69
+ If `dsh` command is not found, install DSH globally:
70
+ 如果 `dsh` 命令提示"无法识别/找不到",先安装 DSH:
57
71
 
58
72
  ```bash
59
73
  npm install -g @deepseek-ai/dsh
60
74
  ```
61
75
 
62
- > 若没有全局安装的权限,也可以用 `npx` 方式(无需全局安装):
76
+ > Alternative without global install (use `npx`):
77
+ > 若没有全局安装的权限,也可以用 `npx` 方式:
63
78
  > ```bash
64
79
  > npx --yes @deepseek-ai/dsh plugin --profile web add @wenbin_wb/dsh-bridge
65
80
  > ```
66
81
 
67
- ## 安装
82
+ ---
68
83
 
69
- npm 安装:
84
+ ## Installation / 安装
85
+
86
+ ### From npm / 从 npm 安装
70
87
 
71
88
  ```bash
72
89
  dsh plugin --profile web add @wenbin_wb/dsh-bridge
73
90
  ```
74
91
 
75
- 从源码安装:
92
+ ### From source / 从源码安装
76
93
 
77
94
  ```bash
78
95
  git clone https://github.com/wenbin-wb/dsh-bridge.git
79
96
  dsh plugin --profile web add ./dsh-bridge
80
97
  ```
81
98
 
99
+ After installation, restart DSH and find "Remote Access" in settings.
82
100
  安装完成后重启 DSH,在设置页找到「远程访问」即可使用。
83
101
 
84
- ## 使用
102
+ ---
103
+
104
+ ## Usage / 使用
105
+
106
+ ### LAN Access / 局域网访问
107
+
108
+ **English**: Automatically enabled after plugin starts, no configuration needed. Open Settings → "Remote Access", scan the QR code with your mobile device.
109
+
110
+ **中文**:插件启动后自动开启,无需任何配置。打开设置页「远程访问」,用手机扫描二维码即可访问。
85
111
 
86
- ### 局域网访问
112
+ ### Cloudflare Tunnel
87
113
 
88
- 插件启动后自动开启,无需任何配置。打开设置页「远程访问」,用手机扫描二维码即可访问。
114
+ **English**:
115
+ 1. Click "Enable" in the Cloudflare Tunnel card
116
+ 2. First use will auto-download cloudflared (~30MB) from GitHub
117
+ 3. Public URL and QR code appear within seconds
118
+ 4. URL changes after each restart; click "Reset Link" to get a new one
89
119
 
90
- ### Cloudflare 隧道
120
+ **中文**:
121
+ 1. 点击「Cloudflare 隧道」卡片中的「开启」按钮
122
+ 2. 首次使用会自动从 GitHub 下载 cloudflared(约 30MB)
123
+ 3. 下载完成后自动启动,几秒内显示公网 URL 和二维码
124
+ 4. 每次重启后 URL 会变化;点「重置链接」可主动获取新 URL
91
125
 
92
- 点击「Cloudflare 隧道」卡片中的「开启」按钮:
93
- 1. 首次使用会自动从 GitHub 下载 cloudflared(约 30MB),需要能访问 GitHub
94
- 2. 下载完成后自动启动,几秒内显示公网 URL 和二维码
95
- 3. 每次重启后 URL 会变化;点「重置链接」可主动获取新 URL
126
+ ### Custom Tunnel / 自建隧道
96
127
 
97
- ### 自建隧道
128
+ **English**: Requires a server with public IP. See [Custom Tunnel Guide](docs/custom-tunnel.md) for detailed setup.
98
129
 
99
- 需要一台有公网 IP 的服务器。详细搭建步骤见 [自建隧道教程](docs/custom-tunnel.md)。
130
+ 1. Deploy tunnel server following the guide
131
+ 2. Fill in WebSocket address (`wss://...`) and access token in the "Custom Tunnel" card
132
+ 3. Click "Save Config" then "Enable"
133
+
134
+ Config is auto-persisted to `~/.dsh/dsh-bridge/config.json`, no need to re-enter after restart.
135
+
136
+ **中文**:需要一台有公网 IP 的服务器。详细搭建步骤见 [自建隧道教程](docs/custom-tunnel.md)。
100
137
 
101
138
  1. 按教程在服务器上部署隧道服务端
102
139
  2. 在「自建隧道」卡片中填写 WebSocket 地址(`wss://...`)和访问令牌
103
140
  3. 点「保存配置」后点「开启」
104
141
 
105
- 配置自动持久化到 `~/.dsh/dsh-bridge/config.json`,重启后无需重新填写。
142
+ 配置自动持久化,重启后无需重新填写。
143
+
144
+ ---
106
145
 
107
- ## 可选配置
146
+ ## Optional Configuration / 可选配置
108
147
 
148
+ Plugin works out-of-the-box, no configuration needed. To change proxy port, add to cordis.yml:
109
149
  插件开箱即用,无需配置。如需修改代理端口,在 cordis.yml 中添加:
110
150
 
111
151
  ```yaml
112
152
  - name: dsh-bridge
113
153
  config:
114
- port: 3082 # 默认 3082
154
+ port: 3082 # Default / 默认 3082
115
155
  ```
116
156
 
117
- ## 开发
157
+ ---
158
+
159
+ ## Development / 开发
118
160
 
119
161
  ```bash
120
162
  git clone https://github.com/wenbin-wb/dsh-bridge.git
121
163
  cd dsh-bridge
122
164
  npm install
123
165
 
166
+ # Rebuild after modifying client/index.js
124
167
  # 修改 client/index.js 后重新构建
125
168
  npm run build:client
126
169
 
170
+ # Install to web profile and restart DSH
127
171
  # 安装到 web profile 并重启 DSH
128
172
  dsh plugin --profile web add .
129
173
  ```
130
174
 
175
+ ---
176
+
131
177
  ## License
132
178
 
133
179
  MIT © [wenbin-wb](https://github.com/wenbin-wb)
@@ -1,20 +1,12 @@
1
1
  // DSH Bridge - Custom Tunnel Client
2
- // WebSocket-based reverse tunnel with automatic reconnection and health monitoring
3
-
4
2
  import { WebSocket } from 'ws';
5
3
  import { request as httpRequest } from 'node:http';
4
+ import { connect as netConnect } from 'node:net';
6
5
 
7
- const HEARTBEAT_INTERVAL = 30000; // 30 seconds
8
- const RECONNECT_DELAY = 5000; // 5 seconds
6
+ const HEARTBEAT_INTERVAL = 30000;
7
+ const RECONNECT_DELAY = 5000;
9
8
  const MAX_RECONNECT_ATTEMPTS = 5;
10
9
 
11
- /**
12
- * Custom tunnel client with production-grade features:
13
- * - Automatic reconnection with exponential backoff
14
- * - Heartbeat monitoring
15
- * - Request multiplexing
16
- * - Graceful shutdown
17
- */
18
10
  export class CustomTunnelClient {
19
11
  constructor({ serverUrl, accessToken, localPort, signal, onStateChange, logger }) {
20
12
  this.serverUrl = serverUrl;
@@ -23,7 +15,6 @@ export class CustomTunnelClient {
23
15
  this.signal = signal;
24
16
  this.onStateChange = onStateChange;
25
17
  this.logger = logger;
26
-
27
18
  this.ws = null;
28
19
  this.publicUrl = null;
29
20
  this.connected = false;
@@ -32,14 +23,12 @@ export class CustomTunnelClient {
32
23
  this.reconnectTimer = null;
33
24
  this.heartbeatTimer = null;
34
25
  this.pendingRequests = new Map();
35
- this.requestIdCounter = 0;
26
+ this.localWsSockets = new Map(); // wsId -> net.Socket
36
27
  }
37
-
28
+
38
29
  async connect() {
39
30
  if (this.connected) return;
40
-
41
31
  this._setState('connecting', 'Connecting to tunnel server...');
42
-
43
32
  try {
44
33
  await this._connectWebSocket();
45
34
  this._startHeartbeat();
@@ -50,57 +39,47 @@ export class CustomTunnelClient {
50
39
  throw err;
51
40
  }
52
41
  }
53
-
42
+
54
43
  _connectWebSocket() {
55
44
  return new Promise((resolve, reject) => {
56
- if (this.signal?.aborted) {
57
- return reject(new Error('Aborted'));
58
- }
59
-
45
+ if (this.signal?.aborted) return reject(new Error('Aborted'));
46
+
60
47
  const url = new URL(this.serverUrl);
61
48
  url.searchParams.set('token', this.accessToken);
62
-
49
+
63
50
  this.ws = new WebSocket(url.toString(), {
64
51
  handshakeTimeout: 10000,
65
52
  perMessageDeflate: false,
66
53
  });
67
-
68
- const onAbort = () => {
69
- this.ws?.terminate();
70
- reject(new Error('Aborted'));
71
- };
72
-
54
+
55
+ const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
73
56
  this.signal?.addEventListener('abort', onAbort);
74
-
57
+
75
58
  this.ws.on('open', () => {
76
59
  this.signal?.removeEventListener('abort', onAbort);
77
60
  this.logger?.info('Tunnel WebSocket connected');
78
61
  });
79
-
80
- this.ws.on('message', (data) => {
81
- this._handleMessage(data);
82
- });
83
-
62
+
63
+ this.ws.on('message', (data) => this._handleMessage(data));
64
+
84
65
  this.ws.on('close', (code, reason) => {
85
66
  this.connected = false;
86
67
  this._stopHeartbeat();
87
-
68
+ this._cleanupLocalWs();
88
69
  if (!this.signal?.aborted) {
89
70
  this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
90
71
  this._scheduleReconnect();
91
72
  }
92
73
  });
93
-
74
+
94
75
  this.ws.on('error', (err) => {
95
76
  this.logger?.error('Tunnel WebSocket error: %s', err.message);
96
-
97
77
  if (!this.connected) {
98
78
  this.signal?.removeEventListener('abort', onAbort);
99
79
  reject(err);
100
80
  }
101
81
  });
102
-
103
- // Listen for 'ready' message from server
82
+
104
83
  const readyHandler = (data) => {
105
84
  try {
106
85
  const msg = JSON.parse(data.toString());
@@ -114,10 +93,8 @@ export class CustomTunnelClient {
114
93
  }
115
94
  } catch {}
116
95
  };
117
-
118
96
  this.ws.on('message', readyHandler);
119
-
120
- // Timeout
97
+
121
98
  setTimeout(() => {
122
99
  if (!this.connected) {
123
100
  this.signal?.removeEventListener('abort', onAbort);
@@ -127,126 +104,150 @@ export class CustomTunnelClient {
127
104
  }, 15000);
128
105
  });
129
106
  }
130
-
107
+
131
108
  _handleMessage(data) {
132
109
  try {
133
110
  const msg = JSON.parse(data.toString());
134
-
135
- // HTTP request from server
136
- if (msg.type === 'request') {
137
- this._handleHttpRequest(msg);
138
- }
139
- // Response for our request
140
- else if (msg.type === 'response') {
141
- this._handleHttpResponse(msg);
142
- }
143
- // Heartbeat pong
144
- else if (msg.type === 'pong') {
145
- // Server is alive
146
- }
111
+ if (msg.type === 'request') this._handleHttpRequest(msg);
112
+ else if (msg.type === 'ws-open') this._handleWsOpen(msg);
113
+ else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
114
+ else if (msg.type === 'ws-close') this._handleWsClose(msg);
115
+ // pong: ignore
147
116
  } catch (err) {
148
117
  this.logger?.error('Failed to parse tunnel message: %s', err.message);
149
118
  }
150
119
  }
151
-
120
+
121
+ // ── HTTP 请求代理 ─────────────────────────────────────────────────────────
152
122
  _handleHttpRequest(msg) {
153
123
  const { requestId, method, path, headers } = msg;
154
-
155
- // 过滤掉会干扰本地 HTTP 的 hop-by-hop 头
156
- const SKIP_HEADERS = new Set([
157
- 'transfer-encoding', 'connection', 'keep-alive',
158
- 'proxy-authenticate', 'proxy-authorization',
159
- 'te', 'trailer', 'upgrade',
160
- ]);
124
+ const SKIP = new Set(['transfer-encoding','connection','keep-alive',
125
+ 'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
161
126
  const safeHeaders = Object.fromEntries(
162
- Object.entries(headers ?? {}).filter(([k]) => !SKIP_HEADERS.has(k.toLowerCase()))
127
+ Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
163
128
  );
164
129
 
165
130
  const req = httpRequest({
166
- host: '127.0.0.1',
167
- port: this.localPort,
168
- method,
169
- path: path || '/',
170
- headers: {
171
- ...safeHeaders,
172
- host: `127.0.0.1:${this.localPort}`,
173
- },
131
+ host: '127.0.0.1', port: this.localPort,
132
+ method, path: path || '/',
133
+ headers: { ...safeHeaders, host: `127.0.0.1:${this.localPort}` },
174
134
  }, (res) => {
175
135
  const chunks = [];
176
- res.on('data', (chunk) => { chunks.push(chunk); });
177
- res.on('error', (err) => {
178
- this.logger?.error('Response read error: %s', err.message);
179
- this._sendMessage({
180
- type: 'response', requestId,
181
- statusCode: 502,
136
+ res.on('data', c => chunks.push(c));
137
+ res.on('error', () => {
138
+ this._sendMessage({ type: 'response', requestId, statusCode: 502,
182
139
  headers: { 'content-type': 'text/plain' },
183
- body: Buffer.from('Response Error').toString('base64'),
184
- });
140
+ body: Buffer.from('Response Error').toString('base64') });
185
141
  });
186
142
  res.on('end', () => {
187
- // 过滤响应头里的 hop-by-hop,防止外层 HTTP 解析出错
188
143
  const respHeaders = Object.fromEntries(
189
- Object.entries(res.headers).filter(([k]) => !SKIP_HEADERS.has(k.toLowerCase()))
144
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
190
145
  );
191
- this._sendMessage({
192
- type: 'response',
193
- requestId,
194
- statusCode: res.statusCode,
195
- headers: respHeaders,
196
- body: Buffer.concat(chunks).toString('base64'),
197
- });
146
+ this._sendMessage({ type: 'response', requestId,
147
+ statusCode: res.statusCode, headers: respHeaders,
148
+ body: Buffer.concat(chunks).toString('base64') });
198
149
  });
199
150
  });
200
-
201
- req.on('error', (err) => {
202
- this.logger?.error('Local request failed: %s', err.message);
203
- this._sendMessage({
204
- type: 'response', requestId,
205
- statusCode: 502,
151
+ req.on('error', err => {
152
+ this._sendMessage({ type: 'response', requestId, statusCode: 502,
206
153
  headers: { 'content-type': 'text/plain' },
207
- body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64'),
208
- });
154
+ body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
209
155
  });
210
-
211
- if (msg.body) {
212
- req.write(Buffer.from(msg.body, 'base64'));
213
- }
156
+ if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
214
157
  req.end();
215
158
  }
216
-
217
- _handleHttpResponse(msg) {
218
- const { requestId, statusCode, headers, body } = msg;
219
- const pending = this.pendingRequests.get(requestId);
220
-
221
- if (pending) {
222
- pending.resolve({ statusCode, headers, body });
223
- this.pendingRequests.delete(requestId);
224
- }
159
+
160
+ // ── WebSocket 升级代理 ────────────────────────────────────────────────────
161
+ // 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
162
+ _handleWsOpen(msg) {
163
+ const { wsId, path, headers } = msg;
164
+
165
+ const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
166
+ this.localWsSockets.set(wsId, sock);
167
+
168
+ // 构造 HTTP Upgrade 请求
169
+ const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
170
+ delete reqHeaders['proxy-connection'];
171
+ delete reqHeaders['proxy-authorization'];
172
+
173
+ const lines = [`GET ${path || '/'} HTTP/1.1`];
174
+ for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
175
+ lines.push('', '');
176
+ sock.write(lines.join('\r\n'));
177
+
178
+ let headerBuf = '';
179
+ let upgraded = false;
180
+
181
+ sock.on('data', (chunk) => {
182
+ if (upgraded) {
183
+ this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
184
+ return;
185
+ }
186
+ headerBuf += chunk.toString('binary');
187
+ const sep = headerBuf.indexOf('\r\n\r\n');
188
+ if (sep === -1) return;
189
+
190
+ upgraded = true;
191
+ const replyHeaders = {};
192
+ const headerLines = headerBuf.slice(0, sep).split('\r\n');
193
+ for (let i = 1; i < headerLines.length; i++) {
194
+ const ci = headerLines[i].indexOf(':');
195
+ if (ci > 0) {
196
+ replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
197
+ headerLines[i].slice(ci + 1).trim();
198
+ }
199
+ }
200
+ this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
201
+
202
+ // 握手后紧跟的帧数据
203
+ const rest = headerBuf.slice(sep + 4);
204
+ if (rest.length > 0) {
205
+ this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
206
+ }
207
+ });
208
+
209
+ sock.on('close', () => {
210
+ this._sendMessage({ type: 'ws-close', wsId });
211
+ this.localWsSockets.delete(wsId);
212
+ });
213
+ sock.on('error', (err) => {
214
+ this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
215
+ this._sendMessage({ type: 'ws-close', wsId });
216
+ this.localWsSockets.delete(wsId);
217
+ });
218
+ }
219
+
220
+ _handleWsFrame(msg) {
221
+ const sock = this.localWsSockets.get(msg.wsId);
222
+ if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
223
+ }
224
+
225
+ _handleWsClose(msg) {
226
+ const sock = this.localWsSockets.get(msg.wsId);
227
+ if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
228
+ }
229
+
230
+ _cleanupLocalWs() {
231
+ for (const [, sock] of this.localWsSockets) sock.destroy();
232
+ this.localWsSockets.clear();
225
233
  }
226
-
234
+
235
+ // ── 工具方法 ──────────────────────────────────────────────────────────────
227
236
  _sendMessage(msg) {
228
- if (this.ws?.readyState === WebSocket.OPEN) {
229
- this.ws.send(JSON.stringify(msg));
230
- }
237
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
231
238
  }
232
-
239
+
233
240
  _startHeartbeat() {
234
241
  this._stopHeartbeat();
235
-
236
242
  this.heartbeatTimer = setInterval(() => {
237
- if (this.connected) {
238
- this._sendMessage({ type: 'ping' });
239
- }
243
+ if (this.connected) this._sendMessage({ type: 'ping' });
240
244
  }, HEARTBEAT_INTERVAL);
241
245
  }
242
-
246
+
243
247
  _stopHeartbeat() {
244
- if (this.heartbeatTimer) {
245
- clearInterval(this.heartbeatTimer);
246
- this.heartbeatTimer = null;
247
- }
248
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
248
249
  }
249
-
250
+
250
251
  _scheduleReconnect() {
251
252
  if (this.signal?.aborted || this.disconnecting) return;
252
253
  if (this.reconnectTimer) return;
@@ -254,50 +255,29 @@ export class CustomTunnelClient {
254
255
  this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
255
256
  return;
256
257
  }
257
-
258
258
  this.reconnectAttempts++;
259
259
  const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
260
-
261
260
  this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
262
-
263
261
  this.reconnectTimer = setTimeout(() => {
264
262
  this.reconnectTimer = null;
265
- this.connect().catch(() => {
266
- // Will schedule another reconnect
267
- });
263
+ this.connect().catch(() => {});
268
264
  }, delay);
269
265
  }
270
-
266
+
271
267
  _setState(phase, detail) {
272
- if (this.onStateChange) {
273
- this.onStateChange({ phase, detail });
274
- }
268
+ if (this.onStateChange) this.onStateChange({ phase, detail });
275
269
  }
276
-
270
+
277
271
  disconnect() {
278
- // Mark as disconnected (don't modify external signal)
279
272
  this.disconnecting = true;
280
273
  this._stopHeartbeat();
281
-
282
- if (this.reconnectTimer) {
283
- clearTimeout(this.reconnectTimer);
284
- this.reconnectTimer = null;
285
- }
286
-
287
- if (this.ws) {
288
- this.ws.close();
289
- this.ws = null;
290
- }
291
-
274
+ this._cleanupLocalWs();
275
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
276
+ if (this.ws) { this.ws.close(); this.ws = null; }
292
277
  this.connected = false;
293
278
  this.publicUrl = null;
294
-
295
- // Reject all pending requests
296
- for (const [id, pending] of this.pendingRequests) {
297
- pending.reject(new Error('Disconnected'));
298
- this.pendingRequests.delete(id);
299
- }
300
-
279
+ for (const [, p] of this.pendingRequests) p.reject(new Error('Disconnected'));
280
+ this.pendingRequests.clear();
301
281
  this.logger?.info('Tunnel disconnected');
302
282
  }
303
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "DSH 多渠道接入桥:局域网二维码、自建隧道、机器人集成",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -40,9 +40,19 @@
40
40
  "dsh",
41
41
  "dsh-plugin",
42
42
  "remote",
43
+ "remote-access",
43
44
  "tunnel",
44
- "qr",
45
- "bot"
45
+ "cloudflare",
46
+ "mobile",
47
+ "smartphone",
48
+ "tablet",
49
+ "qr-code",
50
+ "qrcode",
51
+ "websocket",
52
+ "bot",
53
+ "wechat",
54
+ "qq",
55
+ "feishu"
46
56
  ],
47
57
  "dsh": {
48
58
  "bundle": {