@wenbin_wb/dsh-bridge 2.10.1 → 2.10.3

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.
@@ -1,426 +1,419 @@
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
- res.on('data', (c) => {
237
- chunks.push(c);
238
- total += c.length;
239
- if (total > MAX_RESPONSE_BYTES) {
240
- sendResponse(502, { 'content-type': 'text/plain' },
241
- `Response too large for tunnel (>${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB cap)`);
242
- res.destroy();
243
- }
244
- });
245
- res.on('error', () => {
246
- sendResponse(502, { 'content-type': 'text/plain' }, 'Response Error');
247
- });
248
- res.on('end', () => {
249
- if (responded) return;
250
- const respHeaders = Object.fromEntries(
251
- Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
252
- );
253
- let bodyBuf = Buffer.concat(chunks);
254
-
255
- // session.list 响应可能极大(数百会话 × contextHeaders = 60MB+)
256
- // 剥离 contextHeaders 和 contextTimeline(侧边栏列表不需要,打开会话时通过 WebSocket 实时获取)
257
- const cleanPath = String(path || '').split('?')[0];
258
- if (cleanPath === '/api/session.list' && res.statusCode === 200) {
259
- try {
260
- const json = JSON.parse(bodyBuf.toString('utf8'));
261
- if (json?.result?.ok && json.result.value?.items) {
262
- for (const item of json.result.value.items) {
263
- const proj = item?.projections?.values;
264
- if (proj) {
265
- delete proj.contextHeaders;
266
- delete proj.contextTimeline;
267
- }
268
- }
269
- bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
270
- respHeaders['content-length'] = String(bodyBuf.length);
271
- }
272
- } catch {} // 解析失败则原样发送
273
- }
274
-
275
- // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
276
- const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
277
- const alreadyEncoded = String(res.headers['content-encoding'] ?? '').toLowerCase();
278
- const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
279
- if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
280
- gzipAsync(bodyBuf).then((zipped) => {
281
- respHeaders['content-encoding'] = 'gzip';
282
- respHeaders['content-length'] = String(zipped.length);
283
- sendResponse(res.statusCode, respHeaders, zipped);
284
- }).catch(() => {
285
- sendResponse(res.statusCode, respHeaders, bodyBuf);
286
- });
287
- return;
288
- }
289
-
290
- sendResponse(res.statusCode, respHeaders, bodyBuf);
291
- });
292
- });
293
- req.on('error', err => {
294
- this._sendMessage({ type: 'response', requestId, statusCode: 502,
295
- headers: { 'content-type': 'text/plain' },
296
- body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
297
- });
298
- if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
299
- req.end();
300
- }
301
-
302
- // ── WebSocket 升级代理 ────────────────────────────────────────────────────
303
- // 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
304
- _handleWsOpen(msg) {
305
- const { wsId, path, headers } = msg;
306
-
307
- const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
308
- this.localWsSockets.set(wsId, sock);
309
-
310
- // 构造 HTTP Upgrade 请求
311
- const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
312
- delete reqHeaders['proxy-connection'];
313
- delete reqHeaders['proxy-authorization'];
314
- if (this.internalTunnelSecret) {
315
- reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
316
- }
317
-
318
- const lines = [`GET ${path || '/'} HTTP/1.1`];
319
- for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
320
- lines.push('', '');
321
- sock.write(lines.join('\r\n'));
322
-
323
- let headerBuf = '';
324
- let upgraded = false;
325
-
326
- sock.on('data', (chunk) => {
327
- if (upgraded) {
328
- this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
329
- return;
330
- }
331
- headerBuf += chunk.toString('binary');
332
- const sep = headerBuf.indexOf('\r\n\r\n');
333
- if (sep === -1) return;
334
-
335
- upgraded = true;
336
- const replyHeaders = {};
337
- const headerLines = headerBuf.slice(0, sep).split('\r\n');
338
- for (let i = 1; i < headerLines.length; i++) {
339
- const ci = headerLines[i].indexOf(':');
340
- if (ci > 0) {
341
- replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
342
- headerLines[i].slice(ci + 1).trim();
343
- }
344
- }
345
- this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
346
-
347
- // 握手后紧跟的帧数据
348
- const rest = headerBuf.slice(sep + 4);
349
- if (rest.length > 0) {
350
- this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
351
- }
352
- });
353
-
354
- sock.on('close', () => {
355
- this._sendMessage({ type: 'ws-close', wsId });
356
- this.localWsSockets.delete(wsId);
357
- });
358
- sock.on('error', (err) => {
359
- this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
360
- this._sendMessage({ type: 'ws-close', wsId });
361
- this.localWsSockets.delete(wsId);
362
- });
363
- }
364
-
365
- _handleWsFrame(msg) {
366
- const sock = this.localWsSockets.get(msg.wsId);
367
- if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
368
- }
369
-
370
- _handleWsClose(msg) {
371
- const sock = this.localWsSockets.get(msg.wsId);
372
- if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
373
- }
374
-
375
- _cleanupLocalWs() {
376
- for (const [, sock] of this.localWsSockets) sock.destroy();
377
- this.localWsSockets.clear();
378
- }
379
-
380
- // ── 工具方法 ──────────────────────────────────────────────────────────────
381
- _sendMessage(msg) {
382
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
383
- }
384
-
385
- _startHeartbeat() {
386
- this._stopHeartbeat();
387
- this.heartbeatTimer = setInterval(() => {
388
- if (this.connected) this._sendMessage({ type: 'ping' });
389
- }, HEARTBEAT_INTERVAL);
390
- }
391
-
392
- _stopHeartbeat() {
393
- if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
394
- }
395
-
396
- _scheduleReconnect() {
397
- if (this.signal?.aborted || this.disconnecting) return;
398
- if (this.reconnectTimer) return;
399
- if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
400
- this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
401
- return;
402
- }
403
- this.reconnectAttempts++;
404
- const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
405
- this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
406
- this.reconnectTimer = setTimeout(() => {
407
- this.reconnectTimer = null;
408
- this.connect().catch(() => {});
409
- }, delay);
410
- }
411
-
412
- _setState(phase, detail) {
413
- if (this.onStateChange) this.onStateChange({ phase, detail });
414
- }
415
-
416
- disconnect() {
417
- this.disconnecting = true;
418
- this._stopHeartbeat();
419
- this._cleanupLocalWs();
420
- if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
421
- if (this.ws) { this.ws.close(); this.ws = null; }
422
- this.connected = false;
423
- this.publicUrl = null;
424
- this.logger?.info('Tunnel disconnected');
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
+ import { stripSessionProjections } from './session-strip.js';
8
+
9
+ const gzipAsync = promisify(gzipCallback);
10
+
11
+ const HEARTBEAT_INTERVAL = 30000;
12
+ const RECONNECT_DELAY = 5000;
13
+ const MAX_RECONNECT_ATTEMPTS = 5;
14
+
15
+ // 单响应内存上限:隧道把整个 body 缓冲进内存再 base64 传输,无上限会在大文件
16
+ // 下载时把进程内存拖垮(一份 body 三份内存:chunks + Buffer + base64 字符串)
17
+ const MAX_RESPONSE_BYTES = 32 * 1024 * 1024; // 32MB
18
+
19
+ // 大响应 gzip 压缩阈值(超过此大小的可压缩响应将被 gzip)
20
+ const GZIP_THRESHOLD = 102400; // 100KB
21
+ // 可压缩的 content-type 前缀
22
+ const COMPRESSIBLE_TYPES = ['text/', 'application/json', 'application/javascript', 'application/xml'];
23
+
24
+ export class CustomTunnelClient {
25
+ constructor({ serverUrl, accessToken, localPort, internalTunnelSecret, signal, onStateChange, logger }) {
26
+ this.serverUrl = serverUrl;
27
+ this.accessToken = accessToken;
28
+ this.localPort = localPort;
29
+ this.internalTunnelSecret = internalTunnelSecret;
30
+ this.signal = signal;
31
+ this.onStateChange = onStateChange;
32
+ this.logger = logger;
33
+ this.ws = null;
34
+ this.publicUrl = null;
35
+ this.connected = false;
36
+ this.disconnecting = false;
37
+ this.reconnectAttempts = 0;
38
+ this.reconnectTimer = null;
39
+ this.heartbeatTimer = null;
40
+ this.localWsSockets = new Map(); // wsId -> net.Socket
41
+ }
42
+
43
+ async connect() {
44
+ if (this.connected) return;
45
+ this._setState('connecting', 'Connecting to tunnel server...');
46
+ try {
47
+ await this._connectWebSocket();
48
+ this._startHeartbeat();
49
+ this.reconnectAttempts = 0;
50
+ this._setState('ready', 'Tunnel established');
51
+ } catch (err) {
52
+ this._setState('error', err.message);
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ _connectWebSocket() {
58
+ return new Promise((resolve, reject) => {
59
+ if (this.signal?.aborted) return reject(new Error('Aborted'));
60
+
61
+ const url = new URL(this.serverUrl);
62
+ url.searchParams.set('token', this.accessToken);
63
+
64
+ this.ws = new WebSocket(url.toString(), {
65
+ handshakeTimeout: 10000,
66
+ perMessageDeflate: {
67
+ clientNoContextTakeover: true,
68
+ serverNoContextTakeover: true,
69
+ clientMaxWindowBits: 15,
70
+ serverMaxWindowBits: 15,
71
+ },
72
+ });
73
+
74
+ const onAbort = () => { this.ws?.terminate(); reject(new Error('Aborted')); };
75
+ this.signal?.addEventListener('abort', onAbort);
76
+
77
+ this.ws.on('open', () => {
78
+ this.signal?.removeEventListener('abort', onAbort);
79
+ this.logger?.info('Tunnel WebSocket connected');
80
+ });
81
+
82
+ this.ws.on('message', (data) => this._handleMessage(data));
83
+
84
+ this.ws.on('close', (code, reason) => {
85
+ this.connected = false;
86
+ this._stopHeartbeat();
87
+ this._cleanupLocalWs();
88
+ if (!this.signal?.aborted) {
89
+ this.logger?.warn('Tunnel disconnected: code=%d, reason=%s', code, reason.toString());
90
+ this._scheduleReconnect();
91
+ }
92
+ });
93
+
94
+ this.ws.on('error', (err) => {
95
+ this.logger?.error('Tunnel WebSocket error: %s', err.message);
96
+ if (!this.connected) {
97
+ this.signal?.removeEventListener('abort', onAbort);
98
+ reject(err);
99
+ }
100
+ });
101
+
102
+ const readyHandler = (data) => {
103
+ try {
104
+ const msg = JSON.parse(data.toString());
105
+ if (msg.type === 'ready' && msg.publicUrl) {
106
+ this.publicUrl = msg.publicUrl;
107
+ this.connected = true;
108
+ this.ws.off('message', readyHandler);
109
+ this.signal?.removeEventListener('abort', onAbort);
110
+ this.logger?.info('Tunnel ready: %s', this.publicUrl);
111
+ resolve();
112
+ }
113
+ } catch {}
114
+ };
115
+ this.ws.on('message', readyHandler);
116
+
117
+ setTimeout(() => {
118
+ if (!this.connected) {
119
+ this.signal?.removeEventListener('abort', onAbort);
120
+ this.ws?.terminate();
121
+ reject(new Error('Connection timeout'));
122
+ }
123
+ }, 15000);
124
+ });
125
+ }
126
+
127
+ _handleMessage(data) {
128
+ try {
129
+ const msg = JSON.parse(data.toString());
130
+ if (msg.type === 'request') this._handleHttpRequest(msg);
131
+ else if (msg.type === 'ws-open') this._handleWsOpen(msg);
132
+ else if (msg.type === 'ws-frame') this._handleWsFrame(msg);
133
+ else if (msg.type === 'ws-close') this._handleWsClose(msg);
134
+ // pong: ignore
135
+ } catch (err) {
136
+ this.logger?.error('Failed to parse tunnel message: %s', err.message);
137
+ }
138
+ }
139
+
140
+ // ── HTTP 请求代理 ─────────────────────────────────────────────────────────
141
+ _handleHttpRequest(msg) {
142
+ const { requestId, method, path, headers } = msg;
143
+ const SKIP = new Set(['transfer-encoding','connection','keep-alive',
144
+ 'proxy-authenticate','proxy-authorization','te','trailer','upgrade']);
145
+ const safeHeaders = Object.fromEntries(
146
+ Object.entries(headers ?? {}).filter(([k]) => !SKIP.has(k.toLowerCase()))
147
+ );
148
+
149
+ const reqHeaders = { ...safeHeaders, host: `127.0.0.1:${this.localPort}` };
150
+ if (this.internalTunnelSecret) {
151
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
152
+ }
153
+
154
+ const req = httpRequest({
155
+ host: '127.0.0.1', port: this.localPort,
156
+ method, path: path || '/',
157
+ headers: reqHeaders,
158
+ }, (res) => {
159
+ const contentType = String(res.headers['content-type'] ?? '');
160
+ const isSSE = contentType.includes('text/event-stream');
161
+ const chunks = [];
162
+
163
+ if (isSSE) {
164
+ // SSE 流式响应:隧道协议不支持流式,收集初始数据后立即返回
165
+ // 避免 SSE 永不 end 导致隧道服务器超时返回 504
166
+ let sseSent = false;
167
+ const sseTimer = setTimeout(() => {
168
+ if (sseSent) return;
169
+ sseSent = true;
170
+ const respHeaders = Object.fromEntries(
171
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
172
+ );
173
+ this._sendMessage({
174
+ type: 'response', requestId,
175
+ statusCode: res.statusCode, headers: respHeaders,
176
+ body: Buffer.concat(chunks).toString('base64'),
177
+ });
178
+ res.destroy();
179
+ }, 500);
180
+
181
+ res.on('data', (c) => {
182
+ if (sseSent) return;
183
+ chunks.push(c);
184
+ // 收到初始数据后立即发送(不等超时)
185
+ if (chunks.length >= 2) {
186
+ clearTimeout(sseTimer);
187
+ sseSent = true;
188
+ const respHeaders = Object.fromEntries(
189
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
190
+ );
191
+ this._sendMessage({
192
+ type: 'response', requestId,
193
+ statusCode: res.statusCode, headers: respHeaders,
194
+ body: Buffer.concat(chunks).toString('base64'),
195
+ });
196
+ res.destroy();
197
+ }
198
+ });
199
+ res.on('error', () => {
200
+ if (!sseSent) {
201
+ clearTimeout(sseTimer);
202
+ this._sendMessage({
203
+ type: 'response', requestId, statusCode: 502,
204
+ headers: { 'content-type': 'text/plain' },
205
+ body: Buffer.from('Response Error').toString('base64'),
206
+ });
207
+ }
208
+ });
209
+ res.on('end', () => {
210
+ if (!sseSent) {
211
+ clearTimeout(sseTimer);
212
+ sseSent = true;
213
+ const respHeaders = Object.fromEntries(
214
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
215
+ );
216
+ this._sendMessage({
217
+ type: 'response', requestId,
218
+ statusCode: res.statusCode, headers: respHeaders,
219
+ body: Buffer.concat(chunks).toString('base64'),
220
+ });
221
+ }
222
+ });
223
+ return;
224
+ }
225
+
226
+ let responded = false;
227
+ const sendResponse = (statusCode, headers, body) => {
228
+ if (responded) return;
229
+ responded = true;
230
+ this._sendMessage({
231
+ type: 'response', requestId, statusCode, headers,
232
+ body: Buffer.from(body).toString('base64'),
233
+ });
234
+ };
235
+
236
+ let total = 0;
237
+ const cleanPath = String(path || '').split('?')[0];
238
+ // session.list 会在 end 后剥离 contextHeaders 等大字段,跳过 data 阶段的大小检查
239
+ const skipSizeCheck = cleanPath === '/api/session.list';
240
+ res.on('data', (c) => {
241
+ chunks.push(c);
242
+ total += c.length;
243
+ if (!skipSizeCheck && total > MAX_RESPONSE_BYTES) {
244
+ sendResponse(502, { 'content-type': 'text/plain' },
245
+ `Response too large for tunnel (>${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB cap)`);
246
+ res.destroy();
247
+ }
248
+ });
249
+ res.on('error', () => {
250
+ sendResponse(502, { 'content-type': 'text/plain' }, 'Response Error');
251
+ });
252
+ res.on('end', () => {
253
+ if (responded) return;
254
+ const respHeaders = Object.fromEntries(
255
+ Object.entries(res.headers).filter(([k]) => !SKIP.has(k.toLowerCase()))
256
+ );
257
+ let bodyBuf = Buffer.concat(chunks);
258
+
259
+ // 剥离 session.list / session.history 的 contextHeaders 投影(无 UI 读取的大字段)
260
+ if (res.statusCode === 200) {
261
+ const { body, stripped } = stripSessionProjections(path, bodyBuf);
262
+ if (stripped) {
263
+ bodyBuf = body;
264
+ respHeaders['content-length'] = String(bodyBuf.length);
265
+ }
266
+ }
267
+
268
+ // 大响应 gzip 压缩:异步执行,避免 gzipSync 卡住整个事件循环
269
+ const respCt = String(res.headers['content-type'] ?? '').toLowerCase();
270
+ const alreadyEncoded = String(res.headers['content-encoding'] ?? '').toLowerCase();
271
+ const compressible = COMPRESSIBLE_TYPES.some((t) => respCt.startsWith(t));
272
+ if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
273
+ gzipAsync(bodyBuf).then((zipped) => {
274
+ respHeaders['content-encoding'] = 'gzip';
275
+ respHeaders['content-length'] = String(zipped.length);
276
+ sendResponse(res.statusCode, respHeaders, zipped);
277
+ }).catch(() => {
278
+ sendResponse(res.statusCode, respHeaders, bodyBuf);
279
+ });
280
+ return;
281
+ }
282
+
283
+ sendResponse(res.statusCode, respHeaders, bodyBuf);
284
+ });
285
+ });
286
+ req.on('error', err => {
287
+ this._sendMessage({ type: 'response', requestId, statusCode: 502,
288
+ headers: { 'content-type': 'text/plain' },
289
+ body: Buffer.from(`Bad Gateway: ${err.message}`).toString('base64') });
290
+ });
291
+ if (msg.body) req.write(Buffer.from(msg.body, 'base64'));
292
+ req.end();
293
+ }
294
+
295
+ // ── WebSocket 升级代理 ────────────────────────────────────────────────────
296
+ // 服务端通知有浏览器要建 WebSocket,用裸 TCP 连本地 DSH 完成握手再转发帧
297
+ _handleWsOpen(msg) {
298
+ const { wsId, path, headers } = msg;
299
+
300
+ const sock = netConnect({ host: '127.0.0.1', port: this.localPort });
301
+ this.localWsSockets.set(wsId, sock);
302
+
303
+ // 构造 HTTP Upgrade 请求
304
+ const reqHeaders = { ...headers, host: `127.0.0.1:${this.localPort}` };
305
+ delete reqHeaders['proxy-connection'];
306
+ delete reqHeaders['proxy-authorization'];
307
+ if (this.internalTunnelSecret) {
308
+ reqHeaders['x-dsh-internal-tunnel'] = this.internalTunnelSecret;
309
+ }
310
+
311
+ const lines = [`GET ${path || '/'} HTTP/1.1`];
312
+ for (const [k, v] of Object.entries(reqHeaders)) lines.push(`${k}: ${v}`);
313
+ lines.push('', '');
314
+ sock.write(lines.join('\r\n'));
315
+
316
+ let headerBuf = '';
317
+ let upgraded = false;
318
+
319
+ sock.on('data', (chunk) => {
320
+ if (upgraded) {
321
+ this._sendMessage({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
322
+ return;
323
+ }
324
+ headerBuf += chunk.toString('binary');
325
+ const sep = headerBuf.indexOf('\r\n\r\n');
326
+ if (sep === -1) return;
327
+
328
+ upgraded = true;
329
+ const replyHeaders = {};
330
+ const headerLines = headerBuf.slice(0, sep).split('\r\n');
331
+ for (let i = 1; i < headerLines.length; i++) {
332
+ const ci = headerLines[i].indexOf(':');
333
+ if (ci > 0) {
334
+ replyHeaders[headerLines[i].slice(0, ci).trim().toLowerCase()] =
335
+ headerLines[i].slice(ci + 1).trim();
336
+ }
337
+ }
338
+ this._sendMessage({ type: 'ws-accept', wsId, replyHeaders });
339
+
340
+ // 握手后紧跟的帧数据
341
+ const rest = headerBuf.slice(sep + 4);
342
+ if (rest.length > 0) {
343
+ this._sendMessage({ type: 'ws-frame', wsId, data: Buffer.from(rest, 'binary').toString('base64') });
344
+ }
345
+ });
346
+
347
+ sock.on('close', () => {
348
+ this._sendMessage({ type: 'ws-close', wsId });
349
+ this.localWsSockets.delete(wsId);
350
+ });
351
+ sock.on('error', (err) => {
352
+ this.logger?.error('Local WS socket error wsId=%s: %s', wsId, err.message);
353
+ this._sendMessage({ type: 'ws-close', wsId });
354
+ this.localWsSockets.delete(wsId);
355
+ });
356
+ }
357
+
358
+ _handleWsFrame(msg) {
359
+ const sock = this.localWsSockets.get(msg.wsId);
360
+ if (sock && !sock.destroyed) sock.write(Buffer.from(msg.data, 'base64'));
361
+ }
362
+
363
+ _handleWsClose(msg) {
364
+ const sock = this.localWsSockets.get(msg.wsId);
365
+ if (sock) { sock.destroy(); this.localWsSockets.delete(msg.wsId); }
366
+ }
367
+
368
+ _cleanupLocalWs() {
369
+ for (const [, sock] of this.localWsSockets) sock.destroy();
370
+ this.localWsSockets.clear();
371
+ }
372
+
373
+ // ── 工具方法 ──────────────────────────────────────────────────────────────
374
+ _sendMessage(msg) {
375
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
376
+ }
377
+
378
+ _startHeartbeat() {
379
+ this._stopHeartbeat();
380
+ this.heartbeatTimer = setInterval(() => {
381
+ if (this.connected) this._sendMessage({ type: 'ping' });
382
+ }, HEARTBEAT_INTERVAL);
383
+ }
384
+
385
+ _stopHeartbeat() {
386
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
387
+ }
388
+
389
+ _scheduleReconnect() {
390
+ if (this.signal?.aborted || this.disconnecting) return;
391
+ if (this.reconnectTimer) return;
392
+ if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
393
+ this._setState('error', `Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
394
+ return;
395
+ }
396
+ this.reconnectAttempts++;
397
+ const delay = RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1);
398
+ this._setState('reconnecting', `Reconnecting in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
399
+ this.reconnectTimer = setTimeout(() => {
400
+ this.reconnectTimer = null;
401
+ this.connect().catch(() => {});
402
+ }, delay);
403
+ }
404
+
405
+ _setState(phase, detail) {
406
+ if (this.onStateChange) this.onStateChange({ phase, detail });
407
+ }
408
+
409
+ disconnect() {
410
+ this.disconnecting = true;
411
+ this._stopHeartbeat();
412
+ this._cleanupLocalWs();
413
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
414
+ if (this.ws) { this.ws.close(); this.ws = null; }
415
+ this.connected = false;
416
+ this.publicUrl = null;
417
+ this.logger?.info('Tunnel disconnected');
418
+ }
419
+ }