@qzsy/dsh-bridge 3.2.0 → 3.2.1

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,764 +1,769 @@
1
- // SaaS 隧道客户端(控制通道协议 v2)——独立实现,不继承任何第三方隧道类。
2
- //
3
- // 契约见 docs/architecture/saas-tunnel.md §6 与 packages/tunnel-protocol/src/index.ts:
4
- // C→S hello → S→C hello-ack → 转发 request/response(-start|chunk|end) 与 ws-*;
5
- // v2 新增 heartbeat/heartbeat-ack、route-update、drain;未知 type 静默忽略。
6
- // close 4001/4003/4004 为终态;4000/4002 可重连。
7
- import { WebSocket } from 'ws';
8
- import { request as httpRequest } from 'node:http';
9
- import { connect as netConnect } from 'node:net';
10
-
11
- export const SAAS_HEARTBEAT_INTERVAL_MS = 30_000;
12
- export const SAAS_HEARTBEAT_MAX_MISSED = 3;
13
- export const SAAS_HANDSHAKE_TIMEOUT_MS = 15_000;
14
- export const SAAS_RECONNECT_BASE_DELAY_MS = 1000;
15
- export const SAAS_RECONNECT_MAX_DELAY_MS = 60_000;
16
- export const SAAS_DEFAULT_GATEWAY_PATH = '/_gw/connect';
17
- export const SAAS_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
18
- export const SAAS_TERMINAL_CLOSE_CODES = new Set([4001, 4003, 4004]);
19
- export const SAAS_TERMINAL_ERROR_CODES = new Set(['auth_failed', 'revoked', 'bad_request', 'unsupported_version', 'time_exhausted']);
20
-
21
- const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
22
- const TUNNEL_HEADER = 'x-dsh-internal-tunnel';
23
-
24
- export class SaasTunnelError extends Error {
25
- constructor(code, message, { terminal = false, closeCode = 0, cause } = {}) {
26
- super(message, cause === undefined ? undefined : { cause });
27
- this.name = 'SaasTunnelError';
28
- this.code = code;
29
- this.terminal = terminal;
30
- this.closeCode = closeCode;
31
- }
32
- }
33
-
34
- export function buildSaasConnectUrl({ gatewayUrl, gatewayPath = SAAS_DEFAULT_GATEWAY_PATH, deviceToken } = {}) {
35
- let url;
36
- try {
37
- url = new URL(String(gatewayUrl ?? '').trim());
38
- } catch {
39
- throw new SaasTunnelError('invalid_gateway_url', `SaaS 网关地址不合法: ${gatewayUrl}`, { terminal: true });
40
- }
41
- if (url.protocol === 'http:') url.protocol = 'ws:';
42
- else if (url.protocol === 'https:') url.protocol = 'wss:';
43
- else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
44
- throw new SaasTunnelError('invalid_gateway_url', 'SaaS 网关地址只支持 ws/wss/http/https', { terminal: true });
45
- }
46
- let path = String(gatewayPath || SAAS_DEFAULT_GATEWAY_PATH);
47
- if (!path.startsWith('/')) path = '/' + path;
48
- const basePath = url.pathname.replace(/\/+$/, '');
49
- url.pathname = (basePath && basePath !== '/' ? basePath : '') + path;
50
- url.search = '';
51
- url.hash = '';
52
- if (deviceToken) url.searchParams.set('device_token', String(deviceToken));
53
- return url.toString();
54
- }
55
-
56
- export function pickRoutePublicUrl(routes) {
57
- const list = Array.isArray(routes) ? routes.filter((route) => route && typeof route === 'object') : [];
58
- const host = list.find((route) => route.kind === 'host' && typeof route.url === 'string' && route.url);
59
- const any = list.find((route) => typeof route.url === 'string' && route.url);
60
- return String((host ?? any)?.url ?? '');
61
- }
62
-
63
- function sanitizeHeaders(headers) {
64
- const out = {};
65
- for (const [name, value] of Object.entries(headers ?? {})) {
66
- if (HOP_BY_HOP.has(name.toLowerCase()) || value === undefined) continue;
67
- out[name.toLowerCase()] = value;
68
- }
69
- return out;
70
- }
71
-
72
- export class SaasTunnelClient {
73
- constructor(options = {}) {
74
- const {
75
- gatewayUrl = '',
76
- gatewayPath = SAAS_DEFAULT_GATEWAY_PATH,
77
- deviceToken = null,
78
- tokenProvider = null,
79
- localPort,
80
- internalTunnelSecret = '',
81
- protocol = 'v2',
82
- heartbeatIntervalMs = SAAS_HEARTBEAT_INTERVAL_MS,
83
- heartbeatMaxMissed = SAAS_HEARTBEAT_MAX_MISSED,
84
- maxResponseBytes = SAAS_MAX_RESPONSE_BYTES,
85
- reconnectBaseDelayMs = SAAS_RECONNECT_BASE_DELAY_MS,
86
- reconnectMaxDelayMs = SAAS_RECONNECT_MAX_DELAY_MS,
87
- maxReconnectAttempts = Number.POSITIVE_INFINITY,
88
- random = Math.random,
89
- deviceInfo = {},
90
- sseStreaming = true,
91
- onHelloAck = null,
92
- onStateChange = null,
93
- onRouteUpdate = null,
94
- onDrain = null,
95
- onDrainComplete = null,
96
- logger = console,
97
- } = options;
98
-
99
- if (!localPort) throw new Error('localPort is required');
100
- this.gatewayUrl = String(gatewayUrl);
101
- this.gatewayPath = gatewayPath || SAAS_DEFAULT_GATEWAY_PATH;
102
- this.deviceToken = deviceToken;
103
- this.tokenProvider = typeof tokenProvider === 'function'
104
- ? tokenProvider
105
- : (deviceToken ? async () => deviceToken : null);
106
- this.localPort = Number(localPort);
107
- this.internalTunnelSecret = String(internalTunnelSecret ?? '');
108
- this.protocol = protocol === 'auto' ? 'auto' : 'v2';
109
- this.protocolVersion = 2;
110
- this.heartbeatIntervalMs = Math.max(10, Number(heartbeatIntervalMs) || SAAS_HEARTBEAT_INTERVAL_MS);
111
- this.heartbeatMaxMissed = Math.max(1, Number(heartbeatMaxMissed) || SAAS_HEARTBEAT_MAX_MISSED);
112
- this.maxResponseBytes = Math.max(1024, Number(maxResponseBytes) || SAAS_MAX_RESPONSE_BYTES);
113
- this.reconnectBaseDelayMs = Math.max(10, Number(reconnectBaseDelayMs) || SAAS_RECONNECT_BASE_DELAY_MS);
114
- this.reconnectMaxDelayMs = Math.max(this.reconnectBaseDelayMs, Number(reconnectMaxDelayMs) || SAAS_RECONNECT_MAX_DELAY_MS);
115
- this.maxReconnectAttempts = Number.isFinite(maxReconnectAttempts) ? Math.max(0, maxReconnectAttempts) : Number.POSITIVE_INFINITY;
116
- this.random = typeof random === 'function' ? random : Math.random;
117
- this.deviceInfo = deviceInfo ?? {};
118
- this.sseStreaming = sseStreaming !== false;
119
- this.onHelloAck = onHelloAck;
120
- this.onStateChange = onStateChange;
121
- this.onRouteUpdate = onRouteUpdate;
122
- this.onDrain = onDrain;
123
- this.onDrainComplete = onDrainComplete;
124
- this.logger = logger ?? console;
125
-
126
- this._connected = false;
127
- this._state = 'idle';
128
- this._stateDetail = '';
129
- this._terminal = false;
130
- this._draining = false;
131
- this._manuallyClosed = false;
132
- this._connecting = false;
133
- this._ws = null;
134
- this._awaitingHandshake = false;
135
- this._handshakeResolve = null;
136
- this._handshakeReject = null;
137
- this._handshakeTimer = null;
138
- this._heartbeatTimer = null;
139
- this._heartbeatAckPending = false;
140
- this._missedHeartbeatAcks = 0;
141
- this._reconnectTimer = null;
142
- this._reconnectAttempts = 0;
143
- this._routes = [];
144
- this._routeRev = 0;
145
- this.tunnel = null;
146
- this.publicUrl = '';
147
- this.lastError = null;
148
- this.lastAckAt = null;
149
- this.limits = null;
150
- this.features = null;
151
- this.serverTime = null;
152
- this.gateSecret = '';
153
- this._inFlight = new Set();
154
- this._localWs = new Map();
155
- this._bytesIn = 0;
156
- this._bytesOut = 0;
157
- this._drainTimer = null;
158
- this._suppressReconnect = false;
159
- }
160
-
161
- get connected() { return this._connected; }
162
- get state() { return this._state; }
163
- get stateDetail() { return this._stateDetail; }
164
- get draining() { return this._draining; }
165
- get terminal() { return this._terminal; }
166
- get routes() { return this._routes; }
167
- get routeRev() { return this._routeRev; }
168
- get reconnectAttempts() { return this._reconnectAttempts; }
169
-
170
- _setState(state, detail = '') {
171
- this._state = state;
172
- this._stateDetail = detail;
173
- try { this.onStateChange?.({ state, detail, connected: this._connected, publicUrl: this.publicUrl }); } catch {}
174
- }
175
-
176
- async connect() {
177
- if (this._connected || this._connecting || this._manuallyClosed) return;
178
- this._connecting = true;
179
- this._setState('connecting', '正在连接 SaaS 网关…');
180
- try {
181
- const token = await this._acquireToken();
182
- await this._openSocket(token);
183
- this._connected = true;
184
- this._terminal = false;
185
- this._manuallyClosed = false;
186
- this._reconnectAttempts = 0;
187
- this.lastError = null;
188
- this.lastAckAt = Date.now();
189
- this._startHeartbeat();
190
- this._setState('ready', this.publicUrl ? `SaaS 隧道已建立 (${this.publicUrl})` : 'SaaS 隧道已建立');
191
- } catch (err) {
192
- this._connected = false;
193
- this._stopHeartbeat();
194
- const detail = err?.message ?? String(err);
195
- this.lastError = detail;
196
- const terminal = err?.terminal === true || this._terminal;
197
- if (terminal) {
198
- this._terminal = true;
199
- this._setState('error', detail);
200
- } else if (!this._manuallyClosed && !this._draining) {
201
- this._scheduleReconnect(detail);
202
- }
203
- throw err;
204
- } finally {
205
- this._connecting = false;
206
- }
207
- }
208
-
209
- async _acquireToken() {
210
- if (!this.tokenProvider) {
211
- throw new SaasTunnelError('missing_device_credentials', '尚未绑定设备,请先在面板获取设备码并绑定', { terminal: true });
212
- }
213
- const result = await this.tokenProvider();
214
- const token = typeof result === 'string' ? result : result?.token;
215
- if (!token) throw new SaasTunnelError('missing_device_token', '设备令牌获取失败,请重新绑定', { terminal: true });
216
- if (result && typeof result === 'object') {
217
- if (result.gateway?.url) this.gatewayUrl = result.gateway.url;
218
- if (result.gateway?.path) this.gatewayPath = result.gateway.path;
219
- if (result.tunnel) this.tunnel = result.tunnel;
220
- }
221
- this.deviceToken = String(token);
222
- return this.deviceToken;
223
- }
224
-
225
- _openSocket(deviceToken) {
226
- return new Promise((resolve, reject) => {
227
- let ws;
228
- try {
229
- ws = new WebSocket(buildSaasConnectUrl({
230
- gatewayUrl: this.gatewayUrl,
231
- gatewayPath: this.gatewayPath,
232
- deviceToken,
233
- }), {
234
- handshakeTimeout: 10_000,
235
- headers: { authorization: `Bearer ${deviceToken}` },
236
- perMessageDeflate: false,
237
- });
238
- } catch (err) {
239
- reject(new SaasTunnelError('invalid_gateway_url', err?.message ?? String(err), { terminal: true, cause: err }));
240
- return;
241
- }
242
- this._ws = ws;
243
- this._awaitingHandshake = true;
244
- this._handshakeResolve = resolve;
245
- this._handshakeReject = reject;
246
- this._handshakeTimer = setTimeout(() => {
247
- if (!this._awaitingHandshake) return;
248
- try { ws.terminate(); } catch {}
249
- this._settleHandshake(new SaasTunnelError('handshake_timeout', 'SaaS 网关握手超时(15s 内未收到 hello-ack)'));
250
- }, SAAS_HANDSHAKE_TIMEOUT_MS);
251
-
252
- ws.on('open', () => {
253
- this.logger?.info?.('SaaS tunnel WebSocket open');
254
- this._sendHello();
255
- });
256
- ws.on('message', (data) => this._onMessage(data));
257
- ws.on('close', (code, reason) => this._onClose(code, reason));
258
- ws.on('error', (err) => {
259
- this.logger?.error?.(`SaaS tunnel WebSocket error: ${err?.message ?? err}`);
260
- if (this._awaitingHandshake) this._settleHandshake(err instanceof Error ? err : new Error(String(err)));
261
- });
262
- });
263
- }
264
-
265
- _settleHandshake(error) {
266
- if (!this._awaitingHandshake) return;
267
- this._awaitingHandshake = false;
268
- if (this._handshakeTimer) {
269
- clearTimeout(this._handshakeTimer);
270
- this._handshakeTimer = null;
271
- }
272
- const resolve = this._handshakeResolve;
273
- const reject = this._handshakeReject;
274
- this._handshakeResolve = null;
275
- this._handshakeReject = null;
276
- if (error) reject?.(error);
277
- else resolve?.();
278
- }
279
-
280
- _sendHello() {
281
- const device = {};
282
- for (const key of ['name', 'platform', 'hostname', 'pluginVersion', 'dshVersion']) {
283
- const value = this.deviceInfo?.[key];
284
- if (value !== undefined && value !== null && String(value).trim()) device[key] = String(value).trim();
285
- }
286
- if (!device.name) device.name = device.hostname || 'DSH Remote Tunnel';
287
- if (!device.platform) device.platform = process.platform ?? 'unknown';
288
- this._send({
289
- type: 'hello',
290
- v: 2,
291
- device,
292
- caps: { sseStreaming: this.sseStreaming, wsFrames: true, maxResponseBytes: this.maxResponseBytes },
293
- ts: Math.floor(Date.now() / 1000),
294
- });
295
- }
296
-
297
- _onMessage(data) {
298
- let msg;
299
- try {
300
- msg = JSON.parse(typeof data === 'string' ? data : data.toString());
301
- } catch {
302
- return;
303
- }
304
- if (!msg || typeof msg !== 'object') return;
305
-
306
- if (msg.type === 'hello-ack') {
307
- if (this._awaitingHandshake) this._handleHelloAck(msg);
308
- else if (msg.ok === false) this.logger?.warn?.('ignoring duplicate hello-ack');
309
- return;
310
- }
311
- if (msg.type === 'ready' && this._awaitingHandshake && this.protocol === 'auto') {
312
- this.protocolVersion = 1;
313
- this.publicUrl = String(msg.publicUrl ?? '');
314
- this._connected = true;
315
- this._settleHandshake();
316
- return;
317
- }
318
- switch (msg.type) {
319
- case 'heartbeat-ack':
320
- this._heartbeatAckPending = false;
321
- this._missedHeartbeatAcks = 0;
322
- this.lastAckAt = Date.now();
323
- if (msg.serverTime) this.serverTime = msg.serverTime;
324
- if (Array.isArray(msg.actions) && msg.actions.includes('drain') && !this._draining) {
325
- this._startDrain({ deadline: Date.now() + this.heartbeatIntervalMs * 3 });
326
- }
327
- return;
328
- case 'route-update':
329
- this._routeRev = Number(msg.rev) || this._routeRev;
330
- this._applyRoutes(msg.routes);
331
- try { this.onRouteUpdate?.({ routes: this._routes, rev: this._routeRev, publicUrl: this.publicUrl }); } catch {}
332
- return;
333
- case 'drain':
334
- this._handleDrain(msg);
335
- return;
336
- case 'ready':
337
- if (this._connected && !this._routes.some((route) => route.kind === 'host')) {
338
- if (msg.publicUrl) this.publicUrl = String(msg.publicUrl);
339
- }
340
- return;
341
- case 'ping':
342
- this._send({ type: 'pong', ts: Date.now() });
343
- return;
344
- case 'request':
345
- this._handleRequest(msg);
346
- return;
347
- case 'ws-open':
348
- this._handleWsOpen(msg);
349
- return;
350
- case 'ws-frame':
351
- this._handleWsFrame(msg);
352
- return;
353
- case 'ws-close':
354
- this._handleWsClose(msg);
355
- return;
356
- case 'pong':
357
- return;
358
- default:
359
- // 未知 type 必须静默忽略(v1 前后兼容)
360
- return;
361
- }
362
- }
363
-
364
- _handleHelloAck(msg) {
365
- if (msg.ok === false) {
366
- const code = String(msg.error?.code ?? 'bad_request');
367
- const message = String(msg.error?.message ?? '');
368
- const detail = `SaaS 网关拒绝握手: ${message || code} (${code})`;
369
- const terminal = SAAS_TERMINAL_ERROR_CODES.has(code);
370
- this.lastError = detail;
371
- if (terminal) this._terminal = true;
372
- this._setState(terminal ? 'error' : 'connecting', detail);
373
- try { this._ws?.terminate(); } catch {}
374
- this._settleHandshake(new SaasTunnelError(code, detail, { terminal }));
375
- return;
376
- }
377
- this.protocolVersion = Number(msg.v) || 2;
378
- if (msg.tunnel) this.tunnel = { publicId: String(msg.tunnel.publicId ?? ''), name: String(msg.tunnel.name ?? '') };
379
- this.limits = msg.limits ?? null;
380
- this.features = msg.features ?? null;
381
- // 网关为每条控制连接随机下发信任密钥;gate 用它验 x-qzsy-gate 的 HMAC。
382
- this.gateSecret = typeof msg.gate?.secret === 'string' && /^[0-9a-f]{64}$/i.test(msg.gate.secret)
383
- ? msg.gate.secret.toLowerCase()
384
- : '';
385
- this.serverTime = msg.serverTime ?? null;
386
- if (this.limits?.heartbeatMs) this.heartbeatIntervalMs = Math.max(10, Number(this.limits.heartbeatMs) || this.heartbeatIntervalMs);
387
- if (this.limits?.maxResponseBytes) this.maxResponseBytes = Math.max(1024, Number(this.limits.maxResponseBytes) || this.maxResponseBytes);
388
- this._applyRoutes(msg.routes);
389
- this._connected = true;
390
- this._reconnectAttempts = 0;
391
- this.lastAckAt = Date.now();
392
- try { this.onHelloAck?.(msg); } catch {}
393
- this._settleHandshake();
394
- }
395
-
396
- _applyRoutes(routes) {
397
- const list = Array.isArray(routes) ? routes.filter((route) => route && typeof route === 'object') : [];
398
- this._routes = list;
399
- const picked = pickRoutePublicUrl(list);
400
- if (picked) this.publicUrl = picked;
401
- }
402
-
403
- _handleDrain(msg) {
404
- let deadline = Number(msg?.deadline);
405
- if (!Number.isFinite(deadline) || deadline <= 0) deadline = Date.now() + this.heartbeatIntervalMs * 3;
406
- else if (deadline < 1e12) deadline *= 1000;
407
- this._startDrain({ deadline });
408
- }
409
-
410
- _startDrain({ deadline }) {
411
- if (this._draining) return;
412
- this._draining = true;
413
- this._setState('draining', 'SaaS 网关下线中,等待在途请求结束');
414
- try { this.onDrain?.({ deadline }); } catch {}
415
- const finish = () => this._finishDrain();
416
- if (this._inFlight.size === 0) {
417
- this._drainTimer = setTimeout(finish, 20);
418
- } else {
419
- const poll = () => {
420
- if (!this._draining) return;
421
- if (this._inFlight.size === 0 || Date.now() >= deadline) return finish();
422
- this._drainTimer = setTimeout(poll, Math.min(100, Math.max(10, deadline - Date.now())));
423
- };
424
- this._drainTimer = setTimeout(poll, 20);
425
- setTimeout(() => { if (this._draining) finish(); }, Math.max(0, deadline - Date.now()) + 1000).unref?.();
426
- }
427
- }
428
-
429
- _finishDrain() {
430
- if (!this._draining) return;
431
- this._draining = false;
432
- this.gateSecret = '';
433
- this._suppressReconnect = true;
434
- if (this._drainTimer) { clearTimeout(this._drainTimer); this._drainTimer = null; }
435
- this._connected = false;
436
- this._stopHeartbeat();
437
- this._cleanupLocalWs();
438
- this._inFlight.clear();
439
- this.lastError = 'SaaS 网关已下线(drain),隧道已优雅断开';
440
- this._setState('closed', this.lastError);
441
- try { this.onDrainComplete?.({ detail: this.lastError }); } catch {}
442
- this._closeSocket(1000, 'drain');
443
- }
444
-
445
- _handleRequest(msg) {
446
- const requestId = String(msg?.requestId ?? '');
447
- if (!requestId) return;
448
- if (this._draining) {
449
- this._send({ type: 'response', requestId, statusCode: 503, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: Buffer.from('tunnel is draining').toString('base64') });
450
- return;
451
- }
452
- this._inFlight.add(requestId);
453
- if (typeof msg.body === 'string') this._bytesIn += Math.floor((msg.body.length * 3) / 4);
454
- const headers = sanitizeHeaders(msg.headers);
455
- headers.host = `127.0.0.1:${this.localPort}`;
456
- if (this.internalTunnelSecret) headers[TUNNEL_HEADER] = this.internalTunnelSecret;
457
-
458
- const upstream = httpRequest({
459
- host: '127.0.0.1',
460
- port: this.localPort,
461
- method: String(msg.method ?? 'GET'),
462
- path: String(msg.path ?? '/'),
463
- headers,
464
- }, (res) => {
465
- const responseHeaders = sanitizeHeaders(res.headers);
466
- const isSse = String(res.headers['content-type'] ?? '').toLowerCase().includes('text/event-stream');
467
- if (isSse && this.sseStreaming) {
468
- this._send({ type: 'response-start', requestId, statusCode: res.statusCode ?? 200, headers: responseHeaders });
469
- res.on('data', (chunk) => {
470
- this._bytesOut += chunk.length;
471
- this._send({ type: 'response-chunk', requestId, body: Buffer.from(chunk).toString('base64') });
472
- });
473
- res.on('end', () => {
474
- this._send({ type: 'response-end', requestId });
475
- this._inFlight.delete(requestId);
476
- });
477
- res.on('error', () => {
478
- this._send({ type: 'response-end', requestId });
479
- this._inFlight.delete(requestId);
480
- });
481
- return;
482
- }
483
- const chunks = [];
484
- let total = 0;
485
- let finished = false;
486
- const fail = (code, text) => {
487
- if (finished) return;
488
- finished = true;
489
- this._inFlight.delete(requestId);
490
- this._send({ type: 'response', requestId, statusCode: code, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: Buffer.from(text).toString('base64') });
491
- };
492
- res.on('data', (chunk) => {
493
- total += chunk.length;
494
- if (total > this.maxResponseBytes) {
495
- fail(502, `Response too large for tunnel (>${Math.round(this.maxResponseBytes / 1024 / 1024)}MB cap)`);
496
- res.destroy();
497
- return;
498
- }
499
- chunks.push(chunk);
500
- });
501
- res.on('end', () => {
502
- if (finished) return;
503
- finished = true;
504
- this._inFlight.delete(requestId);
505
- const body = Buffer.concat(chunks).toString('base64');
506
- this._bytesOut += Buffer.concat(chunks).length;
507
- this._send({ type: 'response', requestId, statusCode: res.statusCode ?? 200, headers: responseHeaders, body });
508
- });
509
- res.on('error', () => fail(502, 'Response Error'));
510
- });
511
- upstream.on('error', (err) => {
512
- this._inFlight.delete(requestId);
513
- this._send({ type: 'response', requestId, statusCode: 502, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: Buffer.from(`Bad Gateway: ${err?.message ?? err}`).toString('base64') });
514
- });
515
- if (typeof msg.body === 'string' && msg.body) upstream.write(Buffer.from(msg.body, 'base64'));
516
- upstream.end();
517
- }
518
-
519
- // ── WebSocket 原始帧转发 ────────────────────────────────────────────────
520
- // 网关契约(apps/gateway/src/proxy-ws.ts):ws-frame 的 data 是**原始 WebSocket 帧字节**
521
- // (含 opcode/mask/分片),不是消息 payload。因此这里用裸 TCP 完成 Upgrade 握手后
522
- // 原样透传字节,绝不能再经 ws 库二次成帧。
523
- _handleWsOpen(msg) {
524
- const wsId = String(msg?.wsId ?? '');
525
- if (!wsId) return;
526
- if (this._draining) {
527
- this._send({ type: 'ws-accept', wsId, statusCode: 503, statusMessage: 'Service Unavailable', replyHeaders: { 'content-type': 'text/plain' } });
528
- return;
529
- }
530
- // Upgrade 请求必须原样保留 connection/upgrade/sec-websocket-*(不能套用普通
531
- // HTTP 请求的逐跳过滤),只重写 Host 并补本地隧道标记。
532
- const requestHeaders = {};
533
- for (const [name, value] of Object.entries(msg.headers ?? {})) {
534
- const lower = name.toLowerCase();
535
- if (lower === 'host' || lower === TUNNEL_HEADER || value === undefined) continue;
536
- requestHeaders[lower] = value;
537
- }
538
- requestHeaders.host = `127.0.0.1:${this.localPort}`;
539
- // 网关按 hop-by-hop 规则剥掉了 Upgrade/Connection,这里必须补回,否则本机
540
- // WebSocket 升级请求不合法(表现为浏览器收到非 101)。
541
- requestHeaders.connection = requestHeaders.connection ?? 'Upgrade';
542
- requestHeaders.upgrade = requestHeaders.upgrade ?? 'websocket';
543
- if (this.internalTunnelSecret) requestHeaders[TUNNEL_HEADER] = this.internalTunnelSecret;
544
-
545
- const socket = netConnect({ host: '127.0.0.1', port: this.localPort });
546
- const state = { socket, buffer: Buffer.alloc(0), upgraded: false, closed: false, headerSent: false };
547
- this._localWs.set(wsId, state);
548
-
549
- const fail = (code, message) => {
550
- if (state.closed) return;
551
- state.closed = true;
552
- this._send({ type: 'ws-accept', wsId, statusCode: code, statusMessage: message, replyHeaders: { 'content-type': 'text/plain' } });
553
- try { socket.destroy(); } catch {}
554
- this._localWs.delete(wsId);
555
- };
556
-
557
- socket.on('connect', () => {
558
- const path = String(msg.path ?? '/');
559
- const lines = [`GET ${path.startsWith('/') ? path : '/' + path} HTTP/1.1`];
560
- for (const [name, value] of Object.entries(requestHeaders)) {
561
- if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`);
562
- else if (value !== undefined) lines.push(`${name}: ${value}`);
563
- }
564
- lines.push('', '');
565
- state.headerSent = true;
566
- socket.write(lines.join('\r\n'));
567
- });
568
-
569
- socket.on('data', (chunk) => {
570
- if (state.closed) return;
571
- if (!state.upgraded) {
572
- state.buffer = Buffer.concat([state.buffer, chunk]);
573
- const separator = state.buffer.indexOf('\r\n\r\n');
574
- if (separator === -1) {
575
- if (state.buffer.length > 64 * 1024) fail(502, 'Bad Gateway');
576
- return;
577
- }
578
- const head = state.buffer.subarray(0, separator).toString('latin1');
579
- const rest = state.buffer.subarray(separator + 4);
580
- state.buffer = Buffer.alloc(0);
581
- state.upgraded = true;
582
- const [statusLine, ...headerLines] = head.split('\r\n');
583
- const match = /^HTTP\/1\.[01]\s+(\d{3})\s*(.*)$/.exec(statusLine ?? '');
584
- const statusCode = match ? Number(match[1]) : 101;
585
- const statusMessage = match ? match[2] : '';
586
- const replyHeaders = {};
587
- for (const line of headerLines) {
588
- const index = line.indexOf(':');
589
- if (index <= 0) continue;
590
- replyHeaders[line.slice(0, index).trim().toLowerCase()] = line.slice(index + 1).trim();
591
- }
592
- if (statusCode !== 101) {
593
- state.closed = true;
594
- this.logger?.warn?.('local WS upgrade rejected: %s %s', statusCode, statusMessage);
595
- this._send({ type: 'ws-accept', wsId, statusCode, statusMessage, replyHeaders });
596
- try { socket.destroy(); } catch {}
597
- this._localWs.delete(wsId);
598
- return;
599
- }
600
- this._send({ type: 'ws-accept', wsId, statusCode: 101, replyHeaders });
601
- if (rest.length > 0) this._send({ type: 'ws-frame', wsId, data: rest.toString('base64') });
602
- return;
603
- }
604
- this._send({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
605
- });
606
-
607
- const finish = () => {
608
- if (state.closed) return;
609
- state.closed = true;
610
- this._localWs.delete(wsId);
611
- this._send({ type: 'ws-close', wsId });
612
- };
613
- socket.on('close', finish);
614
- socket.on('error', finish);
615
- }
616
-
617
- /** 网关下发的 ws-frame 是原始帧字节:直接写入本地 TCP,不能经 ws 库重编码。 */
618
- _handleWsFrame(msg) {
619
- const state = this._localWs.get(String(msg?.wsId ?? ''));
620
- if (!state || state.closed || !state.upgraded) return;
621
- const data = Buffer.from(String(msg?.data ?? ''), 'base64');
622
- this._bytesIn += data.length;
623
- try { state.socket.write(data); } catch { /* 忽略单帧写入失败,close 会收敛 */ }
624
- }
625
-
626
- _handleWsClose(msg) {
627
- const wsId = String(msg?.wsId ?? '');
628
- const state = this._localWs.get(wsId);
629
- if (!state) return;
630
- state.closed = true;
631
- this._localWs.delete(wsId);
632
- try { state.socket.destroy(); } catch {}
633
- }
634
-
635
- _cleanupLocalWs() {
636
- for (const state of this._localWs.values()) {
637
- state.closed = true;
638
- try { state.socket.destroy(); } catch {}
639
- }
640
- this._localWs.clear();
641
- }
642
-
643
- _send(message) {
644
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
645
- try { this._ws.send(JSON.stringify(message)); } catch (err) {
646
- this.logger?.warn?.(`SaaS tunnel send failed: ${err?.message ?? err}`);
647
- }
648
- }
649
-
650
- _heartbeatTick() {
651
- if (!this._connected || this._draining) return;
652
- if (this._heartbeatAckPending) {
653
- this._missedHeartbeatAcks += 1;
654
- if (this._missedHeartbeatAcks >= this.heartbeatMaxMissed) {
655
- const detail = `SaaS 网关心跳连续 ${this._missedHeartbeatAcks} 次未确认,主动重连`;
656
- this.lastError = detail;
657
- this._setState('reconnecting', detail);
658
- this._stopHeartbeat();
659
- try { this._ws?.terminate(); } catch {}
660
- return;
661
- }
662
- }
663
- this._heartbeatAckPending = true;
664
- this._send({
665
- type: 'heartbeat',
666
- ts: Date.now(),
667
- state: {
668
- requestsInFlight: this._inFlight.size,
669
- wsOpen: this._localWs.size,
670
- bytesIn: this._bytesIn,
671
- bytesOut: this._bytesOut,
672
- ...(this.lastError ? { lastError: String(this.lastError).slice(0, 500) } : {}),
673
- },
674
- });
675
- }
676
-
677
- _startHeartbeat() {
678
- this._stopHeartbeat();
679
- this._heartbeatAckPending = false;
680
- this._missedHeartbeatAcks = 0;
681
- this._heartbeatTimer = setInterval(() => this._heartbeatTick(), this.heartbeatIntervalMs);
682
- this._heartbeatTimer.unref?.();
683
- this._heartbeatTick();
684
- }
685
-
686
- _stopHeartbeat() {
687
- if (this._heartbeatTimer) {
688
- clearInterval(this._heartbeatTimer);
689
- this._heartbeatTimer = null;
690
- }
691
- }
692
-
693
- _onClose(code, reason) {
694
- this._connected = false;
695
- this.gateSecret = '';
696
- this._stopHeartbeat();
697
- this._cleanupLocalWs();
698
- this._inFlight.clear();
699
- const reasonText = reason?.toString?.() ?? '';
700
- if (SAAS_TERMINAL_CLOSE_CODES.has(code)) {
701
- this._terminal = true;
702
- const detail = `SaaS 网关关闭连接 (${code}${reasonText ? `: ${reasonText}` : ''}),请重新绑定设备`;
703
- this.lastError = detail;
704
- this._setState('error', detail);
705
- if (this._awaitingHandshake) {
706
- this._settleHandshake(new SaasTunnelError('connection_closed', detail, { terminal: true, closeCode: code }));
707
- }
708
- return;
709
- }
710
- if (this._awaitingHandshake) {
711
- this._settleHandshake(new SaasTunnelError('connection_closed', `SaaS 网关在握手完成前断开 (code=${code})`, { closeCode: code }));
712
- return;
713
- }
714
- if (this._manuallyClosed || this._draining || this._suppressReconnect) return;
715
- const detail = `SaaS 网关连接已断开 (code=${code})`;
716
- this.lastError = detail;
717
- this._scheduleReconnect(detail);
718
- }
719
-
720
- _scheduleReconnect(reason) {
721
- if (this._manuallyClosed || this._draining || this._terminal) return;
722
- if (this._reconnectTimer) return;
723
- if (Number.isFinite(this.maxReconnectAttempts) && this._reconnectAttempts >= this.maxReconnectAttempts) {
724
- this.lastError = `SaaS 隧道重连已达上限 (${this.maxReconnectAttempts} 次)`;
725
- this._setState('error', this.lastError);
726
- return;
727
- }
728
- this._reconnectAttempts += 1;
729
- const attempt = this._reconnectAttempts;
730
- const base = Math.min(this.reconnectBaseDelayMs * 2 ** (attempt - 1), this.reconnectMaxDelayMs);
731
- const delay = Math.max(20, Math.round(base * (0.75 + this.random() * 0.5)));
732
- this._setState('reconnecting', `SaaS 隧道断开${reason ? `:${reason}` : ''},${Math.round(delay / 100) / 10}s 后重连(第 ${attempt} 次)`);
733
- this._reconnectTimer = setTimeout(() => {
734
- this._reconnectTimer = null;
735
- this.connect().catch((err) => {
736
- if (err?.terminal === true) this._setState('error', err.message);
737
- });
738
- }, delay);
739
- this._reconnectTimer.unref?.();
740
- }
741
-
742
- _closeSocket(code = 1000, reason = '') {
743
- const ws = this._ws;
744
- this._ws = null;
745
- if (!ws) return;
746
- try { ws.close(code, reason); } catch {}
747
- setTimeout(() => { try { ws.terminate(); } catch {} }, 1000).unref?.();
748
- }
749
-
750
- async disconnect() {
751
- this._manuallyClosed = true;
752
- this.gateSecret = '';
753
- this._draining = false;
754
- this._connected = false;
755
- if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
756
- if (this._drainTimer) { clearTimeout(this._drainTimer); this._drainTimer = null; }
757
- this._stopHeartbeat();
758
- if (this._awaitingHandshake) this._settleHandshake(new SaasTunnelError('disconnected', '连接已取消'));
759
- this._cleanupLocalWs();
760
- this._inFlight.clear();
761
- this._closeSocket(1000, 'client disconnect');
762
- this._setState('idle', '已断开');
763
- }
764
- }
1
+ // SaaS 隧道客户端(控制通道协议 v2)——独立实现,不继承任何第三方隧道类。
2
+ //
3
+ // 契约见 docs/architecture/saas-tunnel.md §6 与 packages/tunnel-protocol/src/index.ts:
4
+ // C→S hello → S→C hello-ack → 转发 request/response(-start|chunk|end) 与 ws-*;
5
+ // v2 新增 heartbeat/heartbeat-ack、route-update、drain;未知 type 静默忽略。
6
+ // close 4001/4003/4004 为终态;4000/4002 可重连。
7
+ import { WebSocket } from 'ws';
8
+ import { request as httpRequest } from 'node:http';
9
+ import { connect as netConnect } from 'node:net';
10
+
11
+ export const SAAS_HEARTBEAT_INTERVAL_MS = 30_000;
12
+ export const SAAS_HEARTBEAT_MAX_MISSED = 3;
13
+ export const SAAS_HANDSHAKE_TIMEOUT_MS = 15_000;
14
+ export const SAAS_RECONNECT_BASE_DELAY_MS = 1000;
15
+ export const SAAS_RECONNECT_MAX_DELAY_MS = 60_000;
16
+ export const SAAS_DEFAULT_GATEWAY_PATH = '/_gw/connect';
17
+ export const SAAS_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
18
+ export const SAAS_TERMINAL_CLOSE_CODES = new Set([4001, 4003, 4004]);
19
+ export const SAAS_TERMINAL_ERROR_CODES = new Set(['auth_failed', 'revoked', 'bad_request', 'unsupported_version', 'time_exhausted']);
20
+
21
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
22
+ const TUNNEL_HEADER = 'x-dsh-internal-tunnel';
23
+
24
+ export class SaasTunnelError extends Error {
25
+ constructor(code, message, { terminal = false, closeCode = 0, cause } = {}) {
26
+ super(message, cause === undefined ? undefined : { cause });
27
+ this.name = 'SaasTunnelError';
28
+ this.code = code;
29
+ this.terminal = terminal;
30
+ this.closeCode = closeCode;
31
+ }
32
+ }
33
+
34
+ export function buildSaasConnectUrl({ gatewayUrl, gatewayPath = SAAS_DEFAULT_GATEWAY_PATH, deviceToken } = {}) {
35
+ let url;
36
+ try {
37
+ url = new URL(String(gatewayUrl ?? '').trim());
38
+ } catch {
39
+ throw new SaasTunnelError('invalid_gateway_url', `SaaS 网关地址不合法: ${gatewayUrl}`, { terminal: true });
40
+ }
41
+ if (url.protocol === 'http:') url.protocol = 'ws:';
42
+ else if (url.protocol === 'https:') url.protocol = 'wss:';
43
+ else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
44
+ throw new SaasTunnelError('invalid_gateway_url', 'SaaS 网关地址只支持 ws/wss/http/https', { terminal: true });
45
+ }
46
+ let path = String(gatewayPath || SAAS_DEFAULT_GATEWAY_PATH);
47
+ if (!path.startsWith('/')) path = '/' + path;
48
+ const basePath = url.pathname.replace(/\/+$/, '');
49
+ url.pathname = (basePath && basePath !== '/' ? basePath : '') + path;
50
+ url.search = '';
51
+ url.hash = '';
52
+ if (deviceToken) url.searchParams.set('device_token', String(deviceToken));
53
+ return url.toString();
54
+ }
55
+
56
+ export function pickRoutePublicUrl(routes) {
57
+ const list = Array.isArray(routes) ? routes.filter((route) => route && typeof route === 'object') : [];
58
+ const host = list.find((route) => route.kind === 'host' && typeof route.url === 'string' && route.url);
59
+ const any = list.find((route) => typeof route.url === 'string' && route.url);
60
+ return String((host ?? any)?.url ?? '');
61
+ }
62
+
63
+ function sanitizeHeaders(headers) {
64
+ const out = {};
65
+ for (const [name, value] of Object.entries(headers ?? {})) {
66
+ if (HOP_BY_HOP.has(name.toLowerCase()) || value === undefined) continue;
67
+ out[name.toLowerCase()] = value;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export class SaasTunnelClient {
73
+ constructor(options = {}) {
74
+ const {
75
+ gatewayUrl = '',
76
+ gatewayPath = SAAS_DEFAULT_GATEWAY_PATH,
77
+ deviceToken = null,
78
+ tokenProvider = null,
79
+ localPort,
80
+ internalTunnelSecret = '',
81
+ protocol = 'v2',
82
+ heartbeatIntervalMs = SAAS_HEARTBEAT_INTERVAL_MS,
83
+ heartbeatMaxMissed = SAAS_HEARTBEAT_MAX_MISSED,
84
+ maxResponseBytes = SAAS_MAX_RESPONSE_BYTES,
85
+ reconnectBaseDelayMs = SAAS_RECONNECT_BASE_DELAY_MS,
86
+ reconnectMaxDelayMs = SAAS_RECONNECT_MAX_DELAY_MS,
87
+ maxReconnectAttempts = Number.POSITIVE_INFINITY,
88
+ random = Math.random,
89
+ deviceInfo = {},
90
+ sseStreaming = true,
91
+ onHelloAck = null,
92
+ onStateChange = null,
93
+ onRouteUpdate = null,
94
+ onDrain = null,
95
+ onDrainComplete = null,
96
+ logger = console,
97
+ } = options;
98
+
99
+ if (!localPort) throw new Error('localPort is required');
100
+ this.gatewayUrl = String(gatewayUrl);
101
+ this.gatewayPath = gatewayPath || SAAS_DEFAULT_GATEWAY_PATH;
102
+ this.deviceToken = deviceToken;
103
+ this.tokenProvider = typeof tokenProvider === 'function'
104
+ ? tokenProvider
105
+ : (deviceToken ? async () => deviceToken : null);
106
+ this.localPort = Number(localPort);
107
+ this.internalTunnelSecret = String(internalTunnelSecret ?? '');
108
+ this.protocol = protocol === 'auto' ? 'auto' : 'v2';
109
+ this.protocolVersion = 2;
110
+ this.heartbeatIntervalMs = Math.max(10, Number(heartbeatIntervalMs) || SAAS_HEARTBEAT_INTERVAL_MS);
111
+ this.heartbeatMaxMissed = Math.max(1, Number(heartbeatMaxMissed) || SAAS_HEARTBEAT_MAX_MISSED);
112
+ this.maxResponseBytes = Math.max(1024, Number(maxResponseBytes) || SAAS_MAX_RESPONSE_BYTES);
113
+ this.reconnectBaseDelayMs = Math.max(10, Number(reconnectBaseDelayMs) || SAAS_RECONNECT_BASE_DELAY_MS);
114
+ this.reconnectMaxDelayMs = Math.max(this.reconnectBaseDelayMs, Number(reconnectMaxDelayMs) || SAAS_RECONNECT_MAX_DELAY_MS);
115
+ this.maxReconnectAttempts = Number.isFinite(maxReconnectAttempts) ? Math.max(0, maxReconnectAttempts) : Number.POSITIVE_INFINITY;
116
+ this.random = typeof random === 'function' ? random : Math.random;
117
+ this.deviceInfo = deviceInfo ?? {};
118
+ this.sseStreaming = sseStreaming !== false;
119
+ this.onHelloAck = onHelloAck;
120
+ this.onStateChange = onStateChange;
121
+ this.onRouteUpdate = onRouteUpdate;
122
+ this.onDrain = onDrain;
123
+ this.onDrainComplete = onDrainComplete;
124
+ this.logger = logger ?? console;
125
+
126
+ this._connected = false;
127
+ this._state = 'idle';
128
+ this._stateDetail = '';
129
+ this._terminal = false;
130
+ this._draining = false;
131
+ this._manuallyClosed = false;
132
+ this._connecting = false;
133
+ this._ws = null;
134
+ this._awaitingHandshake = false;
135
+ this._handshakeResolve = null;
136
+ this._handshakeReject = null;
137
+ this._handshakeTimer = null;
138
+ this._heartbeatTimer = null;
139
+ this._heartbeatAckPending = false;
140
+ this._missedHeartbeatAcks = 0;
141
+ this._reconnectTimer = null;
142
+ this._reconnectAttempts = 0;
143
+ this._routes = [];
144
+ this._routeRev = 0;
145
+ this.tunnel = null;
146
+ this.publicUrl = '';
147
+ this.lastError = null;
148
+ this.lastAckAt = null;
149
+ this.limits = null;
150
+ this.features = null;
151
+ this.serverTime = null;
152
+ this.gateSecret = '';
153
+ this._inFlight = new Set();
154
+ this._localWs = new Map();
155
+ this._bytesIn = 0;
156
+ this._bytesOut = 0;
157
+ this._drainTimer = null;
158
+ this._suppressReconnect = false;
159
+ }
160
+
161
+ get connected() { return this._connected; }
162
+ get state() { return this._state; }
163
+ get stateDetail() { return this._stateDetail; }
164
+ get draining() { return this._draining; }
165
+ get terminal() { return this._terminal; }
166
+ get routes() { return this._routes; }
167
+ get routeRev() { return this._routeRev; }
168
+ get reconnectAttempts() { return this._reconnectAttempts; }
169
+
170
+ _setState(state, detail = '') {
171
+ this._state = state;
172
+ this._stateDetail = detail;
173
+ try { this.onStateChange?.({ state, detail, connected: this._connected, publicUrl: this.publicUrl }); } catch {}
174
+ }
175
+
176
+ async connect() {
177
+ if (this._connected || this._connecting || this._manuallyClosed) return;
178
+ this._connecting = true;
179
+ this._setState('connecting', '正在连接 SaaS 网关…');
180
+ try {
181
+ const token = await this._acquireToken();
182
+ await this._openSocket(token);
183
+ this._connected = true;
184
+ this._terminal = false;
185
+ this._manuallyClosed = false;
186
+ this._reconnectAttempts = 0;
187
+ this.lastError = null;
188
+ this.lastAckAt = Date.now();
189
+ this._startHeartbeat();
190
+ this._setState('ready', this.publicUrl ? `SaaS 隧道已建立 (${this.publicUrl})` : 'SaaS 隧道已建立');
191
+ } catch (err) {
192
+ this._connected = false;
193
+ this._stopHeartbeat();
194
+ const detail = err?.message ?? String(err);
195
+ this.lastError = detail;
196
+ const terminal = err?.terminal === true || this._terminal;
197
+ if (terminal) {
198
+ this._terminal = true;
199
+ this._setState('error', detail);
200
+ } else if (!this._manuallyClosed && !this._draining) {
201
+ this._scheduleReconnect(detail);
202
+ }
203
+ throw err;
204
+ } finally {
205
+ this._connecting = false;
206
+ }
207
+ }
208
+
209
+ async _acquireToken() {
210
+ if (!this.tokenProvider) {
211
+ throw new SaasTunnelError('missing_device_credentials', '尚未绑定设备,请先在面板获取设备码并绑定', { terminal: true });
212
+ }
213
+ const result = await this.tokenProvider();
214
+ const token = typeof result === 'string' ? result : result?.token;
215
+ if (!token) throw new SaasTunnelError('missing_device_token', '设备令牌获取失败,请重新绑定', { terminal: true });
216
+ if (result && typeof result === 'object') {
217
+ if (result.gateway?.url) this.gatewayUrl = result.gateway.url;
218
+ if (result.gateway?.path) this.gatewayPath = result.gateway.path;
219
+ if (result.tunnel) this.tunnel = result.tunnel;
220
+ }
221
+ this.deviceToken = String(token);
222
+ return this.deviceToken;
223
+ }
224
+
225
+ _openSocket(deviceToken) {
226
+ return new Promise((resolve, reject) => {
227
+ let ws;
228
+ try {
229
+ ws = new WebSocket(buildSaasConnectUrl({
230
+ gatewayUrl: this.gatewayUrl,
231
+ gatewayPath: this.gatewayPath,
232
+ deviceToken,
233
+ }), {
234
+ handshakeTimeout: 10_000,
235
+ headers: { authorization: `Bearer ${deviceToken}` },
236
+ perMessageDeflate: false,
237
+ });
238
+ } catch (err) {
239
+ reject(new SaasTunnelError('invalid_gateway_url', err?.message ?? String(err), { terminal: true, cause: err }));
240
+ return;
241
+ }
242
+ this._ws = ws;
243
+ this._awaitingHandshake = true;
244
+ this._handshakeResolve = resolve;
245
+ this._handshakeReject = reject;
246
+ this._handshakeTimer = setTimeout(() => {
247
+ if (!this._awaitingHandshake) return;
248
+ try { ws.terminate(); } catch {}
249
+ this._settleHandshake(new SaasTunnelError('handshake_timeout', 'SaaS 网关握手超时(15s 内未收到 hello-ack)'));
250
+ }, SAAS_HANDSHAKE_TIMEOUT_MS);
251
+
252
+ ws.on('open', () => {
253
+ this.logger?.info?.('SaaS tunnel WebSocket open');
254
+ this._sendHello();
255
+ });
256
+ ws.on('message', (data) => this._onMessage(data));
257
+ ws.on('close', (code, reason) => this._onClose(code, reason));
258
+ ws.on('error', (err) => {
259
+ this.logger?.error?.(`SaaS tunnel WebSocket error: ${err?.message ?? err}`);
260
+ if (this._awaitingHandshake) this._settleHandshake(err instanceof Error ? err : new Error(String(err)));
261
+ });
262
+ });
263
+ }
264
+
265
+ _settleHandshake(error) {
266
+ if (!this._awaitingHandshake) return;
267
+ this._awaitingHandshake = false;
268
+ if (this._handshakeTimer) {
269
+ clearTimeout(this._handshakeTimer);
270
+ this._handshakeTimer = null;
271
+ }
272
+ const resolve = this._handshakeResolve;
273
+ const reject = this._handshakeReject;
274
+ this._handshakeResolve = null;
275
+ this._handshakeReject = null;
276
+ if (error) reject?.(error);
277
+ else resolve?.();
278
+ }
279
+
280
+ _sendHello() {
281
+ const device = {};
282
+ for (const key of ['name', 'platform', 'hostname', 'pluginVersion', 'dshVersion']) {
283
+ const value = this.deviceInfo?.[key];
284
+ if (value !== undefined && value !== null && String(value).trim()) device[key] = String(value).trim();
285
+ }
286
+ if (!device.name) device.name = device.hostname || 'DSH Remote Tunnel';
287
+ if (!device.platform) device.platform = process.platform ?? 'unknown';
288
+ this._send({
289
+ type: 'hello',
290
+ v: 2,
291
+ device,
292
+ caps: { sseStreaming: this.sseStreaming, wsFrames: true, maxResponseBytes: this.maxResponseBytes },
293
+ ts: Math.floor(Date.now() / 1000),
294
+ });
295
+ }
296
+
297
+ _onMessage(data) {
298
+ let msg;
299
+ try {
300
+ msg = JSON.parse(typeof data === 'string' ? data : data.toString());
301
+ } catch {
302
+ return;
303
+ }
304
+ if (!msg || typeof msg !== 'object') return;
305
+
306
+ if (msg.type === 'hello-ack') {
307
+ if (this._awaitingHandshake) this._handleHelloAck(msg);
308
+ else if (msg.ok === false) this.logger?.warn?.('ignoring duplicate hello-ack');
309
+ return;
310
+ }
311
+ if (msg.type === 'ready' && this._awaitingHandshake && this.protocol === 'auto') {
312
+ this.protocolVersion = 1;
313
+ this.publicUrl = String(msg.publicUrl ?? '');
314
+ this._connected = true;
315
+ this._settleHandshake();
316
+ return;
317
+ }
318
+ switch (msg.type) {
319
+ case 'heartbeat-ack':
320
+ this._heartbeatAckPending = false;
321
+ this._missedHeartbeatAcks = 0;
322
+ this.lastAckAt = Date.now();
323
+ if (msg.serverTime) this.serverTime = msg.serverTime;
324
+ if (Array.isArray(msg.actions) && msg.actions.includes('drain') && !this._draining) {
325
+ this._startDrain({ deadline: Date.now() + this.heartbeatIntervalMs * 3 });
326
+ }
327
+ return;
328
+ case 'route-update':
329
+ this._routeRev = Number(msg.rev) || this._routeRev;
330
+ this._applyRoutes(msg.routes);
331
+ try { this.onRouteUpdate?.({ routes: this._routes, rev: this._routeRev, publicUrl: this.publicUrl }); } catch {}
332
+ return;
333
+ case 'drain':
334
+ this._handleDrain(msg);
335
+ return;
336
+ case 'ready':
337
+ if (this._connected && !this._routes.some((route) => route.kind === 'host')) {
338
+ if (msg.publicUrl) this.publicUrl = String(msg.publicUrl);
339
+ }
340
+ return;
341
+ case 'ping':
342
+ this._send({ type: 'pong', ts: Date.now() });
343
+ return;
344
+ case 'request':
345
+ this._handleRequest(msg);
346
+ return;
347
+ case 'ws-open':
348
+ this._handleWsOpen(msg);
349
+ return;
350
+ case 'ws-frame':
351
+ this._handleWsFrame(msg);
352
+ return;
353
+ case 'ws-close':
354
+ this._handleWsClose(msg);
355
+ return;
356
+ case 'pong':
357
+ return;
358
+ default:
359
+ // 未知 type 必须静默忽略(v1 前后兼容)
360
+ return;
361
+ }
362
+ }
363
+
364
+ _handleHelloAck(msg) {
365
+ if (msg.ok === false) {
366
+ const code = String(msg.error?.code ?? 'bad_request');
367
+ const message = String(msg.error?.message ?? '');
368
+ const detail = `SaaS 网关拒绝握手: ${message || code} (${code})`;
369
+ const terminal = SAAS_TERMINAL_ERROR_CODES.has(code);
370
+ this.lastError = detail;
371
+ if (terminal) this._terminal = true;
372
+ this._setState(terminal ? 'error' : 'connecting', detail);
373
+ try { this._ws?.terminate(); } catch {}
374
+ this._settleHandshake(new SaasTunnelError(code, detail, { terminal }));
375
+ return;
376
+ }
377
+ this.protocolVersion = Number(msg.v) || 2;
378
+ if (msg.tunnel) this.tunnel = { publicId: String(msg.tunnel.publicId ?? ''), name: String(msg.tunnel.name ?? '') };
379
+ this.limits = msg.limits ?? null;
380
+ this.features = msg.features ?? null;
381
+ // 网关为每条控制连接随机下发信任密钥;gate 用它验 x-qzsy-gate 的 HMAC。
382
+ this.gateSecret = typeof msg.gate?.secret === 'string' && /^[0-9a-f]{64}$/i.test(msg.gate.secret)
383
+ ? msg.gate.secret.toLowerCase()
384
+ : '';
385
+ this.serverTime = msg.serverTime ?? null;
386
+ if (this.limits?.heartbeatMs) this.heartbeatIntervalMs = Math.max(10, Number(this.limits.heartbeatMs) || this.heartbeatIntervalMs);
387
+ if (this.limits?.maxResponseBytes) this.maxResponseBytes = Math.max(1024, Number(this.limits.maxResponseBytes) || this.maxResponseBytes);
388
+ this._applyRoutes(msg.routes);
389
+ this._connected = true;
390
+ this._reconnectAttempts = 0;
391
+ this.lastAckAt = Date.now();
392
+ try { this.onHelloAck?.(msg); } catch {}
393
+ this._settleHandshake();
394
+ }
395
+
396
+ _applyRoutes(routes) {
397
+ const list = Array.isArray(routes) ? routes.filter((route) => route && typeof route === 'object') : [];
398
+ this._routes = list;
399
+ const picked = pickRoutePublicUrl(list);
400
+ if (picked) this.publicUrl = picked;
401
+ }
402
+
403
+ _handleDrain(msg) {
404
+ let deadline = Number(msg?.deadline);
405
+ if (!Number.isFinite(deadline) || deadline <= 0) deadline = Date.now() + this.heartbeatIntervalMs * 3;
406
+ else if (deadline < 1e12) deadline *= 1000;
407
+ this._startDrain({ deadline });
408
+ }
409
+
410
+ _startDrain({ deadline }) {
411
+ if (this._draining) return;
412
+ this._draining = true;
413
+ this._setState('draining', 'SaaS 网关下线中,等待在途请求结束');
414
+ try { this.onDrain?.({ deadline }); } catch {}
415
+ const finish = () => this._finishDrain();
416
+ if (this._inFlight.size === 0) {
417
+ this._drainTimer = setTimeout(finish, 20);
418
+ } else {
419
+ const poll = () => {
420
+ if (!this._draining) return;
421
+ if (this._inFlight.size === 0 || Date.now() >= deadline) return finish();
422
+ this._drainTimer = setTimeout(poll, Math.min(100, Math.max(10, deadline - Date.now())));
423
+ };
424
+ this._drainTimer = setTimeout(poll, 20);
425
+ setTimeout(() => { if (this._draining) finish(); }, Math.max(0, deadline - Date.now()) + 1000).unref?.();
426
+ }
427
+ }
428
+
429
+ _finishDrain() {
430
+ if (!this._draining) return;
431
+ this._draining = false;
432
+ this.gateSecret = '';
433
+ this._suppressReconnect = true;
434
+ if (this._drainTimer) { clearTimeout(this._drainTimer); this._drainTimer = null; }
435
+ this._connected = false;
436
+ this._stopHeartbeat();
437
+ this._cleanupLocalWs();
438
+ this._inFlight.clear();
439
+ this.lastError = 'SaaS 网关已下线(drain),隧道已优雅断开';
440
+ this._setState('closed', this.lastError);
441
+ try { this.onDrainComplete?.({ detail: this.lastError }); } catch {}
442
+ this._closeSocket(1000, 'drain');
443
+ // 网关滚动发布/重启会主动 drain。drain 只表示「这一轮连接该结束了」,不是终态:
444
+ // 不自动重连的话,每次网关发布都要用户手动点「连接」,长连接会一直停在离线。
445
+ this._suppressReconnect = false;
446
+ this._reconnectAttempts = 0;
447
+ this._scheduleReconnect('网关已 drain 下线,准备重连');
448
+ }
449
+
450
+ _handleRequest(msg) {
451
+ const requestId = String(msg?.requestId ?? '');
452
+ if (!requestId) return;
453
+ if (this._draining) {
454
+ this._send({ type: 'response', requestId, statusCode: 503, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: Buffer.from('tunnel is draining').toString('base64') });
455
+ return;
456
+ }
457
+ this._inFlight.add(requestId);
458
+ if (typeof msg.body === 'string') this._bytesIn += Math.floor((msg.body.length * 3) / 4);
459
+ const headers = sanitizeHeaders(msg.headers);
460
+ headers.host = `127.0.0.1:${this.localPort}`;
461
+ if (this.internalTunnelSecret) headers[TUNNEL_HEADER] = this.internalTunnelSecret;
462
+
463
+ const upstream = httpRequest({
464
+ host: '127.0.0.1',
465
+ port: this.localPort,
466
+ method: String(msg.method ?? 'GET'),
467
+ path: String(msg.path ?? '/'),
468
+ headers,
469
+ }, (res) => {
470
+ const responseHeaders = sanitizeHeaders(res.headers);
471
+ const isSse = String(res.headers['content-type'] ?? '').toLowerCase().includes('text/event-stream');
472
+ if (isSse && this.sseStreaming) {
473
+ this._send({ type: 'response-start', requestId, statusCode: res.statusCode ?? 200, headers: responseHeaders });
474
+ res.on('data', (chunk) => {
475
+ this._bytesOut += chunk.length;
476
+ this._send({ type: 'response-chunk', requestId, body: Buffer.from(chunk).toString('base64') });
477
+ });
478
+ res.on('end', () => {
479
+ this._send({ type: 'response-end', requestId });
480
+ this._inFlight.delete(requestId);
481
+ });
482
+ res.on('error', () => {
483
+ this._send({ type: 'response-end', requestId });
484
+ this._inFlight.delete(requestId);
485
+ });
486
+ return;
487
+ }
488
+ const chunks = [];
489
+ let total = 0;
490
+ let finished = false;
491
+ const fail = (code, text) => {
492
+ if (finished) return;
493
+ finished = true;
494
+ this._inFlight.delete(requestId);
495
+ this._send({ type: 'response', requestId, statusCode: code, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: Buffer.from(text).toString('base64') });
496
+ };
497
+ res.on('data', (chunk) => {
498
+ total += chunk.length;
499
+ if (total > this.maxResponseBytes) {
500
+ fail(502, `Response too large for tunnel (>${Math.round(this.maxResponseBytes / 1024 / 1024)}MB cap)`);
501
+ res.destroy();
502
+ return;
503
+ }
504
+ chunks.push(chunk);
505
+ });
506
+ res.on('end', () => {
507
+ if (finished) return;
508
+ finished = true;
509
+ this._inFlight.delete(requestId);
510
+ const body = Buffer.concat(chunks).toString('base64');
511
+ this._bytesOut += Buffer.concat(chunks).length;
512
+ this._send({ type: 'response', requestId, statusCode: res.statusCode ?? 200, headers: responseHeaders, body });
513
+ });
514
+ res.on('error', () => fail(502, 'Response Error'));
515
+ });
516
+ upstream.on('error', (err) => {
517
+ this._inFlight.delete(requestId);
518
+ this._send({ type: 'response', requestId, statusCode: 502, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: Buffer.from(`Bad Gateway: ${err?.message ?? err}`).toString('base64') });
519
+ });
520
+ if (typeof msg.body === 'string' && msg.body) upstream.write(Buffer.from(msg.body, 'base64'));
521
+ upstream.end();
522
+ }
523
+
524
+ // ── WebSocket 原始帧转发 ────────────────────────────────────────────────
525
+ // 网关契约(apps/gateway/src/proxy-ws.ts):ws-frame 的 data 是**原始 WebSocket 帧字节**
526
+ // (含 opcode/mask/分片),不是消息 payload。因此这里用裸 TCP 完成 Upgrade 握手后
527
+ // 原样透传字节,绝不能再经 ws 库二次成帧。
528
+ _handleWsOpen(msg) {
529
+ const wsId = String(msg?.wsId ?? '');
530
+ if (!wsId) return;
531
+ if (this._draining) {
532
+ this._send({ type: 'ws-accept', wsId, statusCode: 503, statusMessage: 'Service Unavailable', replyHeaders: { 'content-type': 'text/plain' } });
533
+ return;
534
+ }
535
+ // Upgrade 请求必须原样保留 connection/upgrade/sec-websocket-*(不能套用普通
536
+ // HTTP 请求的逐跳过滤),只重写 Host 并补本地隧道标记。
537
+ const requestHeaders = {};
538
+ for (const [name, value] of Object.entries(msg.headers ?? {})) {
539
+ const lower = name.toLowerCase();
540
+ if (lower === 'host' || lower === TUNNEL_HEADER || value === undefined) continue;
541
+ requestHeaders[lower] = value;
542
+ }
543
+ requestHeaders.host = `127.0.0.1:${this.localPort}`;
544
+ // 网关按 hop-by-hop 规则剥掉了 Upgrade/Connection,这里必须补回,否则本机
545
+ // WebSocket 升级请求不合法(表现为浏览器收到非 101)。
546
+ requestHeaders.connection = requestHeaders.connection ?? 'Upgrade';
547
+ requestHeaders.upgrade = requestHeaders.upgrade ?? 'websocket';
548
+ if (this.internalTunnelSecret) requestHeaders[TUNNEL_HEADER] = this.internalTunnelSecret;
549
+
550
+ const socket = netConnect({ host: '127.0.0.1', port: this.localPort });
551
+ const state = { socket, buffer: Buffer.alloc(0), upgraded: false, closed: false, headerSent: false };
552
+ this._localWs.set(wsId, state);
553
+
554
+ const fail = (code, message) => {
555
+ if (state.closed) return;
556
+ state.closed = true;
557
+ this._send({ type: 'ws-accept', wsId, statusCode: code, statusMessage: message, replyHeaders: { 'content-type': 'text/plain' } });
558
+ try { socket.destroy(); } catch {}
559
+ this._localWs.delete(wsId);
560
+ };
561
+
562
+ socket.on('connect', () => {
563
+ const path = String(msg.path ?? '/');
564
+ const lines = [`GET ${path.startsWith('/') ? path : '/' + path} HTTP/1.1`];
565
+ for (const [name, value] of Object.entries(requestHeaders)) {
566
+ if (Array.isArray(value)) for (const item of value) lines.push(`${name}: ${item}`);
567
+ else if (value !== undefined) lines.push(`${name}: ${value}`);
568
+ }
569
+ lines.push('', '');
570
+ state.headerSent = true;
571
+ socket.write(lines.join('\r\n'));
572
+ });
573
+
574
+ socket.on('data', (chunk) => {
575
+ if (state.closed) return;
576
+ if (!state.upgraded) {
577
+ state.buffer = Buffer.concat([state.buffer, chunk]);
578
+ const separator = state.buffer.indexOf('\r\n\r\n');
579
+ if (separator === -1) {
580
+ if (state.buffer.length > 64 * 1024) fail(502, 'Bad Gateway');
581
+ return;
582
+ }
583
+ const head = state.buffer.subarray(0, separator).toString('latin1');
584
+ const rest = state.buffer.subarray(separator + 4);
585
+ state.buffer = Buffer.alloc(0);
586
+ state.upgraded = true;
587
+ const [statusLine, ...headerLines] = head.split('\r\n');
588
+ const match = /^HTTP\/1\.[01]\s+(\d{3})\s*(.*)$/.exec(statusLine ?? '');
589
+ const statusCode = match ? Number(match[1]) : 101;
590
+ const statusMessage = match ? match[2] : '';
591
+ const replyHeaders = {};
592
+ for (const line of headerLines) {
593
+ const index = line.indexOf(':');
594
+ if (index <= 0) continue;
595
+ replyHeaders[line.slice(0, index).trim().toLowerCase()] = line.slice(index + 1).trim();
596
+ }
597
+ if (statusCode !== 101) {
598
+ state.closed = true;
599
+ this.logger?.warn?.('local WS upgrade rejected: %s %s', statusCode, statusMessage);
600
+ this._send({ type: 'ws-accept', wsId, statusCode, statusMessage, replyHeaders });
601
+ try { socket.destroy(); } catch {}
602
+ this._localWs.delete(wsId);
603
+ return;
604
+ }
605
+ this._send({ type: 'ws-accept', wsId, statusCode: 101, replyHeaders });
606
+ if (rest.length > 0) this._send({ type: 'ws-frame', wsId, data: rest.toString('base64') });
607
+ return;
608
+ }
609
+ this._send({ type: 'ws-frame', wsId, data: chunk.toString('base64') });
610
+ });
611
+
612
+ const finish = () => {
613
+ if (state.closed) return;
614
+ state.closed = true;
615
+ this._localWs.delete(wsId);
616
+ this._send({ type: 'ws-close', wsId });
617
+ };
618
+ socket.on('close', finish);
619
+ socket.on('error', finish);
620
+ }
621
+
622
+ /** 网关下发的 ws-frame 是原始帧字节:直接写入本地 TCP,不能经 ws 库重编码。 */
623
+ _handleWsFrame(msg) {
624
+ const state = this._localWs.get(String(msg?.wsId ?? ''));
625
+ if (!state || state.closed || !state.upgraded) return;
626
+ const data = Buffer.from(String(msg?.data ?? ''), 'base64');
627
+ this._bytesIn += data.length;
628
+ try { state.socket.write(data); } catch { /* 忽略单帧写入失败,close 会收敛 */ }
629
+ }
630
+
631
+ _handleWsClose(msg) {
632
+ const wsId = String(msg?.wsId ?? '');
633
+ const state = this._localWs.get(wsId);
634
+ if (!state) return;
635
+ state.closed = true;
636
+ this._localWs.delete(wsId);
637
+ try { state.socket.destroy(); } catch {}
638
+ }
639
+
640
+ _cleanupLocalWs() {
641
+ for (const state of this._localWs.values()) {
642
+ state.closed = true;
643
+ try { state.socket.destroy(); } catch {}
644
+ }
645
+ this._localWs.clear();
646
+ }
647
+
648
+ _send(message) {
649
+ if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
650
+ try { this._ws.send(JSON.stringify(message)); } catch (err) {
651
+ this.logger?.warn?.(`SaaS tunnel send failed: ${err?.message ?? err}`);
652
+ }
653
+ }
654
+
655
+ _heartbeatTick() {
656
+ if (!this._connected || this._draining) return;
657
+ if (this._heartbeatAckPending) {
658
+ this._missedHeartbeatAcks += 1;
659
+ if (this._missedHeartbeatAcks >= this.heartbeatMaxMissed) {
660
+ const detail = `SaaS 网关心跳连续 ${this._missedHeartbeatAcks} 次未确认,主动重连`;
661
+ this.lastError = detail;
662
+ this._setState('reconnecting', detail);
663
+ this._stopHeartbeat();
664
+ try { this._ws?.terminate(); } catch {}
665
+ return;
666
+ }
667
+ }
668
+ this._heartbeatAckPending = true;
669
+ this._send({
670
+ type: 'heartbeat',
671
+ ts: Date.now(),
672
+ state: {
673
+ requestsInFlight: this._inFlight.size,
674
+ wsOpen: this._localWs.size,
675
+ bytesIn: this._bytesIn,
676
+ bytesOut: this._bytesOut,
677
+ ...(this.lastError ? { lastError: String(this.lastError).slice(0, 500) } : {}),
678
+ },
679
+ });
680
+ }
681
+
682
+ _startHeartbeat() {
683
+ this._stopHeartbeat();
684
+ this._heartbeatAckPending = false;
685
+ this._missedHeartbeatAcks = 0;
686
+ this._heartbeatTimer = setInterval(() => this._heartbeatTick(), this.heartbeatIntervalMs);
687
+ this._heartbeatTimer.unref?.();
688
+ this._heartbeatTick();
689
+ }
690
+
691
+ _stopHeartbeat() {
692
+ if (this._heartbeatTimer) {
693
+ clearInterval(this._heartbeatTimer);
694
+ this._heartbeatTimer = null;
695
+ }
696
+ }
697
+
698
+ _onClose(code, reason) {
699
+ this._connected = false;
700
+ this.gateSecret = '';
701
+ this._stopHeartbeat();
702
+ this._cleanupLocalWs();
703
+ this._inFlight.clear();
704
+ const reasonText = reason?.toString?.() ?? '';
705
+ if (SAAS_TERMINAL_CLOSE_CODES.has(code)) {
706
+ this._terminal = true;
707
+ const detail = `SaaS 网关关闭连接 (${code}${reasonText ? `: ${reasonText}` : ''}),请重新绑定设备`;
708
+ this.lastError = detail;
709
+ this._setState('error', detail);
710
+ if (this._awaitingHandshake) {
711
+ this._settleHandshake(new SaasTunnelError('connection_closed', detail, { terminal: true, closeCode: code }));
712
+ }
713
+ return;
714
+ }
715
+ if (this._awaitingHandshake) {
716
+ this._settleHandshake(new SaasTunnelError('connection_closed', `SaaS 网关在握手完成前断开 (code=${code})`, { closeCode: code }));
717
+ return;
718
+ }
719
+ if (this._manuallyClosed || this._draining || this._suppressReconnect) return;
720
+ const detail = `SaaS 网关连接已断开 (code=${code})`;
721
+ this.lastError = detail;
722
+ this._scheduleReconnect(detail);
723
+ }
724
+
725
+ _scheduleReconnect(reason) {
726
+ if (this._manuallyClosed || this._draining || this._terminal) return;
727
+ if (this._reconnectTimer) return;
728
+ if (Number.isFinite(this.maxReconnectAttempts) && this._reconnectAttempts >= this.maxReconnectAttempts) {
729
+ this.lastError = `SaaS 隧道重连已达上限 (${this.maxReconnectAttempts} 次)`;
730
+ this._setState('error', this.lastError);
731
+ return;
732
+ }
733
+ this._reconnectAttempts += 1;
734
+ const attempt = this._reconnectAttempts;
735
+ const base = Math.min(this.reconnectBaseDelayMs * 2 ** (attempt - 1), this.reconnectMaxDelayMs);
736
+ const delay = Math.max(20, Math.round(base * (0.75 + this.random() * 0.5)));
737
+ this._setState('reconnecting', `SaaS 隧道断开${reason ? `:${reason}` : ''},${Math.round(delay / 100) / 10}s 后重连(第 ${attempt} 次)`);
738
+ this._reconnectTimer = setTimeout(() => {
739
+ this._reconnectTimer = null;
740
+ this.connect().catch((err) => {
741
+ if (err?.terminal === true) this._setState('error', err.message);
742
+ });
743
+ }, delay);
744
+ this._reconnectTimer.unref?.();
745
+ }
746
+
747
+ _closeSocket(code = 1000, reason = '') {
748
+ const ws = this._ws;
749
+ this._ws = null;
750
+ if (!ws) return;
751
+ try { ws.close(code, reason); } catch {}
752
+ setTimeout(() => { try { ws.terminate(); } catch {} }, 1000).unref?.();
753
+ }
754
+
755
+ async disconnect() {
756
+ this._manuallyClosed = true;
757
+ this.gateSecret = '';
758
+ this._draining = false;
759
+ this._connected = false;
760
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
761
+ if (this._drainTimer) { clearTimeout(this._drainTimer); this._drainTimer = null; }
762
+ this._stopHeartbeat();
763
+ if (this._awaitingHandshake) this._settleHandshake(new SaasTunnelError('disconnected', '连接已取消'));
764
+ this._cleanupLocalWs();
765
+ this._inFlight.clear();
766
+ this._closeSocket(1000, 'client disconnect');
767
+ this._setState('idle', '已断开');
768
+ }
769
+ }