@qzsy/dsh-bridge 2.18.0 → 3.0.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 (81) hide show
  1. package/CHANGELOG.md +12 -1098
  2. package/LICENSE +21 -21
  3. package/README.md +72 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +382 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +302 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
@@ -1,33 +1,27 @@
1
- // DSH Bridge - SaaS 隧道客户端(控制通道协议 v2)
1
+ // SaaS 隧道客户端(控制通道协议 v2)——独立实现,不继承任何第三方隧道类。
2
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
-
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 { CustomTunnelClient } from '../tunnel-client.mjs';
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
- /** 控制通道关闭码: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']);
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 网关连接地址不是合法 URL: ${gatewayUrl}`);
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 网关连接地址只支持 ws/wss/http/https');
45
+ throw new SaasTunnelError('invalid_gateway_url', 'SaaS 网关地址只支持 ws/wss/http/https', { terminal: true });
60
46
  }
61
- const path = String(gatewayPath ?? '') || SAAS_DEFAULT_GATEWAY_PATH;
62
- const normalizedPath = path.startsWith('/') ? path : `/${path}`;
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 = `${basePath}${normalizedPath}`.replace(/\/{2,}/g, '/');
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
- if (!Array.isArray(routes)) return null;
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 any ? any.url : null;
61
+ return String((host ?? any)?.url ?? '');
83
62
  }
84
63
 
85
- function normalizeDevicePlatform(value) {
86
- const text = String(value ?? '').trim();
87
- return text || 'unknown';
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 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,
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
- signal,
143
- onStateChange,
144
- logger,
145
- sseStreaming,
146
- });
147
- this.gatewayUrl = gatewayUrl ?? '';
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
- 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';
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._random = typeof random === 'function' ? random : Math.random;
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.routes = [];
171
- this.routeRev = 0;
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.lastAckAt = null;
177
- this.lastError = null;
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._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;
158
+ this._suppressReconnect = false;
200
159
  }
201
160
 
202
- /** 是否正在 drain 优雅退出。 */
203
- get draining() {
204
- return this._draining;
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.connected || this._connecting) return;
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 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;
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.protocolVersion = this.protocolVersion || 2;
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.connected = false;
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.disconnecting || this.signal?.aborted) {
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', 'SaaS 设备凭证缺失,请重新绑定设备', { terminal: true });
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
- return { token: String(token), gateway: { url: this.gatewayUrl, path: this.gatewayPath } };
221
+ this.deviceToken = String(token);
222
+ return this.deviceToken;
266
223
  }
267
224
 
268
- _connectWebSocket() {
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: this.deviceToken,
232
+ deviceToken,
281
233
  }), {
282
234
  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
- },
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.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 = () => {
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
- settle(new Error('Aborted'));
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 connected');
253
+ this.logger?.info?.('SaaS tunnel WebSocket open');
326
254
  this._sendHello();
327
255
  });
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
-
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?.('SaaS tunnel WebSocket error: %s', err?.message ?? err);
396
- if (!settled) settle(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)));
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 Bridge';
414
- if (!device.platform) device.platform = normalizeDevicePlatform(undefined);
415
- const hello = {
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
- _handleMessage(data) {
297
+ _onMessage(data) {
467
298
  let msg;
468
299
  try {
469
300
  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);
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?.type) {
318
+ switch (msg.type) {
475
319
  case 'heartbeat-ack':
476
- this._handleHeartbeatAck(msg);
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._handleRouteUpdate(msg);
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
- case 'pong':
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._handleHttpRequest(msg);
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
- _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 });
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
- _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 });
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
- 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);
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._drainFinished) return;
560
- this._drainFinished = true;
561
- this._clearDrainTimers();
562
- this.connected = false;
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._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
- }
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
- // ── 转发语义复用父类,仅补计数/在途/drain 拒绝 ─────────────────────────
585
- _handleHttpRequest(msg) {
440
+ _handleRequest(msg) {
441
+ const requestId = String(msg?.requestId ?? '');
442
+ if (!requestId) return;
586
443
  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
- });
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
- 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);
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._sendMessage({
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
- super._handleWsOpen(msg);
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
- _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);
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
- if (typeof msg.data === 'string' && msg.data) {
626
- this._bytesOut += Math.floor((msg.data.length * 3) / 4);
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
- super._sendMessage(msg);
635
+ this._localWs.clear();
630
636
  }
631
637
 
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
- };
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.connected || this._draining) return;
644
- if (this._awaitingHeartbeatAck) {
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.ws?.terminate(); } catch {}
654
+ try { this._ws?.terminate(); } catch {}
652
655
  return;
653
656
  }
654
657
  }
655
- this._awaitingHeartbeatAck = true;
656
- this._sendMessage({ type: 'heartbeat', ts: Date.now(), state: this._heartbeatState() });
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._awaitingHeartbeatAck = false;
663
- this.heartbeatTimer = setInterval(() => this._heartbeatTick(), this.heartbeatIntervalMs);
676
+ this._heartbeatTimer = setInterval(() => this._heartbeatTick(), this.heartbeatIntervalMs);
677
+ this._heartbeatTimer.unref?.();
664
678
  this._heartbeatTick();
665
679
  }
666
680
 
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} 次)`;
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.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(() => {});
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
- disconnect() {
690
- this._clearDrainTimers();
691
- this._drainFinished = true;
692
- this._requestsInFlight.clear();
693
- super.disconnect();
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
  }