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.
- package/BILLING-IMPLEMENTATION-SUMMARY.md +425 -0
- package/BILLING-INDEX.md +293 -0
- package/BILLING-INTEGRATION-GUIDE.md +414 -0
- package/COLLABORATION-INDEX.md +440 -0
- package/COLLABORATION-SYSTEM-SUMMARY.md +548 -0
- package/DOCKER-BUILD-MANIFEST.txt +483 -0
- package/DOCKER-FILES-REFERENCE.md +440 -0
- package/DOCKER-INFRASTRUCTURE.md +475 -0
- package/DOCKER-README.md +435 -0
- package/Dockerfile +33 -55
- package/PWA-FILES-CREATED.txt +350 -0
- package/QUICK-START-TESTING.md +126 -0
- package/STEP-IMPORT-QUICKSTART.md +347 -0
- package/STEP-IMPORT-SYSTEM-SUMMARY.md +502 -0
- package/app/css/mobile.css +1074 -0
- package/app/icons/generate-icons.js +203 -0
- package/app/index.html +93 -0
- package/app/js/billing-ui.js +990 -0
- package/app/js/brep-kernel.js +933 -981
- package/app/js/collab-client.js +750 -0
- package/app/js/mobile-nav.js +623 -0
- package/app/js/mobile-toolbar.js +476 -0
- package/app/js/modules/billing-module.js +724 -0
- package/app/js/modules/step-module-enhanced.js +938 -0
- package/app/js/offline-manager.js +705 -0
- package/app/js/responsive-init.js +360 -0
- package/app/js/touch-handler.js +429 -0
- package/app/manifest.json +211 -0
- package/app/offline.html +508 -0
- package/app/sw.js +571 -0
- package/app/tests/billing-tests.html +779 -0
- package/app/tests/brep-tests.html +980 -0
- package/app/tests/collab-tests.html +743 -0
- package/app/tests/mobile-tests.html +1299 -0
- package/app/tests/pwa-tests.html +1134 -0
- package/app/tests/step-tests.html +1042 -0
- package/app/tests/test-agent-v3.html +719 -0
- package/docker-compose.yml +225 -0
- package/docs/BILLING-HELP.json +260 -0
- package/docs/BILLING-README.md +639 -0
- package/docs/BILLING-TUTORIAL.md +736 -0
- package/docs/BREP-HELP.json +326 -0
- package/docs/BREP-TUTORIAL.md +802 -0
- package/docs/COLLABORATION-HELP.json +228 -0
- package/docs/COLLABORATION-TUTORIAL.md +818 -0
- package/docs/DOCKER-HELP.json +224 -0
- package/docs/DOCKER-TUTORIAL.md +974 -0
- package/docs/MOBILE-HELP.json +243 -0
- package/docs/MOBILE-RESPONSIVE-README.md +378 -0
- package/docs/MOBILE-TUTORIAL.md +747 -0
- package/docs/PWA-HELP.json +228 -0
- package/docs/PWA-README.md +662 -0
- package/docs/PWA-TUTORIAL.md +757 -0
- package/docs/STEP-HELP.json +481 -0
- package/docs/STEP-IMPORT-TUTORIAL.md +824 -0
- package/docs/TESTING-GUIDE.md +528 -0
- package/docs/TESTING-HELP.json +182 -0
- package/fusion-vs-cyclecad.html +1771 -0
- package/nginx.conf +237 -0
- package/package.json +1 -1
- package/server/Dockerfile.converter +51 -0
- package/server/Dockerfile.signaling +28 -0
- package/server/billing-server.js +487 -0
- package/server/converter-enhanced.py +528 -0
- package/server/requirements-converter.txt +29 -0
- package/server/signaling-server.js +801 -0
- package/tests/docker-tests.sh +389 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cycleCAD WebSocket Signaling Server
|
|
4
|
+
* Real-time collaboration via WebRTC signaling, presence, and CRDT operation relay
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Room management (create, join, leave, list)
|
|
8
|
+
* - WebRTC signaling (offer, answer, ICE candidates)
|
|
9
|
+
* - User presence (cursor position, selection, online status)
|
|
10
|
+
* - CRDT operation relay (broadcast ops to all peers in room)
|
|
11
|
+
* - Chat messages with timestamps
|
|
12
|
+
* - Room capacity limits (max 10 users per room)
|
|
13
|
+
* - Heartbeat/ping-pong for connection health
|
|
14
|
+
* - Graceful reconnection with state recovery
|
|
15
|
+
* - Rate limiting (100 messages/sec per client)
|
|
16
|
+
* - Room state persistence (auto-save to disk)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const WebSocket = require('ws');
|
|
20
|
+
const express = require('express');
|
|
21
|
+
const http = require('http');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
const PORT = process.env.PORT || 8788;
|
|
27
|
+
const MAX_USERS_PER_ROOM = 10;
|
|
28
|
+
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
|
|
29
|
+
const RATE_LIMIT_WINDOW = 1000; // 1 second
|
|
30
|
+
const RATE_LIMIT_MAX = 100; // messages per second
|
|
31
|
+
const STATE_SAVE_INTERVAL = 5000; // auto-save every 5 seconds
|
|
32
|
+
const STATE_FILE = path.join(__dirname, 'rooms-state.json');
|
|
33
|
+
|
|
34
|
+
// Data structures
|
|
35
|
+
const rooms = new Map(); // roomId -> Room object
|
|
36
|
+
const clients = new Map(); // clientId -> Client object
|
|
37
|
+
let clientCounter = 0;
|
|
38
|
+
|
|
39
|
+
// Express app for HTTP endpoints
|
|
40
|
+
const app = express();
|
|
41
|
+
app.use(express.json());
|
|
42
|
+
|
|
43
|
+
// Create HTTP server
|
|
44
|
+
const server = http.createServer(app);
|
|
45
|
+
|
|
46
|
+
// Create WebSocket server
|
|
47
|
+
const wss = new WebSocket.Server({ server });
|
|
48
|
+
|
|
49
|
+
// ========== Room Management ==========
|
|
50
|
+
|
|
51
|
+
class Room {
|
|
52
|
+
constructor(roomId, options = {}) {
|
|
53
|
+
this.id = roomId;
|
|
54
|
+
this.name = options.name || `Room ${roomId}`;
|
|
55
|
+
this.password = options.password || null;
|
|
56
|
+
this.users = new Map(); // userId -> User object
|
|
57
|
+
this.operations = []; // CRDT operations log
|
|
58
|
+
this.chatHistory = []; // chat messages
|
|
59
|
+
this.createdAt = new Date();
|
|
60
|
+
this.maxUsers = options.maxUsers || MAX_USERS_PER_ROOM;
|
|
61
|
+
this.isPrivate = !!options.password;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
addUser(userId, user) {
|
|
65
|
+
if (this.users.size >= this.maxUsers) {
|
|
66
|
+
throw new Error(`Room ${this.id} is full (max ${this.maxUsers} users)`);
|
|
67
|
+
}
|
|
68
|
+
this.users.set(userId, user);
|
|
69
|
+
return user;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
removeUser(userId) {
|
|
73
|
+
this.users.delete(userId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getUser(userId) {
|
|
77
|
+
return this.users.get(userId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getUsers() {
|
|
81
|
+
return Array.from(this.users.values());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
broadcast(message, excludeUserId = null) {
|
|
85
|
+
this.users.forEach((user, userId) => {
|
|
86
|
+
if (excludeUserId && userId === excludeUserId) return;
|
|
87
|
+
user.send(message);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
toJSON() {
|
|
92
|
+
return {
|
|
93
|
+
id: this.id,
|
|
94
|
+
name: this.name,
|
|
95
|
+
userCount: this.users.size,
|
|
96
|
+
maxUsers: this.maxUsers,
|
|
97
|
+
isPrivate: this.isPrivate,
|
|
98
|
+
createdAt: this.createdAt,
|
|
99
|
+
users: this.getUsers().map(u => u.toJSON()),
|
|
100
|
+
operationCount: this.operations.length,
|
|
101
|
+
chatCount: this.chatHistory.length
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class User {
|
|
107
|
+
constructor(userId, clientId, name = `User ${userId}`) {
|
|
108
|
+
this.id = userId;
|
|
109
|
+
this.clientId = clientId;
|
|
110
|
+
this.name = name;
|
|
111
|
+
this.cursor = { x: 0, y: 0 };
|
|
112
|
+
this.selection = [];
|
|
113
|
+
this.color = generateUserColor();
|
|
114
|
+
this.status = 'online';
|
|
115
|
+
this.joinedAt = new Date();
|
|
116
|
+
this.lastSeen = new Date();
|
|
117
|
+
this.messageCount = 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
send(message) {
|
|
121
|
+
const client = clients.get(this.clientId);
|
|
122
|
+
if (client && client.ws.readyState === WebSocket.OPEN) {
|
|
123
|
+
client.ws.send(JSON.stringify(message));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
toJSON() {
|
|
128
|
+
return {
|
|
129
|
+
id: this.id,
|
|
130
|
+
name: this.name,
|
|
131
|
+
color: this.color,
|
|
132
|
+
cursor: this.cursor,
|
|
133
|
+
selection: this.selection,
|
|
134
|
+
status: this.status,
|
|
135
|
+
joinedAt: this.joinedAt
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
class Client {
|
|
141
|
+
constructor(ws, clientId) {
|
|
142
|
+
this.ws = ws;
|
|
143
|
+
this.id = clientId;
|
|
144
|
+
this.userId = null;
|
|
145
|
+
this.roomId = null;
|
|
146
|
+
this.messageCount = 0;
|
|
147
|
+
this.messageWindowStart = Date.now();
|
|
148
|
+
this.isAlive = true;
|
|
149
|
+
this.lastHeartbeat = Date.now();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
checkRateLimit() {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
if (now - this.messageWindowStart > RATE_LIMIT_WINDOW) {
|
|
155
|
+
this.messageCount = 0;
|
|
156
|
+
this.messageWindowStart = now;
|
|
157
|
+
}
|
|
158
|
+
return this.messageCount++ < RATE_LIMIT_MAX;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
toJSON() {
|
|
162
|
+
return {
|
|
163
|
+
id: this.id,
|
|
164
|
+
userId: this.userId,
|
|
165
|
+
roomId: this.roomId,
|
|
166
|
+
lastHeartbeat: this.lastHeartbeat
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ========== WebSocket Handlers ==========
|
|
172
|
+
|
|
173
|
+
wss.on('connection', (ws) => {
|
|
174
|
+
const clientId = `client-${++clientCounter}`;
|
|
175
|
+
const client = new Client(ws, clientId);
|
|
176
|
+
clients.set(clientId, client);
|
|
177
|
+
|
|
178
|
+
console.log(`[${new Date().toISOString()}] Client connected: ${clientId}`);
|
|
179
|
+
|
|
180
|
+
// Send welcome message
|
|
181
|
+
ws.send(JSON.stringify({
|
|
182
|
+
type: 'welcome',
|
|
183
|
+
clientId,
|
|
184
|
+
timestamp: new Date().toISOString(),
|
|
185
|
+
version: '1.0.0'
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
// Handle incoming messages
|
|
189
|
+
ws.on('message', (data) => {
|
|
190
|
+
try {
|
|
191
|
+
const message = JSON.parse(data);
|
|
192
|
+
handleMessage(client, message);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`[${new Date().toISOString()}] Parse error from ${clientId}:`, error.message);
|
|
195
|
+
ws.send(JSON.stringify({
|
|
196
|
+
type: 'error',
|
|
197
|
+
message: 'Invalid message format',
|
|
198
|
+
timestamp: new Date().toISOString()
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Handle pong (heartbeat response)
|
|
204
|
+
ws.on('pong', () => {
|
|
205
|
+
client.isAlive = true;
|
|
206
|
+
client.lastHeartbeat = Date.now();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Handle client disconnect
|
|
210
|
+
ws.on('close', () => {
|
|
211
|
+
handleClientDisconnect(client);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Handle errors
|
|
215
|
+
ws.on('error', (error) => {
|
|
216
|
+
console.error(`[${new Date().toISOString()}] WebSocket error from ${clientId}:`, error.message);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ========== Message Handler ==========
|
|
221
|
+
|
|
222
|
+
function handleMessage(client, message) {
|
|
223
|
+
const { type, payload } = message;
|
|
224
|
+
|
|
225
|
+
// Rate limiting
|
|
226
|
+
if (!client.checkRateLimit()) {
|
|
227
|
+
client.ws.send(JSON.stringify({
|
|
228
|
+
type: 'error',
|
|
229
|
+
message: 'Rate limit exceeded',
|
|
230
|
+
timestamp: new Date().toISOString()
|
|
231
|
+
}));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log(`[${new Date().toISOString()}] Message from ${client.id}: ${type}`);
|
|
236
|
+
|
|
237
|
+
switch (type) {
|
|
238
|
+
case 'join-room':
|
|
239
|
+
handleJoinRoom(client, payload);
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case 'create-room':
|
|
243
|
+
handleCreateRoom(client, payload);
|
|
244
|
+
break;
|
|
245
|
+
|
|
246
|
+
case 'leave-room':
|
|
247
|
+
handleLeaveRoom(client);
|
|
248
|
+
break;
|
|
249
|
+
|
|
250
|
+
case 'signaling-offer':
|
|
251
|
+
handleSignalingOffer(client, payload);
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case 'signaling-answer':
|
|
255
|
+
handleSignalingAnswer(client, payload);
|
|
256
|
+
break;
|
|
257
|
+
|
|
258
|
+
case 'ice-candidate':
|
|
259
|
+
handleIceCandidate(client, payload);
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case 'cursor-update':
|
|
263
|
+
handleCursorUpdate(client, payload);
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case 'selection-update':
|
|
267
|
+
handleSelectionUpdate(client, payload);
|
|
268
|
+
break;
|
|
269
|
+
|
|
270
|
+
case 'operation':
|
|
271
|
+
handleOperation(client, payload);
|
|
272
|
+
break;
|
|
273
|
+
|
|
274
|
+
case 'chat-message':
|
|
275
|
+
handleChatMessage(client, payload);
|
|
276
|
+
break;
|
|
277
|
+
|
|
278
|
+
case 'user-status':
|
|
279
|
+
handleUserStatus(client, payload);
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'ping':
|
|
283
|
+
client.ws.send(JSON.stringify({
|
|
284
|
+
type: 'pong',
|
|
285
|
+
timestamp: new Date().toISOString()
|
|
286
|
+
}));
|
|
287
|
+
break;
|
|
288
|
+
|
|
289
|
+
default:
|
|
290
|
+
console.warn(`[${new Date().toISOString()}] Unknown message type: ${type}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ========== Room Management Handlers ==========
|
|
295
|
+
|
|
296
|
+
function handleCreateRoom(client, payload) {
|
|
297
|
+
const { roomId, name, password, maxUsers } = payload;
|
|
298
|
+
|
|
299
|
+
if (!roomId || typeof roomId !== 'string') {
|
|
300
|
+
client.ws.send(JSON.stringify({
|
|
301
|
+
type: 'error',
|
|
302
|
+
message: 'Invalid roomId',
|
|
303
|
+
timestamp: new Date().toISOString()
|
|
304
|
+
}));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (rooms.has(roomId)) {
|
|
309
|
+
client.ws.send(JSON.stringify({
|
|
310
|
+
type: 'error',
|
|
311
|
+
message: 'Room already exists',
|
|
312
|
+
timestamp: new Date().toISOString()
|
|
313
|
+
}));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const room = new Room(roomId, { name, password, maxUsers });
|
|
318
|
+
rooms.set(roomId, room);
|
|
319
|
+
|
|
320
|
+
client.ws.send(JSON.stringify({
|
|
321
|
+
type: 'room-created',
|
|
322
|
+
roomId,
|
|
323
|
+
room: room.toJSON(),
|
|
324
|
+
timestamp: new Date().toISOString()
|
|
325
|
+
}));
|
|
326
|
+
|
|
327
|
+
console.log(`[${new Date().toISOString()}] Room created: ${roomId}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function handleJoinRoom(client, payload) {
|
|
331
|
+
const { roomId, userId, userName, password } = payload;
|
|
332
|
+
|
|
333
|
+
if (!roomId || !userId) {
|
|
334
|
+
client.ws.send(JSON.stringify({
|
|
335
|
+
type: 'error',
|
|
336
|
+
message: 'Invalid roomId or userId',
|
|
337
|
+
timestamp: new Date().toISOString()
|
|
338
|
+
}));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let room = rooms.get(roomId);
|
|
343
|
+
|
|
344
|
+
// Auto-create room if it doesn't exist
|
|
345
|
+
if (!room) {
|
|
346
|
+
room = new Room(roomId, { name: `Room ${roomId}` });
|
|
347
|
+
rooms.set(roomId, room);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Check password if required
|
|
351
|
+
if (room.password && room.password !== password) {
|
|
352
|
+
client.ws.send(JSON.stringify({
|
|
353
|
+
type: 'error',
|
|
354
|
+
message: 'Invalid room password',
|
|
355
|
+
timestamp: new Date().toISOString()
|
|
356
|
+
}));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check capacity
|
|
361
|
+
if (room.users.size >= room.maxUsers) {
|
|
362
|
+
client.ws.send(JSON.stringify({
|
|
363
|
+
type: 'error',
|
|
364
|
+
message: `Room is full (max ${room.maxUsers} users)`,
|
|
365
|
+
timestamp: new Date().toISOString()
|
|
366
|
+
}));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Leave previous room if in one
|
|
371
|
+
if (client.roomId) {
|
|
372
|
+
handleLeaveRoom(client);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Create user and add to room
|
|
376
|
+
const user = new User(userId, client.id, userName);
|
|
377
|
+
room.addUser(userId, user);
|
|
378
|
+
client.userId = userId;
|
|
379
|
+
client.roomId = roomId;
|
|
380
|
+
|
|
381
|
+
// Send confirmation to joining user
|
|
382
|
+
client.ws.send(JSON.stringify({
|
|
383
|
+
type: 'room-joined',
|
|
384
|
+
roomId,
|
|
385
|
+
userId,
|
|
386
|
+
room: room.toJSON(),
|
|
387
|
+
operations: room.operations.slice(-100), // Send last 100 ops for sync
|
|
388
|
+
chatHistory: room.chatHistory.slice(-50), // Send last 50 messages
|
|
389
|
+
timestamp: new Date().toISOString()
|
|
390
|
+
}));
|
|
391
|
+
|
|
392
|
+
// Broadcast user joined to room
|
|
393
|
+
room.broadcast({
|
|
394
|
+
type: 'user-joined',
|
|
395
|
+
user: user.toJSON(),
|
|
396
|
+
userCount: room.users.size,
|
|
397
|
+
timestamp: new Date().toISOString()
|
|
398
|
+
}, userId);
|
|
399
|
+
|
|
400
|
+
console.log(`[${new Date().toISOString()}] User ${userId} joined room ${roomId}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function handleLeaveRoom(client) {
|
|
404
|
+
if (!client.roomId) return;
|
|
405
|
+
|
|
406
|
+
const room = rooms.get(client.roomId);
|
|
407
|
+
if (!room) return;
|
|
408
|
+
|
|
409
|
+
const userId = client.userId;
|
|
410
|
+
room.removeUser(userId);
|
|
411
|
+
|
|
412
|
+
// Broadcast user left
|
|
413
|
+
room.broadcast({
|
|
414
|
+
type: 'user-left',
|
|
415
|
+
userId,
|
|
416
|
+
userCount: room.users.size,
|
|
417
|
+
timestamp: new Date().toISOString()
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Delete empty rooms after 5 minutes
|
|
421
|
+
if (room.users.size === 0) {
|
|
422
|
+
setTimeout(() => {
|
|
423
|
+
if (room.users.size === 0) {
|
|
424
|
+
rooms.delete(client.roomId);
|
|
425
|
+
console.log(`[${new Date().toISOString()}] Room deleted: ${client.roomId}`);
|
|
426
|
+
}
|
|
427
|
+
}, 300000);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
client.roomId = null;
|
|
431
|
+
client.userId = null;
|
|
432
|
+
|
|
433
|
+
client.ws.send(JSON.stringify({
|
|
434
|
+
type: 'room-left',
|
|
435
|
+
timestamp: new Date().toISOString()
|
|
436
|
+
}));
|
|
437
|
+
|
|
438
|
+
console.log(`[${new Date().toISOString()}] User ${userId} left room ${room.id}`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ========== Real-time Handlers ==========
|
|
442
|
+
|
|
443
|
+
function handleSignalingOffer(client, payload) {
|
|
444
|
+
const { targetUserId, offer } = payload;
|
|
445
|
+
const room = rooms.get(client.roomId);
|
|
446
|
+
if (!room) return;
|
|
447
|
+
|
|
448
|
+
const targetUser = room.getUser(targetUserId);
|
|
449
|
+
if (!targetUser) return;
|
|
450
|
+
|
|
451
|
+
targetUser.send({
|
|
452
|
+
type: 'signaling-offer',
|
|
453
|
+
fromUserId: client.userId,
|
|
454
|
+
offer,
|
|
455
|
+
timestamp: new Date().toISOString()
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function handleSignalingAnswer(client, payload) {
|
|
460
|
+
const { targetUserId, answer } = payload;
|
|
461
|
+
const room = rooms.get(client.roomId);
|
|
462
|
+
if (!room) return;
|
|
463
|
+
|
|
464
|
+
const targetUser = room.getUser(targetUserId);
|
|
465
|
+
if (!targetUser) return;
|
|
466
|
+
|
|
467
|
+
targetUser.send({
|
|
468
|
+
type: 'signaling-answer',
|
|
469
|
+
fromUserId: client.userId,
|
|
470
|
+
answer,
|
|
471
|
+
timestamp: new Date().toISOString()
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function handleIceCandidate(client, payload) {
|
|
476
|
+
const { targetUserId, candidate } = payload;
|
|
477
|
+
const room = rooms.get(client.roomId);
|
|
478
|
+
if (!room) return;
|
|
479
|
+
|
|
480
|
+
const targetUser = room.getUser(targetUserId);
|
|
481
|
+
if (!targetUser) return;
|
|
482
|
+
|
|
483
|
+
targetUser.send({
|
|
484
|
+
type: 'ice-candidate',
|
|
485
|
+
fromUserId: client.userId,
|
|
486
|
+
candidate,
|
|
487
|
+
timestamp: new Date().toISOString()
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function handleCursorUpdate(client, payload) {
|
|
492
|
+
const { x, y } = payload;
|
|
493
|
+
const room = rooms.get(client.roomId);
|
|
494
|
+
if (!room) return;
|
|
495
|
+
|
|
496
|
+
const user = room.getUser(client.userId);
|
|
497
|
+
if (!user) return;
|
|
498
|
+
|
|
499
|
+
user.cursor = { x, y };
|
|
500
|
+
|
|
501
|
+
room.broadcast({
|
|
502
|
+
type: 'cursor-update',
|
|
503
|
+
userId: client.userId,
|
|
504
|
+
cursor: { x, y },
|
|
505
|
+
timestamp: new Date().toISOString()
|
|
506
|
+
}, client.userId);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function handleSelectionUpdate(client, payload) {
|
|
510
|
+
const { selection } = payload;
|
|
511
|
+
const room = rooms.get(client.roomId);
|
|
512
|
+
if (!room) return;
|
|
513
|
+
|
|
514
|
+
const user = room.getUser(client.userId);
|
|
515
|
+
if (!user) return;
|
|
516
|
+
|
|
517
|
+
user.selection = selection || [];
|
|
518
|
+
|
|
519
|
+
room.broadcast({
|
|
520
|
+
type: 'selection-update',
|
|
521
|
+
userId: client.userId,
|
|
522
|
+
selection: user.selection,
|
|
523
|
+
timestamp: new Date().toISOString()
|
|
524
|
+
}, client.userId);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function handleOperation(client, payload) {
|
|
528
|
+
const { op } = payload;
|
|
529
|
+
const room = rooms.get(client.roomId);
|
|
530
|
+
if (!room) return;
|
|
531
|
+
|
|
532
|
+
// Add operation to log
|
|
533
|
+
room.operations.push({
|
|
534
|
+
userId: client.userId,
|
|
535
|
+
op,
|
|
536
|
+
timestamp: new Date().toISOString()
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Broadcast to room
|
|
540
|
+
room.broadcast({
|
|
541
|
+
type: 'operation',
|
|
542
|
+
userId: client.userId,
|
|
543
|
+
op,
|
|
544
|
+
timestamp: new Date().toISOString()
|
|
545
|
+
}, client.userId);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function handleChatMessage(client, payload) {
|
|
549
|
+
const { text } = payload;
|
|
550
|
+
const room = rooms.get(client.roomId);
|
|
551
|
+
if (!room) return;
|
|
552
|
+
|
|
553
|
+
const user = room.getUser(client.userId);
|
|
554
|
+
if (!user) return;
|
|
555
|
+
|
|
556
|
+
user.messageCount++;
|
|
557
|
+
|
|
558
|
+
const message = {
|
|
559
|
+
userId: client.userId,
|
|
560
|
+
userName: user.name,
|
|
561
|
+
userColor: user.color,
|
|
562
|
+
text,
|
|
563
|
+
timestamp: new Date().toISOString()
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
room.chatHistory.push(message);
|
|
567
|
+
|
|
568
|
+
// Broadcast to room
|
|
569
|
+
room.broadcast({
|
|
570
|
+
type: 'chat-message',
|
|
571
|
+
...message
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function handleUserStatus(client, payload) {
|
|
576
|
+
const { status } = payload;
|
|
577
|
+
const room = rooms.get(client.roomId);
|
|
578
|
+
if (!room) return;
|
|
579
|
+
|
|
580
|
+
const user = room.getUser(client.userId);
|
|
581
|
+
if (!user) return;
|
|
582
|
+
|
|
583
|
+
user.status = status;
|
|
584
|
+
user.lastSeen = new Date();
|
|
585
|
+
|
|
586
|
+
room.broadcast({
|
|
587
|
+
type: 'user-status',
|
|
588
|
+
userId: client.userId,
|
|
589
|
+
status,
|
|
590
|
+
timestamp: new Date().toISOString()
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function handleClientDisconnect(client) {
|
|
595
|
+
console.log(`[${new Date().toISOString()}] Client disconnected: ${client.id}`);
|
|
596
|
+
|
|
597
|
+
if (client.roomId) {
|
|
598
|
+
handleLeaveRoom(client);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
clients.delete(client.id);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ========== Heartbeat ==========
|
|
605
|
+
|
|
606
|
+
function startHeartbeat() {
|
|
607
|
+
setInterval(() => {
|
|
608
|
+
const now = Date.now();
|
|
609
|
+
wss.clients.forEach((ws) => {
|
|
610
|
+
const client = Array.from(clients.values()).find(c => c.ws === ws);
|
|
611
|
+
if (!client) return;
|
|
612
|
+
|
|
613
|
+
if (!client.isAlive) {
|
|
614
|
+
return ws.terminate();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
client.isAlive = false;
|
|
618
|
+
ws.ping();
|
|
619
|
+
});
|
|
620
|
+
}, HEARTBEAT_INTERVAL);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ========== State Persistence ==========
|
|
624
|
+
|
|
625
|
+
function saveRoomState() {
|
|
626
|
+
const state = {
|
|
627
|
+
timestamp: new Date().toISOString(),
|
|
628
|
+
rooms: Array.from(rooms.values()).map(room => ({
|
|
629
|
+
id: room.id,
|
|
630
|
+
name: room.name,
|
|
631
|
+
password: room.password,
|
|
632
|
+
maxUsers: room.maxUsers,
|
|
633
|
+
createdAt: room.createdAt,
|
|
634
|
+
users: room.getUsers().map(u => u.toJSON()),
|
|
635
|
+
operations: room.operations,
|
|
636
|
+
chatHistory: room.chatHistory
|
|
637
|
+
}))
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function loadRoomState() {
|
|
644
|
+
if (!fs.existsSync(STATE_FILE)) return;
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
648
|
+
// Restore rooms without users (users must rejoin)
|
|
649
|
+
state.rooms.forEach(roomData => {
|
|
650
|
+
const room = new Room(roomData.id, {
|
|
651
|
+
name: roomData.name,
|
|
652
|
+
password: roomData.password,
|
|
653
|
+
maxUsers: roomData.maxUsers
|
|
654
|
+
});
|
|
655
|
+
room.operations = roomData.operations || [];
|
|
656
|
+
room.chatHistory = roomData.chatHistory || [];
|
|
657
|
+
rooms.set(room.id, room);
|
|
658
|
+
});
|
|
659
|
+
console.log(`[${new Date().toISOString()}] Loaded ${rooms.size} rooms from state file`);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
console.error(`[${new Date().toISOString()}] Error loading room state:`, error.message);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
setInterval(saveRoomState, STATE_SAVE_INTERVAL);
|
|
666
|
+
|
|
667
|
+
// ========== HTTP Endpoints ==========
|
|
668
|
+
|
|
669
|
+
app.get('/health', (req, res) => {
|
|
670
|
+
res.json({
|
|
671
|
+
status: 'healthy',
|
|
672
|
+
timestamp: new Date().toISOString(),
|
|
673
|
+
clients: clients.size,
|
|
674
|
+
rooms: rooms.size
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
app.get('/stats', (req, res) => {
|
|
679
|
+
res.json({
|
|
680
|
+
timestamp: new Date().toISOString(),
|
|
681
|
+
clients: clients.size,
|
|
682
|
+
rooms: rooms.size,
|
|
683
|
+
uptime: process.uptime(),
|
|
684
|
+
memory: process.memoryUsage()
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
app.get('/rooms', (req, res) => {
|
|
689
|
+
const roomList = Array.from(rooms.values()).map(room => room.toJSON());
|
|
690
|
+
res.json({
|
|
691
|
+
timestamp: new Date().toISOString(),
|
|
692
|
+
count: roomList.length,
|
|
693
|
+
rooms: roomList
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
app.get('/rooms/:roomId', (req, res) => {
|
|
698
|
+
const room = rooms.get(req.params.roomId);
|
|
699
|
+
if (!room) {
|
|
700
|
+
return res.status(404).json({ error: 'Room not found' });
|
|
701
|
+
}
|
|
702
|
+
res.json({
|
|
703
|
+
timestamp: new Date().toISOString(),
|
|
704
|
+
room: room.toJSON()
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
app.post('/rooms/:roomId/reset', (req, res) => {
|
|
709
|
+
const room = rooms.get(req.params.roomId);
|
|
710
|
+
if (!room) {
|
|
711
|
+
return res.status(404).json({ error: 'Room not found' });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Notify users before reset
|
|
715
|
+
room.broadcast({
|
|
716
|
+
type: 'room-reset',
|
|
717
|
+
timestamp: new Date().toISOString()
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
room.operations = [];
|
|
721
|
+
room.chatHistory = [];
|
|
722
|
+
|
|
723
|
+
res.json({
|
|
724
|
+
timestamp: new Date().toISOString(),
|
|
725
|
+
message: 'Room reset successful'
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
app.post('/rooms/:roomId/close', (req, res) => {
|
|
730
|
+
const room = rooms.get(req.params.roomId);
|
|
731
|
+
if (!room) {
|
|
732
|
+
return res.status(404).json({ error: 'Room not found' });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
room.broadcast({
|
|
736
|
+
type: 'room-closed',
|
|
737
|
+
timestamp: new Date().toISOString()
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
rooms.delete(req.params.roomId);
|
|
741
|
+
|
|
742
|
+
res.json({
|
|
743
|
+
timestamp: new Date().toISOString(),
|
|
744
|
+
message: 'Room closed'
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// ========== Utility Functions ==========
|
|
749
|
+
|
|
750
|
+
function generateUserColor() {
|
|
751
|
+
const colors = [
|
|
752
|
+
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
|
|
753
|
+
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B195', '#D5A6BD'
|
|
754
|
+
];
|
|
755
|
+
return colors[Math.floor(Math.random() * colors.length)];
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ========== Server Startup ==========
|
|
759
|
+
|
|
760
|
+
function start() {
|
|
761
|
+
loadRoomState();
|
|
762
|
+
startHeartbeat();
|
|
763
|
+
|
|
764
|
+
server.listen(PORT, () => {
|
|
765
|
+
console.log(`\n${'='.repeat(60)}`);
|
|
766
|
+
console.log(`cycleCAD WebSocket Signaling Server v1.0.0`);
|
|
767
|
+
console.log(`${'='.repeat(60)}`);
|
|
768
|
+
console.log(`Server listening on port ${PORT}`);
|
|
769
|
+
console.log(`WebSocket: ws://localhost:${PORT}`);
|
|
770
|
+
console.log(`HTTP Health: http://localhost:${PORT}/health`);
|
|
771
|
+
console.log(`HTTP Stats: http://localhost:${PORT}/stats`);
|
|
772
|
+
console.log(`HTTP Rooms: http://localhost:${PORT}/rooms`);
|
|
773
|
+
console.log(`${'='.repeat(60)}\n`);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Graceful shutdown
|
|
777
|
+
process.on('SIGINT', () => {
|
|
778
|
+
console.log(`\n[${new Date().toISOString()}] Shutting down gracefully...`);
|
|
779
|
+
saveRoomState();
|
|
780
|
+
|
|
781
|
+
// Notify all clients
|
|
782
|
+
wss.clients.forEach(ws => {
|
|
783
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
784
|
+
ws.send(JSON.stringify({
|
|
785
|
+
type: 'server-shutdown',
|
|
786
|
+
message: 'Server is shutting down',
|
|
787
|
+
timestamp: new Date().toISOString()
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
wss.close(() => {
|
|
793
|
+
console.log(`[${new Date().toISOString()}] Server closed`);
|
|
794
|
+
process.exit(0);
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
start();
|
|
800
|
+
|
|
801
|
+
module.exports = { rooms, clients, Room, User, Client };
|