import { WebSocketServer } from 'ws'; import { URL } from 'node:url'; import { validateToken } from './auth.js'; const studentsById = new Map(); const teachers = new Set(); function send(ws, obj) { if (ws.readyState !== ws.OPEN) return; try { ws.send(JSON.stringify(obj)); } catch { /* ignore */ } } function broadcastToTeachers(obj) { for (const tws of teachers) send(tws, obj); } function rosterSnapshot() { return Array.from(studentsById.values()).map((s) => ({ userId: s.userId, username: s.username, state: s.lastState, })); } export function attachWsServer(server) { const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', async (req, socket, head) => { let user = null; try { const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname !== '/ws') { socket.destroy(); return; } const token = url.searchParams.get('token'); user = await validateToken(token); } catch { user = null; } if (!user) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { ws.user = user; wss.emit('connection', ws, req); }); }); wss.on('connection', (ws) => { const { id: userId, username, role } = ws.user; if (role === 'teacher') { teachers.add(ws); send(ws, { type: 'roster', students: rosterSnapshot() }); } else { const prev = studentsById.get(userId); if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'Replaced by new connection'); } catch { /* ignore */ } } const entry = { userId, username, ws, lastState: prev?.lastState || null }; studentsById.set(userId, entry); broadcastToTeachers({ type: 'student-update', userId, username, state: entry.lastState, }); } ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw.toString()); } catch { return; } if (!msg || typeof msg !== 'object') return; if (role === 'student') { if (msg.type === 'workspace') { const entry = studentsById.get(userId); if (entry) entry.lastState = msg.state || null; broadcastToTeachers({ type: 'student-update', userId, username, state: msg.state || null, }); } return; } if (role === 'teacher') { if (msg.type === 'list') { send(ws, { type: 'roster', students: rosterSnapshot() }); return; } if (msg.type === 'push-code') { const target = studentsById.get(Number(msg.targetUserId)); if (!target) { send(ws, { type: 'push-code-result', ok: false, targetUserId: msg.targetUserId, error: 'Student not online' }); return; } // Update the cached student state immediately so teacher dashboards // reflect pushed code without waiting for the next student edit. target.lastState = msg.state || null; broadcastToTeachers({ type: 'student-update', userId: target.userId, username: target.username, state: target.lastState, }); send(target.ws, { type: 'push-code', state: msg.state || null, from: ws.user.username }); send(ws, { type: 'push-code-result', ok: true, targetUserId: target.userId }); return; } } }); ws.on('close', () => { if (role === 'teacher') { teachers.delete(ws); } else { const entry = studentsById.get(userId); if (entry && entry.ws === ws) { studentsById.delete(userId); broadcastToTeachers({ type: 'student-leave', userId }); } } }); }); return wss; }