feat: implement session persistence for v2.19.0 (Phase 1 + Phase 2)

Phase 1 - Lazy Session Restoration (REQ-1, REQ-2, REQ-8):
- Add onSessionNotFound hook for restoring sessions from external storage
- Implement idempotent session creation to prevent race conditions
- Add session ID validation for security (prevent injection attacks)
- Comprehensive error handling (400/408/500 status codes)
- 13 integration tests covering all scenarios

Phase 2 - Session Management API (REQ-5):
- getActiveSessions(): Get all active session IDs
- getSessionState(sessionId): Get session state for persistence
- getAllSessionStates(): Bulk session state retrieval
- restoreSession(sessionId, context): Manual session restoration
- deleteSession(sessionId): Manual session termination
- 21 unit tests covering all API methods

Benefits:
- Sessions survive container restarts
- Horizontal scaling support (no session stickiness needed)
- Zero-downtime deployments
- 100% backwards compatible

Implementation Details:
- Backend methods in http-server-single-session.ts
- Public API methods in mcp-engine.ts
- SessionState type exported from index.ts
- Synchronous session creation and deletion for reliable testing
- Version updated from 2.18.10 to 2.19.0

Tests: 34 passing (13 integration + 21 unit)
Coverage: Full API coverage with edge cases
Security: Session ID validation prevents SQL/NoSQL injection and path traversal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-10-12 17:25:38 +02:00
parent 4566253bdc
commit 1d34ad81d5
14 changed files with 9595 additions and 51 deletions

View File

@@ -9,6 +9,7 @@ import { Request, Response } from 'express';
import { SingleSessionHTTPServer } from './http-server-single-session';
import { logger } from './utils/logger';
import { InstanceContext } from './types/instance-context';
import { SessionRestoreHook, SessionState } from './types/session-restoration';
export interface EngineHealth {
status: 'healthy' | 'unhealthy';
@@ -25,6 +26,22 @@ export interface EngineHealth {
export interface EngineOptions {
sessionTimeout?: number;
logLevel?: 'error' | 'warn' | 'info' | 'debug';
/**
* Session restoration hook for multi-tenant persistence
* Called when a client tries to use an unknown session ID
* Return instance context to restore the session, or null to reject
*
* @since 2.19.0
*/
onSessionNotFound?: SessionRestoreHook;
/**
* Maximum time to wait for session restoration (milliseconds)
* @default 5000 (5 seconds)
* @since 2.19.0
*/
sessionRestorationTimeout?: number;
}
export class N8NMCPEngine {
@@ -32,9 +49,9 @@ export class N8NMCPEngine {
private startTime: Date;
constructor(options: EngineOptions = {}) {
this.server = new SingleSessionHTTPServer();
this.server = new SingleSessionHTTPServer(options);
this.startTime = new Date();
if (options.logLevel) {
process.env.LOG_LEVEL = options.logLevel;
}
@@ -97,7 +114,7 @@ export class N8NMCPEngine {
total: Math.round(memoryUsage.heapTotal / 1024 / 1024),
unit: 'MB'
},
version: '2.3.2'
version: '2.19.0'
};
} catch (error) {
logger.error('Health check failed:', error);
@@ -106,7 +123,7 @@ export class N8NMCPEngine {
uptime: 0,
sessionActive: false,
memoryUsage: { used: 0, total: 0, unit: 'MB' },
version: '2.3.2'
version: '2.19.0'
};
}
}
@@ -118,10 +135,118 @@ export class N8NMCPEngine {
getSessionInfo(): { active: boolean; sessionId?: string; age?: number } {
return this.server.getSessionInfo();
}
/**
* Get all active session IDs (Phase 2 - REQ-5)
* Returns array of currently active session IDs
*
* @returns Array of session IDs
* @since 2.19.0
*
* @example
* ```typescript
* const engine = new N8NMCPEngine();
* const sessionIds = engine.getActiveSessions();
* console.log(`Active sessions: ${sessionIds.length}`);
* ```
*/
getActiveSessions(): string[] {
return this.server.getActiveSessions();
}
/**
* Get session state for a specific session (Phase 2 - REQ-5)
* Returns session state or null if session doesn't exist
*
* @param sessionId - The session ID to get state for
* @returns SessionState object or null
* @since 2.19.0
*
* @example
* ```typescript
* const state = engine.getSessionState('session-123');
* if (state) {
* // Save to database
* await db.saveSession(state);
* }
* ```
*/
getSessionState(sessionId: string): SessionState | null {
return this.server.getSessionState(sessionId);
}
/**
* Get all session states (Phase 2 - REQ-5)
* Returns array of all active session states for bulk backup
*
* @returns Array of SessionState objects
* @since 2.19.0
*
* @example
* ```typescript
* // Periodic backup every 5 minutes
* setInterval(async () => {
* const states = engine.getAllSessionStates();
* for (const state of states) {
* await database.upsertSession(state);
* }
* }, 300000);
* ```
*/
getAllSessionStates(): SessionState[] {
return this.server.getAllSessionStates();
}
/**
* Manually restore a session (Phase 2 - REQ-5)
* Creates a session with the given ID and instance context
*
* @param sessionId - The session ID to restore
* @param instanceContext - Instance configuration
* @returns true if session was restored successfully, false otherwise
* @since 2.19.0
*
* @example
* ```typescript
* // Restore session from database
* const session = await db.loadSession('session-123');
* if (session) {
* const restored = engine.restoreSession(
* session.sessionId,
* session.instanceContext
* );
* console.log(`Restored: ${restored}`);
* }
* ```
*/
restoreSession(sessionId: string, instanceContext: InstanceContext): boolean {
return this.server.manuallyRestoreSession(sessionId, instanceContext);
}
/**
* Manually delete a session (Phase 2 - REQ-5)
* Removes the session and cleans up resources
*
* @param sessionId - The session ID to delete
* @returns true if session was deleted, false if not found
* @since 2.19.0
*
* @example
* ```typescript
* // Delete expired session
* const deleted = engine.deleteSession('session-123');
* if (deleted) {
* await db.deleteSession('session-123');
* }
* ```
*/
deleteSession(sessionId: string): boolean {
return this.server.manuallyDeleteSession(sessionId);
}
/**
* Graceful shutdown for service lifecycle
*
*
* @example
* process.on('SIGTERM', async () => {
* await engine.shutdown();