n8n-mcp 2.18.9 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/nodes.db +0 -0
- package/dist/http-server-single-session.d.ts +24 -1
- package/dist/http-server-single-session.d.ts.map +1 -1
- package/dist/http-server-single-session.js +496 -23
- package/dist/http-server-single-session.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +19 -9
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp-engine.d.ts +17 -0
- package/dist/mcp-engine.d.ts.map +1 -1
- package/dist/mcp-engine.js +18 -3
- package/dist/mcp-engine.js.map +1 -1
- package/dist/types/session-restoration.d.ts +25 -0
- package/dist/types/session-restoration.d.ts.map +1 -0
- package/dist/types/session-restoration.js +3 -0
- package/dist/types/session-restoration.js.map +1 -0
- package/package.json +10 -1
package/data/nodes.db
CHANGED
|
Binary file
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { InstanceContext } from './types/instance-context';
|
|
4
|
+
import { SessionRestoreHook, SessionState, SessionLifecycleEvents } from './types/session-restoration';
|
|
4
5
|
export declare class SingleSessionHTTPServer {
|
|
5
6
|
private transports;
|
|
6
7
|
private servers;
|
|
@@ -13,7 +14,19 @@ export declare class SingleSessionHTTPServer {
|
|
|
13
14
|
private sessionTimeout;
|
|
14
15
|
private authToken;
|
|
15
16
|
private cleanupTimer;
|
|
16
|
-
|
|
17
|
+
private onSessionNotFound?;
|
|
18
|
+
private sessionRestorationTimeout;
|
|
19
|
+
private sessionEvents?;
|
|
20
|
+
private sessionRestorationRetries;
|
|
21
|
+
private sessionRestorationRetryDelay;
|
|
22
|
+
constructor(options?: {
|
|
23
|
+
sessionTimeout?: number;
|
|
24
|
+
onSessionNotFound?: SessionRestoreHook;
|
|
25
|
+
sessionRestorationTimeout?: number;
|
|
26
|
+
sessionEvents?: SessionLifecycleEvents;
|
|
27
|
+
sessionRestorationRetries?: number;
|
|
28
|
+
sessionRestorationRetryDelay?: number;
|
|
29
|
+
});
|
|
17
30
|
private startSessionCleanup;
|
|
18
31
|
private cleanupExpiredSessions;
|
|
19
32
|
private removeSession;
|
|
@@ -24,6 +37,11 @@ export declare class SingleSessionHTTPServer {
|
|
|
24
37
|
private updateSessionAccess;
|
|
25
38
|
private switchSessionContext;
|
|
26
39
|
private performContextSwitch;
|
|
40
|
+
private timeout;
|
|
41
|
+
private emitEvent;
|
|
42
|
+
private restoreSessionWithRetry;
|
|
43
|
+
private createSession;
|
|
44
|
+
private generateSessionId;
|
|
27
45
|
private getSessionMetrics;
|
|
28
46
|
private loadAuthToken;
|
|
29
47
|
private validateEnvironment;
|
|
@@ -44,5 +62,10 @@ export declare class SingleSessionHTTPServer {
|
|
|
44
62
|
sessionIds: string[];
|
|
45
63
|
};
|
|
46
64
|
};
|
|
65
|
+
getActiveSessions(): string[];
|
|
66
|
+
getSessionState(sessionId: string): SessionState | null;
|
|
67
|
+
getAllSessionStates(): SessionState[];
|
|
68
|
+
manuallyRestoreSession(sessionId: string, instanceContext: InstanceContext): boolean;
|
|
69
|
+
manuallyDeleteSession(sessionId: string): boolean;
|
|
47
70
|
}
|
|
48
71
|
//# sourceMappingURL=http-server-single-session.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AA+
|
|
1
|
+
{"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AA+CvG,qBAAa,uBAAuB;IAElC,OAAO,CAAC,UAAU,CAA8D;IAChF,OAAO,CAAC,OAAO,CAA0D;IACzE,OAAO,CAAC,eAAe,CAAsE;IAC7F,OAAO,CAAC,eAAe,CAA4D;IACnF,OAAO,CAAC,kBAAkB,CAAyC;IACnE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,cAAc,CAAkB;IACxC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAA+B;IAGnD,OAAO,CAAC,iBAAiB,CAAC,CAAqB;IAC/C,OAAO,CAAC,yBAAyB,CAAS;IAG1C,OAAO,CAAC,aAAa,CAAC,CAAyB;IAG/C,OAAO,CAAC,yBAAyB,CAAS;IAC1C,OAAO,CAAC,4BAA4B,CAAS;gBAEjC,OAAO,GAAE;QACnB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,iBAAiB,CAAC,EAAE,kBAAkB,CAAC;QACvC,yBAAyB,CAAC,EAAE,MAAM,CAAC;QACnC,aAAa,CAAC,EAAE,sBAAsB,CAAC;QACvC,yBAAyB,CAAC,EAAE,MAAM,CAAC;QACnC,4BAA4B,CAAC,EAAE,MAAM,CAAC;KAClC;IA6BN,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,sBAAsB;YAiEhB,aAAa;IAsB3B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,gBAAgB;IAwBxB,OAAO,CAAC,gBAAgB;IAwBxB,OAAO,CAAC,sBAAsB;IAkC9B,OAAO,CAAC,mBAAmB;YAmBb,oBAAoB;YAwBpB,oBAAoB;IA6BlC,OAAO,CAAC,OAAO;YAkBD,SAAS;YAkCT,uBAAuB;IAmGrC,OAAO,CAAC,aAAa;IA+IrB,OAAO,CAAC,iBAAiB;IAwBzB,OAAO,CAAC,iBAAiB;IAsBzB,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,mBAAmB;IAoDrB,aAAa,CACjB,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,eAAe,CAAC,EAAE,eAAe,GAChC,OAAO,CAAC,IAAI,CAAC;YA4WF,eAAe;IA8C7B,OAAO,CAAC,SAAS;IAQX,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgnBtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAkD/B,cAAc,IAAI;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE;YACT,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;YACf,OAAO,EAAE,MAAM,CAAC;YAChB,GAAG,EAAE,MAAM,CAAC;YACZ,UAAU,EAAE,MAAM,EAAE,CAAC;SACtB,CAAC;KACH;IA4CD,iBAAiB,IAAI,MAAM,EAAE;IAsB7B,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI;IA4CvD,mBAAmB,IAAI,YAAY,EAAE;IAkCrC,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,eAAe,EAAE,eAAe,GAAG,OAAO;IAyEpF,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO;CA+ClD"}
|
|
@@ -35,7 +35,7 @@ function extractMultiTenantHeaders(req) {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
class SingleSessionHTTPServer {
|
|
38
|
-
constructor() {
|
|
38
|
+
constructor(options = {}) {
|
|
39
39
|
this.transports = {};
|
|
40
40
|
this.servers = {};
|
|
41
41
|
this.sessionMetadata = {};
|
|
@@ -47,6 +47,14 @@ class SingleSessionHTTPServer {
|
|
|
47
47
|
this.authToken = null;
|
|
48
48
|
this.cleanupTimer = null;
|
|
49
49
|
this.validateEnvironment();
|
|
50
|
+
this.onSessionNotFound = options.onSessionNotFound;
|
|
51
|
+
this.sessionRestorationTimeout = options.sessionRestorationTimeout || 5000;
|
|
52
|
+
this.sessionEvents = options.sessionEvents;
|
|
53
|
+
this.sessionRestorationRetries = options.sessionRestorationRetries ?? 0;
|
|
54
|
+
this.sessionRestorationRetryDelay = options.sessionRestorationRetryDelay || 100;
|
|
55
|
+
if (options.sessionTimeout) {
|
|
56
|
+
this.sessionTimeout = options.sessionTimeout;
|
|
57
|
+
}
|
|
50
58
|
this.startSessionCleanup();
|
|
51
59
|
}
|
|
52
60
|
startSessionCleanup() {
|
|
@@ -79,7 +87,28 @@ class SingleSessionHTTPServer {
|
|
|
79
87
|
logger_1.logger.debug('Cleaned orphaned session context', { sessionId });
|
|
80
88
|
}
|
|
81
89
|
}
|
|
90
|
+
for (const sessionId in this.transports) {
|
|
91
|
+
if (!this.sessionMetadata[sessionId]) {
|
|
92
|
+
logger_1.logger.warn('Orphaned transport detected, cleaning up', { sessionId });
|
|
93
|
+
this.removeSession(sessionId, 'orphaned_transport').catch(err => {
|
|
94
|
+
logger_1.logger.error('Error cleaning orphaned transport', { sessionId, error: err });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const sessionId in this.servers) {
|
|
99
|
+
if (!this.sessionMetadata[sessionId]) {
|
|
100
|
+
logger_1.logger.warn('Orphaned server detected, cleaning up', { sessionId });
|
|
101
|
+
delete this.servers[sessionId];
|
|
102
|
+
logger_1.logger.debug('Cleaned orphaned server', { sessionId });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
82
105
|
for (const sessionId of expiredSessions) {
|
|
106
|
+
this.emitEvent('onSessionExpired', sessionId).catch(err => {
|
|
107
|
+
logger_1.logger.error('Failed to emit onSessionExpired event (non-blocking)', {
|
|
108
|
+
sessionId,
|
|
109
|
+
error: err instanceof Error ? err.message : String(err)
|
|
110
|
+
});
|
|
111
|
+
});
|
|
83
112
|
this.removeSession(sessionId, 'expired');
|
|
84
113
|
}
|
|
85
114
|
if (expiredSessions.length > 0) {
|
|
@@ -111,7 +140,16 @@ class SingleSessionHTTPServer {
|
|
|
111
140
|
return this.getActiveSessionCount() < MAX_SESSIONS;
|
|
112
141
|
}
|
|
113
142
|
isValidSessionId(sessionId) {
|
|
114
|
-
|
|
143
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (sessionId.length > 100) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
115
153
|
}
|
|
116
154
|
sanitizeErrorForClient(error) {
|
|
117
155
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
@@ -138,6 +176,12 @@ class SingleSessionHTTPServer {
|
|
|
138
176
|
updateSessionAccess(sessionId) {
|
|
139
177
|
if (this.sessionMetadata[sessionId]) {
|
|
140
178
|
this.sessionMetadata[sessionId].lastAccess = new Date();
|
|
179
|
+
this.emitEvent('onSessionAccessed', sessionId).catch(err => {
|
|
180
|
+
logger_1.logger.error('Failed to emit onSessionAccessed event (non-blocking)', {
|
|
181
|
+
sessionId,
|
|
182
|
+
error: err instanceof Error ? err.message : String(err)
|
|
183
|
+
});
|
|
184
|
+
});
|
|
141
185
|
}
|
|
142
186
|
}
|
|
143
187
|
async switchSessionContext(sessionId, newContext) {
|
|
@@ -169,6 +213,211 @@ class SingleSessionHTTPServer {
|
|
|
169
213
|
}
|
|
170
214
|
}
|
|
171
215
|
}
|
|
216
|
+
timeout(ms) {
|
|
217
|
+
return new Promise((_, reject) => {
|
|
218
|
+
setTimeout(() => {
|
|
219
|
+
const error = new Error(`Operation timed out after ${ms}ms`);
|
|
220
|
+
error.name = 'TimeoutError';
|
|
221
|
+
reject(error);
|
|
222
|
+
}, ms);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async emitEvent(eventName, ...args) {
|
|
226
|
+
const handler = this.sessionEvents?.[eventName];
|
|
227
|
+
if (!handler)
|
|
228
|
+
return;
|
|
229
|
+
try {
|
|
230
|
+
await Promise.resolve(handler(...args));
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
logger_1.logger.error(`Session event handler failed: ${eventName}`, {
|
|
234
|
+
error: error instanceof Error ? error.message : String(error),
|
|
235
|
+
sessionId: args[0]
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async restoreSessionWithRetry(sessionId) {
|
|
240
|
+
if (!this.onSessionNotFound) {
|
|
241
|
+
throw new Error('onSessionNotFound hook not configured');
|
|
242
|
+
}
|
|
243
|
+
const maxRetries = this.sessionRestorationRetries;
|
|
244
|
+
const retryDelay = this.sessionRestorationRetryDelay;
|
|
245
|
+
const overallTimeout = this.sessionRestorationTimeout;
|
|
246
|
+
const startTime = Date.now();
|
|
247
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
248
|
+
try {
|
|
249
|
+
const remainingTime = overallTimeout - (Date.now() - startTime);
|
|
250
|
+
if (remainingTime <= 0) {
|
|
251
|
+
const error = new Error(`Session restoration timed out after ${overallTimeout}ms`);
|
|
252
|
+
error.name = 'TimeoutError';
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
if (attempt > 0) {
|
|
256
|
+
logger_1.logger.debug('Retrying session restoration', {
|
|
257
|
+
sessionId,
|
|
258
|
+
attempt: attempt,
|
|
259
|
+
maxRetries: maxRetries,
|
|
260
|
+
remainingTime: remainingTime + 'ms'
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const context = await Promise.race([
|
|
264
|
+
this.onSessionNotFound(sessionId),
|
|
265
|
+
this.timeout(remainingTime)
|
|
266
|
+
]);
|
|
267
|
+
if (attempt > 0) {
|
|
268
|
+
logger_1.logger.info('Session restoration succeeded after retry', {
|
|
269
|
+
sessionId,
|
|
270
|
+
attempts: attempt + 1
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return context;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
277
|
+
logger_1.logger.error('Session restoration timeout (no retry)', {
|
|
278
|
+
sessionId,
|
|
279
|
+
timeout: overallTimeout
|
|
280
|
+
});
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
if (attempt === maxRetries) {
|
|
284
|
+
logger_1.logger.error('Session restoration failed after all retries', {
|
|
285
|
+
sessionId,
|
|
286
|
+
attempts: attempt + 1,
|
|
287
|
+
error: error instanceof Error ? error.message : String(error)
|
|
288
|
+
});
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
logger_1.logger.warn('Session restoration failed, will retry', {
|
|
292
|
+
sessionId,
|
|
293
|
+
attempt: attempt + 1,
|
|
294
|
+
maxRetries: maxRetries,
|
|
295
|
+
error: error instanceof Error ? error.message : String(error),
|
|
296
|
+
nextRetryIn: retryDelay + 'ms'
|
|
297
|
+
});
|
|
298
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
throw new Error('Unexpected state in restoreSessionWithRetry');
|
|
302
|
+
}
|
|
303
|
+
createSession(instanceContext, sessionId, waitForConnection = false) {
|
|
304
|
+
const id = sessionId || this.generateSessionId(instanceContext);
|
|
305
|
+
if (this.transports[id]) {
|
|
306
|
+
logger_1.logger.debug('Session already exists, skipping creation (idempotent)', {
|
|
307
|
+
sessionId: id
|
|
308
|
+
});
|
|
309
|
+
return waitForConnection ? Promise.resolve(id) : id;
|
|
310
|
+
}
|
|
311
|
+
if (sessionId && !this.isValidSessionId(sessionId)) {
|
|
312
|
+
logger_1.logger.error('Invalid session ID format during creation', { sessionId });
|
|
313
|
+
throw new Error('Invalid session ID format');
|
|
314
|
+
}
|
|
315
|
+
if (!this.sessionMetadata[id]) {
|
|
316
|
+
this.sessionMetadata[id] = {
|
|
317
|
+
lastAccess: new Date(),
|
|
318
|
+
createdAt: new Date()
|
|
319
|
+
};
|
|
320
|
+
this.sessionContexts[id] = instanceContext;
|
|
321
|
+
}
|
|
322
|
+
const server = new server_1.N8NDocumentationMCPServer(instanceContext);
|
|
323
|
+
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
324
|
+
sessionIdGenerator: () => id,
|
|
325
|
+
onsessioninitialized: (initializedSessionId) => {
|
|
326
|
+
logger_1.logger.info('Session initialized during explicit creation', {
|
|
327
|
+
sessionId: initializedSessionId
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
this.transports[id] = transport;
|
|
332
|
+
this.servers[id] = server;
|
|
333
|
+
transport.onclose = () => {
|
|
334
|
+
if (transport.sessionId) {
|
|
335
|
+
logger_1.logger.info('Transport closed during createSession, cleaning up', {
|
|
336
|
+
sessionId: transport.sessionId
|
|
337
|
+
});
|
|
338
|
+
this.removeSession(transport.sessionId, 'transport_closed').catch(err => {
|
|
339
|
+
logger_1.logger.error('Error during transport close cleanup', {
|
|
340
|
+
sessionId: transport.sessionId,
|
|
341
|
+
error: err instanceof Error ? err.message : String(err)
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
transport.onerror = (error) => {
|
|
347
|
+
if (transport.sessionId) {
|
|
348
|
+
logger_1.logger.error('Transport error during createSession', {
|
|
349
|
+
sessionId: transport.sessionId,
|
|
350
|
+
error: error.message
|
|
351
|
+
});
|
|
352
|
+
this.removeSession(transport.sessionId, 'transport_error').catch(err => {
|
|
353
|
+
logger_1.logger.error('Error during transport error cleanup', { error: err });
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
const initializeSession = async () => {
|
|
358
|
+
try {
|
|
359
|
+
await server.initialized;
|
|
360
|
+
await server.connect(transport);
|
|
361
|
+
if (waitForConnection) {
|
|
362
|
+
logger_1.logger.info('Session created and connected successfully', {
|
|
363
|
+
sessionId: id,
|
|
364
|
+
hasInstanceContext: !!instanceContext,
|
|
365
|
+
instanceId: instanceContext?.instanceId
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
logger_1.logger.info('Session created successfully (connecting server to transport)', {
|
|
370
|
+
sessionId: id,
|
|
371
|
+
hasInstanceContext: !!instanceContext,
|
|
372
|
+
instanceId: instanceContext?.instanceId
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
logger_1.logger.error('Failed to connect server to transport in createSession', {
|
|
378
|
+
sessionId: id,
|
|
379
|
+
error: err instanceof Error ? err.message : String(err),
|
|
380
|
+
waitForConnection
|
|
381
|
+
});
|
|
382
|
+
await this.removeSession(id, 'connection_failed').catch(cleanupErr => {
|
|
383
|
+
logger_1.logger.error('Error during connection failure cleanup', { error: cleanupErr });
|
|
384
|
+
});
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
this.emitEvent('onSessionCreated', id, instanceContext).catch(eventErr => {
|
|
388
|
+
logger_1.logger.error('Failed to emit onSessionCreated event (non-blocking)', {
|
|
389
|
+
sessionId: id,
|
|
390
|
+
error: eventErr instanceof Error ? eventErr.message : String(eventErr)
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
return id;
|
|
394
|
+
};
|
|
395
|
+
if (waitForConnection) {
|
|
396
|
+
return initializeSession();
|
|
397
|
+
}
|
|
398
|
+
initializeSession().catch(error => {
|
|
399
|
+
logger_1.logger.error('Async session creation failed in manual restore flow', {
|
|
400
|
+
sessionId: id,
|
|
401
|
+
error: error instanceof Error ? error.message : String(error)
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
return id;
|
|
405
|
+
}
|
|
406
|
+
generateSessionId(instanceContext) {
|
|
407
|
+
const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true';
|
|
408
|
+
const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance';
|
|
409
|
+
if (isMultiTenantEnabled && sessionStrategy === 'instance' && instanceContext?.instanceId) {
|
|
410
|
+
const configHash = (0, crypto_1.createHash)('sha256')
|
|
411
|
+
.update(JSON.stringify({
|
|
412
|
+
url: instanceContext.n8nApiUrl,
|
|
413
|
+
instanceId: instanceContext.instanceId
|
|
414
|
+
}))
|
|
415
|
+
.digest('hex')
|
|
416
|
+
.substring(0, 8);
|
|
417
|
+
return `instance-${instanceContext.instanceId}-${configHash}-${(0, uuid_1.v4)()}`;
|
|
418
|
+
}
|
|
419
|
+
return (0, uuid_1.v4)();
|
|
420
|
+
}
|
|
172
421
|
getSessionMetrics() {
|
|
173
422
|
const now = Date.now();
|
|
174
423
|
let expiredCount = 0;
|
|
@@ -350,29 +599,142 @@ class SingleSessionHTTPServer {
|
|
|
350
599
|
this.updateSessionAccess(sessionId);
|
|
351
600
|
}
|
|
352
601
|
else {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
602
|
+
if (sessionId) {
|
|
603
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
604
|
+
logger_1.logger.warn('handleRequest: Invalid session ID format rejected', {
|
|
605
|
+
sessionId: sessionId.substring(0, 20)
|
|
606
|
+
});
|
|
607
|
+
res.status(400).json({
|
|
608
|
+
jsonrpc: '2.0',
|
|
609
|
+
error: {
|
|
610
|
+
code: -32602,
|
|
611
|
+
message: 'Invalid session ID format'
|
|
612
|
+
},
|
|
613
|
+
id: req.body?.id || null
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (this.onSessionNotFound) {
|
|
618
|
+
logger_1.logger.info('Attempting session restoration', { sessionId });
|
|
619
|
+
try {
|
|
620
|
+
const restoredContext = await this.restoreSessionWithRetry(sessionId);
|
|
621
|
+
if (restoredContext === null || restoredContext === undefined) {
|
|
622
|
+
logger_1.logger.info('Session restoration declined by hook', {
|
|
623
|
+
sessionId,
|
|
624
|
+
returnValue: restoredContext === null ? 'null' : 'undefined'
|
|
625
|
+
});
|
|
626
|
+
res.status(400).json({
|
|
627
|
+
jsonrpc: '2.0',
|
|
628
|
+
error: {
|
|
629
|
+
code: -32000,
|
|
630
|
+
message: 'Session not found or expired'
|
|
631
|
+
},
|
|
632
|
+
id: req.body?.id || null
|
|
633
|
+
});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const validation = (0, instance_context_1.validateInstanceContext)(restoredContext);
|
|
637
|
+
if (!validation.valid) {
|
|
638
|
+
logger_1.logger.error('Invalid context returned from restoration hook', {
|
|
639
|
+
sessionId,
|
|
640
|
+
errors: validation.errors
|
|
641
|
+
});
|
|
642
|
+
res.status(400).json({
|
|
643
|
+
jsonrpc: '2.0',
|
|
644
|
+
error: {
|
|
645
|
+
code: -32000,
|
|
646
|
+
message: 'Invalid session context'
|
|
647
|
+
},
|
|
648
|
+
id: req.body?.id || null
|
|
649
|
+
});
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
logger_1.logger.info('Session restoration successful, creating session', {
|
|
653
|
+
sessionId,
|
|
654
|
+
instanceId: restoredContext.instanceId
|
|
655
|
+
});
|
|
656
|
+
await this.createSession(restoredContext, sessionId, true);
|
|
657
|
+
if (!this.transports[sessionId]) {
|
|
658
|
+
logger_1.logger.error('Session creation failed after restoration', { sessionId });
|
|
659
|
+
res.status(500).json({
|
|
660
|
+
jsonrpc: '2.0',
|
|
661
|
+
error: {
|
|
662
|
+
code: -32603,
|
|
663
|
+
message: 'Session creation failed'
|
|
664
|
+
},
|
|
665
|
+
id: req.body?.id || null
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
this.emitEvent('onSessionRestored', sessionId, restoredContext).catch(err => {
|
|
670
|
+
logger_1.logger.error('Failed to emit onSessionRestored event (non-blocking)', {
|
|
671
|
+
sessionId,
|
|
672
|
+
error: err instanceof Error ? err.message : String(err)
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
transport = this.transports[sessionId];
|
|
676
|
+
logger_1.logger.info('Using restored session transport', { sessionId });
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
680
|
+
logger_1.logger.error('Session restoration timeout', {
|
|
681
|
+
sessionId,
|
|
682
|
+
timeout: this.sessionRestorationTimeout
|
|
683
|
+
});
|
|
684
|
+
res.status(408).json({
|
|
685
|
+
jsonrpc: '2.0',
|
|
686
|
+
error: {
|
|
687
|
+
code: -32000,
|
|
688
|
+
message: 'Session restoration timeout'
|
|
689
|
+
},
|
|
690
|
+
id: req.body?.id || null
|
|
691
|
+
});
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
logger_1.logger.error('Session restoration failed', {
|
|
695
|
+
sessionId,
|
|
696
|
+
error: error instanceof Error ? error.message : String(error)
|
|
697
|
+
});
|
|
698
|
+
res.status(500).json({
|
|
699
|
+
jsonrpc: '2.0',
|
|
700
|
+
error: {
|
|
701
|
+
code: -32603,
|
|
702
|
+
message: 'Session restoration failed'
|
|
703
|
+
},
|
|
704
|
+
id: req.body?.id || null
|
|
705
|
+
});
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
logger_1.logger.warn('Session not found and no restoration hook configured', {
|
|
711
|
+
sessionId
|
|
712
|
+
});
|
|
713
|
+
res.status(400).json({
|
|
714
|
+
jsonrpc: '2.0',
|
|
715
|
+
error: {
|
|
716
|
+
code: -32000,
|
|
717
|
+
message: 'Session not found or expired'
|
|
718
|
+
},
|
|
719
|
+
id: req.body?.id || null
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
363
723
|
}
|
|
364
|
-
else
|
|
365
|
-
|
|
724
|
+
else {
|
|
725
|
+
logger_1.logger.warn('handleRequest: Invalid request - no session ID and not initialize', {
|
|
726
|
+
isInitialize
|
|
727
|
+
});
|
|
728
|
+
res.status(400).json({
|
|
729
|
+
jsonrpc: '2.0',
|
|
730
|
+
error: {
|
|
731
|
+
code: -32000,
|
|
732
|
+
message: 'Bad Request: No valid session ID provided and not an initialize request'
|
|
733
|
+
},
|
|
734
|
+
id: req.body?.id || null
|
|
735
|
+
});
|
|
736
|
+
return;
|
|
366
737
|
}
|
|
367
|
-
res.status(400).json({
|
|
368
|
-
jsonrpc: '2.0',
|
|
369
|
-
error: {
|
|
370
|
-
code: -32000,
|
|
371
|
-
message: errorMessage
|
|
372
|
-
},
|
|
373
|
-
id: req.body?.id || null
|
|
374
|
-
});
|
|
375
|
-
return;
|
|
376
738
|
}
|
|
377
739
|
logger_1.logger.info('handleRequest: Handling request with transport', {
|
|
378
740
|
sessionId: isInitialize ? 'new' : sessionId,
|
|
@@ -1018,6 +1380,117 @@ class SingleSessionHTTPServer {
|
|
|
1018
1380
|
}
|
|
1019
1381
|
};
|
|
1020
1382
|
}
|
|
1383
|
+
getActiveSessions() {
|
|
1384
|
+
return Object.keys(this.sessionMetadata);
|
|
1385
|
+
}
|
|
1386
|
+
getSessionState(sessionId) {
|
|
1387
|
+
const metadata = this.sessionMetadata[sessionId];
|
|
1388
|
+
if (!metadata) {
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
const instanceContext = this.sessionContexts[sessionId];
|
|
1392
|
+
const expiresAt = new Date(metadata.lastAccess.getTime() + this.sessionTimeout);
|
|
1393
|
+
return {
|
|
1394
|
+
sessionId,
|
|
1395
|
+
instanceContext: instanceContext || {
|
|
1396
|
+
n8nApiUrl: process.env.N8N_API_URL,
|
|
1397
|
+
n8nApiKey: process.env.N8N_API_KEY,
|
|
1398
|
+
instanceId: process.env.N8N_INSTANCE_ID
|
|
1399
|
+
},
|
|
1400
|
+
createdAt: metadata.createdAt,
|
|
1401
|
+
lastAccess: metadata.lastAccess,
|
|
1402
|
+
expiresAt,
|
|
1403
|
+
metadata: instanceContext?.metadata
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
getAllSessionStates() {
|
|
1407
|
+
const sessionIds = this.getActiveSessions();
|
|
1408
|
+
const states = [];
|
|
1409
|
+
for (const sessionId of sessionIds) {
|
|
1410
|
+
const state = this.getSessionState(sessionId);
|
|
1411
|
+
if (state) {
|
|
1412
|
+
states.push(state);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return states;
|
|
1416
|
+
}
|
|
1417
|
+
manuallyRestoreSession(sessionId, instanceContext) {
|
|
1418
|
+
try {
|
|
1419
|
+
if (!this.isValidSessionId(sessionId)) {
|
|
1420
|
+
logger_1.logger.error('Invalid session ID format in manual restoration', { sessionId });
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
const validation = (0, instance_context_1.validateInstanceContext)(instanceContext);
|
|
1424
|
+
if (!validation.valid) {
|
|
1425
|
+
logger_1.logger.error('Invalid instance context in manual restoration', {
|
|
1426
|
+
sessionId,
|
|
1427
|
+
errors: validation.errors
|
|
1428
|
+
});
|
|
1429
|
+
return false;
|
|
1430
|
+
}
|
|
1431
|
+
this.sessionMetadata[sessionId] = {
|
|
1432
|
+
lastAccess: new Date(),
|
|
1433
|
+
createdAt: new Date()
|
|
1434
|
+
};
|
|
1435
|
+
this.sessionContexts[sessionId] = instanceContext;
|
|
1436
|
+
const creationResult = this.createSession(instanceContext, sessionId, false);
|
|
1437
|
+
Promise.resolve(creationResult).catch(error => {
|
|
1438
|
+
logger_1.logger.error('Async session creation failed in manual restoration', {
|
|
1439
|
+
sessionId,
|
|
1440
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1441
|
+
});
|
|
1442
|
+
delete this.sessionMetadata[sessionId];
|
|
1443
|
+
delete this.sessionContexts[sessionId];
|
|
1444
|
+
});
|
|
1445
|
+
logger_1.logger.info('Session manually restored', {
|
|
1446
|
+
sessionId,
|
|
1447
|
+
instanceId: instanceContext.instanceId
|
|
1448
|
+
});
|
|
1449
|
+
return true;
|
|
1450
|
+
}
|
|
1451
|
+
catch (error) {
|
|
1452
|
+
logger_1.logger.error('Failed to manually restore session', {
|
|
1453
|
+
sessionId,
|
|
1454
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1455
|
+
});
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
manuallyDeleteSession(sessionId) {
|
|
1460
|
+
if (!this.sessionMetadata[sessionId]) {
|
|
1461
|
+
logger_1.logger.debug('Session not found for manual deletion', { sessionId });
|
|
1462
|
+
return false;
|
|
1463
|
+
}
|
|
1464
|
+
try {
|
|
1465
|
+
if (this.transports[sessionId]) {
|
|
1466
|
+
this.transports[sessionId].close().catch(error => {
|
|
1467
|
+
logger_1.logger.warn('Error closing transport during manual deletion', {
|
|
1468
|
+
sessionId,
|
|
1469
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1470
|
+
});
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
this.emitEvent('onSessionDeleted', sessionId).catch(err => {
|
|
1474
|
+
logger_1.logger.error('Failed to emit onSessionDeleted event (non-blocking)', {
|
|
1475
|
+
sessionId,
|
|
1476
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1477
|
+
});
|
|
1478
|
+
});
|
|
1479
|
+
delete this.transports[sessionId];
|
|
1480
|
+
delete this.servers[sessionId];
|
|
1481
|
+
delete this.sessionMetadata[sessionId];
|
|
1482
|
+
delete this.sessionContexts[sessionId];
|
|
1483
|
+
logger_1.logger.info('Session manually deleted', { sessionId });
|
|
1484
|
+
return true;
|
|
1485
|
+
}
|
|
1486
|
+
catch (error) {
|
|
1487
|
+
logger_1.logger.error('Error during manual session deletion', {
|
|
1488
|
+
sessionId,
|
|
1489
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1490
|
+
});
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1021
1494
|
}
|
|
1022
1495
|
exports.SingleSessionHTTPServer = SingleSessionHTTPServer;
|
|
1023
1496
|
if (require.main === module) {
|