UNPKG

recoder-code

Version:

Complete AI-powered development platform with ML model training, plugin registry, real-time collaboration, monitoring, infrastructure automation, and enterprise deployment capabilities

786 lines (785 loc) 33 kB
"use strict"; /** * Real-time Collaboration Service * Manages collaborative editing sessions using operational transforms */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CollaborationService = void 0; const redis_adapter_1 = require("@socket.io/redis-adapter"); const uuid_1 = require("uuid"); const operational_transform_1 = require("../operational-transform"); const SessionManager_1 = require("./SessionManager"); const DocumentManager_1 = require("./DocumentManager"); const AuthService_1 = require("./AuthService"); const AwarenessService_1 = require("./AwarenessService"); const logger_1 = __importDefault(require("../utils/logger")); class CollaborationService { constructor(io, redis) { this.sessions = new Map(); this.documentStates = new Map(); this.selectionManagers = new Map(); this.io = io; this.redis = redis; this.sessionManager = new SessionManager_1.SessionManager(io); this.documentManager = new DocumentManager_1.DocumentManager(); this.authService = new AuthService_1.AuthService(); this.awarenessService = new AwarenessService_1.AwarenessService(io); this.setupSocketHandlers(); this.setupRedisAdapter(); } setupRedisAdapter() { const pubClient = this.redis; const subClient = this.redis.duplicate(); this.io.adapter((0, redis_adapter_1.createAdapter)(pubClient, subClient)); logger_1.default.info('Redis adapter configured for Socket.IO'); } setupSocketHandlers() { this.io.on('connection', (socket) => { logger_1.default.info(`Client connected: ${socket.id}`); // Authenticate the connection socket.on('authenticate', (data) => this.handleAuthentication(socket, data)); // Session management socket.on('join-session', (data) => this.handleJoinSession(socket, data)); socket.on('leave-session', (data) => this.handleLeaveSession(socket, data)); socket.on('create-session', (data) => this.handleCreateSession(socket, data)); // Document operations socket.on('operation', (data) => this.handleOperation(socket, data)); socket.on('get-document', (data) => this.handleGetDocument(socket, data)); socket.on('save-document', (data) => this.handleSaveDocument(socket, data)); // Cursor and selection socket.on('cursor-position', (data) => this.handleCursorPosition(socket, data)); socket.on('selection-change', (data) => this.handleSelectionChange(socket, data)); // Chat socket.on('chat-message', (data) => this.handleChatMessage(socket, data)); // Awareness socket.on('request-awareness', (data) => this.handleRequestAwareness(socket, data)); // Disconnection socket.on('disconnect', () => this.handleDisconnection(socket)); // Reconnection and recovery socket.on('reconnect-session', (data) => this.handleReconnectSession(socket, data)); socket.on('get-missed-operations', (data) => this.handleGetMissedOperations(socket, data)); // Error handling socket.on('error', (error) => { logger_1.default.error(`Socket error for ${socket.id}:`, error); }); }); } async handleAuthentication(socket, data) { try { const { token } = data; const authResult = await this.authService.authenticateToken(token); const user = authResult.user; if (!user) { socket.emit('auth-error', { message: 'Invalid token' }); return; } socket.data.user = user; socket.emit('authenticated', { user: { id: user.id, username: user.username } }); logger_1.default.info(`User authenticated: ${user.username} (${socket.id})`); } catch (error) { logger_1.default.error('Authentication error:', error); socket.emit('auth-error', { message: 'Authentication failed' }); } } async handleCreateSession(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { name, settings } = data; const sessionId = (0, uuid_1.v4)(); const session = { id: sessionId, name: name || `Session ${sessionId.slice(0, 8)}`, ownerId: user.id, participants: new Map(), documents: new Map(), settings: { maxParticipants: settings?.maxParticipants || 10, allowAnonymous: settings?.allowAnonymous || false, requireApproval: settings?.requireApproval || false, enableVoiceChat: settings?.enableVoiceChat || false, enableVideoChat: settings?.enableVideoChat || false, autoSave: settings?.autoSave !== false, saveInterval: settings?.saveInterval || 5 }, createdAt: new Date(), lastActivity: new Date(), status: 'active' }; // Add owner as first participant const ownerParticipant = { id: user.id, username: user.username, displayName: user.displayName || user.username, avatar: user.avatar, permissions: { read: true, write: true, comment: true, invite: true, manage: true }, isOnline: true, joinedAt: new Date(), lastSeen: new Date() }; session.participants.set(user.id, ownerParticipant); this.sessions.set(sessionId, session); // Session automatically managed by SessionManager socket.emit('session-created', { sessionId, session: this.serializeSession(session) }); logger_1.default.info(`Session created: ${sessionId} by ${user.username}`); } catch (error) { logger_1.default.error('Create session error:', error); socket.emit('error', { message: 'Failed to create session' }); } } async handleJoinSession(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { sessionId, inviteCode } = data; let session = this.sessions.get(sessionId); if (!session) { // Try to load from SessionManager const sessionData = this.sessionManager.getSession(sessionId); if (sessionData) { // Convert SessionManager session to CollaborativeSession session = { id: sessionData.id, name: `Session ${sessionData.id}`, ownerId: Array.from(sessionData.users.values())[0]?.userId || '', participants: new Map(), documents: new Map(), settings: { maxParticipants: 10, allowAnonymous: false, requireApproval: false, enableVoiceChat: false, enableVideoChat: false, autoSave: true, saveInterval: 5 }, createdAt: sessionData.createdAt, lastActivity: sessionData.lastActivity, status: 'active' }; this.sessions.set(sessionId, session); } } if (!session) { socket.emit('error', { message: 'Session not found' }); return; } if (session.status !== 'active') { socket.emit('error', { message: 'Session is not active' }); return; } // Check if user is already a participant let participant = session.participants.get(user.id); if (!participant) { // Check session limits if (session.participants.size >= session.settings.maxParticipants) { socket.emit('error', { message: 'Session is full' }); return; } // Create new participant participant = { id: user.id, username: user.username, displayName: user.displayName || user.username, avatar: user.avatar, permissions: { read: true, write: true, comment: true, invite: false, manage: false }, isOnline: true, joinedAt: new Date(), lastSeen: new Date() }; session.participants.set(user.id, participant); } else { // Update existing participant participant.isOnline = true; participant.lastSeen = new Date(); } // Join socket rooms socket.join(sessionId); socket.data.sessionId = sessionId; // Update session session.lastActivity = new Date(); // Session automatically persisted // Notify participant of successful join socket.emit('session-joined', { sessionId, session: this.serializeSession(session), participant: this.serializeParticipant(participant) }); // Notify other participants socket.to(sessionId).emit('participant-joined', { participant: this.serializeParticipant(participant) }); // Update awareness this.awarenessService.updatePresence(user.id, sessionId, sessionId, socket.id, { username: user.username, status: 'active' }); this.broadcastAwareness(sessionId); logger_1.default.info(`User ${user.username} joined session ${sessionId}`); } catch (error) { logger_1.default.error('Join session error:', error); socket.emit('error', { message: 'Failed to join session' }); } } async handleLeaveSession(socket, data) { try { const user = socket.data.user; const sessionId = data.sessionId || socket.data.sessionId; if (!user || !sessionId) { return; } const session = this.sessions.get(sessionId); if (!session) { return; } const participant = session.participants.get(user.id); if (participant) { participant.isOnline = false; participant.lastSeen = new Date(); // Leave socket room socket.leave(sessionId); delete socket.data.sessionId; // Notify other participants socket.to(sessionId).emit('participant-left', { participantId: user.id }); // Update awareness this.awarenessService.removeUser(user.id); this.broadcastAwareness(sessionId); logger_1.default.info(`User ${user.username} left session ${sessionId}`); } } catch (error) { logger_1.default.error('Leave session error:', error); } } async handleOperation(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { sessionId, documentId, operation, baseRevision } = data; // Validate input data if (!sessionId || !documentId || !operation) { socket.emit('error', { message: 'Missing required operation data' }); return; } if (typeof baseRevision !== 'number' || baseRevision < 0) { socket.emit('error', { message: 'Invalid base revision' }); return; } // Validate session access const session = this.sessions.get(sessionId); if (!session) { socket.emit('error', { message: 'Session not found' }); return; } const participant = session.participants.get(user.id); if (!participant || !participant.permissions.write) { socket.emit('error', { message: 'No write permission' }); return; } // Get or create document state const documentKey = `${sessionId}:${documentId}`; let documentState = this.documentStates.get(documentKey); if (!documentState) { const docData = this.documentManager.getDocument(documentKey); const content = docData?.content || ''; documentState = new operational_transform_1.DocumentState(content); this.documentStates.set(documentKey, documentState); } // Parse and apply operation const delta = operational_transform_1.Delta.fromJSON(operation); delta.meta = { author: user.id, timestamp: Date.now(), sessionId, documentId, clientId: socket.id, revision: baseRevision }; const result = documentState.applyConcurrentOperation(delta, baseRevision, user.id); if (!result.success) { socket.emit('operation-rejected', { error: result.error, currentRevision: result.newRevision }); return; } // Document automatically persisted in DocumentManager // Transform selections const selectionManager = this.getSelectionManager(documentKey); if (result.transformedOperation) { selectionManager.transformSelections(result.transformedOperation); } // Broadcast operation to other participants socket.to(sessionId).emit('operation', { type: 'operation', sessionId, documentId, operation: result.transformedOperation ? (0, operational_transform_1.textOperationToJSON)(result.transformedOperation) : null, revision: result.newRevision, author: user.id, timestamp: Date.now() }); // Confirm to sender socket.emit('operation-ack', { revision: result.newRevision, operation: result.transformedOperation ? (0, operational_transform_1.textOperationToJSON)(result.transformedOperation) : null }); // Update session activity session.lastActivity = new Date(); logger_1.default.debug(`Operation applied: ${documentKey} rev ${result.newRevision}`); } catch (error) { logger_1.default.error('Operation error:', error); socket.emit('error', { message: 'Failed to apply operation' }); } } async handleGetDocument(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { sessionId, documentId } = data; // Validate session access const session = this.sessions.get(sessionId); if (!session) { socket.emit('error', { message: 'Session not found' }); return; } const participant = session.participants.get(user.id); if (!participant || !participant.permissions.read) { socket.emit('error', { message: 'No read permission' }); return; } const documentKey = `${sessionId}:${documentId}`; let documentState = this.documentStates.get(documentKey); if (!documentState) { const docData = this.documentManager.getDocument(documentKey); const content = docData?.content || ''; documentState = new operational_transform_1.DocumentState(content); this.documentStates.set(documentKey, documentState); } // Get current selections const selectionManager = this.getSelectionManager(documentKey); const selections = selectionManager.getSelections(); socket.emit('document-content', { sessionId, documentId, content: documentState.getContent(), revision: documentState.getRevision(), selections }); } catch (error) { logger_1.default.error('Get document error:', error); socket.emit('error', { message: 'Failed to get document' }); } } async handleSaveDocument(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { sessionId, documentId, content } = data; // Validate session access const session = this.sessions.get(sessionId); if (!session) { socket.emit('error', { message: 'Session not found' }); return; } const participant = session.participants.get(user.id); if (!participant || !participant.permissions.write) { socket.emit('error', { message: 'No write permission' }); return; } const documentKey = `${sessionId}:${documentId}`; // Document automatically persisted // Update document state const documentState = new operational_transform_1.DocumentState(content); this.documentStates.set(documentKey, documentState); socket.emit('document-saved', { sessionId, documentId, timestamp: Date.now() }); // Notify other participants socket.to(sessionId).emit('document-updated', { sessionId, documentId, author: user.id, timestamp: Date.now() }); logger_1.default.info(`Document saved: ${documentKey} by ${user.username}`); } catch (error) { logger_1.default.error('Save document error:', error); socket.emit('error', { message: 'Failed to save document' }); } } async handleCursorPosition(socket, data) { try { const user = socket.data.user; if (!user) return; const { sessionId, documentId, cursor } = data; // Update cursor in awareness this.awarenessService.updateCursor(user.id, cursor); // Broadcast to other participants socket.to(sessionId).emit('cursor-position', { type: 'cursor', sessionId, documentId, cursor, author: user.id }); } catch (error) { logger_1.default.error('Cursor position error:', error); } } async handleSelectionChange(socket, data) { try { const user = socket.data.user; if (!user) return; const { sessionId, documentId, selection } = data; // Update selection const documentKey = `${sessionId}:${documentId}`; const selectionManager = this.getSelectionManager(documentKey); selectionManager.updateSelection(user.id, selection); // Broadcast to other participants socket.to(sessionId).emit('selection-change', { type: 'selection', sessionId, documentId, selection, author: user.id }); } catch (error) { logger_1.default.error('Selection change error:', error); } } async handleChatMessage(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { sessionId, content, type = 'text', metadata } = data; const session = this.sessions.get(sessionId); if (!session) { socket.emit('error', { message: 'Session not found' }); return; } const participant = session.participants.get(user.id); if (!participant || !participant.permissions.comment) { socket.emit('error', { message: 'No comment permission' }); return; } const message = { id: (0, uuid_1.v4)(), sessionId, author: user.id, content, timestamp: Date.now(), type, metadata }; // Message will be broadcasted below // Broadcast to all participants this.io.to(sessionId).emit('chat-message', { ...message, authorName: participant.username }); logger_1.default.debug(`Chat message in ${sessionId} by ${user.username}`); } catch (error) { logger_1.default.error('Chat message error:', error); socket.emit('error', { message: 'Failed to send message' }); } } async handleRequestAwareness(socket, data) { try { const { sessionId } = data; this.broadcastAwareness(sessionId, socket.id); } catch (error) { logger_1.default.error('Request awareness error:', error); } } async handleDisconnection(socket) { try { const user = socket.data.user; const sessionId = socket.data.sessionId; if (user && sessionId) { await this.handleLeaveSession(socket, { sessionId }); } logger_1.default.info(`Client disconnected: ${socket.id}`); } catch (error) { logger_1.default.error('Disconnection error:', error); } } async broadcastAwareness(sessionId, excludeSocketId) { try { const participants = this.awarenessService.getSessionAwareness(sessionId); const message = { type: 'awareness', sessionId, participants: participants.map((p) => { // Convert UserAwareness to SessionParticipant format return { id: p.userId, username: p.username, displayName: p.username, permissions: { read: true, write: true, comment: true, invite: false, manage: false }, isOnline: true, joinedAt: p.lastUpdate, lastSeen: p.lastUpdate }; }) }; if (excludeSocketId) { this.io.to(sessionId).except(excludeSocketId).emit('awareness', message); } else { this.io.to(sessionId).emit('awareness', message); } } catch (error) { logger_1.default.error('Broadcast awareness error:', error); } } getSelectionManager(documentKey) { let selectionManager = this.selectionManagers.get(documentKey); if (!selectionManager) { selectionManager = new operational_transform_1.SelectionManager(); this.selectionManagers.set(documentKey, selectionManager); } return selectionManager; } serializeSession(session) { return { id: session.id, name: session.name, ownerId: session.ownerId, participants: Array.from(session.participants.values()).map(p => this.serializeParticipant(p)), settings: session.settings, createdAt: session.createdAt, lastActivity: session.lastActivity, status: session.status }; } serializeParticipant(participant) { return { id: participant.id, username: participant.username, displayName: participant.displayName, avatar: participant.avatar, permissions: participant.permissions, cursor: participant.cursor, selection: participant.selection, isOnline: participant.isOnline, joinedAt: participant.joinedAt, lastSeen: participant.lastSeen }; } // Public API methods async createSession(ownerId, name, settings) { const sessionId = (0, uuid_1.v4)(); const session = { id: sessionId, name, ownerId, participants: new Map(), documents: new Map(), settings: { maxParticipants: settings?.maxParticipants || 10, allowAnonymous: settings?.allowAnonymous || false, requireApproval: settings?.requireApproval || false, enableVoiceChat: settings?.enableVoiceChat || false, enableVideoChat: settings?.enableVideoChat || false, autoSave: settings?.autoSave !== false, saveInterval: settings?.saveInterval || 5 }, createdAt: new Date(), lastActivity: new Date(), status: 'active' }; this.sessions.set(sessionId, session); // Session automatically managed return sessionId; } async getSession(sessionId) { let session = this.sessions.get(sessionId); if (!session) { const sessionData = this.sessionManager.getSession(sessionId); if (sessionData) { session = { id: sessionData.id, name: `Session ${sessionData.id}`, ownerId: Array.from(sessionData.users.values())[0]?.userId || '', participants: new Map(), documents: new Map(), settings: { maxParticipants: 10, allowAnonymous: false, requireApproval: false, enableVoiceChat: false, enableVideoChat: false, autoSave: true, saveInterval: 5 }, createdAt: sessionData.createdAt, lastActivity: sessionData.lastActivity, status: 'active' }; if (session) { this.sessions.set(sessionId, session); } } } return session ?? null; } async closeSession(sessionId) { const session = this.sessions.get(sessionId); if (session) { session.status = 'closed'; // Session automatically managed // Disconnect all participants this.io.to(sessionId).emit('session-closed', { sessionId }); this.io.socketsLeave(sessionId); } } getActiveSessionsCount() { return Array.from(this.sessions.values()).filter(s => s.status === 'active').length; } getTotalParticipantsCount() { return Array.from(this.sessions.values()).reduce((total, session) => total + session.participants.size, 0); } async handleReconnectSession(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { sessionId, lastRevision } = data; // Rejoin the session await this.handleJoinSession(socket, { sessionId }); // Get any missed operations if (typeof lastRevision === 'number') { await this.handleGetMissedOperations(socket, { sessionId, fromRevision: lastRevision }); } logger_1.default.info(`User ${user.username} reconnected to session ${sessionId}`); } catch (error) { logger_1.default.error('Reconnect session error:', error); socket.emit('error', { message: 'Failed to reconnect to session' }); } } async handleGetMissedOperations(socket, data) { try { const user = socket.data.user; if (!user) { socket.emit('error', { message: 'Not authenticated' }); return; } const { sessionId, documentId, fromRevision } = data; if (typeof fromRevision !== 'number') { socket.emit('error', { message: 'Invalid fromRevision parameter' }); return; } const session = this.sessions.get(sessionId); if (!session) { socket.emit('error', { message: 'Session not found' }); return; } const participant = session.participants.get(user.id); if (!participant) { socket.emit('error', { message: 'Not a session participant' }); return; } const documentKey = documentId ? `${sessionId}:${documentId}` : null; const missedOperations = []; if (documentKey) { // Get missed operations for specific document const documentState = this.documentStates.get(documentKey); if (documentState) { const operations = documentState.getOperationsSince(fromRevision); missedOperations.push(...operations.map(op => ({ documentId, operation: (0, operational_transform_1.textOperationToJSON)(op), revision: documentState.getRevision() }))); } } else { // Get missed operations for all documents in session for (const [docKey, docState] of this.documentStates) { if (docKey.startsWith(`${sessionId}:`)) { const docId = docKey.split(':')[1]; const operations = docState.getOperationsSince(fromRevision); missedOperations.push(...operations.map(op => ({ documentId: docId, operation: (0, operational_transform_1.textOperationToJSON)(op), revision: docState.getRevision() }))); } } } socket.emit('missed-operations', { sessionId, documentId, operations: missedOperations, currentRevision: documentKey ? this.documentStates.get(documentKey)?.getRevision() : undefined }); logger_1.default.debug(`Sent ${missedOperations.length} missed operations to ${user.username}`); } catch (error) { logger_1.default.error('Get missed operations error:', error); socket.emit('error', { message: 'Failed to get missed operations' }); } } } exports.CollaborationService = CollaborationService;