Spaces:
Sleeping
Sleeping
| 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<string, CollaborationSession>(); | |
| const projectRooms = new Map<string, Map<string, CollaborationSession[]>>(); | |
| // Server-authoritative block state per project per file | |
| const projectBlockState = new Map<string, Map<string, { xml: string; updatedBy: string; updatedAt: number }>>(); | |
| // Collaborator pointer positions per project | |
| const projectPointerPositions = new Map<string, Map<string, { x: number; y: number; username: string }>>(); | |
| // Collaborator block drag state per project | |
| const projectBlockDrags = new Map<string, Map<string, { blockId: string; x: number; y: number }>>(); | |
| 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<string, any>, | |
| visual: {} as Record<string, any>, | |
| blocksXml: {} as Record<string, string>, | |
| blockCode: {} as Record<string, string>, | |
| }; | |
| 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<string, { x: number; y: number; username: string }>()); | |
| } | |
| 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; | |
| } | |