feat: WebSocket real-time + rAF render gate + guest board + screen picker

Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-13 18:04:59 +03:00
parent 074ee5687b
commit fd29acbbdd
70 changed files with 12231 additions and 498 deletions
+159
View File
@@ -0,0 +1,159 @@
/**
* WebSocket server for low-latency real-time classroom events.
*
* Handles two message types from clients:
* { type:'cursor', sessionId, x, y, pageNum }
* { type:'preview', sessionId, liveId, tool, data, pageNum, cancel }
*
* Auth: JWT token in ?token= query param on upgrade request.
* Forwards events via SSE to session participants (no DB write).
*
* This replaces the HTTP POST /cursor and /stroke-preview endpoints
* for connected clients, reducing per-event latency from ~50-120ms to ~2-5ms.
*/
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const db = require('./db/db');
const { emit, emitToGuests } = require('./sse');
/* ── Session member cache (avoids DB query per WS message) ────────────── */
const _cache = new Map(); // sessionId → { teacherId, classId, userIds, ts }
const CACHE_TTL = 30_000;
function _getMembers(sessionId) {
const c = _cache.get(sessionId);
if (c && Date.now() - c.ts < CACHE_TTL) return c;
const session = db.prepare(
"SELECT class_id, teacher_id FROM classroom_sessions WHERE id=? AND status='active'"
).get(sessionId);
if (!session) { _cache.delete(sessionId); return null; }
let userIds;
if (session.class_id) {
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id=?').all(session.class_id);
userIds = [session.teacher_id, ...members.map(m => m.user_id)];
} else {
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
userIds = [session.teacher_id, ...invites.map(i => i.user_id)];
}
const entry = { teacherId: session.teacher_id, classId: session.class_id, userIds, ts: Date.now() };
_cache.set(sessionId, entry);
return entry;
}
function _invalidateSession(sessionId) {
_cache.delete(sessionId);
}
/* Forward serialized SSE payload to all session members */
function _broadcast(sessionId, data, includeGuests) {
const members = _getMembers(sessionId);
if (!members) return;
for (const uid of members.userIds) emit(uid, data);
if (includeGuests) emitToGuests(sessionId, data);
}
/* Check draw permissions (teacher always can; students need explicit grant) */
const _drawCache = new Map(); // `${sessionId}:${userId}` → { allowed, ts }
const DRAW_TTL = 10_000;
function _canDraw(sessionId, userId, members) {
if (!members) return false;
if (members.teacherId === userId) return true;
const key = `${sessionId}:${userId}`;
const c = _drawCache.get(key);
if (c && Date.now() - c.ts < DRAW_TTL) return c.allowed;
const allowed = !!db.prepare(
'SELECT 1 FROM classroom_draw_permissions WHERE session_id=? AND user_id=?'
).get(sessionId, userId);
_drawCache.set(key, { allowed, ts: Date.now() });
return allowed;
}
/* ── WebSocket server ──────────────────────────────────────────────────── */
function attach(httpServer) {
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
/* ── Auth ── */
let user = null;
try {
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token') || '';
user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} catch {
ws.close(4001, 'Unauthorized');
return;
}
ws.userId = user.id;
ws.userName = user.name || user.email || '';
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', raw => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
const { type, sessionId } = msg;
if (!sessionId || typeof sessionId !== 'number') return;
/* ── cursor broadcast ── */
if (type === 'cursor') {
const members = _getMembers(sessionId);
if (!members || !members.userIds.includes(ws.userId)) return;
_broadcast(sessionId, {
type: 'classroom_cursor',
sessionId,
x: msg.x,
y: msg.y,
pageNum: msg.pageNum || 1,
userId: ws.userId,
userName: ws.userName,
}, true);
/* ── stroke preview broadcast ── */
} else if (type === 'preview') {
const members = _getMembers(sessionId);
if (!members) return;
if (!_canDraw(sessionId, ws.userId, members)) return;
const liveId = msg.liveId;
if (!liveId && !msg.cancel) return;
_broadcast(sessionId, {
type: 'classroom_stroke_preview',
sessionId,
pageNum: msg.pageNum || 1,
liveId,
tool: msg.tool,
data: msg.data,
cancel: msg.cancel || false,
userId: ws.userId,
userName: ws.userName,
}, true);
}
});
ws.on('error', () => {});
ws.on('close', () => {});
});
/* ── Ping/pong keepalive ── */
const pingTimer = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
try { ws.ping(); } catch {}
}
}, 30_000);
wss.on('close', () => clearInterval(pingTimer));
return wss;
}
module.exports = { attach, invalidateSession: _invalidateSession };