Files
Momento/memento-note/socket-server.ts
Antigravity 1fcea6ed7d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export
- Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG)
- Add document Q&A overlay with streaming chat and PDF preview
- Add note attachments UI with status polling, grid layout, and auto-scroll
- Add task extraction AI tool and agent executor improvements
- Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings
- Fix brainstorm 'Create Note' button: add success toast and redirect to created note
- Fix memory echo notification infinite polling
- Fix chat route to always include document_search tool
- Add brainstorm i18n keys across all 14 locales
- Add socket server for real-time brainstorm collaboration
- Add hierarchical notebook selector and organize notebook dialog improvements
- Add sidebar brainstorm section with session management
- Update prisma schema with brainstorm tables, attachments, and document chunks
2026-05-14 17:43:21 +00:00

270 lines
9.2 KiB
TypeScript

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<string, Map<string, { userId: string; name: string; cursor: { x: number; y: number } | null; color: string }>>()
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<typeof setTimeout>
}
// Map<sessionId, Map<ideaId, IdeaLock>>
const ideaLocks = new Map<string, Map<string, IdeaLock>>()
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}`)