@qzsy/dsh-bridge 0.0.0-stage → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +1107 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +488 -0
  4. package/README.md +509 -2
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +9864 -0
  7. package/client/host-families.js +442 -0
  8. package/client/index.js +6883 -0
  9. package/client/locales.mjs +242 -0
  10. package/client/mobile-styles.js +1299 -0
  11. package/client/mobile-yield.js +219 -0
  12. package/client/panel-locales.mjs +777 -0
  13. package/client/picker-yield.js +77 -0
  14. package/client/resource-url-compat.js +90 -0
  15. package/client/settings-nav-icon.js +105 -0
  16. package/client/theme-tokens.js +49 -0
  17. package/client/tunnel-guard.js +46 -0
  18. package/client/unlock-manager.js +144 -0
  19. package/cordis.patch.yml +4 -0
  20. package/lib/auth/dsh-native-cookie.js +148 -0
  21. package/lib/auth/login-template.js +516 -0
  22. package/lib/auth/manager.js +596 -0
  23. package/lib/bridge-rpc-constants.js +66 -0
  24. package/lib/bridge-rpc.js +722 -0
  25. package/lib/cloudflared-manager.mjs +1277 -0
  26. package/lib/cloudflared-network-args.js +99 -0
  27. package/lib/cloudflared-process-scan.js +153 -0
  28. package/lib/compat.js +172 -0
  29. package/lib/connection-compat.js +115 -0
  30. package/lib/feishu/comment.js +50 -0
  31. package/lib/feishu/gateway.js +644 -0
  32. package/lib/feishu/index.js +432 -0
  33. package/lib/feishu/lark-bundled.mjs +131929 -0
  34. package/lib/feishu/markdown.js +148 -0
  35. package/lib/feishu/node.js +1152 -0
  36. package/lib/feishu/slash-commands.js +255 -0
  37. package/lib/index.js +3554 -0
  38. package/lib/platform/account-ops.js +248 -0
  39. package/lib/platform/activity.js +169 -0
  40. package/lib/platform/ask-user.js +293 -0
  41. package/lib/platform/base.js +147 -0
  42. package/lib/platform/changes-digest.js +93 -0
  43. package/lib/platform/commands.js +267 -0
  44. package/lib/platform/config-restore.js +78 -0
  45. package/lib/platform/conversation-bridge.js +1544 -0
  46. package/lib/platform/dsh-storage.js +222 -0
  47. package/lib/platform/index.js +10 -0
  48. package/lib/platform/instances.js +458 -0
  49. package/lib/platform/manager.js +130 -0
  50. package/lib/platform/message-split.js +229 -0
  51. package/lib/platform/session-catalog.js +405 -0
  52. package/lib/platform/session-config.js +52 -0
  53. package/lib/platform/stream-slices.js +21 -0
  54. package/lib/qq/gateway.js +1107 -0
  55. package/lib/qq/index.js +561 -0
  56. package/lib/qq/node.js +1153 -0
  57. package/lib/qq/qqbot-bundled.mjs +8273 -0
  58. package/lib/restart-helper.mjs +133 -0
  59. package/lib/saas/device-auth.mjs +366 -0
  60. package/lib/saas/tunnel-client.mjs +695 -0
  61. package/lib/security/log-sanitize.js +50 -0
  62. package/lib/security/path-validator.js +195 -0
  63. package/lib/security/rate-limiter.js +93 -0
  64. package/lib/session-strip.js +100 -0
  65. package/lib/sse-keepalive.js +115 -0
  66. package/lib/telegram/gateway.js +643 -0
  67. package/lib/telegram/index.js +255 -0
  68. package/lib/telegram/node.js +410 -0
  69. package/lib/tunnel-client.mjs +537 -0
  70. package/lib/wechat/gateway.js +1000 -0
  71. package/lib/wechat/index.js +282 -0
  72. package/lib/wechat/media.js +302 -0
  73. package/lib/wechat/node.js +360 -0
  74. package/llms.txt +36 -0
  75. package/locale/en.json +6 -0
  76. package/locale/zh.json +6 -0
  77. package/package.json +135 -4
@@ -0,0 +1,695 @@
1
+ // DSH Bridge - SaaS 隧道客户端(控制通道协议 v2)
2
+ //
3
+ // 契约见 docs/architecture/saas-tunnel.md §6(消息名/字段名不可擅自改名)与
4
+ // packages/tunnel-protocol/src/index.ts(共享类型):
5
+ // C→S 首帧 hello → S→C hello-ack(ok=true|false) → 之后沿用 v1 转发消息
6
+ // (request / response / response-start|chunk|end / ws-open|accept|frame|close / ping|pong)
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
+
13
+ import { WebSocket } from 'ws';
14
+ import { CustomTunnelClient } from '../tunnel-client.mjs';
15
+
16
+ export const SAAS_HEARTBEAT_INTERVAL_MS = 30_000;
17
+ export const SAAS_HEARTBEAT_MAX_MISSED = 3;
18
+ export const SAAS_HANDSHAKE_TIMEOUT_MS = 15_000;
19
+ export const SAAS_RECONNECT_BASE_DELAY_MS = 1000;
20
+ export const SAAS_RECONNECT_MAX_DELAY_MS = 60_000;
21
+ export const SAAS_RECONNECT_MAX_ATTEMPTS = Number.POSITIVE_INFINITY;
22
+ export const SAAS_DEFAULT_GATEWAY_PATH = '/_gw/connect';
23
+
24
+ /** 控制通道关闭码:4001 认证失败 / 4003 已吊销 → 终态,不再重连(契约 §4.2)。 */
25
+ export const SAAS_TERMINAL_CLOSE_CODES = new Set([4001, 4003]);
26
+
27
+ /** hello-ack 失败码里的终态集合(quota 视为可重试)。 */
28
+ export const SAAS_TERMINAL_ERROR_CODES = new Set(['auth_failed', 'revoked', 'bad_request', 'unsupported_version']);
29
+
30
+ /** SaaS 隧道协议/连接错误。terminal=true 表示不应再自动重连。 */
31
+ export class SaasTunnelError extends Error {
32
+ constructor(code, message, { terminal = false, closeCode = 0, cause } = {}) {
33
+ super(message, cause === undefined ? undefined : { cause });
34
+ this.name = 'SaasTunnelError';
35
+ this.code = code;
36
+ this.terminal = terminal;
37
+ this.closeCode = closeCode;
38
+ }
39
+ }
40
+
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
+ export function buildSaasConnectUrl({ gatewayUrl, gatewayPath = SAAS_DEFAULT_GATEWAY_PATH, deviceToken } = {}) {
50
+ let url;
51
+ try {
52
+ url = new URL(String(gatewayUrl ?? '').trim());
53
+ } catch {
54
+ throw new SaasTunnelError('invalid_gateway_url', `SaaS 网关连接地址不是合法 URL: ${gatewayUrl}`);
55
+ }
56
+ if (url.protocol === 'http:') url.protocol = 'ws:';
57
+ else if (url.protocol === 'https:') url.protocol = 'wss:';
58
+ else if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
59
+ throw new SaasTunnelError('invalid_gateway_url', 'SaaS 网关连接地址只支持 ws/wss/http/https');
60
+ }
61
+ const path = String(gatewayPath ?? '') || SAAS_DEFAULT_GATEWAY_PATH;
62
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
63
+ const basePath = url.pathname.replace(/\/+$/, '');
64
+ url.pathname = `${basePath}${normalizedPath}`.replace(/\/{2,}/g, '/');
65
+ url.search = '';
66
+ url.hash = '';
67
+ if (deviceToken) url.searchParams.set('device_token', deviceToken);
68
+ return url.toString();
69
+ }
70
+
71
+ /**
72
+ * 从 routes 里挑公网地址:优先 host 形态,其次第一条带 url 的路由。
73
+ * @param {Array<{kind?: string, url?: string}>|null|undefined} routes
74
+ * @returns {string|null}
75
+ */
76
+ export function pickRoutePublicUrl(routes) {
77
+ if (!Array.isArray(routes)) return null;
78
+ const list = routes.filter((route) => route && typeof route === 'object');
79
+ const host = list.find((route) => route.kind === 'host' && typeof route.url === 'string' && route.url);
80
+ if (host) return host.url;
81
+ const any = list.find((route) => typeof route.url === 'string' && route.url);
82
+ return any ? any.url : null;
83
+ }
84
+
85
+ function normalizeDevicePlatform(value) {
86
+ const text = String(value ?? '').trim();
87
+ return text || 'unknown';
88
+ }
89
+
90
+ export class SaasTunnelClient extends CustomTunnelClient {
91
+ /**
92
+ * @param {object} options
93
+ * @param {string} options.gatewayUrl 数据面网关地址(token 接口返回的 gateway.url,或由控制面地址推导)
94
+ * @param {string} [options.gatewayPath] 控制通道路径,默认 /_gw/connect
95
+ * @param {() => Promise<string|{token: string, gateway?: object, tunnel?: object}>} [options.tokenProvider]
96
+ * 每次(重)连前取新短期令牌;返回对象时可用 gateway/tunnel 覆盖缓存
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,
140
+ localPort,
141
+ internalTunnelSecret,
142
+ signal,
143
+ onStateChange,
144
+ logger,
145
+ sseStreaming,
146
+ });
147
+ this.gatewayUrl = gatewayUrl ?? '';
148
+ this.gatewayPath = gatewayPath || SAAS_DEFAULT_GATEWAY_PATH;
149
+ if (typeof tokenProvider === 'function') {
150
+ this.tokenProvider = tokenProvider;
151
+ } else if (deviceToken) {
152
+ this.tokenProvider = async () => String(deviceToken);
153
+ } else {
154
+ this.tokenProvider = null;
155
+ }
156
+ this.deviceToken = deviceToken ?? null;
157
+ this.deviceInfo = deviceInfo ?? {};
158
+ this.caps = caps ?? {};
159
+ this.protocol = protocol === 'v2' ? 'v2' : 'auto';
160
+ this.protocolVersion = 2;
161
+ // 下限取 10ms 只服务于单测注入;生产配置在 lib/index.js 收敛到 >= 5s
162
+ this.heartbeatIntervalMs = Math.max(10, Number(heartbeatIntervalMs) || SAAS_HEARTBEAT_INTERVAL_MS);
163
+ this.heartbeatMaxMissed = Math.max(1, Number(heartbeatMaxMissed) || SAAS_HEARTBEAT_MAX_MISSED);
164
+ this.reconnectBaseDelayMs = Math.max(10, Number(reconnectBaseDelayMs) || SAAS_RECONNECT_BASE_DELAY_MS);
165
+ this.reconnectMaxDelayMs = Math.max(this.reconnectBaseDelayMs, Number(reconnectMaxDelayMs) || SAAS_RECONNECT_MAX_DELAY_MS);
166
+ this.maxReconnectAttempts = Number.isFinite(maxReconnectAttempts) ? Math.max(0, maxReconnectAttempts) : Number.POSITIVE_INFINITY;
167
+ this._random = typeof random === 'function' ? random : Math.random;
168
+
169
+ this.tunnel = null;
170
+ this.routes = [];
171
+ this.routeRev = 0;
172
+ this.limits = null;
173
+ this.features = null;
174
+ this.sessionId = null;
175
+ this.serverTime = null;
176
+ this.lastAckAt = null;
177
+ this.lastError = null;
178
+ this._missedHeartbeatAcks = 0;
179
+ this._awaitingHeartbeatAck = false;
180
+ this._requestsInFlight = new Set();
181
+ this._bytesIn = 0;
182
+ this._bytesOut = 0;
183
+ this._terminal = false;
184
+ this._draining = false;
185
+ this._drainFinished = false;
186
+ this._drainTimer = null;
187
+ this._drainDeadlineTimer = null;
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;
200
+ }
201
+
202
+ /** 是否正在 drain 优雅退出。 */
203
+ get draining() {
204
+ return this._draining;
205
+ }
206
+
207
+ async connect() {
208
+ if (this.connected || this._connecting) return;
209
+ if (this.disconnecting || this._terminal) return;
210
+ this._connecting = true;
211
+ this._setState('connecting', '正在连接 SaaS 网关…');
212
+ try {
213
+ const tokenInfo = await this._acquireToken();
214
+ this.deviceToken = tokenInfo.token;
215
+ this.serverUrl = tokenInfo.gateway?.url || this.gatewayUrl;
216
+ await this._connectWebSocket();
217
+ this._startHeartbeat();
218
+ this.reconnectAttempts = 0;
219
+ this.lastError = null;
220
+ this.protocolVersion = this.protocolVersion || 2;
221
+ this._setState('ready', this.publicUrl ? `SaaS 隧道已建立 (${this.publicUrl})` : 'SaaS 隧道已建立');
222
+ } catch (err) {
223
+ this.connected = false;
224
+ this._stopHeartbeat();
225
+ const terminal = this._isTerminalError(err) || this._terminal;
226
+ const detail = err?.message ?? String(err);
227
+ this.lastError = detail;
228
+ if (terminal) {
229
+ this._terminal = true;
230
+ this._setState('error', detail);
231
+ } else if (this.disconnecting || this.signal?.aborted) {
232
+ this._setState('idle', '连接已取消');
233
+ } else {
234
+ this._scheduleReconnect(detail);
235
+ }
236
+ throw err;
237
+ } finally {
238
+ this._connecting = false;
239
+ }
240
+ }
241
+
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
+ async _acquireToken() {
249
+ if (!this.tokenProvider) {
250
+ throw new SaasTunnelError('missing_device_credentials', 'SaaS 设备凭证缺失,请重新绑定设备', { terminal: true });
251
+ }
252
+ const result = await this.tokenProvider();
253
+ const token = typeof result === 'string' ? result : result?.token;
254
+ if (!token) {
255
+ throw new SaasTunnelError('missing_device_token', 'SaaS 设备令牌获取失败,请重新绑定设备', { terminal: true });
256
+ }
257
+ if (result && typeof result === 'object') {
258
+ if (result.gateway?.url) {
259
+ this.gatewayUrl = result.gateway.url;
260
+ this.serverUrl = result.gateway.url;
261
+ }
262
+ if (result.gateway?.path) this.gatewayPath = result.gateway.path;
263
+ if (result.tunnel) this.tunnel = result.tunnel;
264
+ }
265
+ return { token: String(token), gateway: { url: this.gatewayUrl, path: this.gatewayPath } };
266
+ }
267
+
268
+ _connectWebSocket() {
269
+ return new Promise((resolve, reject) => {
270
+ if (this.signal?.aborted) {
271
+ reject(new Error('Aborted'));
272
+ return;
273
+ }
274
+
275
+ let ws;
276
+ try {
277
+ ws = new WebSocket(buildSaasConnectUrl({
278
+ gatewayUrl: this.gatewayUrl,
279
+ gatewayPath: this.gatewayPath,
280
+ deviceToken: this.deviceToken,
281
+ }), {
282
+ handshakeTimeout: 10_000,
283
+ headers: this.deviceToken ? { authorization: `Bearer ${this.deviceToken}` } : undefined,
284
+ perMessageDeflate: {
285
+ clientNoContextTakeover: true,
286
+ serverNoContextTakeover: true,
287
+ clientMaxWindowBits: 15,
288
+ serverMaxWindowBits: 15,
289
+ },
290
+ });
291
+ } catch (err) {
292
+ reject(new SaasTunnelError('invalid_gateway_url', err?.message ?? String(err), { terminal: true, cause: err }));
293
+ return;
294
+ }
295
+ this.ws = ws;
296
+
297
+ let settled = false;
298
+ let handshakeTimer = null;
299
+ let handshakeHandler = null;
300
+ const cleanupHandshake = () => {
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 = () => {
319
+ try { ws.terminate(); } catch {}
320
+ settle(new Error('Aborted'));
321
+ };
322
+ this.signal?.addEventListener?.('abort', onAbort, { once: true });
323
+
324
+ ws.on('open', () => {
325
+ this.logger?.info?.('SaaS tunnel WebSocket connected');
326
+ this._sendHello();
327
+ });
328
+
329
+ ws.on('message', (data) => this._handleMessage(data));
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
+
394
+ ws.on('error', (err) => {
395
+ this.logger?.error?.('SaaS tunnel WebSocket error: %s', err?.message ?? err);
396
+ if (!settled) settle(err);
397
+ });
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
+ });
405
+ }
406
+
407
+ _sendHello() {
408
+ const device = {};
409
+ for (const key of ['name', 'platform', 'hostname', 'pluginVersion', 'dshVersion']) {
410
+ const value = this.deviceInfo?.[key];
411
+ if (value !== undefined && value !== null && String(value).trim()) device[key] = String(value).trim();
412
+ }
413
+ if (!device.name) device.name = device.hostname || 'DSH Bridge';
414
+ if (!device.platform) device.platform = normalizeDevicePlatform(undefined);
415
+ const hello = {
416
+ type: 'hello',
417
+ v: 2,
418
+ device,
419
+ caps: {
420
+ sseStreaming: Boolean(this.sseStreaming),
421
+ wsFrames: true,
422
+ maxResponseBytes: 32 * 1024 * 1024,
423
+ ...(this.caps ?? {}),
424
+ },
425
+ 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;
464
+ }
465
+
466
+ _handleMessage(data) {
467
+ let msg;
468
+ try {
469
+ msg = JSON.parse(typeof data === 'string' ? data : data.toString());
470
+ } catch (err) {
471
+ this.logger?.error?.('Failed to parse SaaS tunnel message: %s', err?.message ?? err);
472
+ return;
473
+ }
474
+ switch (msg?.type) {
475
+ case 'heartbeat-ack':
476
+ this._handleHeartbeatAck(msg);
477
+ return;
478
+ case 'route-update':
479
+ this._handleRouteUpdate(msg);
480
+ return;
481
+ case 'drain':
482
+ this._handleDrain(msg);
483
+ return;
484
+ case 'ping':
485
+ this._sendMessage({ type: 'pong', ts: Date.now() });
486
+ return;
487
+ case 'hello-ack':
488
+ case 'ready':
489
+ case 'pong':
490
+ // 握手由一次性 handler 负责;运行期重复的 hello-ack/ready 不改变已建立状态
491
+ if (msg.type === 'ready' && this.connected && !this._publicUrlLockedByRoutes) {
492
+ if (msg.publicUrl) this.publicUrl = String(msg.publicUrl);
493
+ }
494
+ return;
495
+ case 'request':
496
+ this._handleHttpRequest(msg);
497
+ return;
498
+ case 'ws-open':
499
+ this._handleWsOpen(msg);
500
+ return;
501
+ case 'ws-frame':
502
+ this._handleWsFrame(msg);
503
+ return;
504
+ case 'ws-close':
505
+ this._handleWsClose(msg);
506
+ return;
507
+ default:
508
+ // 未知 type 必须静默忽略(v1 前后兼容)
509
+ return;
510
+ }
511
+ }
512
+
513
+ _handleHeartbeatAck(msg) {
514
+ this._awaitingHeartbeatAck = false;
515
+ this._missedHeartbeatAcks = 0;
516
+ this.lastAckAt = Date.now();
517
+ if (msg?.serverTime) this.serverTime = msg.serverTime;
518
+ const actions = Array.isArray(msg?.actions) ? msg.actions : [];
519
+ if (actions.includes('drain') && !this._draining) {
520
+ this._startDrain({ deadline: Date.now() + this.heartbeatIntervalMs * 3 });
521
+ }
522
+ }
523
+
524
+ _handleRouteUpdate(msg) {
525
+ this.routeRev = Number(msg?.rev) || this.routeRev;
526
+ this._applyRoutes(msg?.routes);
527
+ this.logger?.info?.('SaaS tunnel route updated (rev=%s, publicUrl=%s)', this.routeRev, this.publicUrl ?? '');
528
+ this.onRouteUpdate?.({ routes: this.routes, rev: this.routeRev, publicUrl: this.publicUrl });
529
+ }
530
+
531
+ _handleDrain(msg) {
532
+ let deadline = Number(msg?.deadline);
533
+ if (!Number.isFinite(deadline) || deadline <= 0) deadline = Date.now() + this.heartbeatIntervalMs * 3;
534
+ else if (deadline < 1e12) deadline *= 1000; // 秒级时间戳
535
+ this._startDrain({ deadline });
536
+ }
537
+
538
+ _startDrain({ deadline } = {}) {
539
+ if (this._draining) return;
540
+ this._draining = true;
541
+ const deadlineMs = Number.isFinite(deadline) && deadline > 0 ? deadline : Date.now() + this.heartbeatIntervalMs * 3;
542
+ this._setState('draining', 'SaaS 网关正在下线,等待在途请求结束后断开');
543
+ this.onDrain?.({ deadline: deadlineMs });
544
+ this.logger?.info?.('SaaS tunnel draining (deadline=%s, inFlight=%d)', deadlineMs, this._requestsInFlight.size);
545
+ const poll = () => {
546
+ if (this._drainFinished) return;
547
+ if (this._requestsInFlight.size === 0 || Date.now() >= deadlineMs) {
548
+ this._finishDrain();
549
+ return;
550
+ }
551
+ const waitMs = Math.min(250, Math.max(20, deadlineMs - Date.now()));
552
+ this._drainTimer = setTimeout(poll, waitMs);
553
+ };
554
+ this._drainTimer = setTimeout(poll, 20);
555
+ this._drainDeadlineTimer = setTimeout(() => this._finishDrain(), Math.max(0, deadlineMs - Date.now()) + 1000);
556
+ }
557
+
558
+ _finishDrain() {
559
+ if (this._drainFinished) return;
560
+ this._drainFinished = true;
561
+ this._clearDrainTimers();
562
+ this.connected = false;
563
+ this._stopHeartbeat();
564
+ this._cleanupLocalWs();
565
+ this._requestsInFlight.clear();
566
+ const detail = 'SaaS 网关已下线(drain),隧道已优雅断开';
567
+ this.lastError = detail;
568
+ this._setState('closed', detail);
569
+ this.onDrainComplete?.({ detail });
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
+ }
582
+ }
583
+
584
+ // ── 转发语义复用父类,仅补计数/在途/drain 拒绝 ─────────────────────────
585
+ _handleHttpRequest(msg) {
586
+ if (this._draining) {
587
+ this._sendMessage({
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
+ });
594
+ return;
595
+ }
596
+ if (msg?.requestId) this._requestsInFlight.add(msg.requestId);
597
+ if (typeof msg?.body === 'string' && msg.body) {
598
+ this._bytesIn += Math.floor((msg.body.length * 3) / 4);
599
+ }
600
+ super._handleHttpRequest(msg);
601
+ }
602
+
603
+ _handleWsOpen(msg) {
604
+ if (this._draining) {
605
+ this._sendMessage({
606
+ type: 'ws-accept',
607
+ wsId: msg?.wsId,
608
+ statusCode: 503,
609
+ statusMessage: 'Service Unavailable',
610
+ replyHeaders: { 'content-type': 'text/plain' },
611
+ });
612
+ return;
613
+ }
614
+ super._handleWsOpen(msg);
615
+ }
616
+
617
+ _sendMessage(msg) {
618
+ if (msg && typeof msg === 'object') {
619
+ if ((msg.type === 'response' || msg.type === 'response-end') && msg.requestId) {
620
+ this._requestsInFlight.delete(msg.requestId);
621
+ }
622
+ if (typeof msg.body === 'string' && msg.body) {
623
+ this._bytesOut += Math.floor((msg.body.length * 3) / 4);
624
+ }
625
+ if (typeof msg.data === 'string' && msg.data) {
626
+ this._bytesOut += Math.floor((msg.data.length * 3) / 4);
627
+ }
628
+ }
629
+ super._sendMessage(msg);
630
+ }
631
+
632
+ _heartbeatState() {
633
+ return {
634
+ requestsInFlight: this._requestsInFlight.size,
635
+ wsOpen: this.localWsSockets.size,
636
+ bytesIn: this._bytesIn,
637
+ bytesOut: this._bytesOut,
638
+ ...(this.lastError ? { lastError: String(this.lastError).slice(0, 500) } : {}),
639
+ };
640
+ }
641
+
642
+ _heartbeatTick() {
643
+ if (!this.connected || this._draining) return;
644
+ if (this._awaitingHeartbeatAck) {
645
+ this._missedHeartbeatAcks += 1;
646
+ if (this._missedHeartbeatAcks >= this.heartbeatMaxMissed) {
647
+ const detail = `SaaS 网关心跳连续 ${this._missedHeartbeatAcks} 次未确认,主动重连`;
648
+ this.lastError = detail;
649
+ this._setState('reconnecting', detail);
650
+ this._stopHeartbeat();
651
+ try { this.ws?.terminate(); } catch {}
652
+ return;
653
+ }
654
+ }
655
+ this._awaitingHeartbeatAck = true;
656
+ this._sendMessage({ type: 'heartbeat', ts: Date.now(), state: this._heartbeatState() });
657
+ }
658
+
659
+ _startHeartbeat() {
660
+ this._stopHeartbeat();
661
+ this._missedHeartbeatAcks = 0;
662
+ this._awaitingHeartbeatAck = false;
663
+ this.heartbeatTimer = setInterval(() => this._heartbeatTick(), this.heartbeatIntervalMs);
664
+ this._heartbeatTick();
665
+ }
666
+
667
+ _scheduleReconnect(reason) {
668
+ if (this.signal?.aborted || this.disconnecting || this._terminal || this._draining) return;
669
+ if (this.reconnectTimer) return;
670
+ if (Number.isFinite(this.maxReconnectAttempts) && this.reconnectAttempts >= this.maxReconnectAttempts) {
671
+ const detail = `SaaS 隧道重连已达上限 (${this.maxReconnectAttempts} 次)`;
672
+ this.lastError = detail;
673
+ this._setState('error', detail);
674
+ return;
675
+ }
676
+ this.reconnectAttempts += 1;
677
+ const attempt = this.reconnectAttempts;
678
+ const baseDelay = Math.min(this.reconnectBaseDelayMs * 2 ** (attempt - 1), this.reconnectMaxDelayMs);
679
+ const jitter = 0.75 + this._random() * 0.5;
680
+ const delay = Math.max(50, Math.round(baseDelay * jitter));
681
+ const seconds = Math.round((delay / 1000) * 10) / 10;
682
+ this._setState('reconnecting', `SaaS 隧道断开${reason ? `:${reason}` : ''},${seconds}s 后重连(第 ${attempt} 次)`);
683
+ this.reconnectTimer = setTimeout(() => {
684
+ this.reconnectTimer = null;
685
+ this.connect().catch(() => {});
686
+ }, delay);
687
+ }
688
+
689
+ disconnect() {
690
+ this._clearDrainTimers();
691
+ this._drainFinished = true;
692
+ this._requestsInFlight.clear();
693
+ super.disconnect();
694
+ }
695
+ }