UNPKG

realtimecursor

Version:

Real-time collaboration system with cursor tracking and approval workflow

305 lines (255 loc) • 8.49 kB
const express = require('express'); const cors = require('cors'); const http = require('http'); const { Server } = require('socket.io'); // Create a simple analytics service const AnalyticsService = { recordProjectJoin: (projectId, userId) => { console.log(`Analytics: User ${userId} joined project ${projectId}`); }, recordProjectLeave: (projectId, userId) => { console.log(`Analytics: User ${userId} left project ${projectId}`); }, trackConnection: (socketId, key, projectId, userId) => { console.log(`Analytics: Connection tracked for ${socketId}`); }, trackDisconnection: (socketId) => { console.log(`Analytics: Disconnection tracked for ${socketId}`); }, trackContentUpdate: (projectId, userId, length) => { console.log(`Analytics: Content updated in project ${projectId} by user ${userId}`); } }; // Create a simple API key service const ApiKeyService = { validateApiKey: (apiKey) => { return { valid: true, userId: 'user-123' }; } }; const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'], credentials: true } }); // Track connected users by project const projectUsers = {}; // Track socket to user mapping const socketToUser = {}; const PORT = process.env.PORT || 3001; app.use(cors({ origin: '*', credentials: true })); app.use(express.json()); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString() }); }); // Root endpoint app.get('/', (req, res) => { res.json({ status: 'OK', message: 'RealtimeCursor API is running' }); }); // Real-time collaboration rooms const rooms = new Map(); // Socket.IO connection handling io.on('connection', (socket) => { console.log('New client connected:', socket.id); // Store user data to prevent duplicate join events const userJoinedProjects = new Map(); // Handle project join socket.on('join-project', (data) => { const { projectId, user } = data; if (!projectId || !user || !user.id) { socket.emit('error', { message: 'Invalid join data' }); return; } // Prevent duplicate join events for the same project const existingProject = userJoinedProjects.get(projectId); if (existingProject) { return; // User already joined this project } // Join the room socket.join(projectId); socket.projectId = projectId; socket.user = user; // Mark this project as joined userJoinedProjects.set(projectId, true); // Initialize project users if needed if (!projectUsers[projectId]) { projectUsers[projectId] = {}; } // Store user data projectUsers[projectId][user.id] = { ...user, socketId: socket.id, lastActivity: Date.now() }; // Store socket to user mapping socketToUser[socket.id] = { userId: user.id, projectId }; // Initialize room if needed if (!rooms.has(projectId)) { rooms.set(projectId, new Map()); } const room = rooms.get(projectId); room.set(socket.id, { ...user, socketId: socket.id }); // Send current users to the new user const usersInProject = Object.values(projectUsers[projectId]); socket.emit('room-users', { users: usersInProject }); // Notify others about the new user socket.to(projectId).emit('user-joined', { user: { ...user, socketId: socket.id } }); console.log(`User ${user.id} joined project ${projectId}`); AnalyticsService.recordProjectJoin(projectId, user.id); }); // Handle cursor position updates socket.on('cursor-position', (data) => { const { projectId, position } = data; const socketData = socketToUser[socket.id]; if (!socketData || !projectId || socketData.projectId !== projectId) { return; } const userId = socketData.userId; // Update user's cursor position if (projectUsers[projectId] && projectUsers[projectId][userId]) { projectUsers[projectId][userId].position = position; projectUsers[projectId][userId].lastActivity = Date.now(); // Broadcast to others in the project socket.to(projectId).emit('cursor-update', { userId, position }); } }); // Handle cursor move socket.on('cursor-move', ({ x, y, relativeX, relativeY, textPosition }) => { if (socket.projectId) { socket.to(socket.projectId).emit('cursor-update', { x, y, relativeX, relativeY, textPosition, user: socket.user, socketId: socket.id, timestamp: Date.now() }); } }); // Handle content updates socket.on('content-update', (data) => { const { projectId, content, version } = data; const socketData = socketToUser[socket.id]; if (!socketData || !projectId || socketData.projectId !== projectId) { return; } // Broadcast to others in the project socket.to(projectId).emit('content-update', { userId: socketData.userId, content, version }); }); // Handle content change socket.on('content-change', ({ content, cursorPosition }) => { if (socket.projectId) { // Track content update for analytics AnalyticsService.trackContentUpdate( socket.projectId, socket.user?.id || 'anonymous', content ? content.length : 0 ); socket.to(socket.projectId).emit('content-update', { content, cursorPosition, user: socket.user, socketId: socket.id }); } }); // Handle typing status socket.on('user-typing', ({ isTyping }) => { if (socket.projectId && socket.user) { socket.to(socket.projectId).emit('user-typing', { socketId: socket.id, isTyping, user: socket.user }); } }); // Handle disconnection socket.on('disconnect', () => { const socketData = socketToUser[socket.id]; if (socketData) { const { projectId, userId } = socketData; if (projectUsers[projectId] && projectUsers[projectId][userId]) { // Remove user from project const userData = projectUsers[projectId][userId]; delete projectUsers[projectId][userId]; // Notify others about the user leaving socket.to(projectId).emit('user-left', { userId }); console.log(`User ${userId} left project ${projectId}`); AnalyticsService.recordProjectLeave(projectId, userId); } // Clean up empty projects if (projectUsers[projectId] && Object.keys(projectUsers[projectId]).length === 0) { delete projectUsers[projectId]; } // Remove socket mapping delete socketToUser[socket.id]; } // Clean up room data if (socket.projectId) { const room = rooms.get(socket.projectId); if (room) { room.delete(socket.id); if (room.size === 0) { rooms.delete(socket.projectId); } else { socket.to(socket.projectId).emit('user-left', { socketId: socket.id }); } } } console.log('Client disconnected:', socket.id); AnalyticsService.trackDisconnection(socket.id); }); }); // Start server with error handling server.listen(PORT, () => { console.log(`šŸš€ RealtimeCursor API server running on port ${PORT}`); console.log(`āœ… Server is ready to accept connections!`); }); server.on('error', (error) => { if (error.code === 'EADDRINUSE') { console.error(`āŒ Port ${PORT} is already in use. Please use a different port or kill the existing process.`); process.exit(1); } else { console.error('āŒ Server error:', error); } }); // Graceful shutdown process.on('SIGTERM', () => { console.log('\nšŸ›‘ SIGTERM received, shutting down gracefully...'); server.close(() => { console.log('āœ… Server closed'); process.exit(0); }); }); process.on('SIGINT', () => { console.log('\nšŸ›‘ SIGINT received, shutting down gracefully...'); server.close(() => { console.log('āœ… Server closed'); process.exit(0); }); }); // 404 handler - must be the last middleware app.use((req, res) => { console.log(`404 Not Found: ${req.method} ${req.originalUrl}`); res.status(404).json({ success: false, message: 'Endpoint not found', path: req.originalUrl, method: req.method }); }); module.exports = app;