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