@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw CoClaw channel plugin for remote chat",
@@ -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
+ }
@@ -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 data = JSON.stringify(payload);
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 { dc.send(data); }
68
- catch (err) { this.__logDebug(`[${connId}] broadcast send failed: ${err.message}`); }
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
- const session = { pc, rpcChannel: null };
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 type = nominated.localCandidate?.type ?? 'unknown';
150
- this.logger.info?.(`[coclaw/rtc] [${connId}] ICE connected via ${type}`);
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
- const raw = typeof event.data === 'string' ? event.data : event.data.toString();
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 parse failed: ${err.message}`);
258
+ this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message error: ${err.message}`);
234
259
  }
235
260
  };
236
261
  }