- 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
375 lines
13 KiB
JavaScript
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();
|
|
});
|
|
}); |