@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 +5 -13
- package/package.json +2 -4
- package/src/auto-upgrade/registry-fallback.js +61 -0
- package/src/auto-upgrade/updater-check.js +1 -1
- package/src/auto-upgrade/updater.js +4 -1
- package/src/auto-upgrade/worker.js +42 -10
- package/src/webrtc/ndc-preloader.js +2 -0
- package/src/webrtc/webrtc-peer.js +91 -10
- 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
|
@@ -172,22 +172,14 @@ openclaw gateway call coclaw.upgradeHealth --json
|
|
|
172
172
|
|
|
173
173
|
## WebRTC 实现
|
|
174
174
|
|
|
175
|
-
|
|
175
|
+
插件在运行时按优先级选择 WebRTC 实现:
|
|
176
176
|
|
|
177
|
-
1. **node
|
|
178
|
-
2. **werift**(回退)— 纯 JavaScript 实现,作为
|
|
177
|
+
1. **pion**(主力)— 通过 `@coclaw/pion-node` SDK 驱动 Go 侧 pion-ipc 进程,实现完整 WebRTC 能力。
|
|
178
|
+
2. **werift**(回退)— 纯 JavaScript 实现,作为 pion 加载失败时的兜底。
|
|
179
179
|
|
|
180
|
-
选择结果通过
|
|
180
|
+
选择结果通过 `bridge.started` / `coclaw.env impl=...` 日志上报。
|
|
181
181
|
|
|
182
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
timeout:
|
|
42
|
+
const execOpts = {
|
|
43
|
+
timeout: UPDATE_TIMEOUT_MS,
|
|
38
44
|
shell: process.platform === 'win32',
|
|
39
|
-
}
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 保留
|
|
9
|
+
// Failed session 保留 12 小时,支持 Capacitor 后台恢复后 ICE restart。
|
|
10
10
|
// 超时后 session 被回收释放 IPC listeners 和 Go 侧资源。
|
|
11
|
-
const FAILED_SESSION_TTL_MS =
|
|
11
|
+
const FAILED_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
12
12
|
|
|
13
13
|
// Session 总数上限(活跃 + failed)。溢出时淘汰最旧的 failed session。
|
|
14
|
-
//
|
|
15
|
-
const MAX_SESSIONS =
|
|
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
|
|
485
|
-
const
|
|
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) {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|