sar / src /lib /session-manager.ts
Mafia2008's picture
chore: completely remove base64 payloads to fix buffer explosion and silent socket crashes, rely strictly on lightweight xethub urls
a394126
Raw
History Blame Contribute Delete
23.6 kB
// ═══════════════════════════════════════════════════════════════════════════
// SESSION MANAGER - In-Memory Session State Management with SSE Broadcasting
// ═══════════════════════════════════════════════════════════════════════════
import { v4 as uuidv4 } from 'uuid';
import {
Session,
User,
ChatMessage,
Poll,
PollOption,
Doubt,
WhiteboardStroke,
ReactionType,
} from './types';
// ═══════════════════════════════════════════════════════════════════════════
// IN-MEMORY STORES
// ═══════════════════════════════════════════════════════════════════════════
class SessionManager {
// Core data stores
private sessions: Map<string, Session> = new Map();
private users: Map<string, User> = new Map();
private sessionUsers: Map<string, Set<string>> = new Map();
// Feature data stores
private polls: Map<string, Map<string, Poll>> = new Map();
private doubts: Map<string, Map<string, Doubt>> = new Map();
private reactions: Map<string, Map<ReactionType, number>> = new Map();
private whiteboard: Map<string, WhiteboardStroke[]> = new Map();
private chatMessages: Map<string, ChatMessage[]> = new Map();
private currentSlide: Map<string, number> = new Map();
// SSE clients for broadcasting
private sseClients: Map<string, Set<{
userId: string;
write: (data: string) => void;
}>> = new Map();
// 4-digit room code index: roomCode -> sessionId
private roomCodeIndex: Map<string, string> = new Map();
// Event ID counter for SSE
private eventIdCounter: number = 0;
// ═══════════════════════════════════════════════════════════════════════════
// SESSION MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Create a new session
* @param teacherId - Unique ID for the teacher
* @param teacherName - Display name of the teacher
* @param sessionName - Name of the session/class
* @param description - Optional description
* @param slideUrls - Optional array of slide URLs
* @returns The created Session object
*/
createSession(
teacherId: string,
teacherName: string,
sessionName: string,
description?: string,
slideUrls: string[] = []
): Session {
const sessionId = uuidv4();
// Generate unique 4-digit room code
let roomCode: string;
do {
roomCode = String(Math.floor(1000 + Math.random() * 9000));
} while (this.roomCodeIndex.has(roomCode));
this.roomCodeIndex.set(roomCode, sessionId);
const session: Session = {
id: sessionId,
name: sessionName,
description,
teacherId,
teacherName,
createdAt: new Date().toISOString(),
isActive: true,
currentSlide: 0,
totalSlides: slideUrls.length,
slideUrls,
studentCount: 0,
maxStudents: 100000,
roomCode
} as any;
// Initialize session data
this.sessions.set(sessionId, session);
this.sessionUsers.set(sessionId, new Set([teacherId]));
this.polls.set(sessionId, new Map());
this.doubts.set(sessionId, new Map());
this.reactions.set(sessionId, new Map());
this.whiteboard.set(sessionId, []);
this.chatMessages.set(sessionId, []);
this.currentSlide.set(sessionId, 0);
this.sseClients.set(sessionId, new Set());
// Initialize reactions
const reactionEmojis: ReactionType[] = ['πŸ‘', '❀️', 'πŸŽ‰', 'πŸ˜•', 'πŸš€', 'πŸ‘', 'πŸ”₯'];
const sessionReactions = this.reactions.get(sessionId)!;
reactionEmojis.forEach(emoji => sessionReactions.set(emoji, 0));
return session;
}
/**
* Get a session by ID
*/
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId);
}
/**
* Get all active sessions
*/
getAllSessions(): Session[] {
return Array.from(this.sessions.values()).filter(s => s.isActive);
}
/**
* Lookup session by 4-digit room code
*/
getSessionByRoomCode(roomCode: string): Session | undefined {
const sessionId = this.roomCodeIndex.get(roomCode);
if (!sessionId) return undefined;
return this.sessions.get(sessionId);
}
/**
* End a session
*/
async endSession(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (session) {
// Export session data
const exportUrlOnly = this.getSessionState(sessionId);
session.isActive = false;
await this.broadcast(sessionId, 'session_ended', {
sessionId,
endedAt: new Date().toISOString()
});
// Save to disk stringifying the object directly
try {
const fs = await import('fs/promises');
const path = await import('path');
const storageRoot = process.env.SPACE_ID ? '/data' : path.join(process.cwd(), 'data');
const sessionDir = path.join(storageRoot, 'sessions', sessionId);
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
path.join(sessionDir, 'session_url.json'),
JSON.stringify(exportUrlOnly, null, 2)
);
} catch (err) {
console.error('Failed to export session files:', err);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// USER MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Join a session
*/
joinSession(
sessionId: string,
userId: string,
userName: string,
role: 'teacher' | 'student'
): User {
const user: User = {
id: userId,
name: userName,
role,
sessionId,
joinedAt: new Date().toISOString()
};
this.users.set(userId, user);
const sessionUsers = this.sessionUsers.get(sessionId);
if (sessionUsers) {
sessionUsers.add(userId);
}
const session = this.sessions.get(sessionId);
if (session && role === 'student') {
session.studentCount++;
}
// Broadcast to all
this.broadcast(sessionId, 'student_joined', {
userId,
userName,
role,
studentCount: session?.studentCount || 0,
timestamp: new Date().toISOString()
});
return user;
}
/**
* Leave a session
*/
leaveSession(userId: string): void {
const user = this.users.get(userId);
if (!user || !user.sessionId) return;
const sessionUsers = this.sessionUsers.get(user.sessionId);
if (sessionUsers) {
sessionUsers.delete(userId);
}
const session = this.sessions.get(user.sessionId);
if (session && user.role === 'student') {
session.studentCount--;
}
this.users.delete(userId);
// Broadcast to all
if (user.sessionId) {
this.broadcast(user.sessionId, 'student_left', {
userId,
userName: user.name,
studentCount: session?.studentCount || 0,
timestamp: new Date().toISOString()
});
}
}
/**
* Get online users in a session
*/
getOnlineUsers(sessionId: string): User[] {
const sessionUsers = this.sessionUsers.get(sessionId);
if (!sessionUsers) return [];
return Array.from(sessionUsers)
.map(userId => this.users.get(userId))
.filter(Boolean) as User[];
}
// ═══════════════════════════════════════════════════════════════════════════
// SSE CLIENT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Add an SSE client
*/
addSSEClient(sessionId: string, userId: string, write: (data: string) => void): void {
const clients = this.sseClients.get(sessionId);
if (clients) {
clients.add({ userId, write });
}
}
/**
* Remove an SSE client
*/
removeSSEClient(sessionId: string, userId: string): void {
const clients = this.sseClients.get(sessionId);
if (clients) {
for (const client of clients) {
if (client.userId === userId) {
clients.delete(client);
break;
}
}
}
}
/**
* Get connected client count
*/
getClientCount(sessionId: string): number {
return this.sseClients.get(sessionId)?.size || 0;
}
// ═══════════════════════════════════════════════════════════════════════════
// BROADCASTING (SSE)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Broadcast an event to all clients in a session
*/
async broadcast(sessionId: string, event: string, data: any): Promise<string> {
const eventId = `${++this.eventIdCounter}`;
const clients = this.sseClients.get(sessionId);
if (!clients || clients.size === 0) return eventId;
const message = `id: ${eventId}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
try {
client.write(message);
} catch (error) {
console.error(`Failed to send to client ${client.userId}:`, error);
}
}
return eventId;
}
// ═══════════════════════════════════════════════════════════════════════════
// SLIDE MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Change slide
*/
async changeSlide(sessionId: string, slideNumber: number): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
// Allow any non-negative slide number (slides uploaded dynamically)
const validSlide = Math.max(0, slideNumber);
session.currentSlide = validSlide;
this.currentSlide.set(sessionId, validSlide);
// Include the stored imageBase64 if we have it
const imageBase64 = session.slideUrls[validSlide] || null;
await this.broadcast(sessionId, 'slide_change', {
slideNumber: validSlide,
imageBase64,
totalSlides: session.totalSlides,
timestamp: new Date().toISOString()
});
}
/**
* Get current slide
*/
getSlide(sessionId: string): number {
return this.currentSlide.get(sessionId) || 0;
}
// ═══════════════════════════════════════════════════════════════════════════
// WHITEBOARD MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Add a whiteboard stroke
*/
async addWhiteboardStroke(sessionId: string, stroke: Omit<WhiteboardStroke, 'id' | 'timestamp'>): Promise<WhiteboardStroke> {
const strokes = this.whiteboard.get(sessionId);
const newStroke: WhiteboardStroke = {
...stroke,
id: uuidv4(),
timestamp: new Date().toISOString()
};
if (strokes) {
strokes.push(newStroke);
}
await this.broadcast(sessionId, 'whiteboard_draw', newStroke);
return newStroke;
}
/**
* Clear whiteboard
*/
async clearWhiteboard(sessionId: string): Promise<void> {
this.whiteboard.set(sessionId, []);
await this.broadcast(sessionId, 'whiteboard_clear', {
sessionId,
timestamp: new Date().toISOString()
});
}
/**
* Get whiteboard strokes
*/
getWhiteboardStrokes(sessionId: string): WhiteboardStroke[] {
return this.whiteboard.get(sessionId) || [];
}
// ═══════════════════════════════════════════════════════════════════════════
// POLL MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Create a new poll
*/
async createPoll(
sessionId: string,
question: string,
options: string[],
type: 'single' | 'multiple' = 'single',
correctAnswer?: string,
duration: number = 60
): Promise<Poll> {
const pollId = uuidv4();
const now = new Date();
const endsAt = new Date(now.getTime() + duration * 1000).toISOString();
const poll: Poll = {
id: pollId,
sessionId,
question,
options: options.map((text, index) => ({
id: `option_${index}`,
text,
isCorrect: text === correctAnswer,
votes: 0
})),
type,
correctAnswer,
duration,
createdAt: now.toISOString(),
endsAt,
isActive: true,
results: {},
totalVotes: 0,
votedStudents: []
};
// Initialize results
options.forEach(opt => {
poll.results[opt] = 0;
});
const sessionPolls = this.polls.get(sessionId);
if (sessionPolls) {
sessionPolls.set(pollId, poll);
}
// Broadcast new poll
await this.broadcast(sessionId, 'new_poll', poll);
// Auto-end poll after duration (only if duration > 0)
if (duration > 0) {
setTimeout(() => {
this.endPoll(sessionId, pollId);
}, duration * 1000);
}
return poll;
}
/**
* Vote on a poll
*/
async votePoll(sessionId: string, pollId: string, option: string, studentId: string): Promise<Poll | null> {
const sessionPolls = this.polls.get(sessionId);
if (!sessionPolls) return null;
const poll = sessionPolls.get(pollId);
if (!poll || !poll.isActive) return null;
// Check if already voted
if (poll.votedStudents.includes(studentId)) {
return poll;
}
// Record vote
poll.results[option] = (poll.results[option] || 0) + 1;
poll.totalVotes++;
poll.votedStudents.push(studentId);
// Update option votes
const optIndex = poll.options.findIndex(o => o.text === option);
if (optIndex >= 0) {
poll.options[optIndex].votes++;
}
// Broadcast update
await this.broadcast(sessionId, 'poll_update', {
pollId,
results: poll.results,
totalVotes: poll.totalVotes,
timestamp: new Date().toISOString()
});
return poll;
}
/**
* End a poll
*/
async endPoll(sessionId: string, pollId: string): Promise<void> {
const sessionPolls = this.polls.get(sessionId);
if (!sessionPolls) return;
const poll = sessionPolls.get(pollId);
if (!poll || !poll.isActive) return;
poll.isActive = false;
// Broadcast results
await this.broadcast(sessionId, 'poll_ended', {
pollId,
results: poll.results,
totalVotes: poll.totalVotes,
correctAnswer: poll.correctAnswer,
timestamp: new Date().toISOString()
});
}
/**
* Get active poll
*/
getActivePoll(sessionId: string): Poll | null {
const sessionPolls = this.polls.get(sessionId);
if (!sessionPolls) return null;
for (const poll of sessionPolls.values()) {
if (poll.isActive) return poll;
}
return null;
}
/**
* Get all polls for a session
*/
getPolls(sessionId: string): Poll[] {
const sessionPolls = this.polls.get(sessionId);
if (!sessionPolls) return [];
return Array.from(sessionPolls.values());
}
// ═══════════════════════════════════════════════════════════════════════════
// CHAT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Send a chat message
*/
async sendChatMessage(
sessionId: string,
userId: string,
userName: string,
message: string,
isTeacher: boolean
): Promise<ChatMessage> {
const chatMessage: ChatMessage = {
id: uuidv4(),
sessionId,
userId,
userName,
message,
isTeacher,
timestamp: new Date().toISOString()
};
const messages = this.chatMessages.get(sessionId);
if (messages) {
messages.push(chatMessage);
// Keep only last 500 messages
if (messages.length > 500) {
messages.shift();
}
}
// Broadcast to all
await this.broadcast(sessionId, 'chat_message', chatMessage);
return chatMessage;
}
/**
* Get chat messages
*/
getChatMessages(sessionId: string, limit: number = 100): ChatMessage[] {
const messages = this.chatMessages.get(sessionId) || [];
return messages.slice(-limit);
}
// ═══════════════════════════════════════════════════════════════════════════
// DOUBT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Create a doubt
*/
async createDoubt(
sessionId: string,
studentId: string,
studentName: string,
question: string,
image?: string,
slideNumber?: number
): Promise<Doubt> {
const doubtId = uuidv4();
const doubt: Doubt = {
id: doubtId,
sessionId,
studentId,
studentName,
question,
image,
slideNumber: slideNumber ?? this.currentSlide.get(sessionId) ?? 0,
status: 'pending',
createdAt: new Date().toISOString()
};
const sessionDoubts = this.doubts.get(sessionId);
if (sessionDoubts) {
sessionDoubts.set(doubtId, doubt);
}
// Broadcast to teacher
await this.broadcast(sessionId, 'new_doubt', doubt);
return doubt;
}
/**
* Solve a doubt
*/
async solveDoubt(
sessionId: string,
doubtId: string,
annotatedImage?: string,
teacherResponse?: string
): Promise<Doubt | null> {
const sessionDoubts = this.doubts.get(sessionId);
if (!sessionDoubts) return null;
const doubt = sessionDoubts.get(doubtId);
if (!doubt) return null;
doubt.status = 'solved';
doubt.solvedAt = new Date().toISOString();
doubt.annotatedImage = annotatedImage;
doubt.teacherResponse = teacherResponse;
// Broadcast solution
await this.broadcast(sessionId, 'doubt_solved', doubt);
return doubt;
}
/**
* Get doubts
*/
getDoubts(sessionId: string): Doubt[] {
const sessionDoubts = this.doubts.get(sessionId);
if (!sessionDoubts) return [];
return Array.from(sessionDoubts.values());
}
// ═══════════════════════════════════════════════════════════════════════════
// REACTIONS MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Add a reaction
*/
async addReaction(sessionId: string, emoji: ReactionType): Promise<Record<ReactionType, number>> {
const sessionReactions = this.reactions.get(sessionId);
if (!sessionReactions) return {} as Record<ReactionType, number>;
const currentCount = sessionReactions.get(emoji) || 0;
sessionReactions.set(emoji, currentCount + 1);
const allReactions = {} as Record<ReactionType, number>;
sessionReactions.forEach((count, em) => {
allReactions[em as ReactionType] = count;
});
// Broadcast update
await this.broadcast(sessionId, 'reaction', {
reactions: allReactions,
lastReaction: emoji,
timestamp: new Date().toISOString()
});
return allReactions;
}
/**
* Get reactions
*/
getReactions(sessionId: string): Record<ReactionType, number> {
const sessionReactions = this.reactions.get(sessionId);
if (!sessionReactions) return {} as Record<ReactionType, number>;
const allReactions = {} as Record<ReactionType, number>;
sessionReactions.forEach((count, emoji) => {
allReactions[emoji as ReactionType] = count;
});
return allReactions;
}
// ═══════════════════════════════════════════════════════════════════════════
// SESSION STATE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get complete session state (for new connections/reconnection)
*/
getSessionState(sessionId: string) {
const sessionObj = this.sessions.get(sessionId);
if (!sessionObj) return null;
return {
session: sessionObj,
currentSlide: this.getSlide(sessionId),
whiteboardStrokes: this.getWhiteboardStrokes(sessionId),
reactions: this.getReactions(sessionId),
activePoll: this.getActivePoll(sessionId),
allPolls: this.getPolls(sessionId),
recentChats: this.getChatMessages(sessionId, 5000),
doubts: this.getDoubts(sessionId),
onlineUsers: this.getOnlineUsers(sessionId)
};
}
}
// Singleton instance
export const sessionManager = new SessionManager();