import { createServer } from 'http' import { Server } from 'socket.io' const SOCKET_PORT = parseInt(process.env.SOCKET_PORT || '3002') const HTTP_PORT = parseInt(process.env.SOCKET_HTTP_PORT || '3003') const INTERNAL_KEY = process.env.SOCKET_INTERNAL_KEY || '' // ─── Socket.io Server ──────────────────────────────────────────────────────── const io = new Server(SOCKET_PORT, { cors: { origin: true, // Accepte dynamiquement toutes les origines (CORS fix) methods: ['GET', 'POST'], credentials: true, }, }) const rooms = new Map>() const COLORS = [ '#e57373', '#81c784', '#64b5f6', '#ffb74d', '#ba68c8', '#4dd0e1', '#f06292', '#aed581', '#ff8a65', '#4fc3f7', '#dce775', '#f48fb1', ] // ─── [UPDATE - TEMPS RÉEL] Node Locking in-memory ─────────────────────────── // Choix : Map in-memory + TTL automatique. // Justification : locks volatiles par design, O(1), pas de deadlocks PostgreSQL. // Migration Redis triviale si multi-instances (remplacer Map par SET NX EX). const LOCK_TTL_MS = 15_000 interface IdeaLock { userId: string userName: string lockedAt: number timeoutHandle: ReturnType } // Map> const ideaLocks = new Map>() function acquireLock( sessionId: string, ideaId: string, userId: string, userName: string ): boolean { if (!ideaLocks.has(sessionId)) { ideaLocks.set(sessionId, new Map()) } const sessionLocks = ideaLocks.get(sessionId)! const existing = sessionLocks.get(ideaId) if (existing && existing.userId !== userId) { const age = Date.now() - existing.lockedAt if (age < LOCK_TTL_MS) return false // Lock expiré → nettoyer clearTimeout(existing.timeoutHandle) sessionLocks.delete(ideaId) } const handle = setTimeout(() => { releaseLock(sessionId, ideaId, userId, true) io.to(sessionId).emit('idea:unlocked', { ideaId, reason: 'ttl_expired' }) }, LOCK_TTL_MS) sessionLocks.set(ideaId, { userId, userName, lockedAt: Date.now(), timeoutHandle: handle }) return true } function releaseLock( sessionId: string, ideaId: string, userId: string, expired = false ): boolean { const sessionLocks = ideaLocks.get(sessionId) if (!sessionLocks) return false const lock = sessionLocks.get(ideaId) if (!lock || lock.userId !== userId) return false if (!expired) clearTimeout(lock.timeoutHandle) sessionLocks.delete(ideaId) return true } function releaseAllLocksForUser(sessionId: string, userId: string): string[] { const sessionLocks = ideaLocks.get(sessionId) if (!sessionLocks) return [] const released: string[] = [] for (const [ideaId, lock] of sessionLocks.entries()) { if (lock.userId === userId) { clearTimeout(lock.timeoutHandle) sessionLocks.delete(ideaId) released.push(ideaId) } } return released } // ─── Socket.io Middleware ───────────────────────────────────────────────────── io.use((socket, next) => { const userId = socket.handshake.auth.userId || `guest_${socket.id.slice(0, 8)}` const sessionId = socket.handshake.auth.sessionId const name = socket.handshake.auth.name || 'Guest' const isGuest = socket.handshake.auth.isGuest === true if (!sessionId) { return next(new Error('Missing sessionId')) } socket.data.userId = userId socket.data.sessionId = sessionId socket.data.name = name socket.data.isGuest = isGuest next() }) // ─── Socket.io Connection Handler ──────────────────────────────────────────── io.on('connection', (socket) => { const sessionId = socket.data.sessionId const userId = socket.data.userId const name = socket.data.name socket.join(sessionId) if (!rooms.has(sessionId)) { rooms.set(sessionId, new Map()) } const colorIdx = rooms.get(sessionId)!.size % COLORS.length const color = COLORS[colorIdx] // IMPORTANT: Utiliser socket.id au lieu de userId pour supporter le refresh et multi-onglets rooms.get(sessionId)!.set(socket.id, { name, cursor: null, color, userId }) // Note: On doit ajouter userId dans l'objet pour le retrouver io.to(sessionId).emit('presence:update', Array.from(rooms.get(sessionId)!.values()).map(data => ({ userId: data.userId, // On renvoie bien le userId attendu par le client name: data.name, cursor: data.cursor, color: data.color, }))) // ── Curseur ────────────────────────────────────────────────────────────── socket.on('cursor:move', (cursor: { x: number; y: number }) => { const room = rooms.get(sessionId) if (!room) return const user = room.get(socket.id) if (!user) return user.cursor = cursor socket.to(sessionId).emit('cursor:update', { userId, cursor }) }) // ── Idées (événements existants) ───────────────────────────────────────── socket.on('idea:added', (data: { ideaId: string; title: string; userId: string; userName: string }) => { socket.to(sessionId).emit('idea:added', data) }) socket.on('idea:dismissed', (data: { ideaId: string; userId: string }) => { // Libérer le lock si l'idée était verrouillée par cet utilisateur releaseLock(sessionId, data.ideaId, userId) socket.to(sessionId).emit('idea:dismissed', data) }) socket.on('idea:moved', (data: { ideaId: string; positionX: number; positionY: number; userId: string }) => { socket.to(sessionId).emit('idea:moved', data) }) socket.on('activity:new', (data: { action: string; userId: string; userName: string; details: any }) => { io.to(sessionId).emit('activity:new', data) }) // ── [UPDATE - TEMPS RÉEL] Node Locking ─────────────────────────────────── socket.on('idea:lock_request', (data: { ideaId: string }) => { const acquired = acquireLock(sessionId, data.ideaId, userId, name) if (acquired) { // Notifier tout le room — le nœud est grisé pour tous les autres io.to(sessionId).emit('idea:locked', { ideaId: data.ideaId, lockedBy: userId, lockedByName: name, ttl: LOCK_TTL_MS, }) } else { // Refus uniquement au demandeur const lock = ideaLocks.get(sessionId)?.get(data.ideaId) socket.emit('idea:lock_denied', { ideaId: data.ideaId, lockedBy: lock?.userId, lockedByName: lock?.userName, }) } }) socket.on('idea:unlock', (data: { ideaId: string }) => { const released = releaseLock(sessionId, data.ideaId, userId) if (released) { io.to(sessionId).emit('idea:unlocked', { ideaId: data.ideaId }) } }) // ── Déconnexion ─────────────────────────────────────────────────────────── socket.on('disconnect', () => { // Libérer tous les locks de cet utilisateur const releasedIds = releaseAllLocksForUser(sessionId, userId) for (const ideaId of releasedIds) { io.to(sessionId).emit('idea:unlocked', { ideaId }) } const room = rooms.get(sessionId) if (room) { room.delete(socket.id) // Utilise socket.id, pas userId if (room.size === 0) { rooms.delete(sessionId) ideaLocks.delete(sessionId) } else { io.to(sessionId).emit('presence:update', Array.from(room.values()).map(d => ({ userId: d.userId, // Renvoie le userId correct name: d.name, cursor: d.cursor, color: d.color, }))) } } }) }) // ─── [UPDATE - TEMPS RÉEL] Endpoint HTTP interne pour l'émission depuis les API routes ── // Permet à next.js d'émettre des événements Socket sans couplage direct. // Sécurisé par SOCKET_INTERNAL_KEY. const httpServer = createServer((req, res) => { if (req.method === 'POST' && req.url === '/emit') { const key = req.headers['x-internal-key'] if (INTERNAL_KEY && key !== INTERNAL_KEY) { res.writeHead(403).end('Forbidden') return } let body = '' req.on('data', chunk => (body += chunk)) req.on('end', () => { try { const { sessionId, event, data } = JSON.parse(body) if (!sessionId || !event) { res.writeHead(400).end('Missing sessionId or event') return } io.to(sessionId).emit(event, data) res.writeHead(200).end('ok') } catch { res.writeHead(400).end('Invalid JSON') } }) } else { res.writeHead(404).end() } }) httpServer.listen(HTTP_PORT, () => { console.log(`Socket.io internal HTTP endpoint listening on port ${HTTP_PORT}`) }) console.log(`Socket.io server running on port ${SOCKET_PORT}`)