esp32blockly/server/ws.js

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;
}