Spaces:
Sleeping
Sleeping
| // 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}`); | |
| }); | |