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
text/typescript
/**
* 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();
}
}