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

527 lines (451 loc) 13.3 kB
/** * Awareness Service for Collaboration Service * Manages real-time user awareness (cursors, selections, presence) in collaborative documents */ import { EventEmitter } from 'events'; import { Server as SocketIOServer } from 'socket.io'; export interface UserCursor { line: number; column: number; timestamp: Date; } export interface UserSelection { start: { line: number; column: number }; end: { line: number; column: number }; timestamp: Date; } export interface UserPresence { userId: string; username: string; color: string; avatar_url?: string; status: 'active' | 'idle' | 'away'; lastActivity: Date; documentId: string; sessionId: string; socketId: string; } export interface UserAwareness { userId: string; username: string; documentId: string; sessionId: string; socketId: string; presence: UserPresence; cursor?: UserCursor; selection?: UserSelection; viewport?: { startLine: number; endLine: number; scrollTop: number; }; typing: boolean; lastUpdate: Date; } export class AwarenessService extends EventEmitter { private awareness: Map<string, UserAwareness> = new Map(); // userId -> awareness private documentAwareness: Map<string, Set<string>> = new Map(); // documentId -> userIds private sessionAwareness: Map<string, Set<string>> = new Map(); // sessionId -> userIds private io: SocketIOServer; private cleanupInterval!: NodeJS.Timeout; constructor(io: SocketIOServer) { super(); this.io = io; this.startCleanupInterval(); } /** * Update user presence information */ updatePresence( userId: string, documentId: string, sessionId: string, socketId: string, presence: Partial<UserPresence> ): boolean { const existing = this.awareness.get(userId); const userAwareness: UserAwareness = { userId, username: presence.username || existing?.username || 'Unknown User', documentId, sessionId, socketId, presence: { userId, username: presence.username || existing?.username || 'Unknown User', color: presence.color || existing?.presence.color || this.generateUserColor(userId), avatar_url: presence.avatar_url || existing?.presence.avatar_url, status: presence.status || 'active', lastActivity: new Date(), documentId, sessionId, socketId }, cursor: existing?.cursor, selection: existing?.selection, viewport: existing?.viewport, typing: existing?.typing || false, lastUpdate: new Date() }; this.awareness.set(userId, userAwareness); // Track document awareness if (!this.documentAwareness.has(documentId)) { this.documentAwareness.set(documentId, new Set()); } this.documentAwareness.get(documentId)!.add(userId); // Track session awareness if (!this.sessionAwareness.has(sessionId)) { this.sessionAwareness.set(sessionId, new Set()); } this.sessionAwareness.get(sessionId)!.add(userId); // Broadcast presence update this.io.to(sessionId).emit('presenceUpdate', { userId, presence: userAwareness.presence }); this.emit('presenceUpdated', { userId, documentId, sessionId, presence: userAwareness.presence }); return true; } /** * Update user cursor position */ updateCursor( userId: string, cursor: { line: number; column: number } ): boolean { const awareness = this.awareness.get(userId); if (!awareness) { return false; } awareness.cursor = { ...cursor, timestamp: new Date() }; awareness.lastUpdate = new Date(); awareness.presence.lastActivity = new Date(); // Broadcast cursor update this.io.to(awareness.sessionId).emit('cursorUpdate', { userId, cursor: awareness.cursor, color: awareness.presence.color }); this.emit('cursorUpdated', { userId, documentId: awareness.documentId, cursor: awareness.cursor }); return true; } /** * Update user selection */ updateSelection( userId: string, selection: { start: { line: number; column: number }; end: { line: number; column: number } } ): boolean { const awareness = this.awareness.get(userId); if (!awareness) { return false; } awareness.selection = { ...selection, timestamp: new Date() }; awareness.lastUpdate = new Date(); awareness.presence.lastActivity = new Date(); // Broadcast selection update this.io.to(awareness.sessionId).emit('selectionUpdate', { userId, selection: awareness.selection, color: awareness.presence.color }); this.emit('selectionUpdated', { userId, documentId: awareness.documentId, selection: awareness.selection }); return true; } /** * Update user viewport (visible area of the document) */ updateViewport( userId: string, viewport: { startLine: number; endLine: number; scrollTop: number } ): boolean { const awareness = this.awareness.get(userId); if (!awareness) { return false; } awareness.viewport = viewport; awareness.lastUpdate = new Date(); awareness.presence.lastActivity = new Date(); // Broadcast viewport update (less frequently than cursor/selection) this.io.to(awareness.sessionId).emit('viewportUpdate', { userId, viewport }); this.emit('viewportUpdated', { userId, documentId: awareness.documentId, viewport }); return true; } /** * Update typing status */ updateTypingStatus(userId: string, typing: boolean): boolean { const awareness = this.awareness.get(userId); if (!awareness) { return false; } if (awareness.typing !== typing) { awareness.typing = typing; awareness.lastUpdate = new Date(); awareness.presence.lastActivity = new Date(); // Broadcast typing status this.io.to(awareness.sessionId).emit('typingUpdate', { userId, typing, username: awareness.username }); this.emit('typingStatusUpdated', { userId, documentId: awareness.documentId, typing }); } return true; } /** * Update user activity status */ updateActivity(userId: string, status: 'active' | 'idle' | 'away'): boolean { const awareness = this.awareness.get(userId); if (!awareness) { return false; } if (awareness.presence.status !== status) { awareness.presence.status = status; awareness.presence.lastActivity = new Date(); awareness.lastUpdate = new Date(); // Broadcast status update this.io.to(awareness.sessionId).emit('statusUpdate', { userId, status, username: awareness.username }); this.emit('activityStatusUpdated', { userId, documentId: awareness.documentId, status }); } return true; } /** * Remove user from awareness tracking */ removeUser(userId: string): boolean { const awareness = this.awareness.get(userId); if (!awareness) { return false; } const { documentId, sessionId } = awareness; // Remove from awareness this.awareness.delete(userId); // Remove from document tracking const docUsers = this.documentAwareness.get(documentId); if (docUsers) { docUsers.delete(userId); if (docUsers.size === 0) { this.documentAwareness.delete(documentId); } } // Remove from session tracking const sessionUsers = this.sessionAwareness.get(sessionId); if (sessionUsers) { sessionUsers.delete(userId); if (sessionUsers.size === 0) { this.sessionAwareness.delete(sessionId); } } // Broadcast user left this.io.to(sessionId).emit('userLeft', { userId, username: awareness.username }); this.emit('userRemoved', { userId, documentId, sessionId }); return true; } /** * Get awareness information for all users in a document */ getDocumentAwareness(documentId: string): UserAwareness[] { const userIds = this.documentAwareness.get(documentId); if (!userIds) { return []; } return Array.from(userIds) .map(userId => this.awareness.get(userId)) .filter(awareness => awareness !== undefined) as UserAwareness[]; } /** * Get awareness information for all users in a session */ getSessionAwareness(sessionId: string): UserAwareness[] { const userIds = this.sessionAwareness.get(sessionId); if (!userIds) { return []; } return Array.from(userIds) .map(userId => this.awareness.get(userId)) .filter(awareness => awareness !== undefined) as UserAwareness[]; } /** * Get awareness information for a specific user */ getUserAwareness(userId: string): UserAwareness | undefined { return this.awareness.get(userId); } /** * Get all users currently viewing a specific area of a document */ getUsersInViewport( documentId: string, startLine: number, endLine: number ): UserAwareness[] { const documentUsers = this.getDocumentAwareness(documentId); return documentUsers.filter(awareness => { if (!awareness.viewport) return false; // Check if viewports overlap return !(awareness.viewport.endLine < startLine || awareness.viewport.startLine > endLine); }); } /** * Get users who are currently typing in a document */ getTypingUsers(documentId: string): UserAwareness[] { return this.getDocumentAwareness(documentId).filter(awareness => awareness.typing); } /** * Broadcast awareness state to all users in a session */ broadcastAwarenessState(sessionId: string): void { const sessionUsers = this.getSessionAwareness(sessionId); const awarenessState = sessionUsers.map(awareness => ({ userId: awareness.userId, username: awareness.username, presence: awareness.presence, cursor: awareness.cursor, selection: awareness.selection, viewport: awareness.viewport, typing: awareness.typing })); this.io.to(sessionId).emit('awarenessState', { sessionId, users: awarenessState }); } /** * Start periodic cleanup of inactive users */ private startCleanupInterval(): void { this.cleanupInterval = setInterval(() => { this.cleanupInactiveUsers(); }, 30 * 1000); // Clean up every 30 seconds } /** * Clean up users who have been inactive */ private cleanupInactiveUsers(): void { const now = new Date(); const inactiveThreshold = 5 * 60 * 1000; // 5 minutes const idleThreshold = 2 * 60 * 1000; // 2 minutes const toRemove: string[] = []; const toMarkIdle: string[] = []; for (const [userId, awareness] of this.awareness.entries()) { const timeSinceLastActivity = now.getTime() - awareness.presence.lastActivity.getTime(); if (timeSinceLastActivity > inactiveThreshold) { toRemove.push(userId); } else if (timeSinceLastActivity > idleThreshold && awareness.presence.status === 'active') { toMarkIdle.push(userId); } } // Mark users as idle for (const userId of toMarkIdle) { this.updateActivity(userId, 'idle'); } // Remove inactive users for (const userId of toRemove) { this.removeUser(userId); } if (toRemove.length > 0 || toMarkIdle.length > 0) { this.emit('awarenessCleanup', { removed: toRemove.length, markedIdle: toMarkIdle.length }); } } /** * Generate a consistent color for a user */ private generateUserColor(userId: string): string { const colors = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9', '#F8C471', '#82E0AA', '#FF8C94', '#FFD3A5', '#FD9644', '#A8E6CF', '#88D8B0' ]; let hash = 0; for (let i = 0; i < userId.length; i++) { hash = userId.charCodeAt(i) + ((hash << 5) - hash); } return colors[Math.abs(hash) % colors.length]; } /** * Get awareness statistics */ getStats(): { totalUsers: number; activeUsers: number; idleUsers: number; awayUsers: number; typingUsers: number; documentsWithUsers: number; activeSessions: number; } { const allUsers = Array.from(this.awareness.values()); return { totalUsers: allUsers.length, activeUsers: allUsers.filter(a => a.presence.status === 'active').length, idleUsers: allUsers.filter(a => a.presence.status === 'idle').length, awayUsers: allUsers.filter(a => a.presence.status === 'away').length, typingUsers: allUsers.filter(a => a.typing).length, documentsWithUsers: this.documentAwareness.size, activeSessions: this.sessionAwareness.size }; } /** * Destroy the awareness service and clean up resources */ destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } this.awareness.clear(); this.documentAwareness.clear(); this.sessionAwareness.clear(); this.removeAllListeners(); } }