/** * Espène — WebSocket collaborative server v2 * Manages sessions with multiple pages, broadcasts strokes. * * Client → Server: * { type: "create_session", name?: string } * { type: "join", sessionId: string } * { type: "leave" } * { type: "list_sessions" } * { type: "draw", action, x, y, color?, size?, isEraser? } * { type: "undo" } * { type: "clear" } * { type: "switch_page", page: N } * { type: "add_page" } * { type: "delete_page", page: N } * { type: "rename_page", page: N, name: string } * { type: "pages_info" } * * Server → Client: * { type: "sessions", sessions: [...] } * { type: "session_joined", sessionId, clientId, pages: [...], currentPage } * { type: "draw", ... } ← broadcast to other clients * { type: "undo" } ← broadcast * { type: "clear" } ← broadcast * { type: "clients", count: N } * { type: "page_switched", page, strokes, pageCount } * { type: "page_added", page, pageCount } * { type: "page_deleted", page, pageCount, currentPage } * { type: "pages_info", pages: [...] } * { type: "error", message } */ import { WebSocketServer } from 'ws'; const PORT = parseInt(process.env.ESPENE_PORT || '8080', 10); const MAX_STROKES_PER_PAGE = 2000; // ── Session store ──────────────────────────────────────────────── // Map // Session = { id, name, pages: Page[], clientIds: Set } // Page = { name: string, strokes: Stroke[] } const sessions = new Map(); function generateId() { return Math.random().toString(36).slice(2, 8).toUpperCase(); } function createSession(name) { const id = generateId(); sessions.set(id, { id, name: name || `Session ${id}`, pages: [{ name: 'Page 1', strokes: [] }], clientIds: new Set(), }); return id; } // ── WebSocket server ───────────────────────────────────────────── const wss = new WebSocketServer({ port: PORT }); console.log(`[Espène WS v2] Server listening on port ${PORT}`); wss.on('connection', (ws) => { const clientId = generateId(); let currentSessionId = null; let currentPage = 0; // per-client page function broadcastToSession(sessionId, message, exclude = null) { const session = sessions.get(sessionId); if (!session) return; const raw = JSON.stringify(message); wss.clients.forEach((client) => { if (client.readyState === 1 && client._sessionId === sessionId && client !== exclude) { client.send(raw); } }); } function broadcastToAll(message) { const raw = JSON.stringify(message); wss.clients.forEach((client) => { if (client.readyState === 1) client.send(raw); }); } function getSessionList() { return Array.from(sessions.entries()) .filter(([_, s]) => s.clientIds.size > 0 || s.pages.some(p => p.strokes.length > 0)) .map(([id, s]) => ({ id, name: s.name, clientCount: s.clientIds.size, pageCount: s.pages.length, strokeCount: s.pages.reduce((sum, p) => sum + p.strokes.length, 0), })); } function broadcastSessionList() { broadcastToAll({ type: 'sessions', sessions: getSessionList() }); } function broadcastClientCount(sessionId) { const session = sessions.get(sessionId); if (!session) return; broadcastToSession(sessionId, { type: 'clients', count: session.clientIds.size, clientId }); } function getCurrentPageStrokes(session) { if (!session || !session.pages[currentPage]) return []; return session.pages[currentPage].strokes; } ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' })); return; } const session = currentSessionId ? sessions.get(currentSessionId) : null; switch (msg.type) { // ── List sessions ───────────────────────────────────────── case 'list_sessions': { ws.send(JSON.stringify({ type: 'sessions', sessions: getSessionList() })); break; } // ── Create session ───────────────────────────────────────── case 'create_session': { const sid = createSession(msg.name || null); const s = sessions.get(sid); ws.send(JSON.stringify({ type: 'session_created', sessionId: sid, name: s.name })); broadcastSessionList(); break; } // ── Join session ─────────────────────────────────────────── case 'join': { const sid = msg.sessionId; if (!sid || !sessions.has(sid)) { ws.send(JSON.stringify({ type: 'error', message: `Session "${sid}" not found` })); return; } const s = sessions.get(sid); if (currentSessionId) { const old = sessions.get(currentSessionId); if (old) old.clientIds.delete(clientId); broadcastClientCount(currentSessionId); } currentSessionId = sid; currentPage = 0; ws._sessionId = sid; s.clientIds.add(clientId); ws.send(JSON.stringify({ type: 'session_joined', sessionId: sid, clientId, name: s.name, pages: s.pages.map((p, i) => ({ index: i, name: p.name, strokeCount: p.strokes.length })), currentPage: 0, strokes: s.pages[0].strokes, })); broadcastClientCount(sid); broadcastSessionList(); break; } // ── Leave session ────────────────────────────────────────── case 'leave': { if (currentSessionId) { const s = sessions.get(currentSessionId); if (s) s.clientIds.delete(clientId); broadcastClientCount(currentSessionId); broadcastSessionList(); ws._sessionId = null; currentSessionId = null; } ws.send(JSON.stringify({ type: 'left' })); break; } // ── Draw stroke ──────────────────────────────────────────── case 'draw': { if (!session) { ws.send(JSON.stringify({ type: 'error', message: 'Not in a session' })); return; } const page = session.pages[currentPage]; if (!page) return; if (msg.action === 'start') { session._pendingStroke = { points: [{ x: msg.x, y: msg.y }], color: msg.color || '#e94560', size: msg.size || 3, isEraser: msg.isEraser || false, clientId, id: generateId(), page: currentPage, }; } else if (msg.action === 'move' && session._pendingStroke) { session._pendingStroke.points.push({ x: msg.x, y: msg.y }); } else if (msg.action === 'end' && session._pendingStroke) { session._pendingStroke.points.push({ x: msg.x, y: msg.y }); page.strokes.push(session._pendingStroke); if (page.strokes.length > MAX_STROKES_PER_PAGE) { page.strokes.splice(0, page.strokes.length - MAX_STROKES_PER_PAGE); } delete session._pendingStroke; } broadcastToSession(currentSessionId, { type: 'draw', action: msg.action, x: msg.x, y: msg.y, color: msg.color, size: msg.size, isEraser: msg.isEraser, clientId, page: currentPage, }, ws); break; } // ── Undo ─────────────────────────────────────────────────── case 'undo': { if (!session) return; const page = session.pages[currentPage]; if (!page || page.strokes.length === 0) return; page.strokes.pop(); broadcastToSession(currentSessionId, { type: 'undo', clientId, page: currentPage }, ws); break; } // ── Clear ────────────────────────────────────────────────── case 'clear': { if (!session) return; const page = session.pages[currentPage]; if (!page) return; page.strokes = []; broadcastToSession(currentSessionId, { type: 'clear', clientId, page: currentPage }, ws); break; } // ── Switch page ──────────────────────────────────────────── case 'switch_page': { if (!session) return; const newPage = parseInt(msg.page, 10); if (isNaN(newPage) || newPage < 0 || newPage >= session.pages.length) { ws.send(JSON.stringify({ type: 'error', message: `Invalid page: ${newPage}` })); return; } currentPage = newPage; ws.send(JSON.stringify({ type: 'page_switched', page: currentPage, pageCount: session.pages.length, strokes: session.pages[currentPage].strokes, })); break; } // ── Add page ─────────────────────────────────────────────── case 'add_page': { if (!session) return; const idx = session.pages.length; const name = `Page ${idx + 1}`; session.pages.push({ name, strokes: [] }); // Broadcast to all clients broadcastToSession(currentSessionId, { type: 'page_added', page: idx, name, pageCount: session.pages.length, }); break; } // ── Delete page ──────────────────────────────────────────── case 'delete_page': { if (!session) return; const delPage = parseInt(msg.page, 10); if (isNaN(delPage) || delPage < 0 || delPage >= session.pages.length) { ws.send(JSON.stringify({ type: 'error', message: `Invalid page: ${delPage}` })); return; } if (session.pages.length <= 1) { ws.send(JSON.stringify({ type: 'error', message: 'Cannot delete the last page' })); return; } session.pages.splice(delPage, 1); const newCurrent = Math.min(currentPage, session.pages.length - 1); currentPage = newCurrent; broadcastToSession(currentSessionId, { type: 'page_deleted', page: delPage, pageCount: session.pages.length, currentPage: newCurrent, strokes: session.pages[newCurrent].strokes, }); break; } // ── Pages info ───────────────────────────────────────────── case 'pages_info': { if (!session) return; ws.send(JSON.stringify({ type: 'pages_info', currentPage, pages: session.pages.map((p, i) => ({ index: i, name: p.name, strokeCount: p.strokes.length })), })); break; } // ── Batch strokes (offline sync) ────────────────────────── case 'batch_strokes': { if (!session) { ws.send(JSON.stringify({ type: 'error', message: 'Not in a session' })); return; } if (!msg.strokes || !Array.isArray(msg.strokes)) return; const stored = []; for (const stroke of msg.strokes) { const pIdx = stroke.page !== undefined ? Math.min(stroke.page, session.pages.length - 1) : currentPage; const page = session.pages[pIdx]; if (!page) continue; const full = { points: stroke.points || [], color: stroke.color || '#e94560', size: stroke.size || 3, isEraser: stroke.isEraser || false, clientId, id: generateId(), page: pIdx, }; page.strokes.push(full); if (page.strokes.length > MAX_STROKES_PER_PAGE) { page.strokes.splice(0, page.strokes.length - MAX_STROKES_PER_PAGE); } stored.push(full); } if (stored.length > 0) { broadcastToSession(currentSessionId, { type: 'batch_strokes', strokes: stored }, ws); } break; } default: ws.send(JSON.stringify({ type: 'error', message: `Unknown message type: ${msg.type}` })); } }); ws.on('close', () => { if (currentSessionId) { const s = sessions.get(currentSessionId); if (s) { s.clientIds.delete(clientId); broadcastClientCount(currentSessionId); } } // Clean up empty sessions let changed = false; for (const [sid, s] of sessions) { if (s.clientIds.size === 0 && s.pages.every(p => p.strokes.length === 0)) { sessions.delete(sid); changed = true; } } if (changed || currentSessionId) broadcastSessionList(); }); });