UNPKG

mcp-web-ui

Version:

Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size

822 lines 36.8 kB
import { v4 as uuidv4 } from 'uuid'; import { TokenRegistry } from '../proxy/TokenRegistry.js'; import { MongoClient } from 'mongodb'; import os from 'os'; /** * Session Manager that handles both direct and proxy modes * * Direct Mode: Uses local memory for session management * Proxy Mode: Uses TokenRegistry (MongoDB) for distributed session management */ export class SessionManager { // Version tracking for debugging VERSION = '1.1.4-gateway-test'; // Local session management (direct mode) localSessions = new Map(); usedPorts = new Set(); blockedPorts = new Set(); portRange; baseUrl; protocol; userSessionLimits = new Map(); // Proxy mode components tokenRegistry; mongoClient; proxyMode = false; serverName; constructor(sessionTimeout = 30 * 60 * 1000, portRange = [3000, 65535], baseUrl = 'localhost', protocol, blockedPorts = [], options = {}) { // Validate blocked ports are within range const [minPort, maxPort] = portRange; const invalidPorts = blockedPorts.filter(port => port < minPort || port > maxPort); if (invalidPorts.length > 0) { throw new Error(`Blocked ports ${invalidPorts.join(', ')} are outside the port range [${minPort}, ${maxPort}]`); } this.blockedPorts = new Set(blockedPorts); this.portRange = portRange; this.serverName = options.serverName || 'mcp-webui'; // Check if we're in development mode const isDevelopment = process.env.NODE_ENV === 'development' || process.env.MCP_WEB_UI_DEV_MODE === 'true' || baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1') || baseUrl.includes('dev.'); // Auto-detect protocol from baseUrl if not provided if (protocol) { this.protocol = protocol; // If baseUrl contains a protocol, strip it since we have an explicit protocol if (baseUrl.startsWith('https://')) { this.baseUrl = baseUrl.replace('https://', ''); } else if (baseUrl.startsWith('http://')) { this.baseUrl = baseUrl.replace('http://', ''); } else { this.baseUrl = baseUrl; } } else { // Extract protocol from baseUrl if it contains one if (baseUrl.startsWith('https://')) { this.protocol = 'https'; this.baseUrl = baseUrl.replace('https://', ''); } else if (baseUrl.startsWith('http://')) { this.protocol = 'http'; this.baseUrl = baseUrl.replace('http://', ''); } else { // In development mode, default to HTTP this.protocol = isDevelopment ? 'http' : 'http'; this.baseUrl = baseUrl; } } // Force HTTP in development mode if (isDevelopment) { this.protocol = 'http'; this.log('INFO', `Development mode detected, forcing HTTP protocol`); } // Debug logging this.log('INFO', `SessionManager initialized with baseUrl: "${baseUrl}", final baseUrl: "${this.baseUrl}", protocol: "${this.protocol}", isDevelopment: ${isDevelopment}`); // Initialize proxy mode if requested this.proxyMode = options.proxyMode || false; if (this.proxyMode) { this.initializeProxyMode(options).catch(error => { this.log('ERROR', 'Failed to initialize proxy mode:', error); }); } } /** * Initialize proxy mode with MongoDB connection */ async initializeProxyMode(options) { const mongoUrl = options.mongoUrl || process.env.MCP_WEB_UI_MONGO_URL; const mongoDbName = options.mongoDbName || process.env.MCP_WEB_UI_MONGO_DB_NAME || 'mcp_webui'; const jwtSecret = options.jwtSecret || process.env.MCP_WEB_UI_JWT_SECRET; if (!mongoUrl) { throw new Error('MongoDB URL is required for proxy mode'); } if (!jwtSecret) { throw new Error('JWT secret is required for proxy mode'); } try { this.mongoClient = new MongoClient(mongoUrl); await this.mongoClient.connect(); const db = this.mongoClient.db(mongoDbName); this.tokenRegistry = new TokenRegistry(db, { jwtSecret }); this.log('INFO', 'Proxy mode initialized successfully'); } catch (error) { this.log('ERROR', 'Failed to initialize proxy mode:', error); throw error; } } /** * Check if proxy mode is enabled */ isProxyMode() { return this.proxyMode; } /** * Generate composite session key for proper session isolation * Format: userId:serverName:serverType */ generateSessionKey(userId, serverName, serverType) { const safeServerName = serverName || this.serverName || 'mcp-webui'; const safeServerType = serverType || 'mcp-webui'; return `${userId}:${safeServerName}:${safeServerType}`; } /** * Find existing session by composite key */ findSessionByCompositeKey(userId, serverName, serverType) { const sessionKey = this.generateSessionKey(userId, serverName, serverType); for (const session of this.localSessions.values()) { if (session.isActive && session.expiresAt > new Date()) { const sessionSessionKey = this.generateSessionKey(session.userId, session.serverName, session.serverType); if (sessionSessionKey === sessionKey) { return session; } } } return null; } /** * Create a new session for a user * Automatically terminates any existing session for the same user */ async createSession(userId) { this.log('INFO', `[GATEWAY-DEBUG] SessionManager VERSION: ${this.VERSION}`); this.log('INFO', `[GATEWAY-DEBUG] SessionManager.createSession() called for user: ${userId}`); // Check if we should use gateway mode const useGateway = process.env.MCP_WEB_UI_USE_GATEWAY === 'true'; const gatewayUrl = process.env.MCP_WEB_UI_GATEWAY_URL || 'http://localhost:3082'; // Debug logging for gateway mode detection this.log('INFO', `[GATEWAY-DEBUG] Environment check:`, { MCP_WEB_UI_USE_GATEWAY: process.env.MCP_WEB_UI_USE_GATEWAY, MCP_WEB_UI_GATEWAY_URL: process.env.MCP_WEB_UI_GATEWAY_URL, useGateway: useGateway, gatewayUrl: gatewayUrl, userId: userId }); if (useGateway) { this.log('INFO', `[GATEWAY-DEBUG] Using gateway mode, calling createGatewaySession`); return this.createGatewaySession(userId, gatewayUrl); } this.log('INFO', `[GATEWAY-DEBUG] Using direct mode, calling createDirectSession`); return this.createDirectSession(userId); } /** * Create a session through the gateway */ async createGatewaySession(userId, gatewayUrl) { let allocatedPort = 0; try { this.log('INFO', `[GATEWAY-DEBUG] Starting gateway session creation for user ${userId}`); this.log('INFO', `[GATEWAY-DEBUG] Gateway URL: ${gatewayUrl}`); // CRITICAL: Check gateway health before attempting any gateway operations const gatewayHealthy = await this.validateGatewayHealth(gatewayUrl); if (!gatewayHealthy) { this.log('WARN', `[GATEWAY-DEBUG] Gateway is not available, clearing stale sessions and falling back to direct mode`); // Clear all local sessions and used ports when gateway is down // This prevents reusing ports from previous gateway sessions that are outside the configured range this.clearStaleSessions(); return this.createDirectSession(userId); } // Cleanup expired sessions for this user const expiredSessions = Array.from(this.localSessions.entries()) .filter(([sessionId, session]) => session.userId === userId && session.expiresAt <= new Date()); for (const [sessionId, session] of expiredSessions) { this.log('INFO', `[GATEWAY-DEBUG] Cleaning up expired session for user ${userId}: ${session.token.substring(0, 20)}...`); this.localSessions.delete(sessionId); } // Check for existing active session for this user+server combination const sessionKey = this.generateSessionKey(userId, this.serverName, 'mcp-webui'); this.log('INFO', `[GATEWAY-DEBUG] Looking for existing session with composite key: ${sessionKey}`); const existingSession = this.findSessionByCompositeKey(userId, this.serverName, 'mcp-webui'); if (existingSession) { this.log('INFO', `[GATEWAY-DEBUG] Reusing existing session for composite key ${sessionKey}: ${existingSession.token.substring(0, 20)}...`); return existingSession; } else { this.log('INFO', `[GATEWAY-DEBUG] No existing session found for composite key ${sessionKey}, creating new session`); } // Try to discover existing registered server first const serverName = this.serverName || 'mcp-webui'; let backend; try { this.log('INFO', `[GATEWAY-DEBUG] Attempting to discover registered server: ${serverName}`); const discoveredServer = await this.discoverRegisteredServer(serverName); if (discoveredServer) { // Check if the discovered server's port is available const discoveredPort = discoveredServer.backend.port; if (discoveredPort && this.usedPorts.has(discoveredPort)) { this.log('WARN', `[GATEWAY-DEBUG] Discovered server port ${discoveredPort} is already in use, allocating new port`); // Allocate a new port instead allocatedPort = this.allocatePort(); const resolvedBackendHost = this.resolveBackendHost(); backend = { type: 'tcp', host: resolvedBackendHost, port: allocatedPort }; } else { backend = discoveredServer.backend; // Mark the discovered port as used if (discoveredPort) { this.usedPorts.add(discoveredPort); } this.log('INFO', `[GATEWAY-DEBUG] Using discovered server: ${JSON.stringify(backend)}`); } } else { this.log('WARN', `[GATEWAY-DEBUG] No registered server found for ${serverName}, falling back to port allocation`); // Fallback to old behavior allocatedPort = this.allocatePort(); const resolvedBackendHost = this.resolveBackendHost(); backend = { type: 'tcp', host: resolvedBackendHost, port: allocatedPort }; } } catch (error) { this.log('ERROR', `[GATEWAY-DEBUG] Server discovery failed: ${error}, falling back to port allocation`); // Fallback to old behavior allocatedPort = this.allocatePort(); const resolvedBackendHost = this.resolveBackendHost(); backend = { type: 'tcp', host: resolvedBackendHost, port: allocatedPort }; } const requestBody = { userId, serverName, serverType: 'mcp-webui', backend, ttlMinutes: 30 }; this.log('INFO', `[GATEWAY-DEBUG] Request body:`, requestBody); this.log('INFO', `[GATEWAY-DEBUG] Making POST request to ${gatewayUrl}/create-session`); // Call gateway to create session const response = await fetch(`${gatewayUrl}/create-session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); this.log('INFO', `[GATEWAY-DEBUG] Response received:`, { status: response.status, statusText: response.statusText, ok: response.ok, headers: Object.fromEntries(response.headers.entries()) }); if (!response.ok) { this.log('ERROR', `[GATEWAY-DEBUG] Gateway session creation failed: ${response.statusText}`); throw new Error(`Gateway session creation failed: ${response.statusText}`); } const result = await response.json(); this.log('INFO', `[GATEWAY-DEBUG] Gateway response:`, result); const token = result.token; // Check if we already have a local session with this token (from previous gateway call) const existingLocalSession = Array.from(this.localSessions.values()) .find(session => session.token === token); if (existingLocalSession) { this.log('INFO', `[GATEWAY-DEBUG] Found existing local session for token ${token.substring(0, 20)}..., reusing session ${existingLocalSession.id}`); return existingLocalSession; } // Generate URL with gateway token const proxyPrefix = process.env.MCP_WEB_UI_PROXY_PREFIX || '/mcp'; const sessionUrl = `${this.protocol}://${this.baseUrl}${proxyPrefix}/${token}/`; const session = { id: uuidv4(), token, userId, url: sessionUrl, port: backend.port || allocatedPort, // UI server will bind to this port startTime: new Date(), lastActivity: new Date(), expiresAt: new Date(Date.now() + 30 * 60 * 1000), isActive: true, serverName: this.serverName || 'mcp-webui', serverType: 'mcp-webui' }; // Store the session in local memory for future reuse this.localSessions.set(session.id, session); const createdSessionKey = this.generateSessionKey(userId, this.serverName, 'mcp-webui'); this.log('INFO', `Created gateway session ${session.id} for composite key ${createdSessionKey} (user: ${userId}, server: ${this.serverName})`); return session; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; this.log('ERROR', `[GATEWAY-DEBUG] Failed to create gateway session:`, { error: errorMessage, stack: errorStack, userId: userId }); this.log('INFO', `[GATEWAY-DEBUG] Falling back to direct session creation`); // Clear stale sessions and ports when gateway operations fail // This ensures we don't reuse ports from failed gateway sessions this.clearStaleSessions(); // Fallback to direct session try { const fallback = await this.createDirectSession(userId); return fallback; } catch (fallbackError) { throw fallbackError; } } } /** * Determine the backend host that the gateway can use to reach this UI server * Priority: * 1) MCP_WEB_UI_BACKEND_HOST env var (explicit override) * 2) First non-internal IPv4 address (container/host primary interface) * 3) Fallback to 127.0.0.1 */ resolveBackendHost() { const explicit = process.env.MCP_WEB_UI_BACKEND_HOST; if (explicit && explicit.trim().length > 0) { this.log('INFO', `[GATEWAY-DEBUG] Using explicit backend host from env: ${explicit}`); return explicit.trim(); } try { const interfaces = os.networkInterfaces(); for (const name of Object.keys(interfaces)) { const addrs = interfaces[name] || []; for (const addr of addrs) { if (addr && addr.family === 'IPv4' && !addr.internal) { this.log('INFO', `[GATEWAY-DEBUG] Auto-detected backend host ${addr.address} on iface ${name}`); return addr.address; } } } } catch (e) { // Ignore detection errors } this.log('WARN', `[GATEWAY-DEBUG] Falling back to 127.0.0.1 for backend host`); return '127.0.0.1'; } /** * Validate gateway health before attempting operations */ async validateGatewayHealth(gatewayUrl) { try { this.log('INFO', `[GATEWAY-HEALTH] Checking gateway health at ${gatewayUrl}/health`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout const response = await fetch(`${gatewayUrl}/health`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, signal: controller.signal }); clearTimeout(timeoutId); const isHealthy = response.ok; this.log('INFO', `[GATEWAY-HEALTH] Gateway health check result: ${isHealthy ? 'HEALTHY' : 'UNHEALTHY'} (status: ${response.status})`); return isHealthy; } catch (error) { if (error.name === 'AbortError') { this.log('WARN', `[GATEWAY-HEALTH] Gateway health check timed out after 5 seconds`); } else { this.log('WARN', `[GATEWAY-HEALTH] Gateway health check failed: ${error.message}`); } return false; } } /** * Clear stale sessions and ports when gateway is unavailable * This prevents reusing ports from previous gateway sessions that may be outside the configured range */ clearStaleSessions() { this.log('INFO', `[GATEWAY-CLEANUP] Clearing ${this.localSessions.size} stale sessions and ${this.usedPorts.size} used ports`); // Clear all local sessions this.localSessions.clear(); // Clear all used ports - this is critical to prevent reusing ports from previous gateway sessions this.usedPorts.clear(); this.log('INFO', `[GATEWAY-CLEANUP] Stale sessions and ports cleared successfully`); } /** * Discover a registered server from the gateway */ async discoverRegisteredServer(serverName) { const gatewayUrl = process.env.MCP_WEB_UI_GATEWAY_URL || 'http://localhost:3081'; try { this.log('INFO', `[GATEWAY-DEBUG] Querying gateway for server: ${serverName}`); const response = await fetch(`${gatewayUrl}/discover-server/${serverName}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (response.status === 404) { this.log('INFO', `[GATEWAY-DEBUG] Server ${serverName} not registered with gateway`); return null; } if (!response.ok) { throw new Error(`Gateway discovery failed: ${response.statusText}`); } const result = await response.json(); this.log('INFO', `[GATEWAY-DEBUG] Gateway discovery result:`, result); return result; } catch (error) { this.log('ERROR', `[GATEWAY-DEBUG] Failed to discover server from gateway: ${error.message}`); return null; } } /** * Create a direct session (original logic) */ async createDirectSession(userId) { // Rate limiting: max 5 sessions per user per hour const sessionTime = new Date(); const userLimits = this.userSessionLimits.get(userId); if (userLimits) { const hourAgo = new Date(sessionTime.getTime() - 60 * 60 * 1000); if (userLimits.lastCreated > hourAgo && userLimits.count >= 5) { throw new Error('Session creation rate limit exceeded. Maximum 5 sessions per hour.'); } if (userLimits.lastCreated > hourAgo) { userLimits.count++; } else { userLimits.count = 1; } userLimits.lastCreated = sessionTime; } else { this.userSessionLimits.set(userId, { count: 1, lastCreated: sessionTime }); } // Check for and terminate any existing session for this user+server combination const existingSession = this.findSessionByCompositeKey(userId, this.serverName, 'mcp-webui'); if (existingSession) { const sessionKey = this.generateSessionKey(userId, this.serverName, 'mcp-webui'); this.log('INFO', `Terminating existing session ${existingSession.id} for composite key ${sessionKey} before creating new one`); await this.terminateSession(existingSession.id); } const sessionId = uuidv4(); const port = this.allocatePort(); const now = new Date(); const expiresAt = new Date(now.getTime() + 30 * 60 * 1000); // 30 minutes if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Create session through TokenRegistry const resolvedBackendHost = this.resolveBackendHost(); const proxySession = await this.tokenRegistry.createSession({ userId, serverName: this.serverName, backend: { type: 'tcp', host: resolvedBackendHost, // ✅ Add the missing host port }, metadata: { sessionId } }); const proxyPrefix = process.env.MCP_WEB_UI_PROXY_PREFIX || '/mcp'; const proxyBaseUrl = process.env.MCP_WEB_UI_PROXY_BASE_URL || this.baseUrl; const sessionUrl = `${this.protocol}://${proxyBaseUrl}${proxyPrefix}/${proxySession.token}/`; const session = { id: sessionId, token: proxySession.token, userId, url: sessionUrl, port, startTime: now, lastActivity: now, expiresAt, isActive: true, serverName: this.serverName || 'mcp-webui', serverType: 'mcp-webui' }; // Store locally for port management this.localSessions.set(sessionId, session); this.log('INFO', `Created proxy session ${sessionId} for user ${userId} on port ${port}`); return session; } else { // Direct mode: Create local session const token = uuidv4(); // Check if we should use gateway proxy route instead of direct port access const proxyPrefix = process.env.MCP_WEB_UI_PROXY_PREFIX; const useGateway = process.env.MCP_WEB_UI_USE_GATEWAY === 'true'; let sessionUrl; if (useGateway && proxyPrefix && proxyPrefix.trim().length > 0) { // Use gateway proxy route: /mcp/<token>/ (token-based routing) // In direct mode fallback, we still use the actual token sessionUrl = `${this.protocol}://${this.baseUrl}${proxyPrefix}/${token}/`; this.log('INFO', `Using gateway proxy route (direct fallback): ${sessionUrl}`); } else if (proxyPrefix && proxyPrefix.trim().length > 0) { // Use nginx proxy route: /mcp/ui/<port>/?token=... (legacy) sessionUrl = `${this.protocol}://${this.baseUrl}${proxyPrefix}/${port}/?token=${token}`; this.log('INFO', `Using nginx proxy route: ${sessionUrl}`); } else { // Use direct port access: :port/?token=... sessionUrl = `${this.protocol}://${this.baseUrl}:${port}?token=${token}`; this.log('INFO', `Using direct port access: ${sessionUrl}`); } // Debug logging for URL generation this.log('INFO', `Direct mode URL generation - protocol: "${this.protocol}", baseUrl: "${this.baseUrl}", port: ${port}, proxyPrefix: "${proxyPrefix}", final URL: "${sessionUrl}"`); const session = { id: sessionId, token, userId, url: sessionUrl, port, startTime: now, lastActivity: now, expiresAt, isActive: true, serverName: this.serverName || 'mcp-webui', serverType: 'mcp-webui' }; this.localSessions.set(sessionId, session); this.log('INFO', `Created direct session ${sessionId} for user ${userId} on port ${port}`); return session; } } /** * Get session by token (for authentication) */ async getSessionByToken(token, updateActivity = false) { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Validate through TokenRegistry const proxySession = await this.tokenRegistry.validateToken(token); if (!proxySession) { return null; } // Convert to WebUISession format const proxyPrefix = process.env.MCP_WEB_UI_PROXY_PREFIX || '/mcp'; const proxyBaseUrl = process.env.MCP_WEB_UI_PROXY_BASE_URL || this.baseUrl; const sessionUrl = `${this.protocol}://${proxyBaseUrl}${proxyPrefix}/${token}/`; return { id: proxySession.metadata?.sessionId || proxySession.token.substring(0, 8), token, userId: proxySession.userId, url: sessionUrl, port: proxySession.backend.port || 0, startTime: proxySession.createdAt, lastActivity: proxySession.lastAccessedAt, expiresAt: proxySession.expiresAt, isActive: true }; } else { // Direct mode: Search local sessions for (const session of this.localSessions.values()) { if (session.token === token && session.isActive) { if (updateActivity) { session.lastActivity = new Date(); } return session; } } return null; } } /** * Get session by ID */ async getSession(sessionId) { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Search through TokenRegistry // For session ID lookup, we need to search through all sessions // This is not ideal for performance, but TokenRegistry doesn't have a direct ID lookup const stats = await this.tokenRegistry.getStats(); if (stats.totalActiveSessions === 0) { return null; } // For now, return null for proxy mode session ID lookup // In a real implementation, you might want to add an index on metadata.sessionId this.log('WARN', 'Session ID lookup not supported in proxy mode - use token lookup instead'); return null; } else { // Direct mode: Search local sessions return this.localSessions.get(sessionId) || null; } } /** * Get session by user ID (returns first active session for user) * Note: This method is deprecated in favor of composite key lookups * Use findSessionByCompositeKey for proper server isolation */ async getSessionByUserId(userId) { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Search through TokenRegistry const sessions = await this.tokenRegistry.getUserSessions(userId); if (sessions.length === 0) { return null; } // Return the most recent session for the user const proxySession = sessions[0]; // getUserSessions returns sorted by createdAt desc // Convert to WebUISession format const proxyPrefix = process.env.MCP_WEB_UI_PROXY_PREFIX || '/mcp'; const proxyBaseUrl = process.env.MCP_WEB_UI_PROXY_BASE_URL || this.baseUrl; const sessionUrl = `${this.protocol}://${proxyBaseUrl}${proxyPrefix}/${proxySession.token}/`; return { id: proxySession.metadata?.sessionId || proxySession.token.substring(0, 8), token: proxySession.token, userId: proxySession.userId, url: sessionUrl, port: proxySession.backend.port || 0, startTime: proxySession.createdAt, lastActivity: proxySession.lastAccessedAt, expiresAt: proxySession.expiresAt, isActive: true, serverName: this.serverName || 'mcp-webui', serverType: 'mcp-webui' }; } else { // Direct mode: Search local sessions (returns first active session) for (const session of this.localSessions.values()) { if (session.userId === userId && session.isActive) { return session; } } return null; } } /** * Get all active sessions */ async getActiveSessions() { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Get from TokenRegistry // Note: TokenRegistry doesn't have a direct getAllActiveSessions method // We'll need to implement this differently or use stats const stats = await this.tokenRegistry.getStats(); if (stats.totalActiveSessions === 0) { return []; } // For now, return empty array in proxy mode // In a real implementation, you might want to add a method to get all sessions this.log('WARN', 'getActiveSessions not fully supported in proxy mode'); return []; } else { // Direct mode: Return local sessions return Array.from(this.localSessions.values()).filter(session => session.isActive); } } /** * Terminate a session */ async terminateSession(sessionId) { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Terminate through TokenRegistry const session = await this.getSession(sessionId); if (!session) { return false; } const success = await this.tokenRegistry.revokeSession(session.token); if (success) { // Free the port this.freePort(session.port); this.log('INFO', `Terminated proxy session ${sessionId}`); } return success; } else { // Direct mode: Terminate local session const session = this.localSessions.get(sessionId); if (!session) { return false; } session.isActive = false; this.freePort(session.port); this.localSessions.delete(sessionId); this.log('INFO', `Terminated direct session ${sessionId}`); return true; } } /** * Extend session by token */ async extendSessionByToken(token, extensionMinutes) { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Extend through TokenRegistry return await this.tokenRegistry.extendSession(token, extensionMinutes); } else { // Direct mode: Extend local session const session = await this.getSessionByToken(token); if (!session) { return false; } session.expiresAt = new Date(session.expiresAt.getTime() + extensionMinutes * 60 * 1000); this.log('INFO', `Extended session ${session.id} by ${extensionMinutes} minutes`); return true; } } /** * Get session statistics */ async getStats() { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: Get stats from TokenRegistry const stats = await this.tokenRegistry.getStats(); return { mode: 'proxy', totalActiveSessions: stats.totalActiveSessions, usedPorts: Array.from(this.usedPorts), sessionsByUser: stats.sessionsByUser, sessionsByServer: stats.sessionsByServer }; } else { // Direct mode: Get stats from local sessions const sessionsByUser = {}; this.localSessions.forEach(session => { if (session.isActive) { sessionsByUser[session.userId] = (sessionsByUser[session.userId] || 0) + 1; } }); return { mode: 'direct', totalActiveSessions: Array.from(this.localSessions.values()).filter(s => s.isActive).length, usedPorts: Array.from(this.usedPorts), sessionsByUser }; } } /** * Allocate a random port from the available range */ allocatePort() { const [minPort, maxPort] = this.portRange; let attempts = 0; const maxAttempts = 100; while (attempts < maxAttempts) { const port = Math.floor(Math.random() * (maxPort - minPort + 1)) + minPort; if (!this.usedPorts.has(port) && !this.blockedPorts.has(port)) { this.usedPorts.add(port); return port; } attempts++; } throw new Error(`Unable to allocate port after ${maxAttempts} attempts`); } /** * Free a port when session is terminated */ freePort(port) { this.usedPorts.delete(port); } /** * Clean up expired sessions */ async cleanupExpiredSessions() { if (this.proxyMode && this.tokenRegistry) { // Proxy mode: MongoDB TTL handles cleanup automatically // Just clean up local port tracking // Note: We can't easily get all active sessions in proxy mode // so we'll skip port cleanup for now this.log('INFO', 'Proxy mode cleanup: MongoDB TTL handles session cleanup automatically'); } else { // Direct mode: Clean up expired local sessions const now = new Date(); for (const [sessionId, session] of this.localSessions.entries()) { if (session.expiresAt < now) { this.log('INFO', `Cleaning up expired session ${sessionId}`); session.isActive = false; this.freePort(session.port); this.localSessions.delete(sessionId); } } } } /** * Shutdown the session manager */ async shutdown() { if (this.mongoClient) { await this.mongoClient.close(); } // Clear all sessions this.localSessions.clear(); this.usedPorts.clear(); this.userSessionLimits.clear(); this.log('INFO', 'SessionManager shutdown complete'); } /** * Log messages with optional data */ log(level, message, data) { const timestamp = new Date().toISOString(); if (data) { console.log(`[${timestamp}][${level}][SessionManager] ${message}`, data); } else { console.log(`[${timestamp}][${level}][SessionManager] ${message}`); } } } //# sourceMappingURL=SessionManager.js.map