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