// server.js — Pure async (monotonic) sync, no wall clock. // - Uses perf_hooks.performance.now() on server // - Robust room/admin/client stats // - Commands: start/stop/reset/blackout // - Redundant START + preSync burst for tight simultaneity // - Docker/Spaces-ready (binds 0.0.0.0, uses PORT) const path = require('path'); const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const { performance } = require('perf_hooks'); const app = express(); const server = http.createServer(app); const io = new Server(server, { pingInterval: 10000, pingTimeout: 5000, cors: { origin: true } }); // Serve static files app.use(express.static(path.join(__dirname, 'public'))); // Single entry (merged Admin+Client UI lives in public/index.html) app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html'))); const HOST = '0.0.0.0'; const PORT = process.env.PORT || 7860; // ---------- Helpers ---------- function normRoom(x) { return String(x || 'default').trim().toLowerCase(); } function normRole(x) { return String(x || 'client').trim().toLowerCase(); } function emitStats(room) { const ids = io.sockets.adapter.rooms.get(room) || new Set(); let numAdmins = 0, numClients = 0; for (const id of ids) { const s = io.sockets.sockets.get(id); if (!s) continue; (s.data?.role === 'admin') ? numAdmins++ : numClients++; } io.to(room).emit('stats', { numAdmins, numClients }); } // ---------- Socket.io ---------- io.on('connection', (socket) => { const room = normRoom(socket.handshake.query.room); const role = normRole(socket.handshake.query.role); socket.data.room = room; socket.data.role = role; socket.join(room); emitStats(room); socket.on('stats:refresh', () => emitStats(room)); // SYNC: server sends its monotonic time; client samples arrival with its own monotonic time socket.on('sync:ping', () => { const tS = performance.now(); // server monotonic ms socket.emit('sync:pong', { tS }); }); // Admin commands socket.on('admin:start', ({ delayMs = 3000, label = '' } = {}) => { if (socket.data.role !== 'admin') return; const startServerPerf = performance.now() + Math.max(800, Number(delayMs)); // future server perf time (ms) const payload = { type: 'start', startServerPerf, label }; // Redundant emits for resilience io.to(room).emit('cmd', payload); setTimeout(() => io.to(room).emit('cmd', payload), 250); setTimeout(() => io.to(room).emit('cmd', payload), 500); // Ask clients to enter high-rate sync mode until T0 io.to(room).emit('cmd', { type: 'preSync', untilServerPerf: startServerPerf }); }); socket.on('admin:stop', () => { if (socket.data.role !== 'admin') return; io.to(room).emit('cmd', { type: 'stop' }); }); socket.on('admin:reset', () => { if (socket.data.role !== 'admin') return; io.to(room).emit('cmd', { type: 'reset' }); }); socket.on('admin:blackout', ({ on = true } = {}) => { if (socket.data.role !== 'admin') return; io.to(room).emit('cmd', { type: 'blackout', on: !!on }); }); socket.on('disconnect', () => emitStats(room)); }); server.listen(PORT, HOST, () => { console.log(`Timer server listening on http://${HOST}:${PORT}`); });