@coclaw/openclaw-coclaw 0.8.1 → 0.9.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/package.json +1 -1
- package/src/config.js +13 -8
- package/src/file-manager/handler.js +174 -27
- package/src/utils/dc-chunking.js +140 -0
- package/src/webrtc-peer.js +51 -26
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -29,19 +29,24 @@ function toRecord(value) {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
async function readJson(filePath) {
|
|
32
|
+
let raw;
|
|
32
33
|
try {
|
|
33
|
-
|
|
34
|
-
if (!String(raw).trim()) {
|
|
35
|
-
return {};
|
|
36
|
-
}
|
|
37
|
-
return JSON.parse(raw);
|
|
34
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
38
35
|
}
|
|
39
36
|
catch (err) {
|
|
40
|
-
if (err?.code === 'ENOENT') {
|
|
41
|
-
|
|
42
|
-
}
|
|
37
|
+
if (err?.code === 'ENOENT') return {};
|
|
38
|
+
/* c8 ignore next 2 */
|
|
43
39
|
throw err;
|
|
44
40
|
}
|
|
41
|
+
if (!String(raw).trim()) return {};
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// 文件损坏,删除后当空文件处理
|
|
47
|
+
await fs.unlink(filePath).catch(() => {});
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
const bindingsMutex = createMutex();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import nodePath from 'node:path';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
5
5
|
|
|
6
6
|
// --- 常量 ---
|
|
7
7
|
|
|
@@ -93,6 +93,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
93
93
|
const _readdir = deps.readdir ?? fsp.readdir;
|
|
94
94
|
const _unlink = deps.unlink ?? fsp.unlink;
|
|
95
95
|
const _rmdir = deps.rmdir ?? fsp.rmdir;
|
|
96
|
+
const _rm = deps.rm ?? fsp.rm;
|
|
96
97
|
const _stat = deps.stat ?? fsp.stat;
|
|
97
98
|
const _mkdir = deps.mkdir ?? fsp.mkdir;
|
|
98
99
|
const _rename = deps.rename ?? fsp.rename;
|
|
@@ -102,22 +103,28 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
102
103
|
|
|
103
104
|
const pathDeps = { lstat: _lstat, realpath: _realpath };
|
|
104
105
|
|
|
105
|
-
// --- RPC 处理(rpc DC 上的 coclaw.
|
|
106
|
+
// --- RPC 处理(rpc DC 上的 coclaw.files.* 方法) ---
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
|
-
* 处理 rpc DC 上的 coclaw.
|
|
109
|
+
* 处理 rpc DC 上的 coclaw.files.* 请求
|
|
109
110
|
* @param {object} payload - { id, method, params }
|
|
110
111
|
* @param {function} sendFn - (responseObj) => void
|
|
111
112
|
*/
|
|
112
113
|
async function handleRpcRequest(payload, sendFn) {
|
|
113
114
|
const { id, method, params } = payload;
|
|
114
115
|
try {
|
|
115
|
-
if (method === 'coclaw.
|
|
116
|
+
if (method === 'coclaw.files.list') {
|
|
116
117
|
const result = await listFiles(params);
|
|
117
118
|
sendFn({ type: 'res', id, ok: true, payload: result });
|
|
118
|
-
} else if (method === 'coclaw.
|
|
119
|
+
} else if (method === 'coclaw.files.delete') {
|
|
119
120
|
const result = await deleteFile(params);
|
|
120
121
|
sendFn({ type: 'res', id, ok: true, payload: result });
|
|
122
|
+
} else if (method === 'coclaw.files.mkdir') {
|
|
123
|
+
const result = await mkdirOp(params);
|
|
124
|
+
sendFn({ type: 'res', id, ok: true, payload: result });
|
|
125
|
+
} else if (method === 'coclaw.files.create') {
|
|
126
|
+
const result = await createFile(params);
|
|
127
|
+
sendFn({ type: 'res', id, ok: true, payload: result });
|
|
121
128
|
} else {
|
|
122
129
|
sendFn({
|
|
123
130
|
type: 'res', id, ok: false,
|
|
@@ -207,15 +214,19 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
207
214
|
throw e;
|
|
208
215
|
}
|
|
209
216
|
if (stat.isDirectory()) {
|
|
210
|
-
|
|
211
|
-
await
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
+
if (params?.force) {
|
|
218
|
+
await _rm(resolved, { recursive: true, force: true });
|
|
219
|
+
} else {
|
|
220
|
+
try {
|
|
221
|
+
await _rmdir(resolved);
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (e.code === 'ENOTEMPTY') {
|
|
224
|
+
const err = new Error(`Directory not empty: ${userPath}`);
|
|
225
|
+
err.code = 'NOT_EMPTY';
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
throw e;
|
|
217
229
|
}
|
|
218
|
-
throw e;
|
|
219
230
|
}
|
|
220
231
|
} else {
|
|
221
232
|
await _unlink(resolved);
|
|
@@ -223,6 +234,79 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
223
234
|
return {};
|
|
224
235
|
}
|
|
225
236
|
|
|
237
|
+
async function mkdirOp(params) {
|
|
238
|
+
const agentId = params?.agentId?.trim?.() || 'main';
|
|
239
|
+
const userPath = params?.path;
|
|
240
|
+
if (!userPath) {
|
|
241
|
+
const err = new Error('path is required');
|
|
242
|
+
err.code = 'PATH_DENIED';
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
const workspaceDir = await resolveWorkspace(agentId);
|
|
246
|
+
const resolved = await validatePath(workspaceDir, userPath, pathDeps);
|
|
247
|
+
await _mkdir(resolved, { recursive: true });
|
|
248
|
+
return {};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function createFile(params) {
|
|
252
|
+
const agentId = params?.agentId?.trim?.() || 'main';
|
|
253
|
+
const userPath = params?.path;
|
|
254
|
+
if (!userPath) {
|
|
255
|
+
const err = new Error('path is required');
|
|
256
|
+
err.code = 'PATH_DENIED';
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
const workspaceDir = await resolveWorkspace(agentId);
|
|
260
|
+
const resolved = await validatePath(workspaceDir, userPath, pathDeps);
|
|
261
|
+
|
|
262
|
+
// 检查文件是否已存在
|
|
263
|
+
try {
|
|
264
|
+
await _lstat(resolved);
|
|
265
|
+
// 没抛异常说明存在
|
|
266
|
+
const err = new Error(`File already exists: ${userPath}`);
|
|
267
|
+
err.code = 'ALREADY_EXISTS';
|
|
268
|
+
throw err;
|
|
269
|
+
} catch (e) {
|
|
270
|
+
if (e.code === 'ALREADY_EXISTS') throw e;
|
|
271
|
+
if (e.code !== 'ENOENT') throw e;
|
|
272
|
+
// ENOENT — 不存在,继续创建
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 确保父目录存在
|
|
276
|
+
await _mkdir(nodePath.dirname(resolved), { recursive: true });
|
|
277
|
+
await (deps.writeFile ?? fsp.writeFile)(resolved, '');
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 生成唯一文件名:<name>-<4hex>.<ext>,碰撞时重试
|
|
283
|
+
* @param {string} dir - 目标目录绝对路径
|
|
284
|
+
* @param {string} fileName - 原始文件名
|
|
285
|
+
* @returns {Promise<string>} 唯一文件名(仅文件名,非完整路径)
|
|
286
|
+
*/
|
|
287
|
+
async function generateUniqueName(dir, fileName) {
|
|
288
|
+
const ext = nodePath.extname(fileName);
|
|
289
|
+
const base = nodePath.basename(fileName, ext);
|
|
290
|
+
const maxAttempts = 20;
|
|
291
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
292
|
+
const hex = randomBytes(2).toString('hex');
|
|
293
|
+
const candidate = `${base}-${hex}${ext}`;
|
|
294
|
+
try {
|
|
295
|
+
await _lstat(nodePath.join(dir, candidate));
|
|
296
|
+
// 存在 → 碰撞,重试
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (e.code === 'ENOENT') return candidate;
|
|
299
|
+
/* c8 ignore next -- lstat 非 ENOENT 属罕见 IO 异常 */
|
|
300
|
+
throw e;
|
|
301
|
+
} /* c8 ignore next */
|
|
302
|
+
}
|
|
303
|
+
/* c8 ignore start -- 20 次均碰撞几乎不可能 */
|
|
304
|
+
const err = new Error(`Cannot generate unique name for: ${fileName}`);
|
|
305
|
+
err.code = 'WRITE_FAILED';
|
|
306
|
+
throw err;
|
|
307
|
+
/* c8 ignore stop */
|
|
308
|
+
}
|
|
309
|
+
|
|
226
310
|
// --- File DataChannel 处理 ---
|
|
227
311
|
|
|
228
312
|
/**
|
|
@@ -262,15 +346,20 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
262
346
|
return;
|
|
263
347
|
}
|
|
264
348
|
|
|
265
|
-
if (req.method === '
|
|
266
|
-
/* c8 ignore next 3 --
|
|
267
|
-
|
|
268
|
-
log.warn?.(`[coclaw/file]
|
|
349
|
+
if (req.method === 'GET') {
|
|
350
|
+
/* c8 ignore next 3 -- handleGet 内部已完整处理异常,此 catch 纯防御 */
|
|
351
|
+
handleGet(dc, req).catch((err) => {
|
|
352
|
+
log.warn?.(`[coclaw/file] GET error: ${err.message}`);
|
|
353
|
+
});
|
|
354
|
+
} else if (req.method === 'PUT') {
|
|
355
|
+
/* c8 ignore next 3 -- handlePut 内部已完整处理异常,此 catch 纯防御 */
|
|
356
|
+
handlePut(dc, req).catch((err) => {
|
|
357
|
+
log.warn?.(`[coclaw/file] PUT error: ${err.message}`);
|
|
269
358
|
});
|
|
270
|
-
} else if (req.method === '
|
|
271
|
-
/* c8 ignore next 3 --
|
|
272
|
-
|
|
273
|
-
log.warn?.(`[coclaw/file]
|
|
359
|
+
} else if (req.method === 'POST') {
|
|
360
|
+
/* c8 ignore next 3 -- handlePost 内部已完整处理异常,此 catch 纯防御 */
|
|
361
|
+
handlePost(dc, req).catch((err) => {
|
|
362
|
+
log.warn?.(`[coclaw/file] POST error: ${err.message}`);
|
|
274
363
|
});
|
|
275
364
|
} else {
|
|
276
365
|
sendError(dc, 'UNKNOWN_METHOD', `Unknown method: ${req.method}`);
|
|
@@ -278,7 +367,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
278
367
|
};
|
|
279
368
|
}
|
|
280
369
|
|
|
281
|
-
async function
|
|
370
|
+
async function handleGet(dc, req) {
|
|
282
371
|
let workspaceDir, resolved;
|
|
283
372
|
try {
|
|
284
373
|
const agentId = req.agentId?.trim?.() || 'main'; /* c8 ignore next -- ?./?? fallback */
|
|
@@ -367,7 +456,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
367
456
|
});
|
|
368
457
|
}
|
|
369
458
|
|
|
370
|
-
async function
|
|
459
|
+
async function handlePut(dc, req) {
|
|
371
460
|
let workspaceDir, resolved;
|
|
372
461
|
try {
|
|
373
462
|
const agentId = req.agentId?.trim?.() || 'main';
|
|
@@ -377,7 +466,59 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
377
466
|
sendError(dc, err.code ?? 'INTERNAL_ERROR', err.message);
|
|
378
467
|
return;
|
|
379
468
|
}
|
|
469
|
+
await receiveUpload(dc, req, resolved);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function handlePost(dc, req) {
|
|
473
|
+
let workspaceDir, dirResolved;
|
|
474
|
+
try {
|
|
475
|
+
const agentId = req.agentId?.trim?.() || 'main';
|
|
476
|
+
workspaceDir = await resolveWorkspace(agentId);
|
|
477
|
+
dirResolved = await validatePath(workspaceDir, req.path || '.', pathDeps);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
sendError(dc, err.code ?? 'INTERNAL_ERROR', err.message);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
380
482
|
|
|
483
|
+
const fileName = req.fileName;
|
|
484
|
+
if (!fileName || typeof fileName !== 'string') {
|
|
485
|
+
sendError(dc, 'INVALID_INPUT', 'fileName is required for POST');
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 确保集合目录存在
|
|
490
|
+
try {
|
|
491
|
+
await _mkdir(dirResolved, { recursive: true });
|
|
492
|
+
} catch (err) {
|
|
493
|
+
sendError(dc, 'WRITE_FAILED', `Cannot create directory: ${err.message}`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 生成唯一文件名
|
|
498
|
+
let uniqueName;
|
|
499
|
+
try {
|
|
500
|
+
uniqueName = await generateUniqueName(dirResolved, fileName);
|
|
501
|
+
/* c8 ignore start -- generateUniqueName 内部已处理,此为防御 */
|
|
502
|
+
} catch (err) {
|
|
503
|
+
sendError(dc, err.code ?? 'WRITE_FAILED', err.message);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
/* c8 ignore stop */
|
|
507
|
+
|
|
508
|
+
const resolved = nodePath.join(dirResolved, uniqueName);
|
|
509
|
+
// 计算相对于 workspace 的路径,作为响应中的 path
|
|
510
|
+
const relativePath = nodePath.relative(workspaceDir, resolved);
|
|
511
|
+
await receiveUpload(dc, req, resolved, relativePath);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* 共享上传接收逻辑(PUT/POST 复用)
|
|
516
|
+
* @param {object} dc - DataChannel
|
|
517
|
+
* @param {object} req - 请求对象(含 size)
|
|
518
|
+
* @param {string} resolved - 目标文件绝对路径
|
|
519
|
+
* @param {string} [relativePath] - POST 时附带的相对路径(响应中返回)
|
|
520
|
+
*/
|
|
521
|
+
async function receiveUpload(dc, req, resolved, relativePath) {
|
|
381
522
|
const declaredSize = req.size;
|
|
382
523
|
if (!Number.isFinite(declaredSize) || declaredSize < 0) {
|
|
383
524
|
sendError(dc, 'INVALID_INPUT', 'size must be a non-negative number');
|
|
@@ -388,7 +529,7 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
388
529
|
return;
|
|
389
530
|
}
|
|
390
531
|
|
|
391
|
-
//
|
|
532
|
+
// 确保目标目录存在(PUT 场景需要;POST 已在上层创建,幂等无害)
|
|
392
533
|
const targetDir = nodePath.dirname(resolved);
|
|
393
534
|
try {
|
|
394
535
|
await _mkdir(targetDir, { recursive: true });
|
|
@@ -460,8 +601,10 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
460
601
|
try { dc.close(); } catch { /* ignore */ }
|
|
461
602
|
return;
|
|
462
603
|
}
|
|
604
|
+
const result = { ok: true, bytes: receivedBytes };
|
|
605
|
+
if (relativePath) result.path = relativePath;
|
|
463
606
|
try {
|
|
464
|
-
dc.send(JSON.stringify(
|
|
607
|
+
dc.send(JSON.stringify(result));
|
|
465
608
|
/* c8 ignore next */
|
|
466
609
|
} catch { /* ignore */ }
|
|
467
610
|
/* c8 ignore next */
|
|
@@ -580,8 +723,12 @@ export function createFileHandler({ resolveWorkspace, logger, deps = {} }) {
|
|
|
580
723
|
// 暴露内部方法便于测试
|
|
581
724
|
__listFiles: listFiles,
|
|
582
725
|
__deleteFile: deleteFile,
|
|
583
|
-
|
|
584
|
-
|
|
726
|
+
__mkdirOp: mkdirOp,
|
|
727
|
+
__createFile: createFile,
|
|
728
|
+
__handleGet: handleGet,
|
|
729
|
+
__handlePut: handlePut,
|
|
730
|
+
__handlePost: handlePost,
|
|
731
|
+
__generateUniqueName: generateUniqueName,
|
|
585
732
|
__cleanupTmpFilesInDir: cleanupTmpFilesInDir,
|
|
586
733
|
};
|
|
587
734
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataChannel 应用层分片/重组
|
|
3
|
+
* 协议:普通消息用 string,分片消息用 binary(Buffer)
|
|
4
|
+
*
|
|
5
|
+
* 二进制帧格式:
|
|
6
|
+
* Byte 0: flag (0x01=BEGIN, 0x00=MIDDLE, 0x02=END)
|
|
7
|
+
* Byte 1-4: msgId (uint32 BE)
|
|
8
|
+
* Byte 5+: UTF-8 数据片段
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const FLAG_BEGIN = 0x01;
|
|
12
|
+
export const FLAG_MIDDLE = 0x00;
|
|
13
|
+
export const FLAG_END = 0x02;
|
|
14
|
+
export const HEADER_SIZE = 5; // 1 flag + 4 msgId
|
|
15
|
+
|
|
16
|
+
/** 单条消息重组缓冲区上限 */
|
|
17
|
+
export const MAX_REASSEMBLY_BYTES = 50 * 1024 * 1024;
|
|
18
|
+
/** 单条消息最大 chunk 数(防止无 END 的 BEGIN 泄漏) */
|
|
19
|
+
export const MAX_CHUNKS_PER_MSG = 10_000;
|
|
20
|
+
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
const decoder = new TextDecoder();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 按需分片并发送消息
|
|
26
|
+
* 小于 maxMessageSize 直接发 string;否则切成 binary chunk 逐个发送
|
|
27
|
+
* @param {object} dc - DataChannel(werift 或浏览器)
|
|
28
|
+
* @param {string} jsonStr - 已序列化的 JSON 字符串
|
|
29
|
+
* @param {number} maxMessageSize - 对端声明的 maxMessageSize
|
|
30
|
+
* @param {() => number} getNextMsgId - 获取下一个 msgId
|
|
31
|
+
*/
|
|
32
|
+
export function chunkAndSend(dc, jsonStr, maxMessageSize, getNextMsgId, logger) {
|
|
33
|
+
const fullBytes = encoder.encode(jsonStr);
|
|
34
|
+
// 快路径:不需要分片
|
|
35
|
+
if (fullBytes.byteLength <= maxMessageSize) {
|
|
36
|
+
dc.send(jsonStr);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const chunkPayloadSize = maxMessageSize - HEADER_SIZE;
|
|
41
|
+
if (chunkPayloadSize <= 0) {
|
|
42
|
+
throw new Error(`maxMessageSize (${maxMessageSize}) too small for chunking header`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const msgId = getNextMsgId();
|
|
46
|
+
const totalChunks = Math.ceil(fullBytes.byteLength / chunkPayloadSize);
|
|
47
|
+
logger?.info?.(`[dc-chunking] chunking msgId=${msgId}: ${fullBytes.byteLength} bytes → ${totalChunks} chunks (maxMsgSize=${maxMessageSize})`);
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
50
|
+
const start = i * chunkPayloadSize;
|
|
51
|
+
const end = Math.min(start + chunkPayloadSize, fullBytes.byteLength);
|
|
52
|
+
const flag = i === 0 ? FLAG_BEGIN : (i === totalChunks - 1 ? FLAG_END : FLAG_MIDDLE);
|
|
53
|
+
|
|
54
|
+
const chunk = Buffer.allocUnsafe(HEADER_SIZE + (end - start));
|
|
55
|
+
chunk[0] = flag;
|
|
56
|
+
chunk.writeUInt32BE(msgId, 1);
|
|
57
|
+
chunk.set(fullBytes.subarray(start, end), HEADER_SIZE);
|
|
58
|
+
|
|
59
|
+
dc.send(chunk);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 创建分片重组器
|
|
65
|
+
* @param {(jsonStr: string) => void} onComplete - 完整消息回调
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {object} [opts.logger] - warn 日志输出
|
|
68
|
+
* @returns {{ feed: (data: string|Buffer) => void, reset: () => void }}
|
|
69
|
+
*/
|
|
70
|
+
export function createReassembler(onComplete, opts = {}) {
|
|
71
|
+
const logger = opts.logger;
|
|
72
|
+
/** @type {Map<number, { chunks: Buffer[], totalBytes: number }>} */
|
|
73
|
+
const pending = new Map();
|
|
74
|
+
|
|
75
|
+
function feed(data) {
|
|
76
|
+
// string = 普通消息,直接交付
|
|
77
|
+
if (typeof data === 'string') {
|
|
78
|
+
onComplete(data);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// binary = 分片 chunk
|
|
83
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
84
|
+
if (buf.length < HEADER_SIZE) {
|
|
85
|
+
logger?.warn?.('[dc-chunking] chunk too short, discarding');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const flag = buf[0];
|
|
90
|
+
const msgId = buf.readUInt32BE(1);
|
|
91
|
+
const payload = buf.subarray(HEADER_SIZE);
|
|
92
|
+
|
|
93
|
+
if (flag === FLAG_BEGIN) {
|
|
94
|
+
// 若已有同 msgId 的未完成重组,丢弃旧的
|
|
95
|
+
if (pending.has(msgId)) {
|
|
96
|
+
logger?.warn?.(`[dc-chunking] orphan reassembly discarded for msgId=${msgId}`);
|
|
97
|
+
}
|
|
98
|
+
pending.set(msgId, { chunks: [payload], totalBytes: payload.length });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const entry = pending.get(msgId);
|
|
103
|
+
if (!entry) {
|
|
104
|
+
logger?.warn?.(`[dc-chunking] chunk for unknown msgId=${msgId}, discarding`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
entry.totalBytes += payload.length;
|
|
109
|
+
|
|
110
|
+
// 安全检查:缓冲区大小上限
|
|
111
|
+
if (entry.totalBytes > MAX_REASSEMBLY_BYTES) {
|
|
112
|
+
logger?.warn?.(`[dc-chunking] reassembly buffer exceeded ${MAX_REASSEMBLY_BYTES} bytes for msgId=${msgId}, discarding`);
|
|
113
|
+
pending.delete(msgId);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 安全检查:chunk 数量上限
|
|
118
|
+
if (entry.chunks.length >= MAX_CHUNKS_PER_MSG) {
|
|
119
|
+
logger?.warn?.(`[dc-chunking] too many chunks for msgId=${msgId}, discarding`);
|
|
120
|
+
pending.delete(msgId);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
entry.chunks.push(payload);
|
|
125
|
+
|
|
126
|
+
if (flag === FLAG_END) {
|
|
127
|
+
pending.delete(msgId);
|
|
128
|
+
const merged = Buffer.concat(entry.chunks);
|
|
129
|
+
logger?.info?.(`[dc-chunking] reassembled msgId=${msgId}: ${entry.chunks.length} chunks, ${merged.length} bytes`);
|
|
130
|
+
onComplete(decoder.decode(merged));
|
|
131
|
+
}
|
|
132
|
+
// flag === FLAG_MIDDLE → 继续等待
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function reset() {
|
|
136
|
+
pending.clear();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { feed, reset };
|
|
140
|
+
}
|
package/src/webrtc-peer.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RTCPeerConnection as WeriftRTCPeerConnection } from 'werift';
|
|
2
|
+
import { chunkAndSend, createReassembler } from './utils/dc-chunking.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
|
|
@@ -9,7 +10,7 @@ export class WebRtcPeer {
|
|
|
9
10
|
* @param {object} opts
|
|
10
11
|
* @param {function} opts.onSend - 将信令消息交给 RealtimeBridge 发送
|
|
11
12
|
* @param {function} [opts.onRequest] - DataChannel 收到 req 消息时的回调 (payload, connId) => void
|
|
12
|
-
* @param {function} [opts.onFileRpc] - rpc DC 上 coclaw.
|
|
13
|
+
* @param {function} [opts.onFileRpc] - rpc DC 上 coclaw.files.* 请求的回调 (payload, sendFn, connId) => void
|
|
13
14
|
* @param {function} [opts.onFileChannel] - file:<transferId> DataChannel 的回调 (dc, connId) => void
|
|
14
15
|
* @param {object} [opts.logger] - pino 风格 logger
|
|
15
16
|
* @param {function} [opts.PeerConnection] - 可替换的构造函数(测试用)
|
|
@@ -21,7 +22,7 @@ export class WebRtcPeer {
|
|
|
21
22
|
this.__onFileChannel = onFileChannel;
|
|
22
23
|
this.logger = logger ?? console;
|
|
23
24
|
this.__PeerConnection = PeerConnection ?? WeriftRTCPeerConnection;
|
|
24
|
-
/** @type {Map<string, { pc: object, rpcChannel: object|null }>} */
|
|
25
|
+
/** @type {Map<string, { pc: object, rpcChannel: object|null, remoteMaxMessageSize: number, nextMsgId: number }>} */
|
|
25
26
|
this.__sessions = new Map();
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -58,14 +59,17 @@ export class WebRtcPeer {
|
|
|
58
59
|
await Promise.all(closing);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/** 向所有已打开的 rpcChannel
|
|
62
|
+
/** 向所有已打开的 rpcChannel 广播(大消息自动分片) */
|
|
62
63
|
broadcast(payload) {
|
|
63
|
-
const
|
|
64
|
+
const jsonStr = JSON.stringify(payload);
|
|
64
65
|
for (const [connId, session] of this.__sessions) {
|
|
65
66
|
const dc = session.rpcChannel;
|
|
66
67
|
if (dc?.readyState === 'open') {
|
|
67
|
-
try {
|
|
68
|
-
|
|
68
|
+
try {
|
|
69
|
+
chunkAndSend(dc, jsonStr, session.remoteMaxMessageSize, () => session.nextMsgId++, this.logger);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this.__logDebug(`[${connId}] broadcast send failed: ${err.message}`);
|
|
72
|
+
}
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
}
|
|
@@ -122,7 +126,12 @@ export class WebRtcPeer {
|
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
const pc = new this.__PeerConnection({ iceServers });
|
|
125
|
-
|
|
129
|
+
|
|
130
|
+
// 从 SDP 解析对端 maxMessageSize(用于分片决策)
|
|
131
|
+
const mmsMatch = msg.payload.sdp?.match(/a=max-message-size:(\d+)/);
|
|
132
|
+
const remoteMaxMessageSize = mmsMatch ? parseInt(mmsMatch[1], 10) : 65536;
|
|
133
|
+
|
|
134
|
+
const session = { pc, rpcChannel: null, remoteMaxMessageSize, nextMsgId: 1 };
|
|
126
135
|
this.__sessions.set(connId, session);
|
|
127
136
|
|
|
128
137
|
// ICE candidate → 发给 UI
|
|
@@ -146,8 +155,11 @@ export class WebRtcPeer {
|
|
|
146
155
|
if (state === 'connected') {
|
|
147
156
|
const nominated = pc.iceTransports?.[0]?.connection?.nominated;
|
|
148
157
|
if (nominated) {
|
|
149
|
-
const
|
|
150
|
-
|
|
158
|
+
const localC = nominated.localCandidate;
|
|
159
|
+
const remoteC = nominated.remoteCandidate;
|
|
160
|
+
const localInfo = `${localC?.type ?? '?'} ${localC?.host ?? '?'}:${localC?.port ?? '?'}`;
|
|
161
|
+
const remoteInfo = `${remoteC?.type ?? '?'} ${remoteC?.host ?? '?'}:${remoteC?.port ?? '?'}`;
|
|
162
|
+
this.logger.info?.(`[coclaw/rtc] [${connId}] ICE nominated: local=${localInfo} remote=${remoteInfo}`);
|
|
151
163
|
}
|
|
152
164
|
} else if (state === 'failed' || state === 'closed') {
|
|
153
165
|
const cur = this.__sessions.get(connId);
|
|
@@ -203,34 +215,47 @@ export class WebRtcPeer {
|
|
|
203
215
|
}
|
|
204
216
|
|
|
205
217
|
__setupDataChannel(connId, dc) {
|
|
218
|
+
const reassembler = createReassembler((jsonStr) => {
|
|
219
|
+
const payload = JSON.parse(jsonStr);
|
|
220
|
+
if (payload.type === 'req') {
|
|
221
|
+
// coclaw.files.* 方法本地处理,不转发 gateway
|
|
222
|
+
if (payload.method?.startsWith('coclaw.files.') && this.__onFileRpc) {
|
|
223
|
+
const session = this.__sessions.get(connId);
|
|
224
|
+
const sendFn = (response) => {
|
|
225
|
+
try {
|
|
226
|
+
chunkAndSend(
|
|
227
|
+
dc, JSON.stringify(response),
|
|
228
|
+
session?.remoteMaxMessageSize ?? 65536,
|
|
229
|
+
() => session.nextMsgId++,
|
|
230
|
+
this.logger,
|
|
231
|
+
);
|
|
232
|
+
} catch (err) {
|
|
233
|
+
this.__logDebug(`[${connId}] sendFn failed: ${err.message}`);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
this.__onFileRpc(payload, sendFn, connId);
|
|
237
|
+
} else {
|
|
238
|
+
this.__onRequest?.(payload, connId);
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
this.__logDebug(`[${connId}] unknown DC message type: ${payload.type}`);
|
|
242
|
+
}
|
|
243
|
+
}, { logger: this.logger });
|
|
244
|
+
|
|
206
245
|
dc.onopen = () => {
|
|
207
246
|
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" opened`);
|
|
208
247
|
};
|
|
209
248
|
dc.onclose = () => {
|
|
210
249
|
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" closed`);
|
|
250
|
+
reassembler.reset();
|
|
211
251
|
const session = this.__sessions.get(connId);
|
|
212
252
|
if (session && dc.label === 'rpc') session.rpcChannel = null;
|
|
213
253
|
};
|
|
214
254
|
dc.onmessage = (event) => {
|
|
215
255
|
try {
|
|
216
|
-
|
|
217
|
-
const payload = JSON.parse(raw);
|
|
218
|
-
if (payload.type === 'req') {
|
|
219
|
-
// coclaw.file.* 方法本地处理,不转发 gateway
|
|
220
|
-
if (payload.method?.startsWith('coclaw.file.') && this.__onFileRpc) {
|
|
221
|
-
const sendFn = (response) => {
|
|
222
|
-
try { dc.send(JSON.stringify(response)); }
|
|
223
|
-
catch { /* DC 可能已关闭 */ }
|
|
224
|
-
};
|
|
225
|
-
this.__onFileRpc(payload, sendFn, connId);
|
|
226
|
-
} else {
|
|
227
|
-
this.__onRequest?.(payload, connId);
|
|
228
|
-
}
|
|
229
|
-
} else {
|
|
230
|
-
this.__logDebug(`[${connId}] unknown DC message type: ${payload.type}`);
|
|
231
|
-
}
|
|
256
|
+
reassembler.feed(event.data);
|
|
232
257
|
} catch (err) {
|
|
233
|
-
this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message
|
|
258
|
+
this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message error: ${err.message}`);
|
|
234
259
|
}
|
|
235
260
|
};
|
|
236
261
|
}
|