File size: 3,300 Bytes
ef3c306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// 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}`);
});