cyclecad 3.0.0 → 3.2.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.
Files changed (67) hide show
  1. package/BILLING-IMPLEMENTATION-SUMMARY.md +425 -0
  2. package/BILLING-INDEX.md +293 -0
  3. package/BILLING-INTEGRATION-GUIDE.md +414 -0
  4. package/COLLABORATION-INDEX.md +440 -0
  5. package/COLLABORATION-SYSTEM-SUMMARY.md +548 -0
  6. package/DOCKER-BUILD-MANIFEST.txt +483 -0
  7. package/DOCKER-FILES-REFERENCE.md +440 -0
  8. package/DOCKER-INFRASTRUCTURE.md +475 -0
  9. package/DOCKER-README.md +435 -0
  10. package/Dockerfile +33 -55
  11. package/PWA-FILES-CREATED.txt +350 -0
  12. package/QUICK-START-TESTING.md +126 -0
  13. package/STEP-IMPORT-QUICKSTART.md +347 -0
  14. package/STEP-IMPORT-SYSTEM-SUMMARY.md +502 -0
  15. package/app/css/mobile.css +1074 -0
  16. package/app/icons/generate-icons.js +203 -0
  17. package/app/index.html +93 -0
  18. package/app/js/billing-ui.js +990 -0
  19. package/app/js/brep-kernel.js +933 -981
  20. package/app/js/collab-client.js +750 -0
  21. package/app/js/mobile-nav.js +623 -0
  22. package/app/js/mobile-toolbar.js +476 -0
  23. package/app/js/modules/billing-module.js +724 -0
  24. package/app/js/modules/step-module-enhanced.js +938 -0
  25. package/app/js/offline-manager.js +705 -0
  26. package/app/js/responsive-init.js +360 -0
  27. package/app/js/touch-handler.js +429 -0
  28. package/app/manifest.json +211 -0
  29. package/app/offline.html +508 -0
  30. package/app/sw.js +571 -0
  31. package/app/tests/billing-tests.html +779 -0
  32. package/app/tests/brep-tests.html +980 -0
  33. package/app/tests/collab-tests.html +743 -0
  34. package/app/tests/mobile-tests.html +1299 -0
  35. package/app/tests/pwa-tests.html +1134 -0
  36. package/app/tests/step-tests.html +1042 -0
  37. package/app/tests/test-agent-v3.html +719 -0
  38. package/docker-compose.yml +225 -0
  39. package/docs/BILLING-HELP.json +260 -0
  40. package/docs/BILLING-README.md +639 -0
  41. package/docs/BILLING-TUTORIAL.md +736 -0
  42. package/docs/BREP-HELP.json +326 -0
  43. package/docs/BREP-TUTORIAL.md +802 -0
  44. package/docs/COLLABORATION-HELP.json +228 -0
  45. package/docs/COLLABORATION-TUTORIAL.md +818 -0
  46. package/docs/DOCKER-HELP.json +224 -0
  47. package/docs/DOCKER-TUTORIAL.md +974 -0
  48. package/docs/MOBILE-HELP.json +243 -0
  49. package/docs/MOBILE-RESPONSIVE-README.md +378 -0
  50. package/docs/MOBILE-TUTORIAL.md +747 -0
  51. package/docs/PWA-HELP.json +228 -0
  52. package/docs/PWA-README.md +662 -0
  53. package/docs/PWA-TUTORIAL.md +757 -0
  54. package/docs/STEP-HELP.json +481 -0
  55. package/docs/STEP-IMPORT-TUTORIAL.md +824 -0
  56. package/docs/TESTING-GUIDE.md +528 -0
  57. package/docs/TESTING-HELP.json +182 -0
  58. package/fusion-vs-cyclecad.html +1771 -0
  59. package/nginx.conf +237 -0
  60. package/package.json +1 -1
  61. package/server/Dockerfile.converter +51 -0
  62. package/server/Dockerfile.signaling +28 -0
  63. package/server/billing-server.js +487 -0
  64. package/server/converter-enhanced.py +528 -0
  65. package/server/requirements-converter.txt +29 -0
  66. package/server/signaling-server.js +801 -0
  67. package/tests/docker-tests.sh +389 -0
@@ -0,0 +1,750 @@
1
+ /**
2
+ * cycleCAD Collaboration Client
3
+ * Browser-side WebSocket client for real-time multi-user collaboration
4
+ *
5
+ * Features:
6
+ * - WebSocket connection with auto-reconnect (exponential backoff)
7
+ * - Room management (create, join, leave)
8
+ * - WebRTC peer connection setup (offer/answer/ICE)
9
+ * - CRDT document sync (operation log)
10
+ * - Cursor sharing (throttled position updates)
11
+ * - Selection sharing (synchronized part selection)
12
+ * - Chat messages with timestamps
13
+ * - User list with avatars/colors
14
+ * - Conflict resolution (CRDT for geometry, LWW for properties)
15
+ * - Offline operation queue (syncs on reconnect)
16
+ */
17
+
18
+ class CollaborationClient {
19
+ constructor(signalServerUrl = 'ws://localhost:8788') {
20
+ this.signalServerUrl = signalServerUrl;
21
+ this.ws = null;
22
+ this.clientId = null;
23
+ this.userId = null;
24
+ this.roomId = null;
25
+ this.isConnected = false;
26
+ this.isReconnecting = false;
27
+ this.reconnectAttempts = 0;
28
+ this.maxReconnectAttempts = 10;
29
+ this.reconnectDelay = 1000;
30
+
31
+ // User management
32
+ this.users = new Map(); // userId -> User object
33
+ this.localUser = null;
34
+
35
+ // CRDT & operations
36
+ this.operations = []; // local operation log
37
+ this.operationIndex = 0;
38
+ this.offlineQueue = []; // operations queued while offline
39
+
40
+ // WebRTC
41
+ this.peers = new Map(); // userId -> RTCPeerConnection
42
+ this.dataChannels = new Map(); // userId -> RTCDataChannel
43
+ this.iceServers = [
44
+ { urls: 'stun:stun.l.google.com:19302' },
45
+ { urls: 'stun:stun1.l.google.com:19302' }
46
+ ];
47
+
48
+ // Cursor sharing (throttled)
49
+ this.cursorUpdateInterval = 100; // ms
50
+ this.lastCursorUpdate = 0;
51
+ this.pendingCursorUpdate = null;
52
+
53
+ // Selection sharing
54
+ this.localSelection = [];
55
+ this.remoteSelections = new Map(); // userId -> [partIds]
56
+
57
+ // Chat & presence
58
+ this.chatMessages = [];
59
+ this.maxChatHistory = 50;
60
+ this.userPresence = new Map(); // userId -> { status, lastSeen }
61
+
62
+ // Callbacks
63
+ this.callbacks = {
64
+ onConnected: () => {},
65
+ onDisconnected: () => {},
66
+ onUserJoined: () => {},
67
+ onUserLeft: () => {},
68
+ onOperationReceived: () => {},
69
+ onChatMessage: () => {},
70
+ onCursorUpdate: () => {},
71
+ onSelectionUpdate: () => {},
72
+ onError: () => {}
73
+ };
74
+
75
+ this.init();
76
+ }
77
+
78
+ init() {
79
+ this.connect();
80
+ }
81
+
82
+ // ========== Connection Management ==========
83
+
84
+ connect() {
85
+ if (this.isConnected || this.isReconnecting) return;
86
+
87
+ console.log(`[CollabClient] Connecting to ${this.signalServerUrl}...`);
88
+
89
+ try {
90
+ this.ws = new WebSocket(this.signalServerUrl);
91
+
92
+ this.ws.onopen = () => this.onConnectionOpen();
93
+ this.ws.onmessage = (event) => this.onMessage(event.data);
94
+ this.ws.onclose = () => this.onConnectionClose();
95
+ this.ws.onerror = (error) => this.onConnectionError(error);
96
+ } catch (error) {
97
+ console.error('[CollabClient] Connection error:', error);
98
+ this.scheduleReconnect();
99
+ }
100
+ }
101
+
102
+ onConnectionOpen() {
103
+ console.log('[CollabClient] Connected to signaling server');
104
+ this.isConnected = true;
105
+ this.isReconnecting = false;
106
+ this.reconnectAttempts = 0;
107
+ this.reconnectDelay = 1000;
108
+
109
+ this.callbacks.onConnected();
110
+ }
111
+
112
+ onConnectionClose() {
113
+ console.log('[CollabClient] Disconnected from signaling server');
114
+ this.isConnected = false;
115
+ this.callbacks.onDisconnected();
116
+
117
+ this.scheduleReconnect();
118
+ }
119
+
120
+ onConnectionError(error) {
121
+ console.error('[CollabClient] Connection error:', error);
122
+ this.callbacks.onError(error);
123
+ }
124
+
125
+ scheduleReconnect() {
126
+ if (this.isReconnecting || this.reconnectAttempts >= this.maxReconnectAttempts) {
127
+ console.error('[CollabClient] Max reconnect attempts reached');
128
+ return;
129
+ }
130
+
131
+ this.isReconnecting = true;
132
+ const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 30000);
133
+ console.log(`[CollabClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
134
+
135
+ setTimeout(() => {
136
+ this.reconnectAttempts++;
137
+ this.isReconnecting = false;
138
+ this.connect();
139
+ }, delay);
140
+ }
141
+
142
+ // ========== Message Handling ==========
143
+
144
+ onMessage(data) {
145
+ try {
146
+ const message = JSON.parse(data);
147
+ this.handleMessage(message);
148
+ } catch (error) {
149
+ console.error('[CollabClient] Parse error:', error);
150
+ }
151
+ }
152
+
153
+ handleMessage(message) {
154
+ const { type, payload } = message;
155
+
156
+ switch (type) {
157
+ case 'welcome':
158
+ this.clientId = message.clientId;
159
+ console.log(`[CollabClient] Welcome! Client ID: ${this.clientId}`);
160
+ break;
161
+
162
+ case 'room-created':
163
+ console.log('[CollabClient] Room created:', message.roomId);
164
+ break;
165
+
166
+ case 'room-joined':
167
+ this.onRoomJoined(message);
168
+ break;
169
+
170
+ case 'room-left':
171
+ this.onRoomLeft();
172
+ break;
173
+
174
+ case 'user-joined':
175
+ this.onUserJoined(message);
176
+ break;
177
+
178
+ case 'user-left':
179
+ this.onUserLeft(message);
180
+ break;
181
+
182
+ case 'user-status':
183
+ this.onUserStatus(message);
184
+ break;
185
+
186
+ case 'cursor-update':
187
+ this.onCursorUpdate(message);
188
+ break;
189
+
190
+ case 'selection-update':
191
+ this.onSelectionUpdate(message);
192
+ break;
193
+
194
+ case 'operation':
195
+ this.onOperationReceived(message);
196
+ break;
197
+
198
+ case 'chat-message':
199
+ this.onChatMessage(message);
200
+ break;
201
+
202
+ case 'signaling-offer':
203
+ this.onSignalingOffer(message);
204
+ break;
205
+
206
+ case 'signaling-answer':
207
+ this.onSignalingAnswer(message);
208
+ break;
209
+
210
+ case 'ice-candidate':
211
+ this.onIceCandidate(message);
212
+ break;
213
+
214
+ case 'room-reset':
215
+ this.onRoomReset();
216
+ break;
217
+
218
+ case 'room-closed':
219
+ this.onRoomClosed();
220
+ break;
221
+
222
+ case 'error':
223
+ console.error('[CollabClient] Server error:', message.message);
224
+ this.callbacks.onError(new Error(message.message));
225
+ break;
226
+
227
+ default:
228
+ console.warn('[CollabClient] Unknown message type:', type);
229
+ }
230
+ }
231
+
232
+ // ========== Room Management ==========
233
+
234
+ createRoom(roomId, options = {}) {
235
+ if (!this.isConnected) {
236
+ throw new Error('Not connected to signaling server');
237
+ }
238
+
239
+ this.send({
240
+ type: 'create-room',
241
+ payload: {
242
+ roomId,
243
+ name: options.name || `Room ${roomId}`,
244
+ password: options.password || null,
245
+ maxUsers: options.maxUsers || 10
246
+ }
247
+ });
248
+ }
249
+
250
+ joinRoom(roomId, userId, userName, password = null) {
251
+ if (!this.isConnected) {
252
+ throw new Error('Not connected to signaling server');
253
+ }
254
+
255
+ this.roomId = roomId;
256
+ this.userId = userId;
257
+
258
+ this.send({
259
+ type: 'join-room',
260
+ payload: {
261
+ roomId,
262
+ userId,
263
+ userName,
264
+ password
265
+ }
266
+ });
267
+ }
268
+
269
+ leaveRoom() {
270
+ if (!this.isConnected || !this.roomId) return;
271
+
272
+ this.send({
273
+ type: 'leave-room',
274
+ payload: {}
275
+ });
276
+
277
+ this.cleanup();
278
+ }
279
+
280
+ onRoomJoined(message) {
281
+ const { roomId, userId, room, operations, chatHistory } = message;
282
+
283
+ this.roomId = roomId;
284
+ this.userId = userId;
285
+ this.operations = operations || [];
286
+ this.operationIndex = this.operations.length;
287
+ this.chatMessages = chatHistory || [];
288
+
289
+ // Initialize users
290
+ this.users.clear();
291
+ if (room.users) {
292
+ room.users.forEach(userInfo => {
293
+ this.users.set(userInfo.id, {
294
+ id: userInfo.id,
295
+ name: userInfo.name,
296
+ color: userInfo.color,
297
+ cursor: userInfo.cursor,
298
+ selection: userInfo.selection,
299
+ status: userInfo.status
300
+ });
301
+ });
302
+ }
303
+
304
+ console.log(`[CollabClient] Joined room ${roomId} as ${userId}`);
305
+ this.callbacks.onConnected();
306
+ }
307
+
308
+ onRoomLeft() {
309
+ console.log('[CollabClient] Left room');
310
+ this.cleanup();
311
+ }
312
+
313
+ cleanup() {
314
+ // Close all peer connections
315
+ this.peers.forEach(peer => peer.close());
316
+ this.peers.clear();
317
+ this.dataChannels.clear();
318
+
319
+ // Clear room state
320
+ this.roomId = null;
321
+ this.userId = null;
322
+ this.users.clear();
323
+ this.remoteSelections.clear();
324
+ this.userPresence.clear();
325
+ }
326
+
327
+ // ========== User Presence ==========
328
+
329
+ onUserJoined(message) {
330
+ const { user } = message;
331
+
332
+ this.users.set(user.id, {
333
+ id: user.id,
334
+ name: user.name,
335
+ color: user.color,
336
+ cursor: user.cursor,
337
+ selection: user.selection,
338
+ status: user.status
339
+ });
340
+
341
+ console.log(`[CollabClient] User joined: ${user.name} (${user.id})`);
342
+
343
+ // Initiate WebRTC connection
344
+ if (user.id !== this.userId) {
345
+ this.setupPeerConnection(user.id);
346
+ }
347
+
348
+ this.callbacks.onUserJoined({
349
+ userId: user.id,
350
+ name: user.name,
351
+ color: user.color
352
+ });
353
+ }
354
+
355
+ onUserLeft(message) {
356
+ const { userId } = message;
357
+
358
+ this.users.delete(userId);
359
+ this.peers.get(userId)?.close();
360
+ this.peers.delete(userId);
361
+ this.dataChannels.delete(userId);
362
+ this.remoteSelections.delete(userId);
363
+
364
+ console.log(`[CollabClient] User left: ${userId}`);
365
+
366
+ this.callbacks.onUserLeft({ userId });
367
+ }
368
+
369
+ onUserStatus(message) {
370
+ const { userId, status } = message;
371
+
372
+ const user = this.users.get(userId);
373
+ if (user) {
374
+ user.status = status;
375
+ }
376
+
377
+ this.userPresence.set(userId, {
378
+ status,
379
+ lastSeen: new Date(message.timestamp)
380
+ });
381
+ }
382
+
383
+ // ========== Cursor & Selection ==========
384
+
385
+ updateCursor(x, y) {
386
+ const now = Date.now();
387
+
388
+ // Throttle cursor updates
389
+ if (now - this.lastCursorUpdate < this.cursorUpdateInterval) {
390
+ this.pendingCursorUpdate = { x, y };
391
+ return;
392
+ }
393
+
394
+ this.lastCursorUpdate = now;
395
+ this.pendingCursorUpdate = null;
396
+
397
+ this.send({
398
+ type: 'cursor-update',
399
+ payload: { x, y }
400
+ });
401
+ }
402
+
403
+ onCursorUpdate(message) {
404
+ const { userId, cursor } = message;
405
+
406
+ const user = this.users.get(userId);
407
+ if (user) {
408
+ user.cursor = cursor;
409
+ }
410
+
411
+ this.callbacks.onCursorUpdate({
412
+ userId,
413
+ x: cursor.x,
414
+ y: cursor.y
415
+ });
416
+ }
417
+
418
+ updateSelection(partIds) {
419
+ this.localSelection = partIds;
420
+
421
+ this.send({
422
+ type: 'selection-update',
423
+ payload: { selection: partIds }
424
+ });
425
+ }
426
+
427
+ onSelectionUpdate(message) {
428
+ const { userId, selection } = message;
429
+
430
+ const user = this.users.get(userId);
431
+ if (user) {
432
+ user.selection = selection;
433
+ }
434
+
435
+ this.remoteSelections.set(userId, selection);
436
+
437
+ this.callbacks.onSelectionUpdate({
438
+ userId,
439
+ partIds: selection
440
+ });
441
+ }
442
+
443
+ // ========== CRDT Operations ==========
444
+
445
+ sendOperation(op) {
446
+ // Queue operation if offline
447
+ if (!this.isConnected) {
448
+ this.offlineQueue.push(op);
449
+ return;
450
+ }
451
+
452
+ this.send({
453
+ type: 'operation',
454
+ payload: { op }
455
+ });
456
+
457
+ this.operations.push({
458
+ userId: this.userId,
459
+ op,
460
+ timestamp: new Date().toISOString()
461
+ });
462
+ this.operationIndex++;
463
+ }
464
+
465
+ onOperationReceived(message) {
466
+ const { userId, op, timestamp } = message;
467
+
468
+ this.operations.push({
469
+ userId,
470
+ op,
471
+ timestamp
472
+ });
473
+
474
+ this.callbacks.onOperationReceived({
475
+ userId,
476
+ op,
477
+ timestamp
478
+ });
479
+ }
480
+
481
+ syncOfflineQueue() {
482
+ if (this.offlineQueue.length === 0) return;
483
+
484
+ console.log(`[CollabClient] Syncing ${this.offlineQueue.length} offline operations`);
485
+
486
+ while (this.offlineQueue.length > 0) {
487
+ const op = this.offlineQueue.shift();
488
+ this.sendOperation(op);
489
+ }
490
+ }
491
+
492
+ // ========== Chat Messages ==========
493
+
494
+ sendMessage(text) {
495
+ if (!this.isConnected || !this.roomId) {
496
+ throw new Error('Not in a room');
497
+ }
498
+
499
+ this.send({
500
+ type: 'chat-message',
501
+ payload: { text }
502
+ });
503
+ }
504
+
505
+ onChatMessage(message) {
506
+ const { userId, userName, userColor, text, timestamp } = message;
507
+
508
+ this.chatMessages.push({
509
+ userId,
510
+ name: userName,
511
+ color: userColor,
512
+ text,
513
+ timestamp
514
+ });
515
+
516
+ // Keep chat history bounded
517
+ if (this.chatMessages.length > this.maxChatHistory) {
518
+ this.chatMessages.shift();
519
+ }
520
+
521
+ this.callbacks.onChatMessage({
522
+ userId,
523
+ name: userName,
524
+ color: userColor,
525
+ text,
526
+ timestamp
527
+ });
528
+ }
529
+
530
+ onRoomReset() {
531
+ console.log('[CollabClient] Room was reset by admin');
532
+ this.operations = [];
533
+ this.operationIndex = 0;
534
+ this.chatMessages = [];
535
+ }
536
+
537
+ onRoomClosed() {
538
+ console.log('[CollabClient] Room was closed');
539
+ this.leaveRoom();
540
+ }
541
+
542
+ // ========== WebRTC Peer Connections ==========
543
+
544
+ async setupPeerConnection(remoteUserId) {
545
+ try {
546
+ const config = {
547
+ iceServers: this.iceServers
548
+ };
549
+
550
+ const peerConnection = new RTCPeerConnection(config);
551
+ this.peers.set(remoteUserId, peerConnection);
552
+
553
+ // Handle ICE candidates
554
+ peerConnection.onicecandidate = (event) => {
555
+ if (event.candidate) {
556
+ this.send({
557
+ type: 'ice-candidate',
558
+ payload: {
559
+ targetUserId: remoteUserId,
560
+ candidate: event.candidate
561
+ }
562
+ });
563
+ }
564
+ };
565
+
566
+ // Handle data channels
567
+ peerConnection.ondatachannel = (event) => {
568
+ this.setupDataChannel(remoteUserId, event.channel);
569
+ };
570
+
571
+ // Create data channel for us (we're initiating)
572
+ const dataChannel = peerConnection.createDataChannel('cyclecad-collab', {
573
+ ordered: true
574
+ });
575
+ this.setupDataChannel(remoteUserId, dataChannel);
576
+
577
+ // Create and send offer
578
+ const offer = await peerConnection.createOffer();
579
+ await peerConnection.setLocalDescription(offer);
580
+
581
+ this.send({
582
+ type: 'signaling-offer',
583
+ payload: {
584
+ targetUserId: remoteUserId,
585
+ offer
586
+ }
587
+ });
588
+
589
+ console.log(`[CollabClient] WebRTC offer sent to ${remoteUserId}`);
590
+ } catch (error) {
591
+ console.error(`[CollabClient] Error setting up peer connection:`, error);
592
+ }
593
+ }
594
+
595
+ async onSignalingOffer(message) {
596
+ const { fromUserId, offer } = message;
597
+
598
+ try {
599
+ let peerConnection = this.peers.get(fromUserId);
600
+
601
+ if (!peerConnection) {
602
+ const config = { iceServers: this.iceServers };
603
+ peerConnection = new RTCPeerConnection(config);
604
+ this.peers.set(fromUserId, peerConnection);
605
+
606
+ peerConnection.onicecandidate = (event) => {
607
+ if (event.candidate) {
608
+ this.send({
609
+ type: 'ice-candidate',
610
+ payload: {
611
+ targetUserId: fromUserId,
612
+ candidate: event.candidate
613
+ }
614
+ });
615
+ }
616
+ };
617
+
618
+ peerConnection.ondatachannel = (event) => {
619
+ this.setupDataChannel(fromUserId, event.channel);
620
+ };
621
+ }
622
+
623
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
624
+
625
+ const answer = await peerConnection.createAnswer();
626
+ await peerConnection.setLocalDescription(answer);
627
+
628
+ this.send({
629
+ type: 'signaling-answer',
630
+ payload: {
631
+ targetUserId: fromUserId,
632
+ answer
633
+ }
634
+ });
635
+
636
+ console.log(`[CollabClient] WebRTC answer sent to ${fromUserId}`);
637
+ } catch (error) {
638
+ console.error(`[CollabClient] Error handling offer:`, error);
639
+ }
640
+ }
641
+
642
+ async onSignalingAnswer(message) {
643
+ const { fromUserId, answer } = message;
644
+
645
+ try {
646
+ const peerConnection = this.peers.get(fromUserId);
647
+ if (peerConnection) {
648
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
649
+ console.log(`[CollabClient] WebRTC answer received from ${fromUserId}`);
650
+ }
651
+ } catch (error) {
652
+ console.error(`[CollabClient] Error handling answer:`, error);
653
+ }
654
+ }
655
+
656
+ async onIceCandidate(message) {
657
+ const { fromUserId, candidate } = message;
658
+
659
+ try {
660
+ const peerConnection = this.peers.get(fromUserId);
661
+ if (peerConnection && candidate) {
662
+ await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
663
+ }
664
+ } catch (error) {
665
+ console.error(`[CollabClient] Error adding ICE candidate:`, error);
666
+ }
667
+ }
668
+
669
+ setupDataChannel(userId, dataChannel) {
670
+ console.log(`[CollabClient] Data channel established with ${userId}`);
671
+
672
+ dataChannel.onopen = () => {
673
+ console.log(`[CollabClient] Data channel open with ${userId}`);
674
+ };
675
+
676
+ dataChannel.onclose = () => {
677
+ console.log(`[CollabClient] Data channel closed with ${userId}`);
678
+ };
679
+
680
+ dataChannel.onmessage = (event) => {
681
+ try {
682
+ const message = JSON.parse(event.data);
683
+ this.handleMessage(message);
684
+ } catch (error) {
685
+ console.error('[CollabClient] Data channel parse error:', error);
686
+ }
687
+ };
688
+
689
+ dataChannel.onerror = (error) => {
690
+ console.error(`[CollabClient] Data channel error with ${userId}:`, error);
691
+ };
692
+
693
+ this.dataChannels.set(userId, dataChannel);
694
+ }
695
+
696
+ // ========== Utility Methods ==========
697
+
698
+ send(message) {
699
+ if (!this.isConnected || !this.ws) {
700
+ console.warn('[CollabClient] Not connected, queuing message');
701
+ return false;
702
+ }
703
+
704
+ try {
705
+ this.ws.send(JSON.stringify(message));
706
+ return true;
707
+ } catch (error) {
708
+ console.error('[CollabClient] Send error:', error);
709
+ return false;
710
+ }
711
+ }
712
+
713
+ on(event, callback) {
714
+ if (this.callbacks.hasOwnProperty(`on${event[0].toUpperCase()}${event.slice(1)}`)) {
715
+ this.callbacks[`on${event[0].toUpperCase()}${event.slice(1)}`] = callback;
716
+ }
717
+ }
718
+
719
+ getUsers() {
720
+ return Array.from(this.users.values());
721
+ }
722
+
723
+ getUser(userId) {
724
+ return this.users.get(userId);
725
+ }
726
+
727
+ getRoomInfo() {
728
+ return {
729
+ roomId: this.roomId,
730
+ userId: this.userId,
731
+ users: this.getUsers(),
732
+ userCount: this.users.size,
733
+ operations: this.operations.length,
734
+ chatMessages: this.chatMessages.length,
735
+ isConnected: this.isConnected
736
+ };
737
+ }
738
+
739
+ disconnect() {
740
+ if (this.ws) {
741
+ this.ws.close();
742
+ }
743
+ this.cleanup();
744
+ }
745
+ }
746
+
747
+ // Export for use in browser or Node.js
748
+ if (typeof module !== 'undefined' && module.exports) {
749
+ module.exports = CollaborationClient;
750
+ }