Files
hermes-sites/espene-example.hidrago.click/server.mjs
T
jerome 486d395e6b Initial commit — site configs
- vault.hidrago.click: Vaultwarden (password manager)
- git.hidrago.click: Gitea (git service)
- All static sites (suma, weather, combien-jour, tokyo-trip, etc.)
- Nginx configs for all subdomains
- Systemd user service for Vaultwarden
2026-07-31 11:38:43 +01:00

375 lines
13 KiB
JavaScript

/**
* 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<sessionId, Session>
// Session = { id, name, pages: Page[], clientIds: Set<string> }
// 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();
});
});