@coclaw/openclaw-coclaw 0.8.0 → 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.0",
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
 
@@ -45,6 +46,9 @@ export class WebRtcPeer {
45
46
  const session = this.__sessions.get(connId);
46
47
  if (!session) return;
47
48
  this.__sessions.delete(connId);
49
+ // 先 detach 事件,防止 pc.close() 异步触发 onconnectionstatechange 删除新 session
50
+ session.pc.onconnectionstatechange = null;
51
+ session.pc.onicecandidate = null;
48
52
  await session.pc.close();
49
53
  this.logger.info?.(`[coclaw/rtc] [${connId}] closed`);
50
54
  }
@@ -55,20 +59,50 @@ export class WebRtcPeer {
55
59
  await Promise.all(closing);
56
60
  }
57
61
 
58
- /** 向所有已打开的 rpcChannel 广播 */
62
+ /** 向所有已打开的 rpcChannel 广播(大消息自动分片) */
59
63
  broadcast(payload) {
60
- const data = JSON.stringify(payload);
64
+ const jsonStr = JSON.stringify(payload);
61
65
  for (const [connId, session] of this.__sessions) {
62
66
  const dc = session.rpcChannel;
63
67
  if (dc?.readyState === 'open') {
64
- try { dc.send(data); }
65
- 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
+ }
66
73
  }
67
74
  }
68
75
  }
69
76
 
70
77
  async __handleOffer(msg) {
71
78
  const connId = msg.fromConnId;
79
+ const isIceRestart = !!msg.payload?.iceRestart;
80
+
81
+ // ICE restart:在现有 PC 上重新协商,保持 DTLS session
82
+ if (isIceRestart) {
83
+ const existing = this.__sessions.get(connId);
84
+ if (existing) {
85
+ this.logger.info?.(`[coclaw/rtc] ICE restart offer from ${connId}, renegotiating`);
86
+ try {
87
+ await existing.pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
88
+ const answer = await existing.pc.createAnswer();
89
+ await existing.pc.setLocalDescription(answer);
90
+ this.__onSend({
91
+ type: 'rtc:answer',
92
+ toConnId: connId,
93
+ payload: { sdp: answer.sdp },
94
+ });
95
+ this.logger.info?.(`[coclaw/rtc] ICE restart answer sent to ${connId}`);
96
+ return;
97
+ } catch (err) {
98
+ // ICE restart 协商失败 → 回退到 full rebuild
99
+ this.logger.warn?.(`[coclaw/rtc] ICE restart failed for ${connId}, falling back to rebuild: ${err?.message}`);
100
+ await this.closeByConnId(connId);
101
+ }
102
+ }
103
+ // 无现有 session 或 ICE restart 失败 → 按 full rebuild 继续
104
+ }
105
+
72
106
  this.logger.info?.(`[coclaw/rtc] offer received from ${connId}, creating answer`);
73
107
 
74
108
  // 同一 connId 重复 offer → 先关闭旧连接
@@ -92,7 +126,12 @@ export class WebRtcPeer {
92
126
  }
93
127
 
94
128
  const pc = new this.__PeerConnection({ iceServers });
95
- 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 };
96
135
  this.__sessions.set(connId, session);
97
136
 
98
137
  // ICE candidate → 发给 UI
@@ -109,18 +148,24 @@ export class WebRtcPeer {
109
148
  });
110
149
  };
111
150
 
112
- // 连接状态变更
151
+ // 连接状态变更(校验 pc 归属,防止旧 PC 异步回调删除新 session)
113
152
  pc.onconnectionstatechange = () => {
114
153
  const state = pc.connectionState;
115
154
  this.logger.info?.(`[coclaw/rtc] [${connId}] connectionState: ${state}`);
116
155
  if (state === 'connected') {
117
156
  const nominated = pc.iceTransports?.[0]?.connection?.nominated;
118
157
  if (nominated) {
119
- const type = nominated.localCandidate?.type ?? 'unknown';
120
- 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}`);
121
163
  }
122
164
  } else if (state === 'failed' || state === 'closed') {
123
- this.__sessions.delete(connId);
165
+ const cur = this.__sessions.get(connId);
166
+ if (cur && cur.pc === pc) {
167
+ this.__sessions.delete(connId);
168
+ }
124
169
  }
125
170
  };
126
171
 
@@ -149,7 +194,10 @@ export class WebRtcPeer {
149
194
  this.logger.info?.(`[coclaw/rtc] answer sent to ${connId}`);
150
195
  } catch (err) {
151
196
  // SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
152
- this.__sessions.delete(connId);
197
+ const cur = this.__sessions.get(connId);
198
+ if (cur && cur.pc === pc) {
199
+ this.__sessions.delete(connId);
200
+ }
153
201
  await pc.close().catch(() => {});
154
202
  throw err;
155
203
  }
@@ -167,34 +215,47 @@ export class WebRtcPeer {
167
215
  }
168
216
 
169
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
+
170
245
  dc.onopen = () => {
171
246
  this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" opened`);
172
247
  };
173
248
  dc.onclose = () => {
174
249
  this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" closed`);
250
+ reassembler.reset();
175
251
  const session = this.__sessions.get(connId);
176
252
  if (session && dc.label === 'rpc') session.rpcChannel = null;
177
253
  };
178
254
  dc.onmessage = (event) => {
179
255
  try {
180
- const raw = typeof event.data === 'string' ? event.data : event.data.toString();
181
- const payload = JSON.parse(raw);
182
- if (payload.type === 'req') {
183
- // coclaw.file.* 方法本地处理,不转发 gateway
184
- if (payload.method?.startsWith('coclaw.file.') && this.__onFileRpc) {
185
- const sendFn = (response) => {
186
- try { dc.send(JSON.stringify(response)); }
187
- catch { /* DC 可能已关闭 */ }
188
- };
189
- this.__onFileRpc(payload, sendFn, connId);
190
- } else {
191
- this.__onRequest?.(payload, connId);
192
- }
193
- } else {
194
- this.__logDebug(`[${connId}] unknown DC message type: ${payload.type}`);
195
- }
256
+ reassembler.feed(event.data);
196
257
  } catch (err) {
197
- this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message parse failed: ${err.message}`);
258
+ this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message error: ${err.message}`);
198
259
  }
199
260
  };
200
261
  }