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
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Espène Collaborative WebSocket Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=jerome
|
||||
WorkingDirectory=/home/jerome/hermes-sites/espene-example.hidrago.click
|
||||
ExecStart=/home/linuxbrew/.linuxbrew/bin/node /home/jerome/hermes-sites/espene-example.hidrago.click/server.mjs
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
Environment=ESPENE_PORT=8080
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
File diff suppressed because it is too large
Load Diff
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "espene-example.hidrago.click",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "espene-example.hidrago.click",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ws": "^8.21.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "espene-example.hidrago.click",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"ws": "^8.21.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user