@coclaw/openclaw-coclaw 0.8.1 → 0.8.2
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/utils/dc-chunking.js +140 -0
- package/src/webrtc-peer.js +50 -25
package/package.json
CHANGED
|
@@ -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 为粒度)。
|
|
@@ -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.file.* 方法本地处理,不转发 gateway
|
|
222
|
+
if (payload.method?.startsWith('coclaw.file.') && 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
|
}
|