import { Server as HttpServer } from 'http'; import { Server, Socket } from 'socket.io'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { config } from '../config'; import { getDatabase } from '../database'; import { encryptCombined, decryptCombined } from './encryption'; interface AuthPayload { userId: string; username: string; email: string; } interface CollaborationSession { userId: string; username: string; projectId: string; socketId: string; activeFileId: string | null; cursor: { line: number; ch: number } | null; selectedBlockId: string | null; selectedElementId: string | null; joinedAt: number; permission: 'view' | 'edit' | 'admin'; } const sessions = new Map(); const projectRooms = new Map>(); // Server-authoritative block state per project per file const projectBlockState = new Map>(); // Collaborator pointer positions per project const projectPointerPositions = new Map>(); // Collaborator block drag state per project const projectBlockDrags = new Map>(); let io: Server; // ─── Broadcast helper ─── function broadcastToRoom(projectId: string, event: string, data: any) { const room = `project:${projectId}`; io.to(room).emit(event, data); } // ─── Default project data (moved from routes/projects.ts) ─── function getDefaultProjectData(framework: string): any { const base = { files: [] as any[], blocks: {} as Record, visual: {} as Record, blocksXml: {} as Record, blockCode: {} as Record, }; switch (framework) { case 'web': return { ...base, files: [ { id: 'index.html', name: 'index.html', type: 'html', content: '' }, { id: 'styles.css', name: 'styles.css', type: 'css', content: '' }, { id: 'app.js', name: 'app.js', type: 'js', content: '' }, ], }; case 'electron': return { ...base, files: [ { id: 'main.ts', name: 'main.ts', type: 'typescript', content: '' }, { id: 'preload.ts', name: 'preload.ts', type: 'typescript', content: '' }, { id: 'index.html', name: 'index.html', type: 'html', content: '' }, { id: 'styles.css', name: 'styles.css', type: 'css', content: '' }, { id: 'renderer.ts', name: 'renderer.ts', type: 'typescript', content: '' }, { id: 'package.json', name: 'package.json', type: 'json', content: '' }, ], }; case 'maui': return { ...base, files: [ { id: 'MainPage.xaml', name: 'MainPage.xaml', type: 'xaml', content: '' }, { id: 'MainPage.xaml.cs', name: 'MainPage.xaml.cs', type: 'csharp', content: '' }, { id: 'App.xaml', name: 'App.xaml', type: 'xaml', content: '' }, { id: 'App.xaml.cs', name: 'App.xaml.cs', type: 'csharp', content: '' }, { id: 'MauiProgram.cs', name: 'MauiProgram.cs', type: 'csharp', content: '' }, { id: 'Models', name: 'Models', type: 'folder', children: [] }, { id: 'ViewModels', name: 'ViewModels', type: 'folder', children: [] }, { id: 'Services', name: 'Services', type: 'folder', children: [] }, ], }; case 'nodejs': return { ...base, files: [ { id: 'server.js', name: 'server.js', type: 'js', content: '' }, { id: 'package.json', name: 'package.json', type: 'json', content: '' }, { id: '.env', name: '.env', type: 'env', content: '' }, { id: 'routes', name: 'routes', type: 'folder', children: [] }, { id: 'models', name: 'models', type: 'folder', children: [] }, { id: 'middleware', name: 'middleware', type: 'folder', children: [] }, ], }; default: return base; } } export function initRealtime(httpServer: HttpServer): Server { io = new Server(httpServer, { cors: { origin: config.corsOrigin, credentials: true, methods: ['GET', 'POST'], }, pingInterval: 25000, pingTimeout: 20000, }); // Auth middleware io.use((socket, next) => { const token = socket.handshake.auth?.token || socket.handshake.query?.token; if (!token) { return next(new Error('Authentication required')); } try { const decoded = jwt.verify(token as string, config.jwtSecret) as { userId: string; email: string }; const db = getDatabase(); const user = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(decoded.userId) as any; if (!user) return next(new Error('User not found')); (socket as any).user = { userId: user.id, username: user.username, email: user.email }; next(); } catch { next(new Error('Invalid token')); } }); // ─── Dev-mode catch-all logger ─── if (config.nodeEnv === 'development') { io.on('connection', (socket: Socket) => { socket.onAny((event, ...args) => { const payloadSize = JSON.stringify(args).length; console.log(`[WS] ${event} from ${socket.id} (${payloadSize} bytes)`); }); }); } io.on('connection', (socket: Socket) => { const user = (socket as any).user as AuthPayload; // ═══════════════════════════════════════════════ // PROJECT ROOM JOINING // ═══════════════════════════════════════════════ socket.on('join_project', ({ projectId }: { projectId: string }) => { if (!projectId) return; const db = getDatabase(); const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(projectId) as any; if (!project) return; let permission: 'view' | 'edit' | 'admin' = 'view'; if (project.user_id === user.userId) { permission = 'admin'; } else { const collab = db.prepare( 'SELECT permission FROM project_collaborators WHERE project_id = ? AND user_id = ?' ).get(projectId, user.userId) as any; if (!collab) return; permission = collab.permission; } socket.join(`project:${projectId}`); const session: CollaborationSession = { userId: user.userId, username: user.username, projectId, socketId: socket.id, activeFileId: null, cursor: null, selectedBlockId: null, selectedElementId: null, joinedAt: Date.now(), permission, }; sessions.set(socket.id, session); if (!projectRooms.has(projectId)) { projectRooms.set(projectId, new Map()); } const room = projectRooms.get(projectId)!; if (!room.has(user.userId)) { room.set(user.userId, []); } room.get(user.userId)!.push(session); socket.to(`project:${projectId}`).emit('collaborator_joined', { userId: user.userId, username: user.username, permission, joinedAt: session.joinedAt, }); // Deduplicate: only send the most recent session per user const collaboratorList: any[] = []; for (const [uid, sessions] of room) { const latest = sessions.reduce((a, b) => a.joinedAt > b.joinedAt ? a : b); collaboratorList.push({ userId: uid, username: latest.username, permission: latest.permission, activeFileId: latest.activeFileId, cursor: latest.cursor, selectedBlockId: latest.selectedBlockId, selectedElementId: latest.selectedElementId, joinedAt: latest.joinedAt, }); } socket.emit('collaborator_list', { collaborators: collaboratorList }); // Send initial block state for this project const blockState = projectBlockState.get(projectId); console.log('[server:join_project] Sending initialBlocksXml for project:', projectId, 'blockState size:', blockState?.size, 'entries:'); if (blockState) { for (const [fid, entry] of blockState) { console.log('[server:join_project] file:', fid, 'xmlLength:', entry.xml?.length, 'updatedBy:', entry.updatedBy); } socket.emit('block_state_synced', { fileId: '', xml: '', updatedBy: '', initialBlocksXml: Object.fromEntries(blockState), }); } }); // ═══════════════════════════════════════════════ // LEAVE PROJECT // ═══════════════════════════════════════════════ socket.on('leave_project', ({ projectId }: { projectId: string }) => { leaveProject(socket, projectId); }); // ═══════════════════════════════════════════════ // FILE CHANGE // ═══════════════════════════════════════════════ socket.on('file_changed', (data: { projectId: string; fileId: string; content: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; socket.to(`project:${data.projectId}`).emit('file_updated', { fileId: data.fileId, content: data.content, updatedBy: user.userId, updatedByUsername: user.username, }); }); // ═══════════════════════════════════════════════ // ACTIVE FILE CHANGE // ═══════════════════════════════════════════════ socket.on('active_file_changed', (data: { projectId: string; fileId: string | null }) => { const session = sessions.get(socket.id); if (session) { session.activeFileId = data.fileId; } socket.to(`project:${data.projectId}`).emit('collaborator_active_file', { userId: user.userId, username: user.username, fileId: data.fileId, }); }); // ═══════════════════════════════════════════════ // CURSOR MOVE // ═══════════════════════════════════════════════ socket.on('cursor_move', (data: { projectId: string; fileId: string; cursor: { line: number; ch: number } }) => { const session = sessions.get(socket.id); if (session) { session.cursor = data.cursor; session.activeFileId = data.fileId; } socket.to(`project:${data.projectId}`).emit('collaborator_cursor', { userId: user.userId, username: user.username, fileId: data.fileId, cursor: data.cursor, }); }); // ═══════════════════════════════════════════════ // BLOCK SELECTION // ═══════════════════════════════════════════════ socket.on('block_selection_changed', (data: { projectId: string; blockId: string | null }) => { const session = sessions.get(socket.id); if (session) { session.selectedBlockId = data.blockId; } socket.to(`project:${data.projectId}`).emit('collaborator_block_selected', { userId: user.userId, username: user.username, blockId: data.blockId, }); }); // ═══════════════════════════════════════════════ // VISUAL ELEMENT HOVER // ═══════════════════════════════════════════════ socket.on('visual_element_hover', (data: { projectId: string; elementId: string | null }) => { const session = sessions.get(socket.id); if (session) { session.selectedElementId = data.elementId; } socket.to(`project:${data.projectId}`).emit('collaborator_element_hover', { userId: user.userId, username: user.username, elementId: data.elementId, }); }); // ═══════════════════════════════════════════════ // FILE ADDED // ═══════════════════════════════════════════════ socket.on('file_added', (data: { projectId: string; file: any; parentId?: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; socket.to(`project:${data.projectId}`).emit('file_added', { file: data.file, parentId: data.parentId, addedBy: user.username, }); }); // ═══════════════════════════════════════════════ // FILE RENAMED // ═══════════════════════════════════════════════ socket.on('file_renamed', (data: { projectId: string; fileId: string; name: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; socket.to(`project:${data.projectId}`).emit('file_renamed', { fileId: data.fileId, name: data.name, renamedBy: user.username, }); }); // ═══════════════════════════════════════════════ // FILE DELETED // ═══════════════════════════════════════════════ socket.on('file_deleted', (data: { projectId: string; fileId: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; socket.to(`project:${data.projectId}`).emit('file_deleted', { fileId: data.fileId, deletedBy: user.username, }); }); // ═══════════════════════════════════════════════ // VISUAL ELEMENT ADDED // ═══════════════════════════════════════════════ socket.on('visual_element_added', (data: { projectId: string; element: any; parentId?: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'visual_element_added', { element: data.element, parentId: data.parentId, }); }); // ═══════════════════════════════════════════════ // VISUAL ELEMENT UPDATED // ═══════════════════════════════════════════════ socket.on('visual_element_updated', (data: { projectId: string; elementId: string; updates: any }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'visual_element_updated', { elementId: data.elementId, updates: data.updates, }); }); // ═══════════════════════════════════════════════ // VISUAL ELEMENT REMOVED // ═══════════════════════════════════════════════ socket.on('visual_element_removed', (data: { projectId: string; elementId: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'visual_element_removed', { elementId: data.elementId, }); }); // ═══════════════════════════════════════════════ // VISUAL ELEMENTS REORDERED // ═══════════════════════════════════════════════ socket.on('visual_elements_reordered', (data: { projectId: string; elements: any[] }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'visual_elements_reordered', { elements: data.elements, }); }); // ═══════════════════════════════════════════════ // SAVE PROJECT // ═══════════════════════════════════════════════ socket.on('save_project', (data: { projectId: string; data: any }, callback?: (response: any) => void) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') { if (callback) callback({ error: 'Permission denied' }); return; } saveProjectData(session, data.projectId, data.data, callback); }); // ═══════════════════════════════════════════════ // POINTER MOVE (server tracks + broadcasts) // ═══════════════════════════════════════════════ socket.on('pointer_move', (data: { projectId: string; x: number; y: number }) => { const session = sessions.get(socket.id); if (!session) return; // Store pointer position if (!projectPointerPositions.has(data.projectId)) { projectPointerPositions.set(data.projectId, new Map()); } projectPointerPositions.get(data.projectId)!.set(session.userId, { x: data.x, y: data.y, username: session.username, }); // Broadcast to room socket.to(`project:${data.projectId}`).emit('collaborator_pointer_move', { userId: session.userId, username: session.username, x: data.x, y: data.y, }); }); // ═══════════════════════════════════════════════ // BLOCK DRAG (server tracks + broadcasts) // ═══════════════════════════════════════════════ socket.on('block_drag_start', (data: { projectId: string; blockId: string; x: number; y: number }) => { const session = sessions.get(socket.id); if (!session) return; if (!projectBlockDrags.has(data.projectId)) { projectBlockDrags.set(data.projectId, new Map()); } projectBlockDrags.get(data.projectId)!.set(session.userId, { blockId: data.blockId, x: data.x, y: data.y, }); socket.to(`project:${data.projectId}`).emit('collaborator_block_drag_start', { userId: session.userId, blockId: data.blockId, x: data.x, y: data.y, }); }); socket.on('block_drag_move', (data: { projectId: string; blockId: string; x: number; y: number }) => { const session = sessions.get(socket.id); if (!session) return; const drags = projectBlockDrags.get(data.projectId); if (drags) { drags.set(session.userId, { blockId: data.blockId, x: data.x, y: data.y }); } socket.to(`project:${data.projectId}`).emit('collaborator_block_drag_move', { userId: session.userId, blockId: data.blockId, x: data.x, y: data.y, }); }); socket.on('block_drag_end', (data: { projectId: string; blockId: string }) => { const session = sessions.get(socket.id); if (!session) return; const drags = projectBlockDrags.get(data.projectId); if (drags) { drags.delete(session.userId); } socket.to(`project:${data.projectId}`).emit('collaborator_block_drag_end', { userId: session.userId, blockId: data.blockId, }); }); socket.on('block_connect', (data: { projectId: string; childId: string; parentId: string; inputName?: string }) => { const session = sessions.get(socket.id); if (!session) return; socket.to(`project:${data.projectId}`).emit('collaborator_block_connect', { userId: session.userId, childId: data.childId, parentId: data.parentId, inputName: data.inputName, }); }); socket.on('block_change', (data: { projectId: string; blockId: string; name: string; value: any; element: string }) => { const session = sessions.get(socket.id); if (!session) return; socket.to(`project:${data.projectId}`).emit('collaborator_block_change', { userId: session.userId, blockId: data.blockId, name: data.name, value: data.value, element: data.element, }); }); socket.on('block_create_event', (data: { projectId: string; fileId: string; blockId: string; xml: string }) => { const session = sessions.get(socket.id); if (!session) return; socket.to(`project:${data.projectId}`).emit('collaborator_block_create_event', { userId: session.userId, fileId: data.fileId, blockId: data.blockId, xml: data.xml, }); }); // ═══════════════════════════════════════════════ // BLOCK STATE SYNC (server-authoritative) // ═══════════════════════════════════════════════ socket.on('block_state_sync', (data: { projectId: string; fileId: string; xml: string }, callback?: (response: any) => void) => { const session = sessions.get(socket.id); if (!session) { console.log('[server:block_state_sync] No session for socket', socket.id); if (callback) callback({ error: 'Not joined' }); return; } if (session.permission === 'view') { console.log('[server:block_state_sync] Permission denied for user', session.userId); if (callback) callback({ error: 'Permission denied' }); return; } console.log('[server:block_state_sync] Storing blocks for project:', data.projectId, 'file:', data.fileId, 'xmlLength:', data.xml?.length, 'user:', session.userId); if (!projectBlockState.has(data.projectId)) { projectBlockState.set(data.projectId, new Map()); } projectBlockState.get(data.projectId)!.set(data.fileId, { xml: data.xml, updatedBy: session.userId, updatedAt: Date.now(), }); console.log('[server:block_state_sync] projectBlockState now has', projectBlockState.get(data.projectId)!.size, 'files'); // Broadcast to ALL other clients in the room socket.to(`project:${data.projectId}`).emit('block_state_synced', { fileId: data.fileId, xml: data.xml, updatedBy: session.userId, }); if (callback) callback({ success: true }); }); socket.on('request_initial_block_state', (data: { projectId: string }, callback?: (response: any) => void) => { const session = sessions.get(socket.id); if (!session) { if (callback) callback({ error: 'Not joined' }); return; } const state = projectBlockState.get(data.projectId); if (callback) callback({ blocksXml: state ? Object.fromEntries(state) : {} }); }); // ═══════════════════════════════════════════════ // TEXT FIELD CURSOR (stub — Phase F) // ═══════════════════════════════════════════════ socket.on('text_field_focus', (data: { projectId: string; fieldId: string; fileId: string; cursorPosition: number }) => { const session = sessions.get(socket.id); if (!session) return; socket.to(`project:${data.projectId}`).emit('text_field_focus', { userId: session.userId, username: session.username, fieldId: data.fieldId, fileId: data.fileId, cursorPosition: data.cursorPosition, active: true, }); }); socket.on('text_field_cursor', (data: { projectId: string; fieldId: string; cursorPosition: number }) => { const session = sessions.get(socket.id); if (!session) return; socket.to(`project:${data.projectId}`).emit('text_field_cursor', { userId: session.userId, fieldId: data.fieldId, cursorPosition: data.cursorPosition, active: true, }); }); socket.on('text_field_blur', (data: { projectId: string; fieldId: string }) => { const session = sessions.get(socket.id); if (!session) return; socket.to(`project:${data.projectId}`).emit('text_field_blur', { userId: session.userId, fieldId: data.fieldId, }); }); // ═══════════════════════════════════════════════ // CSS RULES (stub — Phase E) // ═══════════════════════════════════════════════ socket.on('css_rule_added', (data: { projectId: string; rule: any }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'css_rule_added', { rule: data.rule }); }); socket.on('css_rule_updated', (data: { projectId: string; ruleId: string; updates: any }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'css_rule_updated', { ruleId: data.ruleId, updates: data.updates }); }); socket.on('css_rule_removed', (data: { projectId: string; ruleId: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'css_rule_removed', { ruleId: data.ruleId }); }); socket.on('css_rule_reordered', (data: { projectId: string; ruleIds: string[] }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'css_rule_reordered', { ruleIds: data.ruleIds }); }); socket.on('css_prop_added', (data: { projectId: string; ruleId: string; property: any }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'css_prop_added', { ruleId: data.ruleId, property: data.property }); }); socket.on('css_prop_updated', (data: { projectId: string; ruleId: string; propertyId: string; updates: any }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'css_prop_updated', { ruleId: data.ruleId, propertyId: data.propertyId, updates: data.updates }); }); socket.on('css_prop_removed', (data: { projectId: string; ruleId: string; propertyId: string }) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') return; broadcastToRoom(data.projectId, 'css_prop_removed', { ruleId: data.ruleId, propertyId: data.propertyId }); }); // ═══════════════════════════════════════════════ // ELEMENT REGISTRY / ID (stub — Phase B) // ═══════════════════════════════════════════════ socket.on('element_registry_updated', (data: { projectId: string; registry: any }) => { const session = sessions.get(socket.id); if (!session) return; broadcastToRoom(data.projectId, 'element_registry_updated', { registry: data.registry }); }); socket.on('element_id_changed', (data: { projectId: string; elementId: string; newId: string; fileId: string }) => { const session = sessions.get(socket.id); if (!session) return; broadcastToRoom(data.projectId, 'element_id_changed', { elementId: data.elementId, newId: data.newId, fileId: data.fileId, }); }); // ═══════════════════════════════════════════════ // PROJECT CREATE (moved from REST) // ═══════════════════════════════════════════════ socket.on('project_create', (data: { name: string; framework: string; description?: string }, callback?: (response: any) => void) => { try { const db = getDatabase(); const defaultData = getDefaultProjectData(data.framework); const id = uuidv4(); const now = Date.now(); const combined = encryptCombined(JSON.stringify(defaultData)); db.prepare(` INSERT INTO projects (id, user_id, name, framework, description, encrypted_data, encryption_iv, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, user.userId, data.name, data.framework, data.description || '', combined, '', now, now); if (callback) { callback({ project: { id, name: data.name, framework: data.framework, description: data.description || '', data: defaultData, created_at: now, updated_at: now, }, }); } } catch (error: any) { console.error('Create project error:', error); if (callback) callback({ error: 'Internal server error' }); } }); // ═══════════════════════════════════════════════ // PROJECT LIST (moved from REST) // ═══════════════════════════════════════════════ socket.on('project_list', (_data: any, callback?: (response: any) => void) => { try { const db = getDatabase(); const projects = db.prepare( 'SELECT id, name, framework, description, created_at, updated_at FROM projects WHERE user_id = ? ORDER BY updated_at DESC' ).all(user.userId); if (callback) callback({ projects }); } catch (error: any) { console.error('List projects error:', error); if (callback) callback({ error: 'Internal server error' }); } }); // ═══════════════════════════════════════════════ // PROJECT LOAD (moved from REST) // ═══════════════════════════════════════════════ socket.on('project_load', (data: { projectId: string }, callback?: (response: any) => void) => { try { const db = getDatabase(); let project = db.prepare( 'SELECT * FROM projects WHERE id = ? AND user_id = ?' ).get(data.projectId, user.userId) as any; if (!project) { const collab = db.prepare( 'SELECT p.* FROM projects p JOIN project_collaborators c ON p.id = c.project_id WHERE p.id = ? AND c.user_id = ?' ).get(data.projectId, user.userId) as any; if (!collab) { if (callback) callback({ error: 'Project not found' }); return; } project = collab; } const decrypted = decryptCombined(project.encrypted_data); const projectData = JSON.parse(decrypted); if (callback) { callback({ project: { id: project.id, name: project.name, framework: project.framework, description: project.description, data: projectData, created_at: project.created_at, updated_at: project.updated_at, }, }); } } catch (error: any) { console.error('Load project error:', error); if (callback) callback({ error: 'Failed to decrypt project data' }); } }); // ═══════════════════════════════════════════════ // PROJECT DELETE (moved from REST) // ═══════════════════════════════════════════════ socket.on('project_delete', (data: { projectId: string }, callback?: (response: any) => void) => { try { const db = getDatabase(); const project = db.prepare('SELECT user_id FROM projects WHERE id = ?').get(data.projectId) as any; if (!project) { if (callback) callback({ error: 'Project not found' }); return; } if (project.user_id !== user.userId) { if (callback) callback({ error: 'Only the owner can delete a project' }); return; } db.prepare('DELETE FROM projects WHERE id = ?').run(data.projectId); // Kick all sockets from the project room broadcastToRoom(data.projectId, 'project_deleted', { projectId: data.projectId }); if (callback) callback({ message: 'Project deleted successfully' }); } catch (error: any) { console.error('Delete project error:', error); if (callback) callback({ error: 'Internal server error' }); } }); // ═══════════════════════════════════════════════ // PROJECT UPDATE // ═══════════════════════════════════════════════ socket.on('project_update', (data: { projectId: string; updates: any }, callback?: (response: any) => void) => { try { const db = getDatabase(); let project = db.prepare( 'SELECT * FROM projects WHERE id = ? AND user_id = ?' ).get(data.projectId, user.userId) as any; let permission: string | null = 'admin'; if (!project) { const collab = db.prepare( "SELECT p.*, c.permission FROM projects p JOIN project_collaborators c ON p.id = c.project_id WHERE p.id = ? AND c.user_id = ?" ).get(data.projectId, user.userId) as any; if (!collab || collab.permission === 'view') { if (callback) callback({ error: 'Permission denied' }); return; } project = collab; permission = collab.permission; } const now = Date.now(); let encryptedData = project.encrypted_data; let encryptedIv = project.encryption_iv; if (data.updates.data) { encryptedData = encryptCombined(JSON.stringify(data.updates.data)); encryptedIv = ''; } db.prepare(` UPDATE projects SET name = ?, description = ?, encrypted_data = ?, encryption_iv = ?, updated_at = ? WHERE id = ? `).run( data.updates.name || project.name, data.updates.description ?? project.description, encryptedData, encryptedIv, now, data.projectId ); if (callback) callback({ message: 'Project updated', updated_at: now }); } catch (error: any) { console.error('Update project error:', error); if (callback) callback({ error: 'Internal server error' }); } }); // ═══════════════════════════════════════════════ // PROJECT SYNC (sync to /data storage) // ═══════════════════════════════════════════════ socket.on('project_sync', async (data: { projectId: string }, callback?: (response: any) => void) => { try { const db = getDatabase(); const project = db.prepare( 'SELECT * FROM projects WHERE id = ? AND user_id = ?' ).get(data.projectId, user.userId) as any; if (!project) { if (callback) callback({ error: 'Project not found' }); return; } const { uploadToStorage } = await import('./storage'); const result = await uploadToStorage( `projects/${user.userId}/${data.projectId}.enc`, Buffer.from(project.encrypted_data) ); if (result.success) { if (callback) callback({ message: 'Project synced to storage' }); } else { if (callback) callback({ error: 'Sync failed', details: result.message }); } } catch (error: any) { console.error('Sync project error:', error); if (callback) callback({ error: 'Sync failed' }); } }); // ═══════════════════════════════════════════════ // VERSION (stub — Phase G) // ═══════════════════════════════════════════════ socket.on('save_version', (data: { projectId: string; diffs: any; message: string; parentId?: string; type?: string }, callback?: (response: any) => void) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') { if (callback) callback({ error: 'Permission denied' }); return; } try { const db = getDatabase(); const id = uuidv4(); const now = Date.now(); db.prepare(` INSERT INTO versions (id, project_id, user_id, username, timestamp, parent_id, type, message, diffs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(id, data.projectId, session.userId, session.username, now, data.parentId || null, data.type || 'save', data.message || '', JSON.stringify(data.diffs)); broadcastToRoom(data.projectId, 'version_saved', { id, projectId: data.projectId, userId: session.userId, username: session.username, timestamp: now, parentId: data.parentId, type: data.type, message: data.message, }); if (callback) callback({ success: true, versionId: id }); } catch (error: any) { console.error('Save version error:', error); if (callback) callback({ error: 'Save failed' }); } }); socket.on('get_versions', (data: { projectId: string }, callback?: (response: any) => void) => { try { const db = getDatabase(); const versions = db.prepare( 'SELECT id, project_id, user_id, username, timestamp, parent_id, type, message FROM versions WHERE project_id = ? ORDER BY timestamp ASC' ).all(data.projectId); if (callback) callback({ versions }); } catch (error: any) { console.error('Get versions error:', error); if (callback) callback({ error: 'Internal server error' }); } }); socket.on('get_version_snapshot', (data: { projectId: string; versionId: string }, callback?: (response: any) => void) => { try { const db = getDatabase(); const allVersions = db.prepare( 'SELECT * FROM versions WHERE project_id = ? ORDER BY timestamp ASC' ).all(data.projectId) as any[]; // Find the target version's index const targetIdx = allVersions.findIndex((v: any) => v.id === data.versionId); if (targetIdx === -1) { if (callback) callback({ error: 'Version not found' }); return; } // Reconstruct by applying diffs from version 0 to target const project = db.prepare('SELECT encrypted_data FROM projects WHERE id = ?').get(data.projectId) as any; if (!project) { if (callback) callback({ error: 'Project not found' }); return; } const decrypted = decryptCombined(project.encrypted_data); let currentData = JSON.parse(decrypted); for (let i = 0; i <= targetIdx; i++) { const version = allVersions[i]; const diffs = JSON.parse(version.diffs); currentData = applyDiffs(currentData, diffs); } if (callback) callback({ project: currentData }); } catch (error: any) { console.error('Get version snapshot error:', error); if (callback) callback({ error: 'Failed to reconstruct version' }); } }); socket.on('restore_version', (data: { projectId: string; versionId: string }, callback?: (response: any) => void) => { const session = sessions.get(socket.id); if (!session || session.permission === 'view') { if (callback) callback({ error: 'Permission denied' }); return; } try { const db = getDatabase(); const allVersions = db.prepare( 'SELECT * FROM versions WHERE project_id = ? ORDER BY timestamp ASC' ).all(data.projectId) as any[]; const targetIdx = allVersions.findIndex((v: any) => v.id === data.versionId); if (targetIdx === -1) { if (callback) callback({ error: 'Version not found' }); return; } const project = db.prepare('SELECT encrypted_data FROM projects WHERE id = ?').get(data.projectId) as any; let currentData = project ? JSON.parse(decryptCombined(project.encrypted_data)) : null; for (let i = 0; i <= targetIdx; i++) { const version = allVersions[i]; const diffs = JSON.parse(version.diffs); currentData = applyDiffs(currentData, diffs); } // Overwrite project with restored data const encryptedData = encryptCombined(JSON.stringify(currentData)); const now = Date.now(); db.prepare( 'UPDATE projects SET encrypted_data = ?, encryption_iv = ?, updated_at = ? WHERE id = ?' ).run(encryptedData, '', now, data.projectId); // Record restore in versions const restoreId = uuidv4(); const restoreDiffs = JSON.stringify([{ fileId: 'metadata', op: 'project_meta_change', newValue: { restoredFrom: data.versionId } }]); db.prepare(` INSERT INTO versions (id, project_id, user_id, username, timestamp, parent_id, type, message, diffs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(restoreId, data.projectId, session.userId, session.username, now, data.versionId, 'restore', `Restored version ${data.versionId}`, restoreDiffs); // Broadcast new project data + version to room broadcastToRoom(data.projectId, 'project_loaded', { project: currentData }); broadcastToRoom(data.projectId, 'version_saved', { id: restoreId, projectId: data.projectId, userId: session.userId, username: session.username, timestamp: now, parentId: data.versionId, type: 'restore', message: `Restored version`, }); if (callback) callback({ success: true }); } catch (error: any) { console.error('Restore version error:', error); if (callback) callback({ error: 'Restore failed' }); } }); // ═══════════════════════════════════════════════ // DISCONNECT // ═══════════════════════════════════════════════ socket.on('disconnect', () => { const session = sessions.get(socket.id); if (session) { leaveProject(socket, session.projectId); sessions.delete(socket.id); } }); }); return io; } // ─── Helpers ─── function leaveProject(socket: Socket, projectId: string) { const room = projectRooms.get(projectId); const userId = (socket as any).user?.userId; let userHasRemainingSessions = false; if (room) { const userSessions = room.get(userId); if (userSessions) { const idx = userSessions.findIndex(s => s.socketId === socket.id); if (idx !== -1) userSessions.splice(idx, 1); userHasRemainingSessions = userSessions.length > 0; if (!userHasRemainingSessions) room.delete(userId); } if (room.size === 0) projectRooms.delete(projectId); } socket.leave(`project:${projectId}`); sessions.delete(socket.id); // Only broadcast leave if user has no remaining sessions (other tabs still open) if (!userHasRemainingSessions) { const pointers = projectPointerPositions.get(projectId); if (pointers) pointers.delete(userId); const drags = projectBlockDrags.get(projectId); if (drags) drags.delete(userId); socket.to(`project:${projectId}`).emit('collaborator_left', { userId }); } } function saveProjectData(session: CollaborationSession, projectId: string, data: any, callback?: (response: any) => void) { try { const db = getDatabase(); const encryptedData = encryptCombined(JSON.stringify(data)); const now = Date.now(); db.prepare( 'UPDATE projects SET encrypted_data = ?, encryption_iv = ?, updated_at = ? WHERE id = ?' ).run(encryptedData, '', now, projectId); io.to(`project:${projectId}`).emit('project_saved', { updatedAt: now, savedBy: session.username, }); if (callback) callback({ success: true, updatedAt: now }); } catch (error: any) { console.error('Save project error:', error); if (callback) callback({ error: 'Save failed' }); } } function applyDiffs(currentData: any, diffs: any[]): any { if (!diffs || !Array.isArray(diffs)) return currentData; const data = JSON.parse(JSON.stringify(currentData)); // deep clone for (const diff of diffs) { switch (diff.op) { case 'content_change': { if (!data.files) break; const file = data.files.find((f: any) => f.id === diff.fileId); if (file) file.content = diff.newValue; break; } case 'file_add': { if (!data.files) data.files = []; data.files.push({ id: diff.fileId, ...diff.newValue }); break; } case 'file_remove': { if (!data.files) break; data.files = data.files.filter((f: any) => f.id !== diff.fileId); break; } case 'file_rename': { if (!data.files) break; const file = data.files.find((f: any) => f.id === diff.fileId); if (file) { file.name = diff.newValue; file.id = diff.newValue; } break; } case 'project_meta_change': { if (diff.newValue.name) data.name = diff.newValue.name; if (diff.newValue.description) data.description = diff.newValue.description; break; } } } return data; } export function getIO(): Server { return io; }