/** * WebSocket Server for Yjs CRDT Sync * Node.js server using y-websocket adapter */ import { WebSocketServer } from 'ws'; import { applyUpdate, encodeStateAsUpdate, Doc } from 'yjs'; type DocMessage = { type: 'sync'; args: [Uint8Array]; }; type SyncStep1Message = { type: 'sync'; args: [Uint8Array]; }; type SyncStep2Message = { type: 'sync'; args: [Uint8Array, Uint8Array]; }; type UpdateMessage = { type: 'update'; args: [Uint8Array]; }; export type Message = DocMessage | SyncStep1Message | SyncStep2Message | UpdateMessage; // Store document states in memory (in production, use Redis or persistent storage) const docs: Map = new Map(); const clients: Map> = new Map(); interface WebSocketWithDoc extends WebSocket { docName?: string; } /** * Initialize the WebSocket server */ export function createWebSocketServer( port: number, options: { authMiddleware?: (token: string) => Promise<{ userId: string; projectId: string }>; } = {} ): WebSocketServer { const { authMiddleware } = options; const server = new WebSocketServer({ port }); server.on('connection', async (ws: WebSocketWithDoc, req) => { // Extract document name from URL query params const url = new URL(req.url || '', `http://${req.headers.host}`); const docName = url.pathname.split('/').pop() || 'default'; // Validate origin to prevent WebSocket CSRF const origin = req.headers.origin; if (authMiddleware && origin) { const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000']; if (!allowedOrigins.includes(origin)) { console.warn(`Origin validation failed: ${origin}`); ws.close(4002); return; } } // Authenticate connection if auth middleware provided const token = url.searchParams.get('token'); let userId: string | undefined; let projectId: string | undefined; if (authMiddleware) { if (!token) { console.warn('Authentication required but no token provided'); ws.send(JSON.stringify({ type: 'error', message: 'Authentication required' })); ws.close(4001); return; } try { const auth = await authMiddleware(token); userId = auth.userId; projectId = auth.projectId; console.log(`WebSocket connection authenticated: ${userId} for ${docName}`); } catch (error) { console.error('Authentication failed:', error); ws.send(JSON.stringify({ type: 'error', message: 'Authentication failed' })); ws.close(); return; } } ws.docName = docName; // Initialize document state if not exists if (!docs.has(docName)) { docs.set(docName, new Uint8Array()); clients.set(docName, new Set()); } // Add client to the document's client set clients.get(docName)!.add(ws); // Send initial sync const initialState = docs.get(docName)!; ws.send(encodeSyncStep1(initialState)); // Handle incoming messages ws.on('message', (data: Buffer | ArrayBuffer) => { handleMessage(ws, docName, data, userId, projectId); }); // Handle disconnection ws.on('close', () => { clients.get(docName)?.delete(ws); console.log(`Client disconnected from ${docName}. Remaining clients: ${clients.get(docName)?.size || 0}`); }); console.log(`Client connected to ${docName}${userId ? ` (user: ${userId})` : ''}`); }); return server; } /** * Encode sync step 1 (send document state as binary) */ function encodeSyncStep1(state: Uint8Array): Uint8Array { return state; } /** * Handle incoming WebSocket message */ function handleMessage( ws: WebSocketWithDoc, docName: string, data: Buffer | ArrayBuffer, userId?: string, projectId?: string ) { try { const message = JSON.parse(data.toString()) as Message; switch (message.type) { case 'sync': handleSync(ws, docName, message, userId, projectId); break; case 'update': handleUpdate(ws, docName, message, userId); break; } } catch (error) { console.error('Error handling message:', error); ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); } } /** * Handle sync message */ function handleSync(ws: WebSocketWithDoc, docName: string, message: SyncStep1Message | SyncStep2Message) { const currentState = docs.get(docName) || new Uint8Array(); if (message.args.length === 1) { // Sync step 1: client sends its state, server responds with full state const clientState = message.args[0]; // Send full document state to client const response = encodeSyncStep1(currentState); ws.send(response); } else if (message.args.length === 2) { // Sync step 2: client sends its state, server sends missing updates const clientState = message.args[0]; // Calculate missing updates (simplified - in production use Yjs protocol) const missingUpdates = currentState; const response = JSON.stringify({ type: 'sync', args: [Array.from(missingUpdates)], }); ws.send(new TextEncoder().encode(response)); } } /** * Handle update message */ function handleUpdate(ws: WebSocketWithDoc, docName: string, message: UpdateMessage) { const update = message.args[0]; let currentState = docs.get(docName) || new Uint8Array(); // Apply update to document state try { const doc = new Doc(); applyUpdate(doc, update); currentState = encodeStateAsUpdate(doc); docs.set(docName, currentState); console.log(`Update applied to ${docName}. Size: ${currentState.length} bytes`); // Broadcast update to all other clients const broadcastMsg = { type: 'update', args: [Array.from(update)], }; clients.get(docName)?.forEach(client => { if (client !== ws && client.readyState === WebSocket.OPEN) { client.send(new TextEncoder().encode(JSON.stringify(broadcastMsg))); } }); } catch (error) { console.error('Error applying update:', error); ws.send(JSON.stringify({ type: 'error', message: 'Failed to apply update' })); } } /** * Get document stats (for monitoring) */ export function getDocStats(): Record { const stats: Record = {}; docs.forEach((state, docName) => { stats[docName] = { clientCount: clients.get(docName)?.size || 0, stateSize: state.length, }; }); return stats; }