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
JavaScript
"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;