@coclaw/openclaw-coclaw 0.26.5 → 0.27.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 +3 -6
- package/package.json +1 -2
- package/src/auto-upgrade/state.js +12 -2
- package/src/auto-upgrade/updater.js +6 -2
- package/src/file-manager/handler.js +1 -1
- package/src/realtime-bridge.js +19 -26
- package/src/session-manager/manager.js +0 -7
- package/src/utils/file-backed-queue.js +1 -0
- package/src/webrtc/pion-preloader.js +1 -1
- package/src/webrtc/webrtc-peer.js +12 -4
- package/src/webrtc/ndc-preloader.js +0 -247
package/README.md
CHANGED
|
@@ -172,14 +172,11 @@ openclaw gateway call coclaw.upgradeHealth --json
|
|
|
172
172
|
|
|
173
173
|
## WebRTC 实现
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
WebRTC 唯一实现为 **pion**——通过 `@coclaw/pion-node` SDK 驱动 Go 侧 pion-ipc 进程,实现完整 WebRTC 能力。pion 加载失败时不再兜底(`impl=none`):所有依赖 WebRTC DataChannel 的远程功能(chat、UI RPC、文件传输)不可用;gateway 进程、本地 RPC 面与自动升级不受影响,可通过发布修复版本自动升级恢复。
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
2. **werift**(回退)— 纯 JavaScript 实现,作为 pion 加载失败时的兜底。
|
|
177
|
+
加载结果通过 `coclaw.env impl=...` 日志上报。
|
|
179
178
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
> `ndc-preloader.js`(node-datachannel 路径)的代码仍保留但已摘除 `node-datachannel` 依赖和 vendor 预编译包(2026-04-19)——运行时必然走 fallback 到 werift,待 pion 在全部线上平台稳定观察期结束后与 werift 一并移除。
|
|
179
|
+
> 历史:werift(纯 JS 兜底)与 ndc(node-datachannel)路径已于 2026-07-16 移除。werift 的 DataChannel 不触发 `onbufferedamountlow` 属性回调,而插件的 RPC 背压与文件下载恢复都挂在该回调上——werift 路径实为"能连上但文件/大流量必楔死"的负价值兜底。背景见 `docs/webrtc-impl-strategy.md`。
|
|
183
180
|
|
|
184
181
|
## 运行与排障日志
|
|
185
182
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
|
|
@@ -68,7 +68,6 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@coclaw/pion-node": "^0.4.0",
|
|
71
|
-
"werift": "^0.19.0",
|
|
72
71
|
"ws": "^8.19.0"
|
|
73
72
|
},
|
|
74
73
|
"peerDependencies": {
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* 2. OPENCLAW_STATE_DIR 环境变量(worker 子进程,由 spawner 传入)
|
|
8
8
|
* 3. ~/.openclaw(兜底默认值)
|
|
9
9
|
*
|
|
10
|
+
* 注:第 3 档(homedir)实际够不着——gateway 进程内 runtime 必在(full mode 才启
|
|
11
|
+
* scheduler)、worker 进程 spawner 总会传 OPENCLAW_STATE_DIR,故不存在“worker 写错盘”。
|
|
12
|
+
* 曾被对抗式 review 当 bug 捞出,核实为不可达防御,记此免再捞。
|
|
13
|
+
* 同理“runtime 非空但 .state 缺失”的部分注入形态也不可达,勿为对齐 claw-paths.js 改成抛错。
|
|
14
|
+
*
|
|
10
15
|
* 锁策略:state 文件的 read-modify-write 统一走 stateMutex;纯读不加锁。
|
|
11
16
|
* 锁只能护住同进程内并发(gateway 与 worker 跨进程仍各写各的),但 worker /
|
|
12
17
|
* scheduler 各自内部是串行流,进程内互斥已足够。
|
|
@@ -24,8 +29,13 @@ const STATE_FILENAME = 'upgrade-state.json';
|
|
|
24
29
|
const LOG_FILENAME = 'upgrade-log.jsonl';
|
|
25
30
|
const LOG_MAX_LINES = 200;
|
|
26
31
|
const LOG_KEEP_LINES = 100;
|
|
27
|
-
// lastUpgrade.error 截断上限:远端上报行不宜过长;jsonl 保留完整 error
|
|
28
|
-
|
|
32
|
+
// lastUpgrade.error 截断上限:远端上报行不宜过长;jsonl 保留完整 error。
|
|
33
|
+
// 取 1600 是为 ≥ worker.js formatCmdFailure 复合串的理论最大长度(≈1547:
|
|
34
|
+
// prefix + message/stdout/stderr 三段各尾截 500 + 标签与 " | " 分隔符开销),
|
|
35
|
+
// 保证 worker 格式化的失败串绝不被 state.js 二次全局截断而切掉中段真因;
|
|
36
|
+
// 这里不解析 worker 的分段格式(保持两者解耦),只保留一道防御性硬上限——
|
|
37
|
+
// state.js 的 error 还来自 'boom'/'timeout'/verify 等其它短串,需防意外超长串无界落盘。
|
|
38
|
+
const ERROR_MAX_CHARS = 1600;
|
|
29
39
|
|
|
30
40
|
const stateMutex = createMutex();
|
|
31
41
|
const logMutex = createMutex();
|
|
@@ -582,8 +582,12 @@ export class AutoUpgradeScheduler {
|
|
|
582
582
|
);
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
-
// 来源验明 npm 后才上报 available
|
|
586
|
-
|
|
585
|
+
// 来源验明 npm 后才上报 available——否则永不升级的装置每小时刷一条;
|
|
586
|
+
// available 同为稳定态(直到升级落地或新版发布),与兄弟信号同模式按 (原因, toVersion) 去重防刷屏
|
|
587
|
+
this.__gateSignalOnce(
|
|
588
|
+
`available|${result.latestVersion}`,
|
|
589
|
+
`upgrade.available from=${result.currentVersion} to=${result.latestVersion}`,
|
|
590
|
+
);
|
|
587
591
|
this.__logger.info?.(`[auto-upgrade] Update available: ${result.currentVersion} → ${result.latestVersion}`);
|
|
588
592
|
|
|
589
593
|
// installPath 取自权威记录(新鲜);缺失时回退自推包根,
|
|
@@ -318,7 +318,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
318
318
|
|
|
319
319
|
/**
|
|
320
320
|
* 处理 file:<transferId> DataChannel
|
|
321
|
-
* @param {object} dc -
|
|
321
|
+
* @param {object} dc - DataChannel
|
|
322
322
|
* @param {string} [connId] - 所属 PeerConnection 的连接 ID
|
|
323
323
|
*/
|
|
324
324
|
function handleFileChannel(dc, connId) {
|
package/src/realtime-bridge.js
CHANGED
|
@@ -151,7 +151,6 @@ export class RealtimeBridge {
|
|
|
151
151
|
this.__resolveGatewayAuthToken = deps.resolveGatewayAuthToken ?? defaultResolveGatewayAuthToken;
|
|
152
152
|
this.__loadDeviceIdentity = deps.loadDeviceIdentity ?? loadOrCreateDeviceIdentity;
|
|
153
153
|
this.__preloadPion = deps.preloadPion ?? null;
|
|
154
|
-
this.__preloadNdc = deps.preloadNdc ?? null;
|
|
155
154
|
this.__WebSocket = deps.WebSocket; // undefined=使用 ws 包, null=禁用(测试用), 其他=自定义实现
|
|
156
155
|
this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
|
|
157
156
|
this.__dcReqTtlMs = deps.dcReqTtlMs ?? DC_REQ_TTL_MS;
|
|
@@ -1403,7 +1402,7 @@ export class RealtimeBridge {
|
|
|
1403
1402
|
// 信令消息,与 drain 内的同名日志做区分主要看 msg 文本。
|
|
1404
1403
|
const sigType = payload?.type ?? 'unknown';
|
|
1405
1404
|
const sigConn = payload?.fromConnId ?? payload?.toConnId ?? 'unknown';
|
|
1406
|
-
this.logger.warn?.(`[coclaw/rtc] signaling error
|
|
1405
|
+
this.logger.warn?.(`[coclaw/rtc] signaling error type=${sigType} conn=${sigConn}: ${err?.message}`);
|
|
1407
1406
|
remoteLog(`rtc.signaling-error type=${sigType} conn=${sigConn} msg=${err?.message}`);
|
|
1408
1407
|
}
|
|
1409
1408
|
return;
|
|
@@ -1443,7 +1442,7 @@ export class RealtimeBridge {
|
|
|
1443
1442
|
this.__webrtcPeerReady = null;
|
|
1444
1443
|
if (this.webrtcPeer) {
|
|
1445
1444
|
try { await this.webrtcPeer.closeAll(); }
|
|
1446
|
-
/* c8 ignore next 3 -- 防御性兜底,
|
|
1445
|
+
/* c8 ignore next 3 -- 防御性兜底,closeAll 异常时不可崩溃 gateway */
|
|
1447
1446
|
catch (e) { this.logger.warn?.(`[coclaw/rtc] closeAll failed: ${e?.message}`); }
|
|
1448
1447
|
this.webrtcPeer = null;
|
|
1449
1448
|
}
|
|
@@ -1497,7 +1496,11 @@ export class RealtimeBridge {
|
|
|
1497
1496
|
.then((v) => { this.__pluginVersion = v; })
|
|
1498
1497
|
.catch(() => { this.__pluginVersion = 'unknown'; });
|
|
1499
1498
|
|
|
1500
|
-
//
|
|
1499
|
+
// 唯一实现 pion,失败不再回退(werift 兜底已剔除:其 DataChannel 无
|
|
1500
|
+
// onbufferedamountlow 回调,插件的背压/文件下载恢复挂在该回调上,werift
|
|
1501
|
+
// 路径实为"连上但核心功能楔死"的负价值兜底)。impl='none' 时所有走 DC 的
|
|
1502
|
+
// 远程功能(chat / UI RPC / 文件)不可用;gateway 进程、本地 RPC 面与
|
|
1503
|
+
// 自动升级链路不受影响,可发布修复版把机器捞回。
|
|
1501
1504
|
const preloadPionFn = this.__preloadPion
|
|
1502
1505
|
?? (await import('./webrtc/pion-preloader.js')).preloadPion;
|
|
1503
1506
|
const pionResult = await preloadPionFn({ logger: this.logger }).catch((err) => {
|
|
@@ -1509,17 +1512,10 @@ export class RealtimeBridge {
|
|
|
1509
1512
|
return pionResult;
|
|
1510
1513
|
}
|
|
1511
1514
|
|
|
1512
|
-
//
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
preloadNdcFn().catch((err) => {
|
|
1517
|
-
this.logger.warn?.(`[coclaw] ndc preload unexpected failure: ${err?.message}`);
|
|
1518
|
-
return { PeerConnection: null, cleanup: null, impl: 'none' };
|
|
1519
|
-
}),
|
|
1520
|
-
versionPromise,
|
|
1521
|
-
]);
|
|
1522
|
-
return ndcResult;
|
|
1515
|
+
// none 结果保持非空对象契约(start/stop 竞态守卫直接读 .impl / .cleanup);
|
|
1516
|
+
// 返回前须等版本预热完成,保证 __buildEnvLine 的 plugin= 字段就绪
|
|
1517
|
+
await versionPromise;
|
|
1518
|
+
return { PeerConnection: null, cleanup: null, impl: 'none' };
|
|
1523
1519
|
}
|
|
1524
1520
|
/* c8 ignore stop */
|
|
1525
1521
|
|
|
@@ -1571,7 +1567,7 @@ export class RealtimeBridge {
|
|
|
1571
1567
|
// preload 后还有一道 started 检查兜底(含 pion cleanup),这里先挡住一次无意义的 preload。
|
|
1572
1568
|
if (!this.started) return;
|
|
1573
1569
|
// 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
|
|
1574
|
-
//
|
|
1570
|
+
// 唯一实现 pion,失败即 none(无兜底)
|
|
1575
1571
|
const preloadResult = await this.__preloadWebrtc();
|
|
1576
1572
|
// 竞态保护:若 preload 期间 stop() 已执行,不再赋值,直接返回。
|
|
1577
1573
|
if (!this.started) {
|
|
@@ -1583,10 +1579,9 @@ export class RealtimeBridge {
|
|
|
1583
1579
|
}
|
|
1584
1580
|
this.__ndcPreloadResult = preloadResult;
|
|
1585
1581
|
this.__ndcCleanup = preloadResult.cleanup;
|
|
1586
|
-
|
|
1587
|
-
this.__implLabel = implLabel; // 缓存供 ws.open 时发送
|
|
1582
|
+
this.__implLabel = preloadResult.impl; // 缓存供 ws.open 时发送
|
|
1588
1583
|
// 启动信息只本地 log;远程发送统一由 ws.open 触发,避免重复
|
|
1589
|
-
this.logger.info?.(`[coclaw] WebRTC impl: ${
|
|
1584
|
+
this.logger.info?.(`[coclaw] WebRTC impl: ${this.__implLabel}`);
|
|
1590
1585
|
this.logger.info?.(`[coclaw] ${this.__buildEnvLine()}`);
|
|
1591
1586
|
remoteLog('bridge.started');
|
|
1592
1587
|
// 启动 UI 转发 RPC 路由表周期扫描:1h 间隔扫描 24h 过期条目,避免长程 RPC 残留。
|
|
@@ -1699,8 +1694,7 @@ export class RealtimeBridge {
|
|
|
1699
1694
|
await this.webrtcPeer.closeAll().catch(() => {});
|
|
1700
1695
|
this.webrtcPeer = null;
|
|
1701
1696
|
}
|
|
1702
|
-
// pion: 关闭 Go
|
|
1703
|
-
// ndc: 不调用 cleanup()——同步 join native threads 耗时 10s+,进程退出时 OS 回收
|
|
1697
|
+
// pion: 关闭 Go 进程(异步,快速);none: cleanup 为 null,无事可做
|
|
1704
1698
|
const impl = this.__ndcPreloadResult?.impl;
|
|
1705
1699
|
if (impl === 'pion' && this.__ndcCleanup) {
|
|
1706
1700
|
await this.__ndcCleanup().catch(() => {});
|
|
@@ -1768,20 +1762,19 @@ export async function restartRealtimeBridge(opts) {
|
|
|
1768
1762
|
|
|
1769
1763
|
/**
|
|
1770
1764
|
* @param {object} [opts]
|
|
1771
|
-
* @param {boolean} [opts.forceCleanup] -
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1765
|
+
* @param {boolean} [opts.forceCleanup] - ndc 时代释放 native TSFN 的历史参数(仅测试用)。
|
|
1766
|
+
* 现存实现(pion/none)下恒为 no-op:pion 的 cleanup 已在 stop() 内处理(fast async),
|
|
1767
|
+
* none 无 cleanup。保留签名兼容既有测试调用方。
|
|
1774
1768
|
*/
|
|
1775
1769
|
export async function stopRealtimeBridge({ forceCleanup = false } = {}) {
|
|
1776
1770
|
if (!singleton) {
|
|
1777
1771
|
return;
|
|
1778
1772
|
}
|
|
1779
|
-
// pion 的 cleanup 已在 stop() 内处理(fast async),此处 forceCleanup 仅用于 ndc
|
|
1780
1773
|
const impl = singleton.__ndcPreloadResult?.impl;
|
|
1781
1774
|
const cleanupFn = (forceCleanup && impl !== 'pion') ? singleton.__ndcCleanup : null;
|
|
1782
1775
|
await singleton.stop();
|
|
1783
1776
|
singleton = null; // 置 null 后须通过 restartRealtimeBridge 重建
|
|
1784
|
-
/* c8 ignore next 3 --
|
|
1777
|
+
/* c8 ignore next 3 -- 历史 ndc 分支:现存实现下 cleanupFn 恒 null,不可触发 */
|
|
1785
1778
|
if (typeof cleanupFn === 'function') {
|
|
1786
1779
|
try { cleanupFn(); } catch { /* cleanup 失败不影响 stop 结果 */ }
|
|
1787
1780
|
}
|
|
@@ -32,7 +32,6 @@ async function safeReaddir(dir) {
|
|
|
32
32
|
try { return await fsp.readdir(dir); }
|
|
33
33
|
catch (err) {
|
|
34
34
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return [];
|
|
35
|
-
/* c8 ignore next 2 -- 非 ENOENT/ENOTDIR 的 fs 错误按原样上抛 */
|
|
36
35
|
throw err;
|
|
37
36
|
}
|
|
38
37
|
}
|
|
@@ -45,7 +44,6 @@ async function safeAccess(filePath) {
|
|
|
45
44
|
try { await fsp.access(filePath); return true; }
|
|
46
45
|
catch (err) {
|
|
47
46
|
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return false;
|
|
48
|
-
/* c8 ignore next 2 -- 非 ENOENT/ENOTDIR 的 fs 错误按原样上抛 */
|
|
49
47
|
throw err;
|
|
50
48
|
}
|
|
51
49
|
}
|
|
@@ -150,12 +148,10 @@ export function createSessionManager(options = {}) {
|
|
|
150
148
|
const full = nodePath.join(dir, file);
|
|
151
149
|
let stat;
|
|
152
150
|
try { stat = await fsp.stat(full); }
|
|
153
|
-
/* c8 ignore start -- readdir→stat race window:文件被并发删除时跳过 */
|
|
154
151
|
catch (err) {
|
|
155
152
|
if (err.code === 'ENOENT') continue;
|
|
156
153
|
throw err;
|
|
157
154
|
}
|
|
158
|
-
/* c8 ignore stop */
|
|
159
155
|
const row = {
|
|
160
156
|
sessionId: parsed.sessionId,
|
|
161
157
|
sessionKey: sessionKeyById.get(parsed.sessionId) ?? null,
|
|
@@ -224,12 +220,10 @@ export function createSessionManager(options = {}) {
|
|
|
224
220
|
const full = nodePath.join(dir, name);
|
|
225
221
|
let stat;
|
|
226
222
|
try { stat = await fsp.stat(full); }
|
|
227
|
-
/* c8 ignore start -- readdir→stat race window */
|
|
228
223
|
catch (err) {
|
|
229
224
|
if (err.code === 'ENOENT') continue;
|
|
230
225
|
throw err;
|
|
231
226
|
}
|
|
232
|
-
/* c8 ignore stop */
|
|
233
227
|
candidates.push({
|
|
234
228
|
path: full,
|
|
235
229
|
archiveStamp,
|
|
@@ -240,7 +234,6 @@ export function createSessionManager(options = {}) {
|
|
|
240
234
|
if (a.archiveStamp !== b.archiveStamp) {
|
|
241
235
|
return b.archiveStamp.localeCompare(a.archiveStamp);
|
|
242
236
|
}
|
|
243
|
-
/* c8 ignore next -- 同 sessionId 同 archiveStamp 的归档实测 0 case */
|
|
244
237
|
return b.updatedAt - a.updatedAt;
|
|
245
238
|
});
|
|
246
239
|
if (candidates.length > 0) {
|
|
@@ -486,6 +486,7 @@ class FileBackedQueue {
|
|
|
486
486
|
/* c8 ignore next 2 -- rm with force rarely fails */
|
|
487
487
|
this.logger?.warn?.('fbq.handleFsError rm error', rmErr);
|
|
488
488
|
}
|
|
489
|
+
// 故意不 __dispatchSpillEnd:webrtc 只 destroy 不 clear + fsBroken 粘性不再 spill,spillActive 卡 true 不可达;即便发生也仅 monitor 观测标志失真,不丢消息
|
|
489
490
|
this.spilled = false;
|
|
490
491
|
this.writtenBytes = 0;
|
|
491
492
|
this.readOffset = 0;
|
|
@@ -9,7 +9,7 @@ const SEVERE_LOG_PATTERN = /request timeout|orphan response/;
|
|
|
9
9
|
* 预加载 Pion WebRTC 实现:启动 pion-ipc Go 进程,返回绑定了 ipc 的 PeerConnection。
|
|
10
10
|
*
|
|
11
11
|
* **此函数永不 throw**——所有异常内部捕获,通过 remoteLog 报告。
|
|
12
|
-
* 失败时返回 null
|
|
12
|
+
* 失败时返回 null(调用方按 impl='none' 处理,无兜底实现)。
|
|
13
13
|
*
|
|
14
14
|
* binary 解析由 @coclaw/pion-node 内部处理(env → npm 平台包 → PATH)。
|
|
15
15
|
*
|
|
@@ -729,13 +729,13 @@ export class WebRtcPeer {
|
|
|
729
729
|
// 连接状态变更(校验 pc 归属,防止旧 PC 异步回调删除新 session)
|
|
730
730
|
pc.onconnectionstatechange = () => {
|
|
731
731
|
const state = pc.connectionState;
|
|
732
|
-
this.__remoteLog(`rtc.state conn=${connId} ${state}`);
|
|
733
|
-
this.logger.info?.(`${this.__rtcTag} [${connId}] connectionState: ${state}`);
|
|
734
|
-
|
|
735
732
|
// 校验 pc 归属:旧 PC 的异步回调可能在新 session 已建立后触发
|
|
736
733
|
const cur = this.__sessions.get(connId);
|
|
737
734
|
if (!cur || cur.pc !== pc) return;
|
|
738
735
|
|
|
736
|
+
this.__remoteLog(`rtc.state conn=${connId} ${state}`);
|
|
737
|
+
this.logger.info?.(`${this.__rtcTag} [${connId}] connectionState: ${state}`);
|
|
738
|
+
|
|
739
739
|
// 离开 failed 状态时清理 TTL timer(ICE restart 恢复、自然关闭等)
|
|
740
740
|
if (state !== 'failed' && cur.__failedTimer) {
|
|
741
741
|
clearTimeout(cur.__failedTimer);
|
|
@@ -829,7 +829,7 @@ export class WebRtcPeer {
|
|
|
829
829
|
// 后变 fire-and-forget(WebRTC 实现的 ondatachannel 是 sync 回调,不能 await)
|
|
830
830
|
session.rpcChannel = channel;
|
|
831
831
|
this.__setupDataChannel(connId, channel).catch((err) => {
|
|
832
|
-
/* c8 ignore next 2 --
|
|
832
|
+
/* c8 ignore next 2 -- async 装配段(FBQ 构造+init)无内部 try/catch,此 catch 是其抛错的真实吞没点(非"防御兜底");生产不可达(仅 server 违反 connId 契约触发)暂未修,详见 TODO「__setupDataChannel 装配抛错 → rpcQueue 半残静默丢消息」 */
|
|
833
833
|
this.logger.warn?.(`${this.__rtcTag} [${connId}] __setupDataChannel error: ${err?.message}`);
|
|
834
834
|
});
|
|
835
835
|
} else if (channel.label.startsWith('file:')) {
|
|
@@ -879,6 +879,8 @@ export class WebRtcPeer {
|
|
|
879
879
|
// 注入 __onRequest 或 enqueue 到新 rpcQueue。session 已删除时(rpcChannel===null 或
|
|
880
880
|
// sess undefined)也按 stale 处理,避免向已清空的 session 注入消息。
|
|
881
881
|
const sess = this.__sessions.get(connId);
|
|
882
|
+
// 身份守卫故意放在派发处(而非 dc.onmessage 入口):reassembler 每 dc 独立、陈旧 chunk 派发前
|
|
883
|
+
// 无害,放此处可挡住所有通向派发的路径(含未来 reassembler 定时刷新等非 onmessage 旁路)。
|
|
882
884
|
if (!sess || sess.rpcChannel !== dc) {
|
|
883
885
|
return;
|
|
884
886
|
}
|
|
@@ -924,6 +926,9 @@ export class WebRtcPeer {
|
|
|
924
926
|
}, { logger: this.logger });
|
|
925
927
|
|
|
926
928
|
dc.onopen = () => {
|
|
929
|
+
// 陈旧通道早返回:旧 dc 的迟到 onopen 不应对新 session 误报 transport
|
|
930
|
+
const sess = this.__sessions.get(connId);
|
|
931
|
+
if (!sess || sess.rpcChannel !== dc) return;
|
|
927
932
|
this.__remoteLog(`dc.open conn=${connId} label=${dc.label}`);
|
|
928
933
|
this.logger.info?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" opened`);
|
|
929
934
|
// rpc DC 建立后,把本端 transport 信息单播给 UI。
|
|
@@ -963,6 +968,9 @@ export class WebRtcPeer {
|
|
|
963
968
|
}
|
|
964
969
|
};
|
|
965
970
|
dc.onerror = (err) => {
|
|
971
|
+
// 陈旧通道早返回:旧 dc 的迟到 onerror 不打进新 session 上下文
|
|
972
|
+
const sess = this.__sessions.get(connId);
|
|
973
|
+
if (!sess || sess.rpcChannel !== dc) return;
|
|
966
974
|
this.__remoteLog(`dc.error conn=${connId} label=${dc.label}`);
|
|
967
975
|
/* c8 ignore next -- ?./?? fallback */
|
|
968
976
|
this.logger.warn?.(`${this.__rtcTag} [${connId}] DataChannel "${dc.label}" error: ${String(err?.message ?? err)}`);
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import { createRequire } from 'module';
|
|
2
|
-
import nodePath from 'path';
|
|
3
|
-
import fsSync from 'fs';
|
|
4
|
-
import fsPromises from 'fs/promises';
|
|
5
|
-
import { remoteLog as defaultRemoteLog } from '../remote-log.js';
|
|
6
|
-
|
|
7
|
-
const SUPPORTED_PLATFORMS = new Set([
|
|
8
|
-
'linux-x64',
|
|
9
|
-
'linux-arm64',
|
|
10
|
-
'darwin-x64',
|
|
11
|
-
'darwin-arm64',
|
|
12
|
-
'win32-x64',
|
|
13
|
-
]);
|
|
14
|
-
|
|
15
|
-
const DEFAULT_IMPORT_TIMEOUT_MS = 10_000;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* 给 promise 加超时保护。超时后 reject,但原 promise 仍在后台执行——
|
|
19
|
-
* JS 无法取消 pending 的 import(),超时只是让调用方不再等待。
|
|
20
|
-
* @param {Promise} promise
|
|
21
|
-
* @param {number} ms
|
|
22
|
-
* @param {string} label - 用于错误信息
|
|
23
|
-
*/
|
|
24
|
-
function withTimeout(promise, ms, label) {
|
|
25
|
-
// 超时后原 promise 仍在后台执行(JS 无法取消 pending 的 import())。
|
|
26
|
-
// 必须 .catch 兜住原 promise 的潜在 rejection,否则超时场景下
|
|
27
|
-
// 原 promise 最终 reject 会成为 unhandled rejection,导致进程终止。
|
|
28
|
-
promise.catch(() => {});
|
|
29
|
-
let timer;
|
|
30
|
-
const timeout = new Promise((_, reject) => {
|
|
31
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
32
|
-
timer.unref?.();
|
|
33
|
-
});
|
|
34
|
-
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 解析 vendor 源和部署目标路径。
|
|
39
|
-
* @param {string} platformKey - 如 'linux-x64'
|
|
40
|
-
* @param {string} pluginRoot - 插件根目录
|
|
41
|
-
* @returns {{ src: string, dest: string, destDir: string }}
|
|
42
|
-
*/
|
|
43
|
-
export function defaultResolvePaths(platformKey, pluginRoot) {
|
|
44
|
-
const src = nodePath.join(pluginRoot, 'vendor', 'ndc-prebuilds', platformKey, 'node_datachannel.node');
|
|
45
|
-
|
|
46
|
-
// 定位 node-datachannel 包根:从入口路径向上查找 package.json
|
|
47
|
-
const require = createRequire(nodePath.join(pluginRoot, 'package.json'));
|
|
48
|
-
const entryPath = require.resolve('node-datachannel');
|
|
49
|
-
/* c8 ignore start -- node-datachannel 依赖已于 2026-04-19 摘除,以下路径仅在 ndc 实际安装时命中;代码保留作为过渡期 fallback 自然失败锚点,待 ndc-preloader 整体清理时一并删除 */
|
|
50
|
-
let pkgRoot = nodePath.dirname(entryPath);
|
|
51
|
-
while (pkgRoot !== nodePath.dirname(pkgRoot)) {
|
|
52
|
-
try {
|
|
53
|
-
const pkg = JSON.parse(fsSync.readFileSync(nodePath.join(pkgRoot, 'package.json'), 'utf8'));
|
|
54
|
-
if (pkg.name === 'node-datachannel') break;
|
|
55
|
-
} catch { /* 继续向上 */ }
|
|
56
|
-
pkgRoot = nodePath.dirname(pkgRoot);
|
|
57
|
-
}
|
|
58
|
-
const destDir = nodePath.join(pkgRoot, 'build', 'Release');
|
|
59
|
-
const dest = nodePath.join(destDir, 'node_datachannel.node');
|
|
60
|
-
|
|
61
|
-
return { src, dest, destDir };
|
|
62
|
-
/* c8 ignore stop */
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* ndc polyfill 的 RTCPeerConnection 将 iceServers 的 username:credential 直接拼入 URL,
|
|
67
|
-
* 但 TURN REST API 的 username 格式为 `timestamp:identity`(含冒号),
|
|
68
|
-
* 导致 libdatachannel 的 URL parser 截断 username。
|
|
69
|
-
* 此 wrapper 在传入 polyfill 前对 username/credential 做 percent-encoding 规避该问题。
|
|
70
|
-
*/
|
|
71
|
-
function wrapNdcCredentials(NativeRTC) {
|
|
72
|
-
return class extends NativeRTC {
|
|
73
|
-
constructor(config = {}) {
|
|
74
|
-
if (config?.iceServers) {
|
|
75
|
-
config = {
|
|
76
|
-
...config,
|
|
77
|
-
iceServers: config.iceServers.map(s => {
|
|
78
|
-
/* c8 ignore next -- TURN 无凭据时的短路,集成环境下不经过此路径 */
|
|
79
|
-
if (!s.username && !s.credential) return s;
|
|
80
|
-
return {
|
|
81
|
-
...s,
|
|
82
|
-
username: s.username ? encodeURIComponent(s.username) : s.username,
|
|
83
|
-
credential: s.credential ? encodeURIComponent(s.credential) : s.credential,
|
|
84
|
-
};
|
|
85
|
-
}),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
super(config);
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 预加载 WebRTC 实现:优先 node-datachannel,失败回退 werift,全部失败返回 null。
|
|
95
|
-
*
|
|
96
|
-
* **此函数永不 throw**——所有异常内部捕获,通过 remoteLog 报告。
|
|
97
|
-
* 返回值始终为 { PeerConnection, cleanup, impl } 结构。
|
|
98
|
-
*
|
|
99
|
-
* @param {object} [deps] - 可注入依赖(测试用)
|
|
100
|
-
* @param {object} [deps.fs] - { access, copyFile, mkdir }
|
|
101
|
-
* @param {Function} [deps.dynamicImport] - (specifier) => import(specifier)
|
|
102
|
-
* @param {Function} [deps.remoteLog] - (text) => void
|
|
103
|
-
* @param {string} [deps.platform] - 覆盖 process.platform
|
|
104
|
-
* @param {string} [deps.arch] - 覆盖 process.arch
|
|
105
|
-
* @param {string} [deps.pluginRoot] - 覆盖插件根目录
|
|
106
|
-
* @param {Function} [deps.resolvePaths] - (platformKey, pluginRoot) => { src, dest, destDir }
|
|
107
|
-
* @param {number} [deps.importTimeout] - 动态 import 超时(ms),默认 10s
|
|
108
|
-
* @returns {Promise<{ PeerConnection: Function|null, cleanup: Function|null, impl: string }>}
|
|
109
|
-
*/
|
|
110
|
-
export async function preloadNdc(deps = {}) {
|
|
111
|
-
const fs = deps.fs ?? fsPromises;
|
|
112
|
-
const dynamicImport = deps.dynamicImport ?? ((spec) => import(spec));
|
|
113
|
-
const log = deps.remoteLog ?? defaultRemoteLog;
|
|
114
|
-
const platform = deps.platform ?? process.platform;
|
|
115
|
-
const arch = deps.arch ?? process.arch;
|
|
116
|
-
const pluginRoot = deps.pluginRoot ?? nodePath.resolve(import.meta.dirname, '../..');
|
|
117
|
-
const resolvePaths = deps.resolvePaths ?? defaultResolvePaths;
|
|
118
|
-
const importTimeout = deps.importTimeout ?? DEFAULT_IMPORT_TIMEOUT_MS;
|
|
119
|
-
|
|
120
|
-
const platformKey = `${platform}-${arch}`;
|
|
121
|
-
log(`ndc.preload platform=${platformKey}`);
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
// 平台检查
|
|
125
|
-
if (!SUPPORTED_PLATFORMS.has(platformKey)) {
|
|
126
|
-
log(`ndc.skip reason=unsupported-platform platform=${platformKey}`);
|
|
127
|
-
return weriftFallback(dynamicImport, log, importTimeout);
|
|
128
|
-
}
|
|
129
|
-
const { src, dest, destDir } = resolvePaths(platformKey, pluginRoot);
|
|
130
|
-
|
|
131
|
-
// 检查目标 binary 是否已存在(正常 pnpm install 或已执行过 bootstrap)
|
|
132
|
-
let needCopy = false;
|
|
133
|
-
try {
|
|
134
|
-
await fs.access(dest);
|
|
135
|
-
log('ndc.binary-exists');
|
|
136
|
-
} catch {
|
|
137
|
-
needCopy = true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (needCopy) {
|
|
141
|
-
// 检查 vendor 源 binary
|
|
142
|
-
try {
|
|
143
|
-
await fs.access(src);
|
|
144
|
-
} catch {
|
|
145
|
-
log('ndc.fallback reason=binary-missing');
|
|
146
|
-
return weriftFallback(dynamicImport, log, importTimeout);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// 部署 binary
|
|
150
|
-
try {
|
|
151
|
-
await fs.mkdir(destDir, { recursive: true });
|
|
152
|
-
await fs.copyFile(src, dest);
|
|
153
|
-
log('ndc.binary-deployed');
|
|
154
|
-
} catch (err) {
|
|
155
|
-
log(`ndc.fallback reason=copy-failed error=${err.message}`);
|
|
156
|
-
return weriftFallback(dynamicImport, log, importTimeout);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// 加载模块(带超时保护,防止 native binding dlopen 卡住)
|
|
161
|
-
let polyfill, ndc;
|
|
162
|
-
try {
|
|
163
|
-
polyfill = await withTimeout(
|
|
164
|
-
dynamicImport('node-datachannel/polyfill'),
|
|
165
|
-
importTimeout,
|
|
166
|
-
'import(node-datachannel/polyfill)',
|
|
167
|
-
);
|
|
168
|
-
ndc = await withTimeout(
|
|
169
|
-
dynamicImport('node-datachannel'),
|
|
170
|
-
importTimeout,
|
|
171
|
-
'import(node-datachannel)',
|
|
172
|
-
);
|
|
173
|
-
} catch (err) {
|
|
174
|
-
log(`ndc.fallback reason=import-failed error=${err.message}`);
|
|
175
|
-
return weriftFallback(dynamicImport, log, importTimeout);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const { RTCPeerConnection } = polyfill;
|
|
179
|
-
const cleanup = ndc.cleanup ?? ndc.default?.cleanup ?? null;
|
|
180
|
-
|
|
181
|
-
// 验证 RTCPeerConnection 可用(不创建实例,避免 native thread 阻止进程退出)
|
|
182
|
-
if (typeof RTCPeerConnection !== 'function') {
|
|
183
|
-
log('ndc.fallback reason=smoke-failed error=RTCPeerConnection is not a function');
|
|
184
|
-
return weriftFallback(dynamicImport, log, importTimeout);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// 注册 libdatachannel 内部日志回调(Warning 级别),
|
|
188
|
-
// 用于捕获 ICE/DTLS/SCTP 层断连原因。
|
|
189
|
-
// initLogger 是进程全局单例,调用一次即可(cleanup 不会被调用,logger 全程有效)。
|
|
190
|
-
// callback 通过 TSFN 投递到 JS 主线程,Warning 级别正常运行时零输出。
|
|
191
|
-
/* c8 ignore next -- ??/?. fallback */
|
|
192
|
-
const initLogger = ndc.initLogger ?? ndc.default?.initLogger;
|
|
193
|
-
if (typeof initLogger === 'function') {
|
|
194
|
-
try {
|
|
195
|
-
initLogger('Warning', (level, message) => {
|
|
196
|
-
try {
|
|
197
|
-
const msg = typeof message === 'string' ? message.replace(/\n/g, '\\n') : message;
|
|
198
|
-
log(`ndc.native level=${level} ${msg}`);
|
|
199
|
-
} catch { /* 不让任何异常传播到 native 层 */ }
|
|
200
|
-
});
|
|
201
|
-
log('ndc.logger-registered level=Warning');
|
|
202
|
-
} catch (err) {
|
|
203
|
-
// initLogger 失败不影响 ndc 正常使用
|
|
204
|
-
log(`ndc.logger-failed error=${err.message}`);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// 重要:调用方在不再需要 node-datachannel 时(如 bridge stop),必须调用 cleanup()。
|
|
209
|
-
// node-datachannel 内部使用 ThreadSafeCallback 维持 native threads,不调用 cleanup()
|
|
210
|
-
// 会阻止 Node 进程正常退出(上游 issue #366)。
|
|
211
|
-
// 当前 RealtimeBridge.stop() 不调用 cleanup()(阻塞 10s+),native threads 保持活跃供复用。
|
|
212
|
-
// 进程退出时 OS 会回收所有资源。
|
|
213
|
-
log(`ndc.loaded platform=${platformKey}`);
|
|
214
|
-
return { PeerConnection: wrapNdcCredentials(RTCPeerConnection), cleanup, impl: 'ndc' };
|
|
215
|
-
} catch (err) {
|
|
216
|
-
// node-datachannel 依赖已摘除:require.resolve 抛 MODULE_NOT_FOUND 是过渡期
|
|
217
|
-
// 预期路径,记为 skip 而非误导性的 unexpected。其余才是真正未预期异常。
|
|
218
|
-
if (err?.code === 'MODULE_NOT_FOUND') {
|
|
219
|
-
log('ndc.skip reason=ndc-not-installed');
|
|
220
|
-
} else {
|
|
221
|
-
log(`ndc.fallback reason=unexpected error=${err.message}`);
|
|
222
|
-
}
|
|
223
|
-
return weriftFallback(dynamicImport, log, importTimeout);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* 回退到 werift。加载也带超时保护。
|
|
229
|
-
* werift 也失败时返回 PeerConnection: null(WebRTC 不可用但不影响 gateway)。
|
|
230
|
-
* @param {Function} dynamicImport
|
|
231
|
-
* @param {Function} log
|
|
232
|
-
* @param {number} importTimeout
|
|
233
|
-
*/
|
|
234
|
-
async function weriftFallback(dynamicImport, log, importTimeout) {
|
|
235
|
-
try {
|
|
236
|
-
const { RTCPeerConnection } = await withTimeout(
|
|
237
|
-
dynamicImport('werift'),
|
|
238
|
-
importTimeout,
|
|
239
|
-
'import(werift)',
|
|
240
|
-
);
|
|
241
|
-
log('webrtc.fallback-to-werift');
|
|
242
|
-
return { PeerConnection: RTCPeerConnection, cleanup: null, impl: 'werift' };
|
|
243
|
-
} catch (err) {
|
|
244
|
-
log(`webrtc.all-unavailable error=${err.message}`);
|
|
245
|
-
return { PeerConnection: null, cleanup: null, impl: 'none' };
|
|
246
|
-
}
|
|
247
|
-
}
|