@coclaw/openclaw-coclaw 0.15.0 → 0.17.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.
package/README.md CHANGED
@@ -172,22 +172,14 @@ openclaw gateway call coclaw.upgradeHealth --json
172
172
 
173
173
  ## WebRTC 实现
174
174
 
175
- 插件支持两个 WebRTC 实现,运行时自动选择:
175
+ 插件在运行时按优先级选择 WebRTC 实现:
176
176
 
177
- 1. **node-datachannel**(首选)— 基于 libdatachannel 的工业级实现,通过 vendor 预编译 native binary 部署。
178
- 2. **werift**(回退)— 纯 JavaScript 实现,作为 node-datachannel 加载失败时的兜底。
177
+ 1. **pion**(主力)— 通过 `@coclaw/pion-node` SDK 驱动 Go pion-ipc 进程,实现完整 WebRTC 能力。
178
+ 2. **werift**(回退)— 纯 JavaScript 实现,作为 pion 加载失败时的兜底。
179
179
 
180
- 选择结果通过 remoteLog 上报(`ndc.loaded` `ndc.using-werift`)。
180
+ 选择结果通过 `bridge.started` / `coclaw.env impl=...` 日志上报。
181
181
 
182
- ### vendor 预编译包
183
-
184
- 由于 OpenClaw 使用 `--ignore-scripts` 安装插件,node-datachannel 的 native binary 需通过 vendor 预编译包提供:
185
-
186
- ```bash
187
- bash scripts/download-ndc-prebuilds.sh # 下载 5 平台预编译包到 vendor/ndc-prebuilds/
188
- ```
189
-
190
- 支持的平台:linux-x64、linux-arm64、darwin-x64、darwin-arm64、win32-x64。vendor 目录不入 git,通过 npm publish 的 `files` 字段包含在发布包中。
182
+ > `ndc-preloader.js`(node-datachannel 路径)的代码仍保留但已摘除 `node-datachannel` 依赖和 vendor 预编译包(2026-04-19)——运行时必然走 fallback 到 werift,待 pion 在全部线上平台稳定观察期结束后与 werift 一并移除。
191
183
 
192
184
  ## 运行与排障日志
193
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -32,7 +32,6 @@
32
32
  "!src/**/*.test.js",
33
33
  "!src/mock-server.helper.js",
34
34
  "openclaw.plugin.json",
35
- "vendor/ndc-prebuilds/**",
36
35
  "LICENSE"
37
36
  ],
38
37
  "main": "index.js",
@@ -59,8 +58,7 @@
59
58
  "release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
60
59
  },
61
60
  "dependencies": {
62
- "node-datachannel": "0.32.2",
63
- "@coclaw/pion-node": "^0.1.2",
61
+ "@coclaw/pion-node": "^0.1.3",
64
62
  "werift": "^0.19.0",
65
63
  "ws": "^8.19.0"
66
64
  },
@@ -0,0 +1,61 @@
1
+ /**
2
+ * registry-fallback.js — npm registry 反向兜底
3
+ *
4
+ * 升级首次失败(timeout/429/网络异常等)后,按当前用户的 registry 选反方向源
5
+ * 再试一次:用户原本走 npmmirror 卡住时切到 npmjs;走 npmjs 卡住(如 IP 段被
6
+ * 限流)时切到 npmmirror。两侧任一可用即能脱困。
7
+ */
8
+ import { execFile as nodeExecFile } from 'node:child_process';
9
+
10
+ export const NPMJS_REGISTRY = 'https://registry.npmjs.org/';
11
+ export const NPMMIRROR_REGISTRY = 'https://registry.npmmirror.com/';
12
+
13
+ const DEFAULT_TIMEOUT_MS = 10_000;
14
+
15
+ /**
16
+ * 读取当前 npm 默认 registry(继承用户 .npmrc 与 env)
17
+ *
18
+ * 失败 / 空字符串均回退到 npmjs URL;上层 pickFallbackRegistry 会据此选 npmmirror。
19
+ * 即"npm 命令本身坏掉时盲选 npmmirror"——在 worker 这种"反正只重试一次"的场景下
20
+ * 是合理代价。
21
+ *
22
+ * 调用方应优先传入 execFileFn 以避免在测试环境拉起真实 npm 进程。
23
+ * @param {object} [opts]
24
+ * @param {Function} [opts.execFileFn]
25
+ * @param {number} [opts.timeoutMs]
26
+ * @returns {Promise<string>}
27
+ */
28
+ export function getCurrentNpmRegistry(opts) {
29
+ /* c8 ignore next -- ?./?? fallback */
30
+ const doExecFile = opts?.execFileFn ?? nodeExecFile;
31
+ /* c8 ignore next -- ?? fallback */
32
+ const timeout = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
33
+ return new Promise((resolve) => {
34
+ doExecFile('npm', ['config', 'get', 'registry'], {
35
+ timeout,
36
+ shell: process.platform === 'win32',
37
+ }, (err, stdout) => {
38
+ if (err) { resolve(NPMJS_REGISTRY); return; }
39
+ const raw = String(stdout).trim();
40
+ resolve(raw || NPMJS_REGISTRY);
41
+ });
42
+ });
43
+ }
44
+
45
+ /**
46
+ * 根据当前 registry 选反向兜底:
47
+ * - 含 `npmmirror.com` → 切到 npmjs
48
+ * - 其他(含 npmjs / cnpmjs.org / 自建 / 非字符串等异常输入) → 一律切到 npmmirror
49
+ *
50
+ * "反向"语义只严格区分 npmmirror,因为它是国内绝对主流;其他国内镜像(cnpmjs 等)
51
+ * 当前直接切到 npmmirror(同方向但换实例),属于"换源"兜底而非真正反向,是有意为之
52
+ * 的简化。
53
+ * @param {string} current
54
+ * @returns {string}
55
+ */
56
+ export function pickFallbackRegistry(current) {
57
+ if (typeof current === 'string' && /npmmirror\.com/i.test(current)) {
58
+ return NPMJS_REGISTRY;
59
+ }
60
+ return NPMMIRROR_REGISTRY;
61
+ }
@@ -2,7 +2,7 @@
2
2
  * updater-check.js — 版本检查
3
3
  *
4
4
  * 通过 `npm view` 查询 registry 最新版本,与本地 package.json 对比。
5
- * 选择 npm view 而非直接 fetch registry API,是因为它自动继承用户完整的
5
+ * 选择 npm view 而非自己打 registry HTTP 接口,是因为它自动继承用户完整的
6
6
  * npm 环境配置(registry 镜像、proxy、scoped registry、auth token 等),
7
7
  * 避免自行解析多层 .npmrc 的复杂性。每小时一次的频率下进程启动开销可忽略。
8
8
  */
@@ -7,7 +7,10 @@ import { readState, resolveStateDir, writeState } from './state.js';
7
7
  import { getRuntime } from '../runtime.js';
8
8
  import { remoteLog } from '../remote-log.js';
9
9
 
10
- const INITIAL_DELAY_MS = 5 * 60 * 1000; // 5 分钟
10
+ // 首次检查延迟较长:失败时由 worker 触发 gateway restart,scheduler 重启后会重新计时;
11
+ // 60 分钟基线(实际随机 60-120 分钟)能把"失败→重启→再次检查"的循环周期拉长,
12
+ // 避免连续升级失败时 gateway 在短时间内反复被打扰。
13
+ const INITIAL_DELAY_MS = 60 * 60 * 1000; // 60 分钟
11
14
  const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 小时
12
15
  const CHANNEL_ID = 'coclaw';
13
16
  const LOCK_FILENAME = 'upgrade.lock';
@@ -17,26 +17,35 @@ import { parseArgs } from 'node:util';
17
17
  import { createBackup, restoreFromBackup, removeBackup } from './worker-backup.js';
18
18
  import { verifyUpgrade, waitForGateway } from './worker-verify.js';
19
19
  import { addSkippedVersion, updateLastUpgrade, appendLog } from './state.js';
20
+ import { getCurrentNpmRegistry, pickFallbackRegistry } from './registry-fallback.js';
20
21
 
21
22
  const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.-]+)?$/;
23
+ // 单次 plugins update 上限:包含 npm install 大型 native deps,慢网络 + 弱机器需较长时间
24
+ const UPDATE_TIMEOUT_MS = 10 * 60 * 1000;
22
25
 
23
26
  /**
24
27
  * 执行 openclaw plugins update
28
+ *
29
+ * 仅支持 source === "npm" 的安装(updater 已做前置过滤)。
30
+ * env 由调用方决定:缺省时子进程继承当前 process.env(含用户 .npmrc 自动生效);
31
+ * 显式传入时用于覆盖 registry 等 npm 配置以做兜底重试。
25
32
  * @param {string} pluginId - 插件 ID
26
33
  * @param {object} [opts]
27
34
  * @param {Function} [opts.execFileFn]
35
+ * @param {NodeJS.ProcessEnv} [opts.env]
28
36
  * @returns {Promise<void>}
29
37
  */
30
- // openclaw plugins update 内部实现为 staged backup-and-replace,
31
- // 仅支持 source === "npm" 的安装(updater 已做前置过滤)
32
38
  function runPluginUpdate(pluginId, opts) {
33
39
  /* c8 ignore next -- ?./?? fallback */
34
40
  const doExecFile = opts?.execFileFn ?? nodeExecFile;
35
41
  return new Promise((resolve, reject) => {
36
- doExecFile('openclaw', ['plugins', 'update', pluginId], {
37
- timeout: 120_000,
42
+ const execOpts = {
43
+ timeout: UPDATE_TIMEOUT_MS,
38
44
  shell: process.platform === 'win32',
39
- }, (err) => {
45
+ };
46
+ // 不传 env 时让 Node 默认继承父进程;显式 env 才覆盖
47
+ if (opts?.env) execOpts.env = opts.env;
48
+ doExecFile('openclaw', ['plugins', 'update', pluginId], execOpts, (err) => {
40
49
  if (err) reject(new Error(`plugins update failed: ${err.message}`));
41
50
  else resolve();
42
51
  });
@@ -110,21 +119,44 @@ export async function runUpgrade({ pluginDir, fromVersion, toVersion, pluginId,
110
119
  await createBackup(pluginDir);
111
120
  log('[upgrade-worker] Backup created');
112
121
 
113
- // 2. 执行升级
122
+ // 2. 执行升级(首次按用户原 env,失败后用反向 mirror 重试一次)
114
123
  log('[upgrade-worker] Running plugins update...');
124
+ let updateErr = null;
115
125
  try {
116
126
  await runPluginUpdate(pluginId, opts);
127
+ log('[upgrade-worker] Update command completed');
128
+ }
129
+ catch (firstErr) {
130
+ log(`[upgrade-worker] Update command failed: ${firstErr.message}`);
131
+ updateErr = firstErr;
132
+ try {
133
+ const current = await getCurrentNpmRegistry(opts);
134
+ const fallback = pickFallbackRegistry(current);
135
+ log(`[upgrade-worker] Retrying with fallback registry: ${fallback}`);
136
+ // npm 同时认 npm_config_X 与 NPM_CONFIG_X 两种 env 命名,
137
+ // 若用户已 export 大写版(国内常见),仅 set 小写不足以覆盖,
138
+ // 显式 delete 大写避免 retry 仍走原 registry。
139
+ const retryEnv = { ...process.env };
140
+ delete retryEnv.NPM_CONFIG_REGISTRY;
141
+ retryEnv.npm_config_registry = fallback;
142
+ await runPluginUpdate(pluginId, { ...opts, env: retryEnv });
143
+ log('[upgrade-worker] Update command completed on retry');
144
+ updateErr = null;
145
+ }
146
+ catch (retryErr) {
147
+ log(`[upgrade-worker] Retry with fallback registry failed: ${retryErr.message}`);
148
+ updateErr = retryErr;
149
+ }
117
150
  }
118
- catch (updateErr) {
119
- // 升级命令本身失败(可能是瞬态故障),恢复备份但不标记版本为 skipped
120
- log(`[upgrade-worker] Update command failed: ${updateErr.message}`);
151
+
152
+ if (updateErr) {
153
+ // 两次都失败仍按瞬态故障处理(保留原 skipVersion: false 设计意图)
121
154
  await handleRollback({
122
155
  pluginDir, fromVersion, toVersion, pluginId, pkgName,
123
156
  error: updateErr.message, skipVersion: false, opts, log,
124
157
  });
125
158
  return;
126
159
  }
127
- log('[upgrade-worker] Update command completed');
128
160
 
129
161
  // 3. 等待 gateway 重启并验证
130
162
  log('[upgrade-worker] Verifying upgrade...');
@@ -46,6 +46,7 @@ export function defaultResolvePaths(platformKey, pluginRoot) {
46
46
  // 定位 node-datachannel 包根:从入口路径向上查找 package.json
47
47
  const require = createRequire(nodePath.join(pluginRoot, 'package.json'));
48
48
  const entryPath = require.resolve('node-datachannel');
49
+ /* c8 ignore start -- node-datachannel 依赖已于 2026-04-19 摘除,以下路径仅在 ndc 实际安装时命中;代码保留作为过渡期 fallback 自然失败锚点,待 ndc-preloader 整体清理时一并删除 */
49
50
  let pkgRoot = nodePath.dirname(entryPath);
50
51
  while (pkgRoot !== nodePath.dirname(pkgRoot)) {
51
52
  try {
@@ -58,6 +59,7 @@ export function defaultResolvePaths(platformKey, pluginRoot) {
58
59
  const dest = nodePath.join(destDir, 'node_datachannel.node');
59
60
 
60
61
  return { src, dest, destDir };
62
+ /* c8 ignore stop */
61
63
  }
62
64
 
63
65
  /**
@@ -6,13 +6,13 @@ import { remoteLog } from '../remote-log.js';
6
6
  // 用于诊断 dump:过大会撑爆 remoteLog 单帧,20 足以覆盖典型多文件传输会话。
7
7
  const FILE_CHANNEL_HISTORY_LIMIT = 20;
8
8
 
9
- // Failed session 保留 24 小时,支持 Capacitor 长时间后台恢复后 ICE restart。
9
+ // Failed session 保留 12 小时,支持 Capacitor 后台恢复后 ICE restart。
10
10
  // 超时后 session 被回收释放 IPC listeners 和 Go 侧资源。
11
- const FAILED_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
11
+ const FAILED_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
12
12
 
13
13
  // Session 总数上限(活跃 + failed)。溢出时淘汰最旧的 failed session。
14
- // 20 足以覆盖多 UI 实例(浏览器多标签 + 移动端)的典型场景。
15
- const MAX_SESSIONS = 20;
14
+ // 10 足以覆盖多 UI 实例(浏览器多标签 + 移动端)的典型场景。
15
+ const MAX_SESSIONS = 10;
16
16
 
17
17
  /**
18
18
  * 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
@@ -110,9 +110,32 @@ export class WebRtcPeer {
110
110
  }
111
111
  }
112
112
 
113
+ /**
114
+ * 向指定 connId 的 rpc DC 单播一个 JSON 帧(不走 server 中转)。
115
+ * 若 session/DC 未就绪返回 false,由调用方决定是否重试。
116
+ * @param {string} connId
117
+ * @param {object} payload - 完整的 JSON 帧(通常是 { type: 'event', event, payload })
118
+ * @returns {boolean} true=已入队发送,false=未能发送(session 不存在 / DC 未 open)
119
+ */
120
+ sendTo(connId, payload) {
121
+ const session = this.__sessions.get(connId);
122
+ if (!session) return false;
123
+ const q = session.rpcSendQueue;
124
+ if (!q || session.rpcChannel?.readyState !== 'open') return false;
125
+ try {
126
+ q.send(JSON.stringify(payload));
127
+ return true;
128
+ } catch (err) {
129
+ this.__logDebug(`[${connId}] sendTo failed: ${err.message}`);
130
+ return false;
131
+ }
132
+ }
133
+
113
134
  async __handleOffer(msg) {
114
135
  const connId = msg.fromConnId;
115
136
  const isIceRestart = !!msg.payload?.iceRestart;
137
+ const credRemain = this.__credRemainSec(msg.turnCreds);
138
+ const credRemainStr = credRemain ?? 'none';
116
139
 
117
140
  // ICE restart:在现有 PC 上重新协商,保持 DTLS session
118
141
  if (isIceRestart) {
@@ -120,7 +143,7 @@ export class WebRtcPeer {
120
143
  if (existing) {
121
144
  // 仅已验证支持 ICE restart 的 impl 放行,其余立即 reject 让 UI 走 rebuild
122
145
  if (this.__impl !== 'pion') {
123
- this.__remoteLog(`rtc.ice-restart-unsupported conn=${connId} impl=${this.__impl}`);
146
+ this.__remoteLog(`rtc.ice-restart-unsupported conn=${connId} impl=${this.__impl} credRemain=${credRemainStr}`);
124
147
  this.logger.info?.(`${this.__rtcTag} ICE restart rejected: impl=${this.__impl} not verified`);
125
148
  this.__onSend({
126
149
  type: 'rtc:restart-rejected',
@@ -134,7 +157,7 @@ export class WebRtcPeer {
134
157
  clearTimeout(existing.__failedTimer);
135
158
  existing.__failedTimer = null;
136
159
  }
137
- this.__remoteLog(`rtc.ice-restart conn=${connId}`);
160
+ this.__remoteLog(`rtc.ice-restart conn=${connId} credRemain=${credRemainStr}`);
138
161
  this.logger.info?.(`${this.__rtcTag} ICE restart offer from ${connId}, renegotiating`);
139
162
  try {
140
163
  await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
@@ -156,7 +179,7 @@ export class WebRtcPeer {
156
179
  return;
157
180
  } catch (err) {
158
181
  // ICE restart 协商失败 → reject,不 fall through
159
- this.__remoteLog(`rtc.ice-restart-failed conn=${connId}`);
182
+ this.__remoteLog(`rtc.ice-restart-failed conn=${connId} credRemain=${credRemainStr}`);
160
183
  this.logger.warn?.(`${this.__rtcTag} ICE restart failed for ${connId}: ${err?.message}`);
161
184
  this.__onSend({
162
185
  type: 'rtc:restart-rejected',
@@ -171,7 +194,7 @@ export class WebRtcPeer {
171
194
  }
172
195
  }
173
196
  // 无 session → reject(plugin 可能已重启)
174
- this.__remoteLog(`rtc.ice-restart-no-session conn=${connId}`);
197
+ this.__remoteLog(`rtc.ice-restart-no-session conn=${connId} credRemain=${credRemainStr}`);
175
198
  this.logger.warn?.(`${this.__rtcTag} ICE restart from ${connId} but no session, rejecting`);
176
199
  this.__onSend({
177
200
  type: 'rtc:restart-rejected',
@@ -307,6 +330,9 @@ export class WebRtcPeer {
307
330
  if (pair) {
308
331
  this.__logNominatedPair(connId, pair);
309
332
  }
333
+ // ICE restart 或初次选中都会触发;让出一次 CPU 后再单播 transport 信息。
334
+ // 签名去重保证 pair 不变时不会重复发送。
335
+ queueMicrotask(() => this.__sendPeerTransport(connId));
310
336
  };
311
337
  }
312
338
 
@@ -424,6 +450,12 @@ export class WebRtcPeer {
424
450
  dc.onopen = () => {
425
451
  this.__remoteLog(`dc.open conn=${connId} label=${dc.label}`);
426
452
  this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" opened`);
453
+ // rpc DC 建立后,把本端 transport 信息单播给 UI。
454
+ // queueMicrotask 让出一次 CPU:确保 pion 侧 selectedCandidatePair setter 已 assign,
455
+ // 同时避免在 onopen 同步栈里触发可能的重入。
456
+ if (dc.label === 'rpc') {
457
+ queueMicrotask(() => this.__sendPeerTransport(connId));
458
+ }
427
459
  };
428
460
  dc.onclose = () => {
429
461
  this.__remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
@@ -481,16 +513,65 @@ export class WebRtcPeer {
481
513
  }
482
514
 
483
515
  __logNominatedPair(connId, pair) {
484
- const localInfo = `${pair.local?.type ?? '?'} ${pair.local?.address ?? pair.local?.host ?? '?'}:${pair.local?.port ?? '?'}`;
485
- const remoteInfo = `${pair.remote?.type ?? '?'} ${pair.remote?.address ?? pair.remote?.host ?? '?'}:${pair.remote?.port ?? '?'}`;
516
+ const l = pair.local, r = pair.remote;
517
+ const lProto = (l?.protocol ?? '?').toLowerCase();
518
+ const rProto = (r?.protocol ?? '?').toLowerCase();
519
+ const lRelay = l?.relayProtocol ? `(${String(l.relayProtocol).toLowerCase()})` : '';
520
+ const localInfo = `${l?.type ?? '?'}/${lProto}${lRelay} ${l?.address ?? l?.host ?? '?'}:${l?.port ?? '?'}`;
521
+ const remoteInfo = `${r?.type ?? '?'}/${rProto} ${r?.address ?? r?.host ?? '?'}:${r?.port ?? '?'}`;
486
522
  this.__remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
487
523
  this.logger.info?.(`${this.__rtcTag} [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
488
524
  }
489
525
 
526
+ /**
527
+ * 把当前 session 本端 candidate 的 transport 信息(type/protocol/relayProtocol)
528
+ * 通过 coclaw.rtc.peerTransport 事件单播给对应 UI。已内置签名去重,
529
+ * 同一签名不会重复发送;发送失败(DC 未 open)时回滚签名允许后续重试。
530
+ *
531
+ * @param {string} connId
532
+ */
533
+ __sendPeerTransport(connId) {
534
+ const session = this.__sessions.get(connId);
535
+ if (!session) return;
536
+ const local = session.pc?.selectedCandidatePair?.local;
537
+ if (!local) return; // nominated pair 尚未产生
538
+ const payload = {
539
+ candidateType: local.type ?? 'unknown',
540
+ protocol: String(local.protocol ?? 'udp').toLowerCase(),
541
+ relayProtocol: local.relayProtocol
542
+ ? String(local.relayProtocol).toLowerCase()
543
+ : null,
544
+ };
545
+ const sig = `${payload.candidateType}|${payload.protocol}|${payload.relayProtocol ?? ''}`;
546
+ if (session.__lastPeerTransportSig === sig) return;
547
+ session.__lastPeerTransportSig = sig;
548
+ const ok = this.sendTo(connId, {
549
+ type: 'event',
550
+ event: 'coclaw.rtc.peerTransport',
551
+ payload,
552
+ });
553
+ if (!ok) {
554
+ // DC 尚未 open,回滚签名以便 dc.onopen 再次触发时重发
555
+ session.__lastPeerTransportSig = null;
556
+ return;
557
+ }
558
+ this.__remoteLog(`rtc.peer-transport conn=${connId} type=${payload.candidateType} proto=${payload.protocol} relay=${payload.relayProtocol ?? '-'}`);
559
+ }
560
+
490
561
  __remoteLog(msg) {
491
562
  remoteLog(this.__impl ? `${msg} rtc=${this.__impl}` : msg);
492
563
  }
493
564
 
565
+ // 解析 HMAC turnCreds 中的剩余秒数(username 形如 "<expireAt>:<userId>");
566
+ // 负值表示已过期;解析失败或 turnCreds 缺失返回 null。仅用于 ICE restart 日志诊断。
567
+ __credRemainSec(turnCreds) {
568
+ const username = turnCreds?.username;
569
+ if (typeof username !== 'string') return null;
570
+ const expireAt = Number(username.split(':')[0]);
571
+ if (!Number.isFinite(expireAt)) return null;
572
+ return expireAt - Math.floor(Date.now() / 1000);
573
+ }
574
+
494
575
  /** 淘汰最旧的 failed session(Map 迭代序 ≈ 创建时间序),用于 queue length 限制 */
495
576
  __evictOldestFailed() {
496
577
  for (const [connId, session] of this.__sessions) {