Download src/lib/session-manager.ts from Mafia2008/sar: direct link, hf CLI and curl.
- Browser
- Download file 23.6 kB
-
https://huggingface.co/spaces/Mafia2008/sar/resolve/main/src/lib/session-manager.ts
- Command line
-
hf download hf://spaces/Mafia2008/sar/src/lib/session-manager.ts
-
curl -L -o session-manager.ts https://huggingface.co/spaces/Mafia2008/sar/resolve/main/src/lib/session-manager.ts
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(); | |