lightclawbot 1.2.9 → 1.2.10
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/dist/src/config.js +12 -0
- package/dist/src/gateway.js +43 -7
- package/dist/src/socket/native-socket.js +186 -23
- package/package.json +1 -1
package/dist/src/config.js
CHANGED
|
@@ -42,6 +42,18 @@ export const SOCKET_PATH = '/ws/agent';
|
|
|
42
42
|
export const SOCKET_RECONNECTION_DELAY = 1000;
|
|
43
43
|
export const SOCKET_RECONNECTION_DELAY_MAX = 30000;
|
|
44
44
|
export const SOCKET_RECONNECTION_ATTEMPTS = Infinity;
|
|
45
|
+
/** 首次重连随机抖动上限(ms),用于打散服务发布后的集中重连 */
|
|
46
|
+
export const SOCKET_RECONNECTION_INITIAL_JITTER_MAX = 10_000;
|
|
47
|
+
/** 后续指数退避随机抖动比例,0.5 表示在 50%~150% 区间内随机 */
|
|
48
|
+
export const SOCKET_RECONNECTION_JITTER_RATIO = 0.5;
|
|
49
|
+
/** WebSocket 协议级 ping 间隔(ms),需小于 ai-server 60s 在线 TTL */
|
|
50
|
+
export const WS_PING_INTERVAL_MS = 20_000;
|
|
51
|
+
/** WebSocket heartbeat 首次 ping 随机抖动上限(ms),用于避免重连后 ping 周期对齐 */
|
|
52
|
+
export const WS_HEARTBEAT_INITIAL_JITTER_MAX = WS_PING_INTERVAL_MS;
|
|
53
|
+
/** WebSocket pong 等待超时时间(ms) */
|
|
54
|
+
export const WS_PONG_TIMEOUT_MS = 10_000;
|
|
55
|
+
/** 连续 pong 超时次数阈值,达到后判定连接半开并主动重连 */
|
|
56
|
+
export const WS_MAX_MISSED_PONG = 2;
|
|
45
57
|
// ============================================================
|
|
46
58
|
// HTTP Header 配置
|
|
47
59
|
// ============================================================
|
package/dist/src/gateway.js
CHANGED
|
@@ -77,6 +77,30 @@ async function resolveBotClientId(apiKey, log) {
|
|
|
77
77
|
ticket: result?.data?.ticket || '',
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* 获取 Bot clientId 与 ticket,失败或 ticket 为空时额外重试一次。
|
|
82
|
+
* @param apiKey - 当前账户的 apiKey
|
|
83
|
+
* @param log - 可选日志对象
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveBotClientIdWithRetry(apiKey, log) {
|
|
86
|
+
let lastError;
|
|
87
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
88
|
+
try {
|
|
89
|
+
const result = await resolveBotClientId(apiKey, log);
|
|
90
|
+
if (result.ticket || attempt === 1)
|
|
91
|
+
return result;
|
|
92
|
+
lastError = new Error(`[${CHANNEL_KEY}] Missing ticket in ${API_PATH_USER_CURRENT} response`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
lastError = error;
|
|
96
|
+
}
|
|
97
|
+
if (attempt === 0) {
|
|
98
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
|
99
|
+
log?.warn(`[${CHANNEL_KEY}] Failed to resolve ticket, retrying once: ${message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
103
|
+
}
|
|
80
104
|
// ============================================================
|
|
81
105
|
// 核心:启动 Gateway
|
|
82
106
|
// ============================================================
|
|
@@ -112,7 +136,7 @@ export async function startGateway(ctx) {
|
|
|
112
136
|
// 通过 HTTP 接口获取 Bot 的 clientId(botClientId)
|
|
113
137
|
// 新配置格式:accountId 即为 uin,apiKey 与 uin 一一对应,直接构建映射表
|
|
114
138
|
log?.info(`${prefix} Resolving botClientId for account ${account.accountId}...`);
|
|
115
|
-
const { botClientId, ticket } = await
|
|
139
|
+
const { botClientId, ticket: initialTicket } = await resolveBotClientIdWithRetry(account.apiKey, log);
|
|
116
140
|
// 一次性构建全局 uin→apiKey 映射(遍历所有 accounts,幂等操作)
|
|
117
141
|
// 解决多账号场景下每个 gateway 启动时覆盖全局 Map 的问题
|
|
118
142
|
buildGlobalApiKeyMap(cfg);
|
|
@@ -306,14 +330,26 @@ export async function startGateway(ctx) {
|
|
|
306
330
|
// 将 cleanup 注册到全局 Map,供防重入检查和外部销毁使用
|
|
307
331
|
activeGateways.set(account.accountId, cleanup);
|
|
308
332
|
// ---- WebSocket 连接 ----
|
|
309
|
-
// 采用 ticket 作为 query 参数进行认证,不再使用 header
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
333
|
+
// 采用 ticket 作为 query 参数进行认证,不再使用 header 方式。
|
|
334
|
+
// ai-server ticket 是一次性凭证,因此每次建连/重连前都必须重新获取 fresh ticket。
|
|
335
|
+
// 首次连接复用启动阶段已获取的 ticket;后续重连再重新请求,避免浪费未消费 ticket。
|
|
336
|
+
let nextTicket = initialTicket;
|
|
337
|
+
const buildSocketQuery = async () => {
|
|
338
|
+
const freshTicket = nextTicket || (await resolveBotClientIdWithRetry(account.apiKey, log)).ticket;
|
|
339
|
+
nextTicket = '';
|
|
340
|
+
if (!freshTicket) {
|
|
341
|
+
throw new Error(`[${CHANNEL_KEY}] Missing ticket in ${API_PATH_USER_CURRENT} response`);
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
ticket: freshTicket,
|
|
345
|
+
enableMultiLogin: false,
|
|
346
|
+
};
|
|
347
|
+
};
|
|
313
348
|
// 使用原生 WebSocket 封装层建立连接
|
|
314
349
|
const socket = new NativeSocketClient(WS_URL, {
|
|
315
|
-
//
|
|
316
|
-
path:
|
|
350
|
+
// 认证:每次连接前由动态 query 获取 fresh ticket,避免自动重连复用旧 ticket 触发 401。
|
|
351
|
+
path: SOCKET_PATH,
|
|
352
|
+
query: buildSocketQuery,
|
|
317
353
|
logPrefix: `[${CHANNEL_KEY}:${account.accountId}:NativeSocket]`,
|
|
318
354
|
});
|
|
319
355
|
currentSocket = socket;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* ACK :{ event: 'message:ack'; data: { msgId: string; relatedMsgId: string; ... } }
|
|
14
14
|
*/
|
|
15
15
|
import WebSocket from 'ws';
|
|
16
|
-
import { SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_DELAY_MAX, SOCKET_RECONNECTION_ATTEMPTS } from '../config.js';
|
|
16
|
+
import { SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_DELAY_MAX, SOCKET_RECONNECTION_ATTEMPTS, SOCKET_RECONNECTION_INITIAL_JITTER_MAX, SOCKET_RECONNECTION_JITTER_RATIO, WS_PING_INTERVAL_MS, WS_HEARTBEAT_INITIAL_JITTER_MAX, WS_PONG_TIMEOUT_MS, WS_MAX_MISSED_PONG, } from '../config.js';
|
|
17
17
|
import { getModuleLogger } from '../utils/logger.js';
|
|
18
18
|
/** 模块级日志器 */
|
|
19
19
|
const moduleLog = getModuleLogger('socket.native-socket');
|
|
@@ -25,8 +25,11 @@ const moduleLog = getModuleLogger('socket.native-socket');
|
|
|
25
25
|
*
|
|
26
26
|
* 使用方式:
|
|
27
27
|
* ```ts
|
|
28
|
-
* //
|
|
29
|
-
* const socket = new NativeSocketClient(
|
|
28
|
+
* // path 仅表达路径;ticket 等鉴权参数通过 query 传入,可用异步函数在重连前刷新
|
|
29
|
+
* const socket = new NativeSocketClient(WS_URL, {
|
|
30
|
+
* path: '/ws/agent',
|
|
31
|
+
* query: async () => ({ ticket: await fetchTicket() }),
|
|
32
|
+
* });
|
|
30
33
|
* socket.on('connect', () => { ... });
|
|
31
34
|
* socket.on('message:private', (data) => { ... });
|
|
32
35
|
* socket.timeout(5000).emit('message:private', data, (err) => { ... });
|
|
@@ -42,6 +45,8 @@ export class NativeSocketClient {
|
|
|
42
45
|
_connected = false;
|
|
43
46
|
_id = undefined;
|
|
44
47
|
_ws = null;
|
|
48
|
+
/** 是否正在构建或发起连接,避免异步 query 获取期间重复建连 */
|
|
49
|
+
_connecting = false;
|
|
45
50
|
/** 是否为主动断开(主动断开时不触发自动重连) */
|
|
46
51
|
_manualDisconnect = false;
|
|
47
52
|
// ----------------------------------------------------------
|
|
@@ -50,6 +55,14 @@ export class NativeSocketClient {
|
|
|
50
55
|
_reconnectAttempts = 0;
|
|
51
56
|
_reconnectTimer = null;
|
|
52
57
|
// ----------------------------------------------------------
|
|
58
|
+
// WebSocket 协议级心跳状态
|
|
59
|
+
// ----------------------------------------------------------
|
|
60
|
+
_heartbeatInitialTimer = null;
|
|
61
|
+
_pingTimer = null;
|
|
62
|
+
_pongTimeoutTimer = null;
|
|
63
|
+
_lastPingAt = 0;
|
|
64
|
+
_missedPongCount = 0;
|
|
65
|
+
// ----------------------------------------------------------
|
|
53
66
|
// 事件监听器映射表
|
|
54
67
|
// ----------------------------------------------------------
|
|
55
68
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -67,16 +80,17 @@ export class NativeSocketClient {
|
|
|
67
80
|
/** 日志前缀,便于在多实例场景下区分日志来源。 */
|
|
68
81
|
logPrefix;
|
|
69
82
|
/**
|
|
70
|
-
* @param url WebSocket 服务端地址(ws:// 或 wss
|
|
83
|
+
* @param url WebSocket 服务端地址(ws:// 或 wss://)
|
|
71
84
|
* @param options 连接选项
|
|
72
|
-
* - path: 连接路径(如 /
|
|
85
|
+
* - path: 连接路径(如 /ws/agent),仅表达路径语义
|
|
86
|
+
* - query: 静态或动态查询参数;ticket 等鉴权参数应放在这里
|
|
73
87
|
* - logPrefix: 日志前缀(可选),默认 `[NativeSocket]`
|
|
74
88
|
*/
|
|
75
89
|
constructor(url, options = {}) {
|
|
76
90
|
this.url = url;
|
|
77
91
|
this.options = options;
|
|
78
92
|
this.logPrefix = options.logPrefix ?? '[NativeSocket]';
|
|
79
|
-
this._connect();
|
|
93
|
+
void this._connect();
|
|
80
94
|
}
|
|
81
95
|
// ----------------------------------------------------------
|
|
82
96
|
// NativeSocket 接口实现
|
|
@@ -184,38 +198,63 @@ export class NativeSocketClient {
|
|
|
184
198
|
connect() {
|
|
185
199
|
// 手动触发重连(用于服务端主动断开后的手动重连场景)
|
|
186
200
|
// 若当前已有活跃连接(CONNECTING 或 OPEN),直接返回,避免连接泄漏
|
|
187
|
-
if (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN)) {
|
|
188
|
-
this.log.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws
|
|
201
|
+
if (this._connecting || (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN))) {
|
|
202
|
+
this.log.debug?.(`${this.logPrefix} connect() skipped, already ${this._ws?.readyState === WebSocket.OPEN ? 'OPEN' : 'CONNECTING'}`);
|
|
189
203
|
return this;
|
|
190
204
|
}
|
|
191
205
|
this.log.info(`${this.logPrefix} connect() called, manually reconnecting`);
|
|
192
206
|
this._manualDisconnect = false;
|
|
193
207
|
this._reconnectAttempts = 0;
|
|
194
208
|
this._clearReconnectTimer();
|
|
195
|
-
this._connect();
|
|
209
|
+
void this._connect();
|
|
196
210
|
return this;
|
|
197
211
|
}
|
|
198
212
|
// ----------------------------------------------------------
|
|
199
213
|
// 内部:建立 WebSocket 连接
|
|
200
214
|
// ----------------------------------------------------------
|
|
201
|
-
_connect() {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
215
|
+
async _connect() {
|
|
216
|
+
if (this._connecting || (this._ws && (this._ws.readyState === WebSocket.CONNECTING || this._ws.readyState === WebSocket.OPEN))) {
|
|
217
|
+
this.log.debug?.(`${this.logPrefix} _connect() skipped, connection already in progress or open`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this._connecting = true;
|
|
221
|
+
let fullUrl;
|
|
222
|
+
try {
|
|
223
|
+
// 构建完整 URL(仅拼接 path,认证通过 URL query 参数完成,无需 headers)
|
|
224
|
+
fullUrl = await this._buildUrl();
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
this._connecting = false;
|
|
228
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
229
|
+
this.log.error(`${this.logPrefix} Failed to build WebSocket URL: ${message}`);
|
|
230
|
+
this._dispatch('connect_error', err instanceof Error ? err : new Error(message));
|
|
231
|
+
if (!this._manualDisconnect) {
|
|
232
|
+
this._scheduleReconnect();
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
this.log.info(`${this.logPrefix} Connecting to ${this._maskUrl(fullUrl)}`);
|
|
205
237
|
try {
|
|
206
238
|
const ws = new WebSocket(fullUrl);
|
|
207
239
|
this._ws = ws;
|
|
208
240
|
ws.onopen = () => {
|
|
241
|
+
this._connecting = false;
|
|
209
242
|
this._reconnectAttempts = 0;
|
|
210
243
|
this._connected = true;
|
|
211
244
|
this.log.info(`${this.logPrefix} WebSocket opened`);
|
|
245
|
+
this._startHeartbeat();
|
|
212
246
|
this._dispatch('connect');
|
|
213
247
|
};
|
|
214
248
|
ws.onmessage = (event) => {
|
|
215
249
|
this._handleMessage(event.data);
|
|
216
250
|
};
|
|
251
|
+
ws.on('pong', () => {
|
|
252
|
+
this._handleHeartbeatPong();
|
|
253
|
+
});
|
|
217
254
|
ws.onclose = (event) => {
|
|
218
255
|
const wasConnected = this._connected;
|
|
256
|
+
this._stopHeartbeat();
|
|
257
|
+
this._connecting = false;
|
|
219
258
|
this._connected = false;
|
|
220
259
|
this._id = undefined;
|
|
221
260
|
this._ws = null;
|
|
@@ -238,6 +277,7 @@ export class NativeSocketClient {
|
|
|
238
277
|
};
|
|
239
278
|
}
|
|
240
279
|
catch (err) {
|
|
280
|
+
this._connecting = false;
|
|
241
281
|
const message = err instanceof Error ? err.message : String(err);
|
|
242
282
|
this.log.error(`${this.logPrefix} Failed to construct WebSocket: ${message}`);
|
|
243
283
|
this._dispatch('connect_error', err instanceof Error ? err : new Error(String(err)));
|
|
@@ -314,8 +354,103 @@ export class NativeSocketClient {
|
|
|
314
354
|
}
|
|
315
355
|
}
|
|
316
356
|
// ----------------------------------------------------------
|
|
357
|
+
// 内部:WebSocket 协议级心跳
|
|
358
|
+
// ----------------------------------------------------------
|
|
359
|
+
_startHeartbeat() {
|
|
360
|
+
this._stopHeartbeat();
|
|
361
|
+
this._missedPongCount = 0;
|
|
362
|
+
const initialDelay = this._randomDelay(0, WS_HEARTBEAT_INITIAL_JITTER_MAX);
|
|
363
|
+
this.log.info(`${this.logPrefix} WebSocket heartbeat started (initialDelay=${initialDelay}ms, interval=${WS_PING_INTERVAL_MS}ms, timeout=${WS_PONG_TIMEOUT_MS}ms, maxMissed=${WS_MAX_MISSED_PONG})`);
|
|
364
|
+
this._heartbeatInitialTimer = setTimeout(() => {
|
|
365
|
+
this._heartbeatInitialTimer = null;
|
|
366
|
+
this._sendHeartbeatPing();
|
|
367
|
+
this._pingTimer = setInterval(() => {
|
|
368
|
+
this._sendHeartbeatPing();
|
|
369
|
+
}, WS_PING_INTERVAL_MS);
|
|
370
|
+
}, initialDelay);
|
|
371
|
+
}
|
|
372
|
+
_stopHeartbeat() {
|
|
373
|
+
if (this._heartbeatInitialTimer !== null) {
|
|
374
|
+
clearTimeout(this._heartbeatInitialTimer);
|
|
375
|
+
this._heartbeatInitialTimer = null;
|
|
376
|
+
}
|
|
377
|
+
if (this._pingTimer !== null) {
|
|
378
|
+
clearInterval(this._pingTimer);
|
|
379
|
+
this._pingTimer = null;
|
|
380
|
+
}
|
|
381
|
+
if (this._pongTimeoutTimer !== null) {
|
|
382
|
+
clearTimeout(this._pongTimeoutTimer);
|
|
383
|
+
this._pongTimeoutTimer = null;
|
|
384
|
+
}
|
|
385
|
+
this._lastPingAt = 0;
|
|
386
|
+
this._missedPongCount = 0;
|
|
387
|
+
}
|
|
388
|
+
_sendHeartbeatPing() {
|
|
389
|
+
if (!this._ws || this._ws.readyState !== WebSocket.OPEN)
|
|
390
|
+
return;
|
|
391
|
+
if (this._pongTimeoutTimer !== null) {
|
|
392
|
+
clearTimeout(this._pongTimeoutTimer);
|
|
393
|
+
this._pongTimeoutTimer = null;
|
|
394
|
+
}
|
|
395
|
+
this._lastPingAt = Date.now();
|
|
396
|
+
try {
|
|
397
|
+
this._ws.ping();
|
|
398
|
+
this.log.debug?.(`${this.logPrefix} WebSocket ping sent`);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
402
|
+
this._terminateStaleConnection(`ping failed: ${message}`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
this._pongTimeoutTimer = setTimeout(() => {
|
|
406
|
+
this._pongTimeoutTimer = null;
|
|
407
|
+
this._missedPongCount++;
|
|
408
|
+
this.log.warn(`${this.logPrefix} WebSocket pong timeout (missed=${this._missedPongCount}/${WS_MAX_MISSED_PONG})`);
|
|
409
|
+
if (this._missedPongCount >= WS_MAX_MISSED_PONG) {
|
|
410
|
+
this._terminateStaleConnection('pong timeout');
|
|
411
|
+
}
|
|
412
|
+
}, WS_PONG_TIMEOUT_MS);
|
|
413
|
+
}
|
|
414
|
+
_handleHeartbeatPong() {
|
|
415
|
+
const rtt = this._lastPingAt > 0 ? Date.now() - this._lastPingAt : 0;
|
|
416
|
+
if (this._pongTimeoutTimer !== null) {
|
|
417
|
+
clearTimeout(this._pongTimeoutTimer);
|
|
418
|
+
this._pongTimeoutTimer = null;
|
|
419
|
+
}
|
|
420
|
+
this._missedPongCount = 0;
|
|
421
|
+
this.log.debug?.(`${this.logPrefix} WebSocket pong received (rtt=${rtt}ms)`);
|
|
422
|
+
}
|
|
423
|
+
_terminateStaleConnection(reason) {
|
|
424
|
+
if (!this._ws)
|
|
425
|
+
return;
|
|
426
|
+
this.log.warn(`${this.logPrefix} WebSocket stale detected: ${reason}, terminating connection`);
|
|
427
|
+
this._stopHeartbeat();
|
|
428
|
+
try {
|
|
429
|
+
this._ws.terminate();
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
433
|
+
this.log.error(`${this.logPrefix} WebSocket terminate failed: ${message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// ----------------------------------------------------------
|
|
317
437
|
// 内部:自动重连
|
|
318
438
|
// ----------------------------------------------------------
|
|
439
|
+
_randomDelay(min, max) {
|
|
440
|
+
const normalizedMin = Math.max(0, Math.floor(min));
|
|
441
|
+
const normalizedMax = Math.max(normalizedMin, Math.floor(max));
|
|
442
|
+
return normalizedMin + Math.floor(Math.random() * (normalizedMax - normalizedMin + 1));
|
|
443
|
+
}
|
|
444
|
+
_getReconnectDelay() {
|
|
445
|
+
const baseDelay = Math.min(SOCKET_RECONNECTION_DELAY * Math.pow(2, this._reconnectAttempts), SOCKET_RECONNECTION_DELAY_MAX);
|
|
446
|
+
if (this._reconnectAttempts === 0) {
|
|
447
|
+
return this._randomDelay(SOCKET_RECONNECTION_DELAY, SOCKET_RECONNECTION_INITIAL_JITTER_MAX);
|
|
448
|
+
}
|
|
449
|
+
const jitterRange = Math.max(0, SOCKET_RECONNECTION_JITTER_RATIO);
|
|
450
|
+
const minDelay = Math.floor(baseDelay * Math.max(0, 1 - jitterRange));
|
|
451
|
+
const maxDelay = Math.min(SOCKET_RECONNECTION_DELAY_MAX, Math.floor(baseDelay * (1 + jitterRange)));
|
|
452
|
+
return this._randomDelay(minDelay, maxDelay);
|
|
453
|
+
}
|
|
319
454
|
_scheduleReconnect() {
|
|
320
455
|
// 超过最大重连次数时停止重连
|
|
321
456
|
if (SOCKET_RECONNECTION_ATTEMPTS !== Infinity && this._reconnectAttempts >= SOCKET_RECONNECTION_ATTEMPTS) {
|
|
@@ -323,14 +458,13 @@ export class NativeSocketClient {
|
|
|
323
458
|
this._dispatch('connect_error', new Error(`Max reconnection attempts (${SOCKET_RECONNECTION_ATTEMPTS}) reached`));
|
|
324
459
|
return;
|
|
325
460
|
}
|
|
326
|
-
|
|
327
|
-
const delay = Math.min(SOCKET_RECONNECTION_DELAY * Math.pow(2, this._reconnectAttempts), SOCKET_RECONNECTION_DELAY_MAX);
|
|
461
|
+
const delay = this._getReconnectDelay();
|
|
328
462
|
this._reconnectAttempts++;
|
|
329
|
-
this.log.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms`);
|
|
463
|
+
this.log.warn(`${this.logPrefix} Scheduling reconnect attempt #${this._reconnectAttempts} in ${delay}ms (jittered)`);
|
|
330
464
|
this._reconnectTimer = setTimeout(() => {
|
|
331
465
|
this._reconnectTimer = null;
|
|
332
466
|
if (!this._manualDisconnect) {
|
|
333
|
-
this._connect();
|
|
467
|
+
void this._connect();
|
|
334
468
|
}
|
|
335
469
|
}, delay);
|
|
336
470
|
}
|
|
@@ -352,6 +486,7 @@ export class NativeSocketClient {
|
|
|
352
486
|
// 内部:关闭 WebSocket
|
|
353
487
|
// ----------------------------------------------------------
|
|
354
488
|
_closeWs() {
|
|
489
|
+
this._stopHeartbeat();
|
|
355
490
|
if (this._ws) {
|
|
356
491
|
// 移除所有回调,防止 onclose 触发重连
|
|
357
492
|
this._ws.onopen = null;
|
|
@@ -366,6 +501,7 @@ export class NativeSocketClient {
|
|
|
366
501
|
}
|
|
367
502
|
this._ws = null;
|
|
368
503
|
}
|
|
504
|
+
this._connecting = false;
|
|
369
505
|
this._connected = false;
|
|
370
506
|
this._id = undefined;
|
|
371
507
|
// onclose 被置 null 后不会自动触发,需手动 flush 挂起的 ACK
|
|
@@ -374,13 +510,40 @@ export class NativeSocketClient {
|
|
|
374
510
|
// ----------------------------------------------------------
|
|
375
511
|
// 内部:构建完整 URL
|
|
376
512
|
// ----------------------------------------------------------
|
|
377
|
-
_buildUrl() {
|
|
378
|
-
let base = this.url;
|
|
379
|
-
// 拼接 path(如 /claw-socket)
|
|
513
|
+
async _buildUrl() {
|
|
514
|
+
let base = this.url.replace(/\/$/, '');
|
|
380
515
|
if (this.options.path) {
|
|
381
|
-
|
|
382
|
-
|
|
516
|
+
const path = this.options.path.startsWith('/')
|
|
517
|
+
? this.options.path
|
|
518
|
+
: `/${this.options.path}`;
|
|
519
|
+
base += path;
|
|
520
|
+
}
|
|
521
|
+
const query = typeof this.options.query === 'function'
|
|
522
|
+
? await this.options.query()
|
|
523
|
+
: this.options.query;
|
|
524
|
+
const searchParams = new URLSearchParams();
|
|
525
|
+
Object.entries(query ?? {}).forEach(([key, value]) => {
|
|
526
|
+
if (value === undefined || value === null || value === '')
|
|
527
|
+
return;
|
|
528
|
+
searchParams.set(key, String(value));
|
|
529
|
+
});
|
|
530
|
+
const queryString = searchParams.toString();
|
|
531
|
+
if (!queryString)
|
|
532
|
+
return base;
|
|
533
|
+
return `${base}${base.includes('?') ? '&' : '?'}${queryString}`;
|
|
534
|
+
}
|
|
535
|
+
/** 对连接 URL 中的敏感 query 做日志脱敏 */
|
|
536
|
+
_maskUrl(url) {
|
|
537
|
+
try {
|
|
538
|
+
const parsed = new URL(url);
|
|
539
|
+
const ticket = parsed.searchParams.get('ticket');
|
|
540
|
+
if (ticket) {
|
|
541
|
+
parsed.searchParams.set('ticket', `${ticket.slice(0, 6)}...${ticket.slice(-4)}`);
|
|
542
|
+
}
|
|
543
|
+
return parsed.toString();
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return url.replace(/([?&]ticket=)([^&]+)/, (_match, prefix, value) => (`${prefix}${value.slice(0, 6)}...${value.slice(-4)}`));
|
|
383
547
|
}
|
|
384
|
-
return base;
|
|
385
548
|
}
|
|
386
549
|
}
|