// server.js — HF Spaces / Docker ready (PORT + 0.0.0.0) // - Robust room/admin/client stats // - Commands: start/stop/reset/blackout // - NTP-style sync (sync:ping -> sync:pong) const path = require('path'); const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); 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 page (index.html handles role=admin|client) app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html'))); // HF Spaces / Docker 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(); } // Emit counts for a room using socket.data.role 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); // Optional: let any client/admin force a refresh socket.on('stats:refresh', () => emitStats(room)); // NTP-like sync socket.on('sync:ping', (msg = {}) => { const t1 = Date.now(); socket.emit('sync:pong', { t0: msg.t0, t1, t2: Date.now() }); }); // Admin commands socket.on('admin:start', ({ delayMs = 3000, label = '' } = {}) => { if (socket.data.role !== 'admin') return; const startAt = Date.now() + Math.max(500, Number(delayMs)); io.to(room).emit('cmd', { type: 'start', startAt, label }); }); 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 on http://${HOST}:${PORT}`); });