@wenbin_wb/dsh-bridge 1.0.4 → 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 +142 -150
  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,114 +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
- // Proxy to local DSH
124
+ const SKIP = new Set(['transfer-encoding','connection','keep-alive',
125
+ 'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
126
+ const safeHeaders = Object.fromEntries(
127
+ Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
128
+ );
129
+
156
130
  const req = httpRequest({
157
- host: '127.0.0.1',
158
- port: this.localPort,
159
- method,
160
- path,
161
- headers: {
162
- ...headers,
163
- host: `127.0.0.1:${this.localPort}`,
164
- },
131
+ host: '127.0.0.1', port: this.localPort,
132
+ method, path: path || '/',
133
+ headers: { ...safeHeaders, host: `127.0.0.1:${this.localPort}` },
165
134
  }, (res) => {
166
135
  const chunks = [];
167
-
168
- res.on('data', (chunk) => {
169
- chunks.push(chunk);
136
+ res.on('data', c => chunks.push(c));
137
+ res.on('error', () => {
138
+ this._sendMessage({ type: 'response', requestId, statusCode: 502,
139
+ headers: { 'content-type': 'text/plain' },
140
+ body: Buffer.from('Response Error').toString('base64') });
170
141
  });
171
-
172
142
  res.on('end', () => {
173
- const body = Buffer.concat(chunks).toString('base64');
174
-
175
- this._sendMessage({
176
- type: 'response',
177
- requestId,
178
- statusCode: res.statusCode,
179
- headers: res.headers,
180
- body,
181
- });
143
+ const respHeaders = Object.fromEntries(
144
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
145
+ );
146
+ this._sendMessage({ type: 'response', requestId,
147
+ statusCode: res.statusCode, headers: respHeaders,
148
+ body: Buffer.concat(chunks).toString('base64') });
182
149
  });
183
150
  });
184
-
185
- req.on('error', (err) => {
186
- this.logger?.error('Local request failed: %s', err.message);
187
-
188
- this._sendMessage({
189
- type: 'response',
190
- requestId,
191
- statusCode: 502,
151
+ req.on('error', err => {
152
+ this._sendMessage({ type: 'response', requestId, statusCode: 502,
192
153
  headers: { 'content-type': 'text/plain' },
193
- body: Buffer.from('Bad Gateway').toString('base64'),
194
- });
154
+ body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
195
155
  });
196
-
197
- // Send request body if present
198
- if (msg.body) {
199
- req.write(Buffer.from(msg.body, 'base64'));
200
- }
201
-
156
+ if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
202
157
  req.end();
203
158
  }
204
-
205
- _handleHttpResponse(msg) {
206
- const { requestId, statusCode, headers, body } = msg;
207
- const pending = this.pendingRequests.get(requestId);
208
-
209
- if (pending) {
210
- pending.resolve({ statusCode, headers, body });
211
- this.pendingRequests.delete(requestId);
212
- }
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();
213
233
  }
214
-
234
+
235
+ // ── 工具方法 ──────────────────────────────────────────────────────────────
215
236
  _sendMessage(msg) {
216
- if (this.ws?.readyState === WebSocket.OPEN) {
217
- this.ws.send(JSON.stringify(msg));
218
- }
237
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
219
238
  }
220
-
239
+
221
240
  _startHeartbeat() {
222
241
  this._stopHeartbeat();
223
-
224
242
  this.heartbeatTimer = setInterval(() => {
225
- if (this.connected) {
226
- this._sendMessage({ type: 'ping' });
227
- }
243
+ if (this.connected) this._sendMessage({ type: 'ping' });
228
244
  }, HEARTBEAT_INTERVAL);
229
245
  }
230
-
246
+
231
247
  _stopHeartbeat() {
232
- if (this.heartbeatTimer) {
233
- clearInterval(this.heartbeatTimer);
234
- this.heartbeatTimer = null;
235
- }
248
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
236
249
  }
237
-
250
+
238
251
  _scheduleReconnect() {
239
252
  if (this.signal?.aborted || this.disconnecting) return;
240
253
  if (this.reconnectTimer) return;
@@ -242,50 +255,29 @@ export class CustomTunnelClient {
242
255
  this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
243
256
  return;
244
257
  }
245
-
246
258
  this.reconnectAttempts++;
247
259
  const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
248
-
249
260
  this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
250
-
251
261
  this.reconnectTimer = setTimeout(() => {
252
262
  this.reconnectTimer = null;
253
- this.connect().catch(() => {
254
- // Will schedule another reconnect
255
- });
263
+ this.connect().catch(() => {});
256
264
  }, delay);
257
265
  }
258
-
266
+
259
267
  _setState(phase, detail) {
260
- if (this.onStateChange) {
261
- this.onStateChange({ phase, detail });
262
- }
268
+ if (this.onStateChange) this.onStateChange({ phase, detail });
263
269
  }
264
-
270
+
265
271
  disconnect() {
266
- // Mark as disconnected (don't modify external signal)
267
272
  this.disconnecting = true;
268
273
  this._stopHeartbeat();
269
-
270
- if (this.reconnectTimer) {
271
- clearTimeout(this.reconnectTimer);
272
- this.reconnectTimer = null;
273
- }
274
-
275
- if (this.ws) {
276
- this.ws.close();
277
- this.ws = null;
278
- }
279
-
274
+ this._cleanupLocalWs();
275
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
276
+ if (this.ws) { this.ws.close(); this.ws = null; }
280
277
  this.connected = false;
281
278
  this.publicUrl = null;
282
-
283
- // Reject all pending requests
284
- for (const [id, pending] of this.pendingRequests) {
285
- pending.reject(new Error('Disconnected'));
286
- this.pendingRequests.delete(id);
287
- }
288
-
279
+ for (const [, p] of this.pendingRequests) p.reject(new Error('Disconnected'));
280
+ this.pendingRequests.clear();
289
281
  this.logger?.info('Tunnel disconnected');
290
282
  }
291
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "DSH 多渠道接入桥:局域网二维码、自建隧道、机器人集成",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",