@coclaw/openclaw-coclaw 0.9.2 → 0.10.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 +21 -4
- package/package.json +5 -5
- package/src/auto-upgrade/updater.js +5 -0
- package/src/config.js +3 -1
- package/src/device-identity.js +4 -2
- package/src/ndc-preloader.js +191 -0
- package/src/realtime-bridge.js +82 -12
- package/src/remote-log.js +70 -0
- package/src/webrtc-peer.js +44 -5
- package/vendor/ndc-prebuilds/darwin-arm64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/darwin-x64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/linux-arm64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/linux-x64/node_datachannel.node +0 -0
- package/vendor/ndc-prebuilds/win32-x64/node_datachannel.node +0 -0
package/README.md
CHANGED
|
@@ -137,6 +137,25 @@ openclaw gateway call coclaw.upgradeHealth --json
|
|
|
137
137
|
|
|
138
138
|
详见设计文档 `docs/auto-upgrade.md`。
|
|
139
139
|
|
|
140
|
+
## WebRTC 实现
|
|
141
|
+
|
|
142
|
+
插件支持两个 WebRTC 实现,运行时自动选择:
|
|
143
|
+
|
|
144
|
+
1. **node-datachannel**(首选)— 基于 libdatachannel 的工业级实现,通过 vendor 预编译 native binary 部署。
|
|
145
|
+
2. **werift**(回退)— 纯 JavaScript 实现,作为 node-datachannel 加载失败时的兜底。
|
|
146
|
+
|
|
147
|
+
选择结果通过 remoteLog 上报(`ndc.loaded` 或 `ndc.using-werift`)。
|
|
148
|
+
|
|
149
|
+
### vendor 预编译包
|
|
150
|
+
|
|
151
|
+
由于 OpenClaw 使用 `--ignore-scripts` 安装插件,node-datachannel 的 native binary 需通过 vendor 预编译包提供:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
bash scripts/download-ndc-prebuilds.sh # 下载 5 平台预编译包到 vendor/ndc-prebuilds/
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
支持的平台:linux-x64、linux-arm64、darwin-x64、darwin-arm64、win32-x64。vendor 目录不入 git,通过 npm publish 的 `files` 字段包含在发布包中。
|
|
158
|
+
|
|
140
159
|
## 运行与排障日志
|
|
141
160
|
|
|
142
161
|
### 日志级别建议
|
|
@@ -176,10 +195,8 @@ openclaw logs --limit 300 --plain | rg -n "ui->server req|bot->server res|bot->s
|
|
|
176
195
|
## 测试门禁
|
|
177
196
|
|
|
178
197
|
```bash
|
|
179
|
-
pnpm check
|
|
180
|
-
pnpm test #
|
|
181
|
-
pnpm coverage # 覆盖率检查
|
|
182
|
-
pnpm verify # 完整验证(check → test:standalone → test:plugin → test → coverage)
|
|
198
|
+
pnpm check # lint + typecheck
|
|
199
|
+
pnpm test # 测试 + 覆盖率检查
|
|
183
200
|
```
|
|
184
201
|
|
|
185
202
|
覆盖率阈值:lines/statements/functions 100%,branches ≥ 95%。未通过禁止接入 gateway。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"!src/**/*.test.js",
|
|
33
33
|
"!src/mock-server.helper.js",
|
|
34
34
|
"openclaw.plugin.json",
|
|
35
|
+
"vendor/ndc-prebuilds/**",
|
|
35
36
|
"LICENSE"
|
|
36
37
|
],
|
|
37
38
|
"main": "index.js",
|
|
@@ -49,11 +50,9 @@
|
|
|
49
50
|
"lint": "eslint \"**/*.{js,mjs,cjs}\" --no-error-on-unmatched-pattern",
|
|
50
51
|
"typecheck": "echo \"No typecheck configured yet (coclaw openclaw plugin)\"",
|
|
51
52
|
"check": "pnpm lint && pnpm typecheck",
|
|
52
|
-
"test:standalone": "node --test src/standalone-mode.test.js",
|
|
53
53
|
"test:plugin": "node --test src/plugin-mode.test.js",
|
|
54
|
-
"test": "node --test",
|
|
55
|
-
"
|
|
56
|
-
"verify": "pnpm check && pnpm coverage",
|
|
54
|
+
"test": "c8 --check-coverage --lines 100 --functions 100 --branches 95 --statements 100 --reporter=text --reporter=lcov bash -c 'for f in src/**/*.test.js src/*.test.js index.test.js; do node --test \"$f\" || exit 1; done'",
|
|
55
|
+
"verify": "pnpm check && pnpm test",
|
|
57
56
|
"link": "bash ./scripts/link.sh",
|
|
58
57
|
"unlink": "bash ./scripts/unlink.sh",
|
|
59
58
|
"install:npm": "bash ./scripts/install-npm.sh",
|
|
@@ -64,6 +63,7 @@
|
|
|
64
63
|
"release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
|
|
65
64
|
},
|
|
66
65
|
"dependencies": {
|
|
66
|
+
"node-datachannel": "0.32.1",
|
|
67
67
|
"werift": "^0.19.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
@@ -5,6 +5,7 @@ import { checkForUpdate } from './updater-check.js';
|
|
|
5
5
|
import { spawnUpgradeWorker } from './updater-spawn.js';
|
|
6
6
|
import { resolveStateDir } from './state.js';
|
|
7
7
|
import { getRuntime } from '../runtime.js';
|
|
8
|
+
import { remoteLog } from '../remote-log.js';
|
|
8
9
|
|
|
9
10
|
const INITIAL_DELAY_MS = 5 * 60 * 1000; // 5 分钟
|
|
10
11
|
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 小时
|
|
@@ -203,6 +204,7 @@ export class AutoUpgradeScheduler {
|
|
|
203
204
|
// 若上一次 spawn 的 worker 仍在运行,跳过本次检查
|
|
204
205
|
const isLocked = this.__opts.isUpgradeLockedFn ?? isUpgradeLocked;
|
|
205
206
|
if (await isLocked({ logger: this.__logger })) {
|
|
207
|
+
remoteLog('upgrade.worker-locked');
|
|
206
208
|
this.__logger.info?.('[auto-upgrade] Upgrade worker still running, skipping check');
|
|
207
209
|
return;
|
|
208
210
|
}
|
|
@@ -221,11 +223,13 @@ export class AutoUpgradeScheduler {
|
|
|
221
223
|
return;
|
|
222
224
|
}
|
|
223
225
|
|
|
226
|
+
remoteLog(`upgrade.available from=${result.currentVersion} to=${result.latestVersion}`);
|
|
224
227
|
this.__logger.info?.(`[auto-upgrade] Update available: ${result.currentVersion} → ${result.latestVersion}`);
|
|
225
228
|
|
|
226
229
|
const getInstallPath = this.__opts.getPluginInstallPathFn ?? getPluginInstallPath;
|
|
227
230
|
const pluginDir = getInstallPath(this.__pluginId);
|
|
228
231
|
if (!pluginDir) {
|
|
232
|
+
remoteLog('upgrade.no-install-path');
|
|
229
233
|
this.__logger.warn?.('[auto-upgrade] Cannot determine plugin install path');
|
|
230
234
|
return;
|
|
231
235
|
}
|
|
@@ -245,6 +249,7 @@ export class AutoUpgradeScheduler {
|
|
|
245
249
|
await writeLock(child.pid);
|
|
246
250
|
}
|
|
247
251
|
catch (err) {
|
|
252
|
+
remoteLog(`upgrade.check-failed msg=${err.message}`);
|
|
248
253
|
this.__logger.warn?.(`[auto-upgrade] Check failed: ${err.message}`);
|
|
249
254
|
}
|
|
250
255
|
finally {
|
package/src/config.js
CHANGED
|
@@ -42,8 +42,10 @@ async function readJson(filePath) {
|
|
|
42
42
|
try {
|
|
43
43
|
return JSON.parse(raw);
|
|
44
44
|
}
|
|
45
|
-
catch {
|
|
45
|
+
catch (err) {
|
|
46
46
|
// 文件损坏,删除后当空文件处理
|
|
47
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
48
|
+
console.warn?.(`[coclaw] corrupt bindings file deleted: ${filePath} (${String(err?.message ?? err)})`);
|
|
47
49
|
await fs.unlink(filePath).catch(() => {});
|
|
48
50
|
return {};
|
|
49
51
|
}
|
package/src/device-identity.js
CHANGED
|
@@ -115,8 +115,10 @@ export function loadOrCreateDeviceIdentity(filePath) {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
-
catch {
|
|
119
|
-
//
|
|
118
|
+
catch (err) {
|
|
119
|
+
// 读取/解析失败时重新生成(将产生新 deviceId,需重新 enroll)
|
|
120
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
121
|
+
console.warn?.(`[coclaw] device identity read failed, regenerating: ${String(err?.message ?? err)}`);
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
const identity = generateIdentity();
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
let pkgRoot = nodePath.dirname(entryPath);
|
|
50
|
+
while (pkgRoot !== nodePath.dirname(pkgRoot)) {
|
|
51
|
+
try {
|
|
52
|
+
const pkg = JSON.parse(fsSync.readFileSync(nodePath.join(pkgRoot, 'package.json'), 'utf8'));
|
|
53
|
+
if (pkg.name === 'node-datachannel') break;
|
|
54
|
+
} catch { /* 继续向上 */ }
|
|
55
|
+
pkgRoot = nodePath.dirname(pkgRoot);
|
|
56
|
+
}
|
|
57
|
+
const destDir = nodePath.join(pkgRoot, 'build', 'Release');
|
|
58
|
+
const dest = nodePath.join(destDir, 'node_datachannel.node');
|
|
59
|
+
|
|
60
|
+
return { src, dest, destDir };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 预加载 WebRTC 实现:优先 node-datachannel,失败回退 werift,全部失败返回 null。
|
|
65
|
+
*
|
|
66
|
+
* **此函数永不 throw**——所有异常内部捕获,通过 remoteLog 报告。
|
|
67
|
+
* 返回值始终为 { PeerConnection, cleanup, impl } 结构。
|
|
68
|
+
*
|
|
69
|
+
* @param {object} [deps] - 可注入依赖(测试用)
|
|
70
|
+
* @param {object} [deps.fs] - { access, copyFile, mkdir }
|
|
71
|
+
* @param {Function} [deps.dynamicImport] - (specifier) => import(specifier)
|
|
72
|
+
* @param {Function} [deps.remoteLog] - (text) => void
|
|
73
|
+
* @param {string} [deps.platform] - 覆盖 process.platform
|
|
74
|
+
* @param {string} [deps.arch] - 覆盖 process.arch
|
|
75
|
+
* @param {string} [deps.pluginRoot] - 覆盖插件根目录
|
|
76
|
+
* @param {Function} [deps.resolvePaths] - (platformKey, pluginRoot) => { src, dest, destDir }
|
|
77
|
+
* @param {number} [deps.importTimeout] - 动态 import 超时(ms),默认 10s
|
|
78
|
+
* @returns {Promise<{ PeerConnection: Function|null, cleanup: Function|null, impl: string }>}
|
|
79
|
+
*/
|
|
80
|
+
export async function preloadNdc(deps = {}) {
|
|
81
|
+
const fs = deps.fs ?? fsPromises;
|
|
82
|
+
const dynamicImport = deps.dynamicImport ?? ((spec) => import(spec));
|
|
83
|
+
const log = deps.remoteLog ?? defaultRemoteLog;
|
|
84
|
+
const platform = deps.platform ?? process.platform;
|
|
85
|
+
const arch = deps.arch ?? process.arch;
|
|
86
|
+
const pluginRoot = deps.pluginRoot ?? nodePath.resolve(import.meta.dirname, '..');
|
|
87
|
+
const resolvePaths = deps.resolvePaths ?? defaultResolvePaths;
|
|
88
|
+
const importTimeout = deps.importTimeout ?? DEFAULT_IMPORT_TIMEOUT_MS;
|
|
89
|
+
|
|
90
|
+
const platformKey = `${platform}-${arch}`;
|
|
91
|
+
log(`ndc.preload platform=${platformKey}`);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// 平台检查
|
|
95
|
+
if (!SUPPORTED_PLATFORMS.has(platformKey)) {
|
|
96
|
+
log(`ndc.skip reason=unsupported-platform platform=${platformKey}`);
|
|
97
|
+
return weriftFallback(dynamicImport, log, importTimeout);
|
|
98
|
+
}
|
|
99
|
+
const { src, dest, destDir } = resolvePaths(platformKey, pluginRoot);
|
|
100
|
+
|
|
101
|
+
// 检查目标 binary 是否已存在(正常 pnpm install 或已执行过 bootstrap)
|
|
102
|
+
let needCopy = false;
|
|
103
|
+
try {
|
|
104
|
+
await fs.access(dest);
|
|
105
|
+
log('ndc.binary-exists');
|
|
106
|
+
} catch {
|
|
107
|
+
needCopy = true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (needCopy) {
|
|
111
|
+
// 检查 vendor 源 binary
|
|
112
|
+
try {
|
|
113
|
+
await fs.access(src);
|
|
114
|
+
} catch {
|
|
115
|
+
log('ndc.fallback reason=binary-missing');
|
|
116
|
+
return weriftFallback(dynamicImport, log, importTimeout);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 部署 binary
|
|
120
|
+
try {
|
|
121
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
122
|
+
await fs.copyFile(src, dest);
|
|
123
|
+
log('ndc.binary-deployed');
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log(`ndc.fallback reason=copy-failed error=${err.message}`);
|
|
126
|
+
return weriftFallback(dynamicImport, log, importTimeout);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 加载模块(带超时保护,防止 native binding dlopen 卡住)
|
|
131
|
+
let polyfill, ndc;
|
|
132
|
+
try {
|
|
133
|
+
polyfill = await withTimeout(
|
|
134
|
+
dynamicImport('node-datachannel/polyfill'),
|
|
135
|
+
importTimeout,
|
|
136
|
+
'import(node-datachannel/polyfill)',
|
|
137
|
+
);
|
|
138
|
+
ndc = await withTimeout(
|
|
139
|
+
dynamicImport('node-datachannel'),
|
|
140
|
+
importTimeout,
|
|
141
|
+
'import(node-datachannel)',
|
|
142
|
+
);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
log(`ndc.fallback reason=import-failed error=${err.message}`);
|
|
145
|
+
return weriftFallback(dynamicImport, log, importTimeout);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { RTCPeerConnection } = polyfill;
|
|
149
|
+
const cleanup = ndc.cleanup ?? ndc.default?.cleanup ?? null;
|
|
150
|
+
|
|
151
|
+
// 验证 RTCPeerConnection 可用(不创建实例,避免 native thread 阻止进程退出)
|
|
152
|
+
if (typeof RTCPeerConnection !== 'function') {
|
|
153
|
+
log('ndc.fallback reason=smoke-failed error=RTCPeerConnection is not a function');
|
|
154
|
+
return weriftFallback(dynamicImport, log, importTimeout);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 重要:调用方在不再需要 node-datachannel 时(如 bridge stop),必须调用 cleanup()。
|
|
158
|
+
// node-datachannel 内部使用 ThreadSafeCallback 维持 native threads,不调用 cleanup()
|
|
159
|
+
// 会阻止 Node 进程正常退出(上游 issue #366)。
|
|
160
|
+
// 当前由 RealtimeBridge.stop() 负责调用。若 gateway 被 SIGKILL 强杀则无法执行,
|
|
161
|
+
// 但 OS 会回收所有资源。若 OpenClaw 提供了优雅终止钩子,应在钩子中也调用 cleanup。
|
|
162
|
+
log(`ndc.loaded platform=${platformKey}`);
|
|
163
|
+
return { PeerConnection: RTCPeerConnection, cleanup, impl: 'ndc' };
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// resolvePaths 或其他未预期异常的兜底
|
|
166
|
+
log(`ndc.fallback reason=unexpected error=${err.message}`);
|
|
167
|
+
return weriftFallback(dynamicImport, log, importTimeout);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 回退到 werift。加载也带超时保护。
|
|
173
|
+
* werift 也失败时返回 PeerConnection: null(WebRTC 不可用但不影响 gateway)。
|
|
174
|
+
* @param {Function} dynamicImport
|
|
175
|
+
* @param {Function} log
|
|
176
|
+
* @param {number} importTimeout
|
|
177
|
+
*/
|
|
178
|
+
async function weriftFallback(dynamicImport, log, importTimeout) {
|
|
179
|
+
try {
|
|
180
|
+
const { RTCPeerConnection } = await withTimeout(
|
|
181
|
+
dynamicImport('werift'),
|
|
182
|
+
importTimeout,
|
|
183
|
+
'import(werift)',
|
|
184
|
+
);
|
|
185
|
+
log('webrtc.fallback-to-werift');
|
|
186
|
+
return { PeerConnection: RTCPeerConnection, cleanup: null, impl: 'werift' };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
log(`webrtc.all-unavailable error=${err.message}`);
|
|
189
|
+
return { PeerConnection: null, cleanup: null, impl: 'none' };
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/realtime-bridge.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
buildDeviceAuthPayloadV3,
|
|
11
11
|
} from './device-identity.js';
|
|
12
12
|
import { getRuntime } from './runtime.js';
|
|
13
|
+
import { setSender as setRemoteLogSender, remoteLog } from './remote-log.js';
|
|
13
14
|
|
|
14
15
|
const DEFAULT_GATEWAY_WS_URL = `ws://127.0.0.1:${process.env.OPENCLAW_GATEWAY_PORT || '18789'}`;
|
|
15
16
|
const RECONNECT_MS = 10_000;
|
|
@@ -52,7 +53,8 @@ function defaultResolveGatewayAuthToken() {
|
|
|
52
53
|
const token = cfg?.gateway?.auth?.token;
|
|
53
54
|
return typeof token === 'string' && token.trim() ? token.trim() : '';
|
|
54
55
|
}
|
|
55
|
-
catch {
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.warn?.(`[coclaw] resolve gateway auth token failed: ${String(err?.message ?? err)}`);
|
|
56
58
|
return '';
|
|
57
59
|
}
|
|
58
60
|
}
|
|
@@ -72,6 +74,7 @@ export class RealtimeBridge {
|
|
|
72
74
|
* @param {Function} [deps.getBindingsPath] - 获取绑定文件路径
|
|
73
75
|
* @param {Function} [deps.resolveGatewayAuthToken] - 获取 gateway 认证 token
|
|
74
76
|
* @param {Function} [deps.loadDeviceIdentity] - 加载设备身份
|
|
77
|
+
* @param {number} [deps.gatewayReadyTimeoutMs] - __waitGatewayReady 默认超时(测试可注入短值)
|
|
75
78
|
*/
|
|
76
79
|
constructor(deps = {}) {
|
|
77
80
|
this.__readConfig = deps.readConfig ?? readConfig;
|
|
@@ -79,7 +82,9 @@ export class RealtimeBridge {
|
|
|
79
82
|
this.__getBindingsPath = deps.getBindingsPath ?? getBindingsPath;
|
|
80
83
|
this.__resolveGatewayAuthToken = deps.resolveGatewayAuthToken ?? defaultResolveGatewayAuthToken;
|
|
81
84
|
this.__loadDeviceIdentity = deps.loadDeviceIdentity ?? loadOrCreateDeviceIdentity;
|
|
85
|
+
this.__preloadNdc = deps.preloadNdc ?? null;
|
|
82
86
|
this.__WebSocket = deps.WebSocket ?? null;
|
|
87
|
+
this.__gatewayReadyTimeoutMs = deps.gatewayReadyTimeoutMs ?? 1500;
|
|
83
88
|
|
|
84
89
|
this.serverWs = null;
|
|
85
90
|
this.gatewayWs = null;
|
|
@@ -100,6 +105,8 @@ export class RealtimeBridge {
|
|
|
100
105
|
this.webrtcPeer = null;
|
|
101
106
|
this.__webrtcPeerReady = null;
|
|
102
107
|
this.__fileHandler = null;
|
|
108
|
+
this.__ndcPreloadResult = null;
|
|
109
|
+
this.__ndcCleanup = null;
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
__resolveWebSocket() {
|
|
@@ -149,6 +156,7 @@ export class RealtimeBridge {
|
|
|
149
156
|
this.serverHbTimer.unref?.();
|
|
150
157
|
return;
|
|
151
158
|
}
|
|
159
|
+
remoteLog(`ws.hb-timeout peer=server misses=${this.__serverHbMissCount}`);
|
|
152
160
|
this.logger.warn?.(
|
|
153
161
|
`[coclaw] server ws heartbeat timeout after ${this.__serverHbMissCount} consecutive misses (~${this.__serverHbMissCount * SERVER_HB_TIMEOUT_MS / 1000}s), closing`
|
|
154
162
|
);
|
|
@@ -198,22 +206,26 @@ export class RealtimeBridge {
|
|
|
198
206
|
}
|
|
199
207
|
|
|
200
208
|
/** 懒加载 WebRtcPeer(promise 锁防并发重复创建) */
|
|
209
|
+
/* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
|
|
201
210
|
async __initWebrtcPeer() {
|
|
211
|
+
const PeerConnection = this.__ndcPreloadResult?.PeerConnection;
|
|
212
|
+
if (!PeerConnection) {
|
|
213
|
+
remoteLog('rtc.unavailable reason=no-webrtc-impl');
|
|
214
|
+
throw new Error('No WebRTC implementation available');
|
|
215
|
+
}
|
|
216
|
+
|
|
202
217
|
const { WebRtcPeer } = await import('./webrtc-peer.js');
|
|
203
218
|
const { createFileHandler } = await import('./file-manager/handler.js');
|
|
204
|
-
/* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
|
|
205
219
|
this.__fileHandler = createFileHandler({
|
|
206
220
|
resolveWorkspace: (agentId) => this.__resolveWorkspace(agentId),
|
|
207
221
|
logger: this.logger,
|
|
208
222
|
});
|
|
209
223
|
this.__fileHandler.scheduleTmpCleanup(() => this.__listAgentWorkspaces());
|
|
210
|
-
/* c8 ignore stop */
|
|
211
224
|
this.webrtcPeer = new WebRtcPeer({
|
|
212
225
|
onSend: (msg) => this.__forwardToServer(msg),
|
|
213
226
|
onRequest: (dcPayload) => {
|
|
214
227
|
void this.__handleGatewayRequestFromServer(dcPayload);
|
|
215
228
|
},
|
|
216
|
-
/* c8 ignore start -- 仅通过 WebRTC 路径触发,集成测试覆盖 */
|
|
217
229
|
onFileRpc: (payload, sendFn) => {
|
|
218
230
|
this.__fileHandler.handleRpcRequest(payload, sendFn)
|
|
219
231
|
.catch((err) => this.logger.warn?.(`[coclaw/file] rpc error: ${err.message}`));
|
|
@@ -221,10 +233,11 @@ export class RealtimeBridge {
|
|
|
221
233
|
onFileChannel: (dc) => {
|
|
222
234
|
this.__fileHandler.handleFileChannel(dc);
|
|
223
235
|
},
|
|
224
|
-
|
|
236
|
+
PeerConnection,
|
|
225
237
|
logger: this.logger,
|
|
226
238
|
});
|
|
227
239
|
}
|
|
240
|
+
/* c8 ignore stop */
|
|
228
241
|
|
|
229
242
|
/* c8 ignore next 7 -- 防御性检查,serverWs 通常在调用时可用 */
|
|
230
243
|
__forwardToServer(payload) {
|
|
@@ -450,8 +463,8 @@ export class RealtimeBridge {
|
|
|
450
463
|
try {
|
|
451
464
|
const ws = await this.__resolveWorkspace(id);
|
|
452
465
|
workspaces.push(ws);
|
|
453
|
-
} catch {
|
|
454
|
-
|
|
466
|
+
} catch (err) {
|
|
467
|
+
this.__logDebug(`workspace resolve failed for agent=${id}: ${err?.message}`);
|
|
455
468
|
}
|
|
456
469
|
}
|
|
457
470
|
return workspaces;
|
|
@@ -523,7 +536,8 @@ export class RealtimeBridge {
|
|
|
523
536
|
params,
|
|
524
537
|
}));
|
|
525
538
|
}
|
|
526
|
-
catch {
|
|
539
|
+
catch (err) {
|
|
540
|
+
this.logger.warn?.(`[coclaw] gateway connect request failed: ${String(err?.message ?? err)}`);
|
|
527
541
|
this.gatewayConnectReqId = null;
|
|
528
542
|
}
|
|
529
543
|
}
|
|
@@ -562,6 +576,7 @@ export class RealtimeBridge {
|
|
|
562
576
|
if (payload.type === 'res' && this.gatewayConnectReqId && payload.id === this.gatewayConnectReqId) {
|
|
563
577
|
if (payload.ok === true) {
|
|
564
578
|
this.gatewayReady = true;
|
|
579
|
+
remoteLog('ws.connected peer=gateway');
|
|
565
580
|
this.__logDebug(`gateway connect ok <- id=${payload.id}`);
|
|
566
581
|
this.gatewayConnectReqId = null;
|
|
567
582
|
this.__ensureSessionsPromise = this.__ensureAllAgentSessions();
|
|
@@ -569,6 +584,7 @@ export class RealtimeBridge {
|
|
|
569
584
|
else {
|
|
570
585
|
this.gatewayReady = false;
|
|
571
586
|
this.gatewayConnectReqId = null;
|
|
587
|
+
remoteLog(`ws.connect-failed peer=gateway msg=${payload?.error?.message ?? 'unknown'}`);
|
|
572
588
|
this.logger.warn?.(`[coclaw] gateway connect failed: ${payload?.error?.message ?? 'unknown'}`);
|
|
573
589
|
try { ws.close(1008, 'gateway_connect_failed'); }
|
|
574
590
|
/* c8 ignore next */
|
|
@@ -592,15 +608,18 @@ export class RealtimeBridge {
|
|
|
592
608
|
return;
|
|
593
609
|
}
|
|
594
610
|
if (payload.type === 'res' || payload.type === 'event') {
|
|
611
|
+
// TODO: UI 已通过 DataChannel 接收业务消息,待旧版 UI 全部更新后移除此转发
|
|
595
612
|
this.__forwardToServer(payload);
|
|
596
613
|
this.webrtcPeer?.broadcast(payload);
|
|
597
614
|
}
|
|
598
615
|
});
|
|
599
616
|
|
|
600
617
|
ws.addEventListener('open', () => {
|
|
601
|
-
|
|
618
|
+
this.__logDebug('gateway ws open, waiting for connect.challenge');
|
|
602
619
|
});
|
|
603
|
-
ws.addEventListener('close', () => {
|
|
620
|
+
ws.addEventListener('close', (ev) => {
|
|
621
|
+
remoteLog(`ws.disconnected peer=gateway code=${ev?.code ?? '?'}`);
|
|
622
|
+
this.logger.info?.(`[coclaw] gateway ws closed (code=${ev?.code ?? '?'} reason=${ev?.reason ?? 'n/a'})`);
|
|
604
623
|
this.gatewayWs = null;
|
|
605
624
|
this.gatewayReady = false;
|
|
606
625
|
this.gatewayConnectReqId = null;
|
|
@@ -610,10 +629,14 @@ export class RealtimeBridge {
|
|
|
610
629
|
}
|
|
611
630
|
this.gatewayPendingRequests.clear();
|
|
612
631
|
});
|
|
613
|
-
ws.addEventListener('error', () => {
|
|
632
|
+
ws.addEventListener('error', (err) => {
|
|
633
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
634
|
+
remoteLog(`ws.error peer=gateway msg=${String(err?.message ?? err)}`);
|
|
635
|
+
this.logger.warn?.(`[coclaw] gateway ws error: ${String(err?.message ?? err)}`);
|
|
636
|
+
});
|
|
614
637
|
}
|
|
615
638
|
|
|
616
|
-
async __waitGatewayReady(timeoutMs =
|
|
639
|
+
async __waitGatewayReady(timeoutMs = this.__gatewayReadyTimeoutMs) {
|
|
617
640
|
this.__ensureGatewayConnection();
|
|
618
641
|
if (this.gatewayWs && this.gatewayWs.readyState === 1 && this.gatewayReady) {
|
|
619
642
|
return true;
|
|
@@ -711,6 +734,7 @@ export class RealtimeBridge {
|
|
|
711
734
|
if (!this.started || this.reconnectTimer) {
|
|
712
735
|
return;
|
|
713
736
|
}
|
|
737
|
+
remoteLog(`ws.reconnecting peer=server delay=${RECONNECT_MS}ms`);
|
|
714
738
|
this.reconnectTimer = setTimeout(() => {
|
|
715
739
|
this.reconnectTimer = null;
|
|
716
740
|
this.__connectIfNeeded().catch((err) => {
|
|
@@ -758,6 +782,7 @@ export class RealtimeBridge {
|
|
|
758
782
|
return;
|
|
759
783
|
}
|
|
760
784
|
this.logger.warn?.(`[coclaw] realtime bridge connect timeout, will retry: ${maskedTarget}`);
|
|
785
|
+
remoteLog('ws.connect-timeout peer=server');
|
|
761
786
|
this.serverWs = null;
|
|
762
787
|
this.__closeGatewayWs();
|
|
763
788
|
this.__scheduleReconnect();
|
|
@@ -770,6 +795,10 @@ export class RealtimeBridge {
|
|
|
770
795
|
sock.addEventListener('open', () => {
|
|
771
796
|
this.__clearConnectTimer();
|
|
772
797
|
this.logger.info?.(`[coclaw] realtime bridge connected: ${maskedTarget}`);
|
|
798
|
+
remoteLog('ws.connected peer=server');
|
|
799
|
+
setRemoteLogSender((msg) => {
|
|
800
|
+
if (sock.readyState === 1) sock.send(JSON.stringify(msg));
|
|
801
|
+
});
|
|
773
802
|
this.__startServerHeartbeat(sock);
|
|
774
803
|
this.__ensureGatewayConnection();
|
|
775
804
|
});
|
|
@@ -779,6 +808,7 @@ export class RealtimeBridge {
|
|
|
779
808
|
try {
|
|
780
809
|
const payload = JSON.parse(String(event.data ?? '{}'));
|
|
781
810
|
if (payload?.type === 'bot.unbound') {
|
|
811
|
+
remoteLog('ws.bot-unbound');
|
|
782
812
|
await this.__clearTokenLocal(payload.botId);
|
|
783
813
|
try { sock.close(4001, 'bot_unbound'); }
|
|
784
814
|
/* c8 ignore next */
|
|
@@ -797,6 +827,7 @@ export class RealtimeBridge {
|
|
|
797
827
|
await this.webrtcPeer.handleSignaling(payload);
|
|
798
828
|
} catch (err) {
|
|
799
829
|
this.logger.warn?.(`[coclaw/rtc] signaling error (or werift not found): ${err?.message}`);
|
|
830
|
+
remoteLog(`rtc.signaling-error msg=${err?.message}`);
|
|
800
831
|
}
|
|
801
832
|
return;
|
|
802
833
|
}
|
|
@@ -820,6 +851,7 @@ export class RealtimeBridge {
|
|
|
820
851
|
if (this.serverWs !== null && this.serverWs !== sock) {
|
|
821
852
|
return;
|
|
822
853
|
}
|
|
854
|
+
setRemoteLogSender(null);
|
|
823
855
|
const wasIntentional = this.intentionallyClosed;
|
|
824
856
|
this.serverWs = null;
|
|
825
857
|
this.intentionallyClosed = false;
|
|
@@ -837,6 +869,8 @@ export class RealtimeBridge {
|
|
|
837
869
|
}
|
|
838
870
|
|
|
839
871
|
if (event?.code === 4001 || event?.code === 4003) {
|
|
872
|
+
remoteLog(`ws.auth-close peer=server code=${event.code}`);
|
|
873
|
+
this.logger.warn?.(`[coclaw] server ws auth-close (code=${event.code}), clearing local token`);
|
|
840
874
|
try {
|
|
841
875
|
await this.__clearTokenLocal();
|
|
842
876
|
}
|
|
@@ -848,6 +882,7 @@ export class RealtimeBridge {
|
|
|
848
882
|
}
|
|
849
883
|
|
|
850
884
|
if (!wasIntentional) {
|
|
885
|
+
remoteLog(`ws.disconnected peer=server code=${event?.code ?? 'unknown'} reason=${event?.reason ?? 'n/a'}`);
|
|
851
886
|
this.logger.warn?.(`[coclaw] realtime bridge closed (${event?.code ?? 'unknown'}: ${event?.reason ?? 'n/a'}), will retry in ${RECONNECT_MS}ms`);
|
|
852
887
|
this.__scheduleReconnect();
|
|
853
888
|
}
|
|
@@ -859,6 +894,8 @@ export class RealtimeBridge {
|
|
|
859
894
|
}
|
|
860
895
|
this.__clearServerHeartbeat();
|
|
861
896
|
this.__clearConnectTimer();
|
|
897
|
+
setRemoteLogSender(null);
|
|
898
|
+
remoteLog(`ws.error peer=server msg=${String(err?.message ?? err)}`);
|
|
862
899
|
this.logger.warn?.(`[coclaw] realtime bridge error, will retry in ${RECONNECT_MS}ms: ${String(err?.message ?? err)}`);
|
|
863
900
|
this.serverWs = null;
|
|
864
901
|
this.__closeGatewayWs();
|
|
@@ -873,6 +910,27 @@ export class RealtimeBridge {
|
|
|
873
910
|
this.logger = logger ?? console;
|
|
874
911
|
this.pluginConfig = pluginConfig ?? {};
|
|
875
912
|
this.started = true;
|
|
913
|
+
// 先完成 WebRTC 实现加载,再建立连接,避免 UI 发来 offer 时 RTC 包未就绪
|
|
914
|
+
const preloadFn = this.__preloadNdc
|
|
915
|
+
?? (await import('./ndc-preloader.js')).preloadNdc;
|
|
916
|
+
const preloadResult = await preloadFn()
|
|
917
|
+
.catch((err) => {
|
|
918
|
+
// preloadNdc 设计上永不 throw,此 catch 为纯防御性兜底
|
|
919
|
+
this.logger.warn?.(`[coclaw] ndc preload unexpected failure: ${err?.message}`);
|
|
920
|
+
return { PeerConnection: null, cleanup: null, impl: 'none' };
|
|
921
|
+
});
|
|
922
|
+
// 竞态保护:若 preload 期间 stop() 已执行,不再赋值,立即释放 cleanup
|
|
923
|
+
if (!this.started) {
|
|
924
|
+
if (preloadResult.cleanup) {
|
|
925
|
+
try { preloadResult.cleanup(); } catch {}
|
|
926
|
+
}
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
this.__ndcPreloadResult = preloadResult;
|
|
930
|
+
this.__ndcCleanup = preloadResult.cleanup;
|
|
931
|
+
const implLabel = preloadResult.impl === 'ndc' ? 'node-datachannel(ndc)' : preloadResult.impl;
|
|
932
|
+
this.logger.info?.(`[coclaw] WebRTC impl: ${implLabel}`);
|
|
933
|
+
remoteLog(`bridge.webrtc-impl impl=${implLabel}`);
|
|
876
934
|
await this.__connectIfNeeded();
|
|
877
935
|
}
|
|
878
936
|
|
|
@@ -886,6 +944,7 @@ export class RealtimeBridge {
|
|
|
886
944
|
|
|
887
945
|
async stop() {
|
|
888
946
|
this.started = false;
|
|
947
|
+
setRemoteLogSender(null);
|
|
889
948
|
this.__clearServerHeartbeat();
|
|
890
949
|
this.__clearConnectTimer();
|
|
891
950
|
if (this.reconnectTimer) {
|
|
@@ -898,6 +957,17 @@ export class RealtimeBridge {
|
|
|
898
957
|
this.webrtcPeer = null;
|
|
899
958
|
this.__webrtcPeerReady = null;
|
|
900
959
|
}
|
|
960
|
+
// ndc cleanup:node-datachannel 的 native threads 必须通过 cleanup() 释放,
|
|
961
|
+
// 否则会阻止进程退出(issue #366)。
|
|
962
|
+
// start() 已 await preload 完成并缓存 cleanup 引用,此处直接使用。
|
|
963
|
+
// 注意:若进程被 SIGKILL 强杀,此处不会执行,OS 会回收资源。
|
|
964
|
+
// TODO: 若 OpenClaw 未来提供 graceful shutdown 钩子,应在钩子中也调用 cleanup。
|
|
965
|
+
if (this.__ndcCleanup) {
|
|
966
|
+
try { this.__ndcCleanup(); }
|
|
967
|
+
catch (err) { remoteLog(`ndc.cleanup-failed error=${err?.message}`); }
|
|
968
|
+
}
|
|
969
|
+
this.__ndcCleanup = null;
|
|
970
|
+
this.__ndcPreloadResult = null;
|
|
901
971
|
if (this.__fileHandler) {
|
|
902
972
|
this.__fileHandler.cancelCleanup();
|
|
903
973
|
this.__fileHandler = null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程日志推送模块
|
|
3
|
+
*
|
|
4
|
+
* 将诊断日志缓冲并通过 WS 通道推送到 CoClaw server。
|
|
5
|
+
* 单例模式——各模块直接 import { remoteLog } 使用。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MAX_BUFFER = 1000;
|
|
9
|
+
const BATCH_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
/** @type {{ ts: number, text: string }[]} */
|
|
12
|
+
const buffer = [];
|
|
13
|
+
|
|
14
|
+
/** @type {((msg: object) => void) | null} */
|
|
15
|
+
let sender = null;
|
|
16
|
+
|
|
17
|
+
let flushing = false;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 注入/移除发送函数。由 RealtimeBridge 在 WS 连接/断开时调用。
|
|
21
|
+
* @param {((msg: object) => void) | null} fn
|
|
22
|
+
*/
|
|
23
|
+
export function setSender(fn) {
|
|
24
|
+
sender = fn;
|
|
25
|
+
if (fn && buffer.length > 0) {
|
|
26
|
+
flush().catch(() => {});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 推送一条远程诊断日志。
|
|
32
|
+
* @param {string} text - 可读文本描述(不含时间戳,内部自动附加)
|
|
33
|
+
*/
|
|
34
|
+
export function remoteLog(text) {
|
|
35
|
+
if (buffer.length >= MAX_BUFFER) {
|
|
36
|
+
buffer.shift();
|
|
37
|
+
}
|
|
38
|
+
buffer.push({ ts: Date.now(), text });
|
|
39
|
+
if (sender && !flushing) {
|
|
40
|
+
flush().catch(() => {});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function flush() {
|
|
45
|
+
if (flushing) return;
|
|
46
|
+
flushing = true;
|
|
47
|
+
try {
|
|
48
|
+
while (buffer.length > 0 && sender) {
|
|
49
|
+
const batch = buffer.slice(0, BATCH_SIZE);
|
|
50
|
+
try {
|
|
51
|
+
sender({ type: 'log', logs: batch });
|
|
52
|
+
buffer.splice(0, batch.length);
|
|
53
|
+
} catch {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
await new Promise(r => setTimeout(r, 0));
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
flushing = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 测试用:重置内部状态
|
|
64
|
+
export function __reset() {
|
|
65
|
+
buffer.length = 0;
|
|
66
|
+
sender = null;
|
|
67
|
+
flushing = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { buffer as __buffer, BATCH_SIZE as __BATCH_SIZE, MAX_BUFFER as __MAX_BUFFER };
|
package/src/webrtc-peer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { RTCPeerConnection as WeriftRTCPeerConnection } from 'werift';
|
|
2
1
|
import { chunkAndSend, createReassembler } from './utils/dc-chunking.js';
|
|
2
|
+
import { remoteLog } from './remote-log.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
|
|
@@ -13,15 +13,18 @@ export class WebRtcPeer {
|
|
|
13
13
|
* @param {function} [opts.onFileRpc] - rpc DC 上 coclaw.files.* 请求的回调 (payload, sendFn, connId) => void
|
|
14
14
|
* @param {function} [opts.onFileChannel] - file:<transferId> DataChannel 的回调 (dc, connId) => void
|
|
15
15
|
* @param {object} [opts.logger] - pino 风格 logger
|
|
16
|
-
* @param {function}
|
|
16
|
+
* @param {function} opts.PeerConnection - RTCPeerConnection 构造函数(由 ndc-preloader 提供)
|
|
17
17
|
*/
|
|
18
18
|
constructor({ onSend, onRequest, onFileRpc, onFileChannel, logger, PeerConnection }) {
|
|
19
|
+
if (!PeerConnection) {
|
|
20
|
+
throw new Error('PeerConnection constructor is required');
|
|
21
|
+
}
|
|
19
22
|
this.__onSend = onSend;
|
|
20
23
|
this.__onRequest = onRequest;
|
|
21
24
|
this.__onFileRpc = onFileRpc;
|
|
22
25
|
this.__onFileChannel = onFileChannel;
|
|
23
26
|
this.logger = logger ?? console;
|
|
24
|
-
this.__PeerConnection = PeerConnection
|
|
27
|
+
this.__PeerConnection = PeerConnection;
|
|
25
28
|
/** @type {Map<string, { pc: object, rpcChannel: object|null, remoteMaxMessageSize: number, nextMsgId: number }>} */
|
|
26
29
|
this.__sessions = new Map();
|
|
27
30
|
}
|
|
@@ -50,6 +53,7 @@ export class WebRtcPeer {
|
|
|
50
53
|
session.pc.onconnectionstatechange = null;
|
|
51
54
|
session.pc.onicecandidate = null;
|
|
52
55
|
await session.pc.close();
|
|
56
|
+
remoteLog(`rtc.closed conn=${connId}`);
|
|
53
57
|
this.logger.info?.(`[coclaw/rtc] [${connId}] closed`);
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -82,6 +86,7 @@ export class WebRtcPeer {
|
|
|
82
86
|
if (isIceRestart) {
|
|
83
87
|
const existing = this.__sessions.get(connId);
|
|
84
88
|
if (existing) {
|
|
89
|
+
remoteLog(`rtc.ice-restart conn=${connId}`);
|
|
85
90
|
this.logger.info?.(`[coclaw/rtc] ICE restart offer from ${connId}, renegotiating`);
|
|
86
91
|
try {
|
|
87
92
|
await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
|
|
@@ -96,6 +101,7 @@ export class WebRtcPeer {
|
|
|
96
101
|
return;
|
|
97
102
|
} catch (err) {
|
|
98
103
|
// ICE restart 协商失败 → 回退到 full rebuild
|
|
104
|
+
remoteLog(`rtc.ice-restart-failed conn=${connId}`);
|
|
99
105
|
this.logger.warn?.(`[coclaw/rtc] ICE restart failed for ${connId}, falling back to rebuild: ${err?.message}`);
|
|
100
106
|
await this.closeByConnId(connId);
|
|
101
107
|
}
|
|
@@ -103,6 +109,7 @@ export class WebRtcPeer {
|
|
|
103
109
|
// 无现有 session 或 ICE restart 失败 → 按 full rebuild 继续
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
remoteLog(`rtc.offer conn=${connId}`);
|
|
106
113
|
this.logger.info?.(`[coclaw/rtc] offer received from ${connId}, creating answer`);
|
|
107
114
|
|
|
108
115
|
// 同一 connId 重复 offer → 先关闭旧连接
|
|
@@ -125,6 +132,11 @@ export class WebRtcPeer {
|
|
|
125
132
|
}
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
// 记录 ICE 服务器配置(脱敏,不含 credential)
|
|
136
|
+
const stunUrl = iceServers.find((s) => s.urls?.startsWith('stun:'))?.urls ?? 'none';
|
|
137
|
+
const turnUrl = iceServers.find((s) => s.urls?.startsWith('turn:'))?.urls ?? 'none';
|
|
138
|
+
remoteLog(`rtc.ice-config conn=${connId} stun=${stunUrl} turn=${turnUrl}`);
|
|
139
|
+
|
|
128
140
|
const pc = new this.__PeerConnection({ iceServers });
|
|
129
141
|
|
|
130
142
|
// 从 SDP 解析对端 maxMessageSize(用于分片决策)
|
|
@@ -134,9 +146,19 @@ export class WebRtcPeer {
|
|
|
134
146
|
const session = { pc, rpcChannel: null, remoteMaxMessageSize, nextMsgId: 1 };
|
|
135
147
|
this.__sessions.set(connId, session);
|
|
136
148
|
|
|
137
|
-
// ICE candidate → 发给 UI
|
|
149
|
+
// ICE candidate → 发给 UI,并统计各类型 candidate 数量
|
|
150
|
+
const candidateCounts = { host: 0, srflx: 0, relay: 0 };
|
|
138
151
|
pc.onicecandidate = ({ candidate }) => {
|
|
139
|
-
if (!candidate)
|
|
152
|
+
if (!candidate) {
|
|
153
|
+
// gathering 完成,输出汇总
|
|
154
|
+
remoteLog(`rtc.ice-gathered conn=${connId} host=${candidateCounts.host} srflx=${candidateCounts.srflx} relay=${candidateCounts.relay}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// 从 candidate 字符串中提取类型(typ host / typ srflx / typ relay)
|
|
158
|
+
const typMatch = candidate.candidate?.match(/typ (\w+)/);
|
|
159
|
+
if (typMatch && candidateCounts[typMatch[1]] !== undefined) {
|
|
160
|
+
candidateCounts[typMatch[1]]++;
|
|
161
|
+
}
|
|
140
162
|
this.__onSend({
|
|
141
163
|
type: 'rtc:ice',
|
|
142
164
|
toConnId: connId,
|
|
@@ -151,6 +173,7 @@ export class WebRtcPeer {
|
|
|
151
173
|
// 连接状态变更(校验 pc 归属,防止旧 PC 异步回调删除新 session)
|
|
152
174
|
pc.onconnectionstatechange = () => {
|
|
153
175
|
const state = pc.connectionState;
|
|
176
|
+
remoteLog(`rtc.state conn=${connId} ${state}`);
|
|
154
177
|
this.logger.info?.(`[coclaw/rtc] [${connId}] connectionState: ${state}`);
|
|
155
178
|
if (state === 'connected') {
|
|
156
179
|
const nominated = pc.iceTransports?.[0]?.connection?.nominated;
|
|
@@ -159,6 +182,7 @@ export class WebRtcPeer {
|
|
|
159
182
|
const remoteC = nominated.remoteCandidate;
|
|
160
183
|
const localInfo = `${localC?.type ?? '?'} ${localC?.host ?? '?'}:${localC?.port ?? '?'}`;
|
|
161
184
|
const remoteInfo = `${remoteC?.type ?? '?'} ${remoteC?.host ?? '?'}:${remoteC?.port ?? '?'}`;
|
|
185
|
+
remoteLog(`rtc.ice-nominated conn=${connId} local=${localInfo} remote=${remoteInfo}`);
|
|
162
186
|
this.logger.info?.(`[coclaw/rtc] [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
|
|
163
187
|
}
|
|
164
188
|
} else if (state === 'failed' || state === 'closed') {
|
|
@@ -171,6 +195,7 @@ export class WebRtcPeer {
|
|
|
171
195
|
|
|
172
196
|
// 监听 UI 创建的 DataChannel
|
|
173
197
|
pc.ondatachannel = ({ channel }) => {
|
|
198
|
+
remoteLog(`dc.received conn=${connId} label=${channel.label}`);
|
|
174
199
|
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${channel.label}" received`);
|
|
175
200
|
if (channel.label === 'rpc') {
|
|
176
201
|
session.rpcChannel = channel;
|
|
@@ -191,6 +216,7 @@ export class WebRtcPeer {
|
|
|
191
216
|
toConnId: connId,
|
|
192
217
|
payload: { sdp: answer.sdp },
|
|
193
218
|
});
|
|
219
|
+
remoteLog(`rtc.answer conn=${connId}`);
|
|
194
220
|
this.logger.info?.(`[coclaw/rtc] answer sent to ${connId}`);
|
|
195
221
|
} catch (err) {
|
|
196
222
|
// SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
|
|
@@ -217,6 +243,12 @@ export class WebRtcPeer {
|
|
|
217
243
|
__setupDataChannel(connId, dc) {
|
|
218
244
|
const reassembler = createReassembler((jsonStr) => {
|
|
219
245
|
const payload = JSON.parse(jsonStr);
|
|
246
|
+
// DC 探测:立即回复,不走 gateway
|
|
247
|
+
if (payload.type === 'probe') {
|
|
248
|
+
try { dc.send(JSON.stringify({ type: 'probe-ack' })); }
|
|
249
|
+
catch { /* DC 已关闭,忽略 */ }
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
220
252
|
if (payload.type === 'req') {
|
|
221
253
|
// coclaw.files.* 方法本地处理,不转发 gateway
|
|
222
254
|
if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
|
|
@@ -243,14 +275,21 @@ export class WebRtcPeer {
|
|
|
243
275
|
}, { logger: this.logger });
|
|
244
276
|
|
|
245
277
|
dc.onopen = () => {
|
|
278
|
+
remoteLog(`dc.open conn=${connId} label=${dc.label}`);
|
|
246
279
|
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" opened`);
|
|
247
280
|
};
|
|
248
281
|
dc.onclose = () => {
|
|
282
|
+
remoteLog(`dc.closed conn=${connId} label=${dc.label}`);
|
|
249
283
|
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" closed`);
|
|
250
284
|
reassembler.reset();
|
|
251
285
|
const session = this.__sessions.get(connId);
|
|
252
286
|
if (session && dc.label === 'rpc') session.rpcChannel = null;
|
|
253
287
|
};
|
|
288
|
+
dc.onerror = (err) => {
|
|
289
|
+
remoteLog(`dc.error conn=${connId} label=${dc.label}`);
|
|
290
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
291
|
+
this.logger.warn?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" error: ${String(err?.message ?? err)}`);
|
|
292
|
+
};
|
|
254
293
|
dc.onmessage = (event) => {
|
|
255
294
|
try {
|
|
256
295
|
reassembler.feed(event.data);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|