All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- 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
270 lines
9.2 KiB
TypeScript
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}`)
|