@wenbin_wb/dsh-bridge 1.0.5 → 1.0.6

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.
Files changed (2) hide show
  1. package/lib/tunnel-client.mjs +136 -156
  2. package/package.json +1 -1
@@ -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.6",
4
4
  "description": "DSH 多渠道接入桥:局域网二维码、自建隧道、机器人集成",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",