140 lines
3.8 KiB
JavaScript
140 lines
3.8 KiB
JavaScript
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;
|
|
}
|