UNPKG

touchdesigner-mcp-server

Version:
277 lines (276 loc) 9.59 kB
import { randomUUID } from "node:crypto"; import { createErrorResult, createSuccessResult } from "../core/result.js"; import { DEFAULT_SESSION_CONFIG } from "./config.js"; /** * Session Manager * * Manages client sessions for Streamable HTTP transport. * Provides session creation, validation, TTL-based expiration, and automatic cleanup. * * Note: Session validation (checking if session ID exists) is now handled by * StreamableHTTPServerTransport.handleRequest(). This SessionManager focuses on: * - Session creation via SDK callbacks (onsessioninitialized) * - Session cleanup via SDK callbacks (onsessionclosed) and TTL-based cleanup * - Session tracking for health checks and monitoring * * @example * ```typescript * const sessionManager = new SessionManager( * { enabled: true, ttl: 60 * 60 * 1000 }, // 1 hour TTL * logger * ); * * // Create session (typically called from SDK callback) * const sessionId = sessionManager.create({ clientVersion: '1.0' }); * * // Start automatic TTL-based cleanup * sessionManager.startTTLCleanup(); * * // Stop cleanup when done * sessionManager.stopTTLCleanup(); * ``` */ export class SessionManager { sessions = new Map(); config; logger; cleanupInterval = null; onSessionExpired = null; constructor(config, logger) { // Apply defaults for optional values to ensure TTL cleanup is active by default this.config = { cleanupInterval: config.cleanupInterval ?? DEFAULT_SESSION_CONFIG.cleanupInterval, enabled: config.enabled ?? DEFAULT_SESSION_CONFIG.enabled, ttl: config.ttl ?? DEFAULT_SESSION_CONFIG.ttl, }; this.logger = logger; } /** * Create a new session with optional metadata * * @param metadata - Optional metadata to associate with the session * @returns Session ID (UUID v4) */ create(metadata) { const sessionId = randomUUID(); const now = Date.now(); const session = { createdAt: now, id: sessionId, lastAccessedAt: now, metadata, }; this.sessions.set(sessionId, session); this.logger.sendLog({ data: `Session created: ${sessionId}${metadata ? ` (metadata: ${JSON.stringify(metadata)})` : ""}`, level: "info", logger: "SessionManager", }); return sessionId; } /** * Update lastAccessedAt when a session receives activity * * @param sessionId - Session ID to refresh */ touch(sessionId) { const session = this.sessions.get(sessionId); if (!session) { return createErrorResult(new Error(`Session not found: ${sessionId}`)); } session.lastAccessedAt = Date.now(); return createSuccessResult(undefined); } /** * Register a callback to run when TTL cleanup expires a session. * * @param handler - Callback invoked with expired session ID */ setExpirationHandler(handler) { this.onSessionExpired = handler; } /** * Register an SDK-created session for tracking * * This method registers sessions that were created by the MCP SDK's * StreamableHTTPServerTransport. The SDK generates session IDs via the * sessionIdGenerator callback, and then calls onsessioninitialized. * This method creates a new tracking entry with current timestamps * so the session can be managed for TTL cleanup and monitoring. * * @param sessionId - Session ID generated by SDK * @param metadata - Optional metadata to associate with the session */ register(sessionId, metadata) { const now = Date.now(); const session = { createdAt: now, id: sessionId, lastAccessedAt: now, metadata, }; this.sessions.set(sessionId, session); this.logger.sendLog({ data: `Session registered: ${sessionId}${metadata ? ` (metadata: ${JSON.stringify(metadata)})` : ""}`, level: "info", logger: "SessionManager", }); } /** * Clean up (delete) a session by ID * * @param sessionId - Session ID to clean up * @returns Result indicating success or failure */ cleanup(sessionId) { const existed = this.sessions.delete(sessionId); if (existed) { this.logger.sendLog({ data: `Session cleaned up: ${sessionId}`, level: "info", logger: "SessionManager", }); return createSuccessResult(undefined); } return createErrorResult(new Error(`Session not found for cleanup: ${sessionId}`)); } /** * List all active sessions * * @returns Array of all active sessions */ list() { return Array.from(this.sessions.values()); } /** * Start automatic TTL-based cleanup * * Runs cleanup task at an interval of TTL/2 to remove expired sessions. * Does nothing if TTL is not configured or cleanup is already running. */ startTTLCleanup() { // Don't start if TTL not configured or already running if (!this.config.ttl || this.cleanupInterval) { return; } const intervalMs = this.config.cleanupInterval || this.config.ttl / 2; this.logger.sendLog({ data: `Starting TTL cleanup (interval: ${intervalMs}ms, TTL: ${this.config.ttl}ms)`, level: "info", logger: "SessionManager", }); this.cleanupInterval = setInterval(() => { try { this.runCleanupTask(); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.sendLog({ data: `CRITICAL: TTL cleanup task failed: ${err.message}. Stack: ${err.stack}`, level: "error", logger: "SessionManager", }); } }, intervalMs); // Don't keep the process alive just for cleanup this.cleanupInterval.unref(); } /** * Stop automatic TTL-based cleanup */ stopTTLCleanup() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; this.logger.sendLog({ data: "Stopped TTL cleanup", level: "info", logger: "SessionManager", }); } } /** * Run a single cleanup task to remove expired sessions * * @private */ runCleanupTask() { const ttl = this.config.ttl; if (!ttl) { return; } const now = Date.now(); const expiredIds = []; for (const [id, session] of this.sessions.entries()) { try { const elapsed = now - session.lastAccessedAt; if (elapsed > ttl) { expiredIds.push(id); } } catch (error) { // Log individual session cleanup errors but continue processing others const err = error instanceof Error ? error : new Error(String(error)); this.logger.sendLog({ data: `Error cleaning session ${id}: ${err.message}`, level: "error", logger: "SessionManager", }); } } for (const sessionId of expiredIds) { try { const result = this.onSessionExpired?.(sessionId); if (result && typeof result.catch === "function") { result.catch((error) => { const err = error instanceof Error ? error : new Error(String(error)); this.logger.sendLog({ data: `Error running expiration handler for ${sessionId}: ${err.message}`, level: "error", logger: "SessionManager", }); }); } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.logger.sendLog({ data: `Error running expiration handler for ${sessionId}: ${err.message}`, level: "error", logger: "SessionManager", }); } // Ensure the session is removed from tracking even if handler fails this.sessions.delete(sessionId); } if (expiredIds.length > 0) { this.logger.sendLog({ data: `Cleanup completed: removed ${expiredIds.length} expired session(s): ${expiredIds.join(", ")}`, level: "info", logger: "SessionManager", }); } } /** * Get number of active sessions * * @returns Number of active sessions */ getActiveSessionCount() { return this.sessions.size; } /** * Clear all sessions * * Useful for testing or emergency cleanup. */ clearAll() { const count = this.sessions.size; this.sessions.clear(); this.logger.sendLog({ data: `All sessions cleared: ${count} session(s) removed`, level: "info", logger: "SessionManager", }); } }