mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-02 07:01:30 -08:00
feat(core): Add support for WebSockets as an alternative to Server-Sent Events (#5443)
Co-authored-by: Matthijs Knigge <matthijs@volcano.nl>
This commit is contained in:
parent
5194513850
commit
538984dc2f
|
@ -99,6 +99,7 @@
|
||||||
"@types/syslog-client": "^1.1.2",
|
"@types/syslog-client": "^1.1.2",
|
||||||
"@types/uuid": "^8.3.2",
|
"@types/uuid": "^8.3.2",
|
||||||
"@types/validator": "^13.7.0",
|
"@types/validator": "^13.7.0",
|
||||||
|
"@types/ws": "^8.5.4",
|
||||||
"@types/yamljs": "^0.2.31",
|
"@types/yamljs": "^0.2.31",
|
||||||
"chokidar": "^3.5.2",
|
"chokidar": "^3.5.2",
|
||||||
"concurrently": "^5.1.0",
|
"concurrently": "^5.1.0",
|
||||||
|
@ -196,6 +197,7 @@
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"validator": "13.7.0",
|
"validator": "13.7.0",
|
||||||
"winston": "^3.3.3",
|
"winston": "^3.3.3",
|
||||||
|
"ws": "^8.12.0",
|
||||||
"yamljs": "^0.3.0"
|
"yamljs": "^0.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,8 @@ import { WEBHOOK_METHODS } from '@/WebhookHelpers';
|
||||||
const emptyBuffer = Buffer.alloc(0);
|
const emptyBuffer = Buffer.alloc(0);
|
||||||
|
|
||||||
export abstract class AbstractServer {
|
export abstract class AbstractServer {
|
||||||
|
protected server: Server;
|
||||||
|
|
||||||
protected app: express.Application;
|
protected app: express.Application;
|
||||||
|
|
||||||
protected externalHooks: IExternalHooksClass;
|
protected externalHooks: IExternalHooksClass;
|
||||||
|
@ -73,7 +75,7 @@ export abstract class AbstractServer {
|
||||||
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setupCommonMiddlewares() {
|
private async setupErrorHandlers() {
|
||||||
const { app } = this;
|
const { app } = this;
|
||||||
|
|
||||||
// Augment errors sent to Sentry
|
// Augment errors sent to Sentry
|
||||||
|
@ -82,6 +84,10 @@ export abstract class AbstractServer {
|
||||||
} = await import('@sentry/node');
|
} = await import('@sentry/node');
|
||||||
app.use(requestHandler());
|
app.use(requestHandler());
|
||||||
app.use(errorHandler());
|
app.use(errorHandler());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupCommonMiddlewares() {
|
||||||
|
const { app } = this;
|
||||||
|
|
||||||
// Compress the response data
|
// Compress the response data
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
@ -147,6 +153,8 @@ export abstract class AbstractServer {
|
||||||
this.app.use(corsMiddleware);
|
this.app.use(corsMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected setupPushServer() {}
|
||||||
|
|
||||||
private async setupHealthCheck() {
|
private async setupHealthCheck() {
|
||||||
this.app.use((req, res, next) => {
|
this.app.use((req, res, next) => {
|
||||||
if (!Db.isInitialized) {
|
if (!Db.isInitialized) {
|
||||||
|
@ -392,10 +400,9 @@ export abstract class AbstractServer {
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
const { app, externalHooks, protocol, sslKey, sslCert } = this;
|
const { app, externalHooks, protocol, sslKey, sslCert } = this;
|
||||||
|
|
||||||
let server: Server;
|
|
||||||
if (protocol === 'https' && sslKey && sslCert) {
|
if (protocol === 'https' && sslKey && sslCert) {
|
||||||
const https = await import('https');
|
const https = await import('https');
|
||||||
server = https.createServer(
|
this.server = https.createServer(
|
||||||
{
|
{
|
||||||
key: await readFile(this.sslKey, 'utf8'),
|
key: await readFile(this.sslKey, 'utf8'),
|
||||||
cert: await readFile(this.sslCert, 'utf8'),
|
cert: await readFile(this.sslCert, 'utf8'),
|
||||||
|
@ -404,13 +411,13 @@ export abstract class AbstractServer {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const http = await import('http');
|
const http = await import('http');
|
||||||
server = http.createServer(app);
|
this.server = http.createServer(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = config.getEnv('port');
|
const PORT = config.getEnv('port');
|
||||||
const ADDRESS = config.getEnv('listen_address');
|
const ADDRESS = config.getEnv('listen_address');
|
||||||
|
|
||||||
server.on('error', (error: Error & { code: string }) => {
|
this.server.on('error', (error: Error & { code: string }) => {
|
||||||
if (error.code === 'EADDRINUSE') {
|
if (error.code === 'EADDRINUSE') {
|
||||||
console.log(
|
console.log(
|
||||||
`n8n's port ${PORT} is already in use. Do you have another instance of n8n running already?`,
|
`n8n's port ${PORT} is already in use. Do you have another instance of n8n running already?`,
|
||||||
|
@ -419,8 +426,10 @@ export abstract class AbstractServer {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve) => server.listen(PORT, ADDRESS, () => resolve()));
|
await new Promise<void>((resolve) => this.server.listen(PORT, ADDRESS, () => resolve()));
|
||||||
|
|
||||||
|
await this.setupErrorHandlers();
|
||||||
|
this.setupPushServer();
|
||||||
await this.setupCommonMiddlewares();
|
await this.setupCommonMiddlewares();
|
||||||
if (inDevelopment) {
|
if (inDevelopment) {
|
||||||
this.setupDevMiddlewares();
|
this.setupDevMiddlewares();
|
||||||
|
|
|
@ -449,56 +449,6 @@ export interface IInternalHooksClass {
|
||||||
onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void>;
|
onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IN8nConfig {
|
|
||||||
database: IN8nConfigDatabase;
|
|
||||||
endpoints: IN8nConfigEndpoints;
|
|
||||||
executions: IN8nConfigExecutions;
|
|
||||||
generic: IN8nConfigGeneric;
|
|
||||||
host: string;
|
|
||||||
nodes: IN8nConfigNodes;
|
|
||||||
port: number;
|
|
||||||
protocol: 'http' | 'https';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IN8nConfigDatabase {
|
|
||||||
type: DatabaseType;
|
|
||||||
postgresdb: {
|
|
||||||
host: string;
|
|
||||||
password: string;
|
|
||||||
port: number;
|
|
||||||
user: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IN8nConfigEndpoints {
|
|
||||||
rest: string;
|
|
||||||
webhook: string;
|
|
||||||
webhookTest: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/export
|
|
||||||
export interface IN8nConfigExecutions {
|
|
||||||
saveDataOnError: SaveExecutionDataType;
|
|
||||||
saveDataOnSuccess: SaveExecutionDataType;
|
|
||||||
saveDataManualExecutions: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/export
|
|
||||||
export interface IN8nConfigExecutions {
|
|
||||||
saveDataOnError: SaveExecutionDataType;
|
|
||||||
saveDataOnSuccess: SaveExecutionDataType;
|
|
||||||
saveDataManualExecutions: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IN8nConfigGeneric {
|
|
||||||
timezone: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IN8nConfigNodes {
|
|
||||||
errorTriggerType: string;
|
|
||||||
exclude: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IVersionNotificationSettings {
|
export interface IVersionNotificationSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
@ -550,6 +500,7 @@ export interface IN8nUISettings {
|
||||||
onboardingCallPromptEnabled: boolean;
|
onboardingCallPromptEnabled: boolean;
|
||||||
missingPackages?: boolean;
|
missingPackages?: boolean;
|
||||||
executionMode: 'regular' | 'queue';
|
executionMode: 'regular' | 'queue';
|
||||||
|
pushBackend: 'sse' | 'websocket';
|
||||||
communityNodesEnabled: boolean;
|
communityNodesEnabled: boolean;
|
||||||
deployment: {
|
deployment: {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
|
@ -1,83 +0,0 @@
|
||||||
import SSEChannel from 'sse-channel';
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { LoggerProxy as Logger } from 'n8n-workflow';
|
|
||||||
import type { IPushData, IPushDataType } from '@/Interfaces';
|
|
||||||
|
|
||||||
export class Push {
|
|
||||||
private channel = new SSEChannel();
|
|
||||||
|
|
||||||
private connections: Record<string, Response> = {};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.channel.on('disconnect', (channel: string, res: Response) => {
|
|
||||||
if (res.req !== undefined) {
|
|
||||||
const { sessionId } = res.req.query;
|
|
||||||
Logger.debug('Remove editor-UI session', { sessionId });
|
|
||||||
delete this.connections[sessionId as string];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a new push connection
|
|
||||||
*
|
|
||||||
* @param {string} sessionId The id of the session
|
|
||||||
* @param {Request} req The request
|
|
||||||
* @param {Response} res The response
|
|
||||||
*/
|
|
||||||
add(sessionId: string, req: Request, res: Response) {
|
|
||||||
Logger.debug('Add editor-UI session', { sessionId });
|
|
||||||
|
|
||||||
if (this.connections[sessionId] !== undefined) {
|
|
||||||
// Make sure to remove existing connection with the same session
|
|
||||||
// id if one exists already
|
|
||||||
this.connections[sessionId].end();
|
|
||||||
this.channel.removeClient(this.connections[sessionId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connections[sessionId] = res;
|
|
||||||
this.channel.addClient(req, res);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends data to the client which is connected via a specific session
|
|
||||||
*
|
|
||||||
* @param {string} sessionId The session id of client to send data to
|
|
||||||
* @param {string} type Type of data to send
|
|
||||||
*/
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
send(type: IPushDataType, data: any, sessionId?: string) {
|
|
||||||
if (sessionId !== undefined && this.connections[sessionId] === undefined) {
|
|
||||||
Logger.error(`The session "${sessionId}" is not registered.`, { sessionId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.debug(`Send data of type "${type}" to editor-UI`, { dataType: type, sessionId });
|
|
||||||
|
|
||||||
const sendData: IPushData = {
|
|
||||||
type,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sessionId === undefined) {
|
|
||||||
// Send to all connected clients
|
|
||||||
this.channel.send(JSON.stringify(sendData));
|
|
||||||
} else {
|
|
||||||
// Send only to a specific client
|
|
||||||
this.channel.send(JSON.stringify(sendData), [this.connections[sessionId]]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let activePushInstance: Push | undefined;
|
|
||||||
|
|
||||||
export function getInstance(): Push {
|
|
||||||
if (activePushInstance === undefined) {
|
|
||||||
activePushInstance = new Push();
|
|
||||||
}
|
|
||||||
|
|
||||||
return activePushInstance;
|
|
||||||
}
|
|
|
@ -3,7 +3,7 @@ import { realpath } from 'fs/promises';
|
||||||
|
|
||||||
import type { LoadNodesAndCredentialsClass } from '@/LoadNodesAndCredentials';
|
import type { LoadNodesAndCredentialsClass } from '@/LoadNodesAndCredentials';
|
||||||
import type { NodeTypesClass } from '@/NodeTypes';
|
import type { NodeTypesClass } from '@/NodeTypes';
|
||||||
import type { Push } from '@/Push';
|
import type { Push } from '@/push';
|
||||||
|
|
||||||
export const reloadNodesAndCredentials = async (
|
export const reloadNodesAndCredentials = async (
|
||||||
loadNodesAndCredentials: LoadNodesAndCredentialsClass,
|
loadNodesAndCredentials: LoadNodesAndCredentialsClass,
|
||||||
|
|
|
@ -1,31 +1,15 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||||
/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */
|
/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */
|
||||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||||
/* eslint-disable @typescript-eslint/no-use-before-define */
|
|
||||||
/* eslint-disable @typescript-eslint/await-thenable */
|
|
||||||
/* eslint-disable new-cap */
|
|
||||||
/* eslint-disable prefer-const */
|
/* eslint-disable prefer-const */
|
||||||
/* eslint-disable @typescript-eslint/no-invalid-void-type */
|
/* eslint-disable @typescript-eslint/no-invalid-void-type */
|
||||||
/* eslint-disable no-return-assign */
|
|
||||||
/* eslint-disable no-param-reassign */
|
|
||||||
/* eslint-disable consistent-return */
|
|
||||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
||||||
/* eslint-disable id-denylist */
|
|
||||||
/* eslint-disable no-console */
|
|
||||||
/* eslint-disable global-require */
|
|
||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
/* eslint-disable @typescript-eslint/no-shadow */
|
/* eslint-disable @typescript-eslint/no-shadow */
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
/* eslint-disable no-continue */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
/* eslint-disable no-restricted-syntax */
|
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
/* eslint-disable import/no-dynamic-require */
|
|
||||||
/* eslint-disable no-await-in-loop */
|
|
||||||
|
|
||||||
import { exec as callbackExec } from 'child_process';
|
import { exec as callbackExec } from 'child_process';
|
||||||
import { access as fsAccess } from 'fs/promises';
|
import { access as fsAccess } from 'fs/promises';
|
||||||
|
@ -60,7 +44,6 @@ import type {
|
||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
ITelemetrySettings,
|
ITelemetrySettings,
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
INodeTypes,
|
|
||||||
ICredentialTypes,
|
ICredentialTypes,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { LoggerProxy, jsonParse } from 'n8n-workflow';
|
import { LoggerProxy, jsonParse } from 'n8n-workflow';
|
||||||
|
@ -78,7 +61,6 @@ import { getSharedWorkflowIds } from '@/WorkflowHelpers';
|
||||||
import { nodesController } from '@/api/nodes.api';
|
import { nodesController } from '@/api/nodes.api';
|
||||||
import { workflowsController } from '@/workflows/workflows.controller';
|
import { workflowsController } from '@/workflows/workflows.controller';
|
||||||
import {
|
import {
|
||||||
AUTH_COOKIE_NAME,
|
|
||||||
EDITOR_UI_DIST_DIR,
|
EDITOR_UI_DIST_DIR,
|
||||||
GENERATED_STATIC_DIR,
|
GENERATED_STATIC_DIR,
|
||||||
inDevelopment,
|
inDevelopment,
|
||||||
|
@ -106,7 +88,6 @@ import {
|
||||||
PasswordResetController,
|
PasswordResetController,
|
||||||
UsersController,
|
UsersController,
|
||||||
} from '@/controllers';
|
} from '@/controllers';
|
||||||
import { resolveJwt } from '@/auth/jwt';
|
|
||||||
|
|
||||||
import { executionsController } from '@/executions/executions.controller';
|
import { executionsController } from '@/executions/executions.controller';
|
||||||
import { nodeTypesController } from '@/api/nodeTypes.api';
|
import { nodeTypesController } from '@/api/nodeTypes.api';
|
||||||
|
@ -143,7 +124,6 @@ import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
||||||
import type { LoadNodesAndCredentialsClass } from '@/LoadNodesAndCredentials';
|
import type { LoadNodesAndCredentialsClass } from '@/LoadNodesAndCredentials';
|
||||||
import type { NodeTypesClass } from '@/NodeTypes';
|
import type { NodeTypesClass } from '@/NodeTypes';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import * as Push from '@/Push';
|
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
import type { WaitTrackerClass } from '@/WaitTracker';
|
import type { WaitTrackerClass } from '@/WaitTracker';
|
||||||
import { WaitTracker } from '@/WaitTracker';
|
import { WaitTracker } from '@/WaitTracker';
|
||||||
|
@ -155,7 +135,9 @@ import { eventBusRouter } from '@/eventbus/eventBusRoutes';
|
||||||
import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper';
|
import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper';
|
||||||
import { getLicense } from '@/License';
|
import { getLicense } from '@/License';
|
||||||
import { licenseController } from './license/license.controller';
|
import { licenseController } from './license/license.controller';
|
||||||
import { corsMiddleware, setupAuthMiddlewares } from './middlewares';
|
import type { Push } from '@/push';
|
||||||
|
import { getPushInstance, setupPushServer, setupPushHandler } from '@/push';
|
||||||
|
import { setupAuthMiddlewares } from './middlewares';
|
||||||
import { initEvents } from './events';
|
import { initEvents } from './events';
|
||||||
import { ldapController } from './Ldap/routes/ldap.controller.ee';
|
import { ldapController } from './Ldap/routes/ldap.controller.ee';
|
||||||
import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers';
|
import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers';
|
||||||
|
@ -183,7 +165,7 @@ class Server extends AbstractServer {
|
||||||
|
|
||||||
credentialTypes: ICredentialTypes;
|
credentialTypes: ICredentialTypes;
|
||||||
|
|
||||||
push: Push.Push;
|
push: Push;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
@ -198,12 +180,12 @@ class Server extends AbstractServer {
|
||||||
this.presetCredentialsLoaded = false;
|
this.presetCredentialsLoaded = false;
|
||||||
this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint');
|
this.endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint');
|
||||||
|
|
||||||
|
this.push = getPushInstance();
|
||||||
|
|
||||||
if (process.env.E2E_TESTS === 'true') {
|
if (process.env.E2E_TESTS === 'true') {
|
||||||
this.app.use('/e2e', require('./api/e2e.api').e2eController);
|
this.app.use('/e2e', require('./api/e2e.api').e2eController);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.push = Push.getInstance();
|
|
||||||
|
|
||||||
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
|
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
|
||||||
const telemetrySettings: ITelemetrySettings = {
|
const telemetrySettings: ITelemetrySettings = {
|
||||||
enabled: config.getEnv('diagnostics.enabled'),
|
enabled: config.getEnv('diagnostics.enabled'),
|
||||||
|
@ -280,6 +262,7 @@ class Server extends AbstractServer {
|
||||||
},
|
},
|
||||||
onboardingCallPromptEnabled: config.getEnv('onboardingCallPrompt.enabled'),
|
onboardingCallPromptEnabled: config.getEnv('onboardingCallPrompt.enabled'),
|
||||||
executionMode: config.getEnv('executions.mode'),
|
executionMode: config.getEnv('executions.mode'),
|
||||||
|
pushBackend: config.getEnv('push.backend'),
|
||||||
communityNodesEnabled: config.getEnv('nodes.communityPackages.enabled'),
|
communityNodesEnabled: config.getEnv('nodes.communityPackages.enabled'),
|
||||||
deployment: {
|
deployment: {
|
||||||
type: config.getEnv('deployment.type'),
|
type: config.getEnv('deployment.type'),
|
||||||
|
@ -434,26 +417,8 @@ class Server extends AbstractServer {
|
||||||
// Parse cookies for easier access
|
// Parse cookies for easier access
|
||||||
this.app.use(cookieParser());
|
this.app.use(cookieParser());
|
||||||
|
|
||||||
// Get push connections
|
const { restEndpoint, app } = this;
|
||||||
this.app.use(`/${this.restEndpoint}/push`, corsMiddleware, async (req, res, next) => {
|
setupPushHandler(restEndpoint, app, isUserManagementEnabled());
|
||||||
const { sessionId } = req.query;
|
|
||||||
if (sessionId === undefined) {
|
|
||||||
next(new Error('The query parameter "sessionId" is missing!'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isUserManagementEnabled()) {
|
|
||||||
try {
|
|
||||||
const authCookie = req.cookies?.[AUTH_COOKIE_NAME] ?? '';
|
|
||||||
await resolveJwt(authCookie);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(401).send('Unauthorized');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.push.add(sessionId as string, req, res);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Make sure that Vue history mode works properly
|
// Make sure that Vue history mode works properly
|
||||||
this.app.use(
|
this.app.use(
|
||||||
|
@ -1324,6 +1289,11 @@ class Server extends AbstractServer {
|
||||||
this.app.use('/', express.static(GENERATED_STATIC_DIR));
|
this.app.use('/', express.static(GENERATED_STATIC_DIR));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected setupPushServer(): void {
|
||||||
|
const { restEndpoint, server, app } = this;
|
||||||
|
setupPushServer(restEndpoint, server, app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function start(): Promise<void> {
|
export async function start(): Promise<void> {
|
||||||
|
|
|
@ -14,14 +14,15 @@ import type {
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { IResponseCallbackData, IWorkflowDb } from '@/Interfaces';
|
import type { IResponseCallbackData, IWorkflowDb } from '@/Interfaces';
|
||||||
import * as Push from '@/Push';
|
import type { Push } from '@/push';
|
||||||
|
import { getPushInstance } from '@/push';
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
import * as WebhookHelpers from '@/WebhookHelpers';
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
||||||
|
|
||||||
const WEBHOOK_TEST_UNREGISTERED_HINT =
|
const WEBHOOK_TEST_UNREGISTERED_HINT =
|
||||||
"Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)";
|
"Click the 'Execute workflow' button on the canvas, then try again. (In test mode, the webhook only works for one call after you click this button)";
|
||||||
|
|
||||||
export class TestWebhooks {
|
class TestWebhooks {
|
||||||
private testWebhookData: {
|
private testWebhookData: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
@ -32,18 +33,14 @@ export class TestWebhooks {
|
||||||
};
|
};
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
private activeWebhooks: ActiveWebhooks | null = null;
|
constructor(private activeWebhooks: ActiveWebhooks, private push: Push) {
|
||||||
|
activeWebhooks.testWebhooks = true;
|
||||||
constructor() {
|
|
||||||
this.activeWebhooks = new ActiveWebhooks();
|
|
||||||
this.activeWebhooks.testWebhooks = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a test-webhook and returns the data. It also makes sure that the
|
* Executes a test-webhook and returns the data. It also makes sure that the
|
||||||
* data gets additionally send to the UI. After the request got handled it
|
* data gets additionally send to the UI. After the request got handled it
|
||||||
* automatically remove the test-webhook.
|
* automatically remove the test-webhook.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
async callTestWebhook(
|
async callTestWebhook(
|
||||||
httpMethod: WebhookHttpMethod,
|
httpMethod: WebhookHttpMethod,
|
||||||
|
@ -59,14 +56,16 @@ export class TestWebhooks {
|
||||||
path = path.slice(0, -1);
|
path = path.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path);
|
const { activeWebhooks, push, testWebhookData } = this;
|
||||||
|
|
||||||
|
let webhookData: IWebhookData | undefined = activeWebhooks.get(httpMethod, path);
|
||||||
|
|
||||||
// check if path is dynamic
|
// check if path is dynamic
|
||||||
if (webhookData === undefined) {
|
if (webhookData === undefined) {
|
||||||
const pathElements = path.split('/');
|
const pathElements = path.split('/');
|
||||||
const webhookId = pathElements.shift();
|
const webhookId = pathElements.shift();
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
webhookData = this.activeWebhooks!.get(httpMethod, pathElements.join('/'), webhookId);
|
webhookData = activeWebhooks.get(httpMethod, pathElements.join('/'), webhookId);
|
||||||
if (webhookData === undefined) {
|
if (webhookData === undefined) {
|
||||||
// The requested webhook is not registered
|
// The requested webhook is not registered
|
||||||
throw new ResponseHelper.NotFoundError(
|
throw new ResponseHelper.NotFoundError(
|
||||||
|
@ -85,14 +84,15 @@ export class TestWebhooks {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookKey = `${this.activeWebhooks!.getWebhookKey(
|
const { workflowId } = webhookData;
|
||||||
|
const webhookKey = `${activeWebhooks.getWebhookKey(
|
||||||
webhookData.httpMethod,
|
webhookData.httpMethod,
|
||||||
webhookData.path,
|
webhookData.path,
|
||||||
webhookData.webhookId,
|
webhookData.webhookId,
|
||||||
)}|${webhookData.workflowId}`;
|
)}|${workflowId}`;
|
||||||
|
|
||||||
// TODO: Clean that duplication up one day and improve code generally
|
// TODO: Clean that duplication up one day and improve code generally
|
||||||
if (this.testWebhookData[webhookKey] === undefined) {
|
if (testWebhookData[webhookKey] === undefined) {
|
||||||
// The requested webhook is not registered
|
// The requested webhook is not registered
|
||||||
throw new ResponseHelper.NotFoundError(
|
throw new ResponseHelper.NotFoundError(
|
||||||
`The requested webhook "${httpMethod} ${path}" is not registered.`,
|
`The requested webhook "${httpMethod} ${path}" is not registered.`,
|
||||||
|
@ -100,7 +100,8 @@ export class TestWebhooks {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { workflow } = this.testWebhookData[webhookKey];
|
const { destinationNode, sessionId, workflow, workflowData, timeout } =
|
||||||
|
testWebhookData[webhookKey];
|
||||||
|
|
||||||
// Get the node which has the webhook defined to know where to start from and to
|
// Get the node which has the webhook defined to know where to start from and to
|
||||||
// get additional data
|
// get additional data
|
||||||
|
@ -116,61 +117,46 @@ export class TestWebhooks {
|
||||||
const executionId = await WebhookHelpers.executeWebhook(
|
const executionId = await WebhookHelpers.executeWebhook(
|
||||||
workflow,
|
workflow,
|
||||||
webhookData!,
|
webhookData!,
|
||||||
this.testWebhookData[webhookKey].workflowData,
|
workflowData,
|
||||||
workflowStartNode,
|
workflowStartNode,
|
||||||
executionMode,
|
executionMode,
|
||||||
this.testWebhookData[webhookKey].sessionId,
|
sessionId,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
request,
|
request,
|
||||||
response,
|
response,
|
||||||
(error: Error | null, data: IResponseCallbackData) => {
|
(error: Error | null, data: IResponseCallbackData) => {
|
||||||
if (error !== null) {
|
if (error !== null) reject(error);
|
||||||
return reject(error);
|
else resolve(data);
|
||||||
}
|
|
||||||
resolve(data);
|
|
||||||
},
|
},
|
||||||
this.testWebhookData[webhookKey].destinationNode,
|
destinationNode,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (executionId === undefined) {
|
// The workflow did not run as the request was probably setup related
|
||||||
// The workflow did not run as the request was probably setup related
|
// or a ping so do not resolve the promise and wait for the real webhook
|
||||||
// or a ping so do not resolve the promise and wait for the real webhook
|
// request instead.
|
||||||
// request instead.
|
if (executionId === undefined) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inform editor-ui that webhook got received
|
// Inform editor-ui that webhook got received
|
||||||
if (this.testWebhookData[webhookKey].sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
const pushInstance = Push.getInstance();
|
push.send('testWebhookReceived', { workflowId, executionId }, sessionId);
|
||||||
pushInstance.send(
|
|
||||||
'testWebhookReceived',
|
|
||||||
{ workflowId: webhookData!.workflowId, executionId },
|
|
||||||
this.testWebhookData[webhookKey].sessionId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} finally {
|
||||||
// Delete webhook also if an error is thrown
|
// Delete webhook also if an error is thrown
|
||||||
}
|
if (timeout) clearTimeout(timeout);
|
||||||
|
delete testWebhookData[webhookKey];
|
||||||
|
|
||||||
// Remove the webhook
|
await activeWebhooks.removeWorkflow(workflow);
|
||||||
if (this.testWebhookData[webhookKey]) {
|
|
||||||
clearTimeout(this.testWebhookData[webhookKey].timeout);
|
|
||||||
delete this.testWebhookData[webhookKey];
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.activeWebhooks!.removeWorkflow(workflow);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets all request methods associated with a single test webhook
|
* Gets all request methods associated with a single test webhook
|
||||||
* @param path webhook path
|
|
||||||
*/
|
*/
|
||||||
async getWebhookMethods(path: string): Promise<string[]> {
|
async getWebhookMethods(path: string): Promise<string[]> {
|
||||||
const webhookMethods: string[] = this.activeWebhooks!.getWebhookMethods(path);
|
const webhookMethods = this.activeWebhooks.getWebhookMethods(path);
|
||||||
|
if (!webhookMethods.length) {
|
||||||
if (webhookMethods === undefined) {
|
|
||||||
// The requested webhook is not registered
|
// The requested webhook is not registered
|
||||||
throw new ResponseHelper.NotFoundError(
|
throw new ResponseHelper.NotFoundError(
|
||||||
`The requested webhook "${path}" is not registered.`,
|
`The requested webhook "${path}" is not registered.`,
|
||||||
|
@ -182,10 +168,8 @@ export class TestWebhooks {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if it has to wait for webhook data to execute the workflow. If yes it waits
|
* Checks if it has to wait for webhook data to execute the workflow.
|
||||||
* for it and resolves with the result of the workflow if not it simply resolves
|
* If yes it waits for it and resolves with the result of the workflow if not it simply resolves with undefined
|
||||||
* with undefined
|
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
async needsWebhookData(
|
async needsWebhookData(
|
||||||
workflowData: IWorkflowDb,
|
workflowData: IWorkflowDb,
|
||||||
|
@ -216,11 +200,13 @@ export class TestWebhooks {
|
||||||
this.cancelTestWebhook(workflowData.id);
|
this.cancelTestWebhook(workflowData.id);
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
|
const { activeWebhooks, testWebhookData } = this;
|
||||||
|
|
||||||
let key: string;
|
let key: string;
|
||||||
const activatedKey: string[] = [];
|
const activatedKey: string[] = [];
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
for (const webhookData of webhooks) {
|
for (const webhookData of webhooks) {
|
||||||
key = `${this.activeWebhooks!.getWebhookKey(
|
key = `${activeWebhooks.getWebhookKey(
|
||||||
webhookData.httpMethod,
|
webhookData.httpMethod,
|
||||||
webhookData.path,
|
webhookData.path,
|
||||||
webhookData.webhookId,
|
webhookData.webhookId,
|
||||||
|
@ -228,7 +214,7 @@ export class TestWebhooks {
|
||||||
|
|
||||||
activatedKey.push(key);
|
activatedKey.push(key);
|
||||||
|
|
||||||
this.testWebhookData[key] = {
|
testWebhookData[key] = {
|
||||||
sessionId,
|
sessionId,
|
||||||
timeout,
|
timeout,
|
||||||
workflow,
|
workflow,
|
||||||
|
@ -238,11 +224,11 @@ export class TestWebhooks {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await this.activeWebhooks!.add(workflow, webhookData, mode, activation);
|
await activeWebhooks.add(workflow, webhookData, mode, activation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
activatedKey.forEach((deleteKey) => delete this.testWebhookData[deleteKey]);
|
activatedKey.forEach((deleteKey) => delete testWebhookData[deleteKey]);
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await this.activeWebhooks!.removeWorkflow(workflow);
|
await activeWebhooks.removeWorkflow(workflow);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -256,40 +242,34 @@ export class TestWebhooks {
|
||||||
*/
|
*/
|
||||||
cancelTestWebhook(workflowId: string): boolean {
|
cancelTestWebhook(workflowId: string): boolean {
|
||||||
let foundWebhook = false;
|
let foundWebhook = false;
|
||||||
|
const { activeWebhooks, push, testWebhookData } = this;
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
for (const webhookKey of Object.keys(this.testWebhookData)) {
|
for (const webhookKey of Object.keys(testWebhookData)) {
|
||||||
const webhookData = this.testWebhookData[webhookKey];
|
const { sessionId, timeout, workflow, workflowData } = testWebhookData[webhookKey];
|
||||||
|
|
||||||
if (webhookData.workflowData.id !== workflowId) {
|
if (workflowData.id !== workflowId) {
|
||||||
// eslint-disable-next-line no-continue
|
// eslint-disable-next-line no-continue
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(this.testWebhookData[webhookKey].timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
// Inform editor-ui that webhook got received
|
// Inform editor-ui that webhook got received
|
||||||
if (this.testWebhookData[webhookKey].sessionId !== undefined) {
|
if (sessionId !== undefined) {
|
||||||
try {
|
try {
|
||||||
const pushInstance = Push.getInstance();
|
push.send('testWebhookDeleted', { workflowId }, sessionId);
|
||||||
pushInstance.send(
|
} catch {
|
||||||
'testWebhookDeleted',
|
|
||||||
{ workflowId },
|
|
||||||
this.testWebhookData[webhookKey].sessionId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// Could not inform editor, probably is not connected anymore. So simply go on.
|
// Could not inform editor, probably is not connected anymore. So simply go on.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { workflow } = this.testWebhookData[webhookKey];
|
|
||||||
|
|
||||||
// Remove the webhook
|
// Remove the webhook
|
||||||
delete this.testWebhookData[webhookKey];
|
delete testWebhookData[webhookKey];
|
||||||
|
|
||||||
if (!foundWebhook) {
|
if (!foundWebhook) {
|
||||||
// As it removes all webhooks of the workflow execute only once
|
// As it removes all webhooks of the workflow execute only once
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
this.activeWebhooks!.removeWorkflow(workflow);
|
activeWebhooks.removeWorkflow(workflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
foundWebhook = true;
|
foundWebhook = true;
|
||||||
|
@ -302,18 +282,7 @@ export class TestWebhooks {
|
||||||
* Removes all the currently active test webhooks
|
* Removes all the currently active test webhooks
|
||||||
*/
|
*/
|
||||||
async removeAll(): Promise<void> {
|
async removeAll(): Promise<void> {
|
||||||
if (this.activeWebhooks === null) {
|
const workflows = Object.values(this.testWebhookData).map(({ workflow }) => workflow);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let workflow: Workflow;
|
|
||||||
const workflows: Workflow[] = [];
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
for (const webhookKey of Object.keys(this.testWebhookData)) {
|
|
||||||
workflow = this.testWebhookData[webhookKey].workflow;
|
|
||||||
workflows.push(workflow);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.activeWebhooks.removeAll(workflows);
|
return this.activeWebhooks.removeAll(workflows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -322,7 +291,7 @@ let testWebhooksInstance: TestWebhooks | undefined;
|
||||||
|
|
||||||
export function getInstance(): TestWebhooks {
|
export function getInstance(): TestWebhooks {
|
||||||
if (testWebhooksInstance === undefined) {
|
if (testWebhooksInstance === undefined) {
|
||||||
testWebhooksInstance = new TestWebhooks();
|
testWebhooksInstance = new TestWebhooks(new ActiveWebhooks(), getPushInstance());
|
||||||
}
|
}
|
||||||
|
|
||||||
return testWebhooksInstance;
|
return testWebhooksInstance;
|
||||||
|
|
|
@ -60,7 +60,7 @@ import type {
|
||||||
} from '@/Interfaces';
|
} from '@/Interfaces';
|
||||||
import { InternalHooksManager } from '@/InternalHooksManager';
|
import { InternalHooksManager } from '@/InternalHooksManager';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import * as Push from '@/Push';
|
import { getPushInstance } from '@/push';
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
import * as WebhookHelpers from '@/WebhookHelpers';
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
||||||
import * as WorkflowHelpers from '@/WorkflowHelpers';
|
import * as WorkflowHelpers from '@/WorkflowHelpers';
|
||||||
|
@ -250,76 +250,67 @@ function hookFunctionsPush(): IWorkflowExecuteHooks {
|
||||||
return {
|
return {
|
||||||
nodeExecuteBefore: [
|
nodeExecuteBefore: [
|
||||||
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
async function (this: WorkflowHooks, nodeName: string): Promise<void> {
|
||||||
|
const { sessionId, executionId } = this;
|
||||||
// Push data to session which started workflow before each
|
// Push data to session which started workflow before each
|
||||||
// node which starts rendering
|
// node which starts rendering
|
||||||
if (this.sessionId === undefined) {
|
if (sessionId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
Logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
||||||
executionId: this.executionId,
|
executionId,
|
||||||
sessionId: this.sessionId,
|
sessionId,
|
||||||
workflowId: this.workflowData.id,
|
workflowId: this.workflowData.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
pushInstance.send(
|
pushInstance.send('nodeExecuteBefore', { executionId, nodeName }, sessionId);
|
||||||
'nodeExecuteBefore',
|
|
||||||
{
|
|
||||||
executionId: this.executionId,
|
|
||||||
nodeName,
|
|
||||||
},
|
|
||||||
this.sessionId,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
nodeExecuteAfter: [
|
nodeExecuteAfter: [
|
||||||
async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise<void> {
|
async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise<void> {
|
||||||
|
const { sessionId, executionId } = this;
|
||||||
// Push data to session which started workflow after each rendered node
|
// Push data to session which started workflow after each rendered node
|
||||||
if (this.sessionId === undefined) {
|
if (sessionId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
Logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, {
|
||||||
executionId: this.executionId,
|
executionId,
|
||||||
sessionId: this.sessionId,
|
sessionId,
|
||||||
workflowId: this.workflowData.id,
|
workflowId: this.workflowData.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
pushInstance.send(
|
pushInstance.send('nodeExecuteAfter', { executionId, nodeName, data }, sessionId);
|
||||||
'nodeExecuteAfter',
|
|
||||||
{
|
|
||||||
executionId: this.executionId,
|
|
||||||
nodeName,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
this.sessionId,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
workflowExecuteBefore: [
|
workflowExecuteBefore: [
|
||||||
async function (this: WorkflowHooks): Promise<void> {
|
async function (this: WorkflowHooks): Promise<void> {
|
||||||
|
const { sessionId, executionId } = this;
|
||||||
|
const { id: workflowId, name: workflowName } = this.workflowData;
|
||||||
Logger.debug('Executing hook (hookFunctionsPush)', {
|
Logger.debug('Executing hook (hookFunctionsPush)', {
|
||||||
executionId: this.executionId,
|
executionId,
|
||||||
sessionId: this.sessionId,
|
sessionId,
|
||||||
workflowId: this.workflowData.id,
|
workflowId,
|
||||||
});
|
});
|
||||||
// Push data to session which started the workflow
|
// Push data to session which started the workflow
|
||||||
if (this.sessionId === undefined) {
|
if (sessionId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
pushInstance.send(
|
pushInstance.send(
|
||||||
'executionStarted',
|
'executionStarted',
|
||||||
{
|
{
|
||||||
executionId: this.executionId,
|
executionId,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
retryOf: this.retryOf,
|
retryOf: this.retryOf,
|
||||||
workflowId: this.workflowData.id,
|
workflowId,
|
||||||
sessionId: this.sessionId,
|
sessionId,
|
||||||
workflowName: this.workflowData.name,
|
workflowName,
|
||||||
},
|
},
|
||||||
this.sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -329,13 +320,15 @@ function hookFunctionsPush(): IWorkflowExecuteHooks {
|
||||||
fullRunData: IRun,
|
fullRunData: IRun,
|
||||||
newStaticData: IDataObject,
|
newStaticData: IDataObject,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const { sessionId, executionId, retryOf } = this;
|
||||||
|
const { id: workflowId } = this.workflowData;
|
||||||
Logger.debug('Executing hook (hookFunctionsPush)', {
|
Logger.debug('Executing hook (hookFunctionsPush)', {
|
||||||
executionId: this.executionId,
|
executionId,
|
||||||
sessionId: this.sessionId,
|
sessionId,
|
||||||
workflowId: this.workflowData.id,
|
workflowId,
|
||||||
});
|
});
|
||||||
// Push data to session which started the workflow
|
// Push data to session which started the workflow
|
||||||
if (this.sessionId === undefined) {
|
if (sessionId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -354,19 +347,19 @@ function hookFunctionsPush(): IWorkflowExecuteHooks {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Push data to editor-ui once workflow finished
|
// Push data to editor-ui once workflow finished
|
||||||
Logger.debug(`Save execution progress to database for execution ID ${this.executionId} `, {
|
Logger.debug(`Save execution progress to database for execution ID ${executionId} `, {
|
||||||
executionId: this.executionId,
|
executionId,
|
||||||
workflowId: this.workflowData.id,
|
workflowId,
|
||||||
});
|
});
|
||||||
// TODO: Look at this again
|
// TODO: Look at this again
|
||||||
const sendData: IPushDataExecutionFinished = {
|
const sendData: IPushDataExecutionFinished = {
|
||||||
executionId: this.executionId,
|
executionId,
|
||||||
data: pushRunData,
|
data: pushRunData,
|
||||||
retryOf: this.retryOf,
|
retryOf,
|
||||||
};
|
};
|
||||||
|
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
pushInstance.send('executionFinished', sendData, this.sessionId);
|
pushInstance.send('executionFinished', sendData, sessionId);
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -1041,11 +1034,11 @@ async function executeWorkflow(
|
||||||
if (data.finished === true) {
|
if (data.finished === true) {
|
||||||
// Workflow did finish successfully
|
// Workflow did finish successfully
|
||||||
|
|
||||||
await ActiveExecutions.getInstance().remove(executionId, data);
|
ActiveExecutions.getInstance().remove(executionId, data);
|
||||||
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data);
|
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data);
|
||||||
return returnData!.data!.main;
|
return returnData!.data!.main;
|
||||||
}
|
}
|
||||||
await ActiveExecutions.getInstance().remove(executionId, data);
|
ActiveExecutions.getInstance().remove(executionId, data);
|
||||||
// Workflow did fail
|
// Workflow did fail
|
||||||
const { error } = data.data.resultData;
|
const { error } = data.data.resultData;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||||
|
@ -1057,20 +1050,21 @@ async function executeWorkflow(
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function sendMessageToUI(source: string, messages: any[]) {
|
export function sendMessageToUI(source: string, messages: any[]) {
|
||||||
if (this.sessionId === undefined) {
|
const { sessionId } = this;
|
||||||
|
if (sessionId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push data to session which started workflow
|
// Push data to session which started workflow
|
||||||
try {
|
try {
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
pushInstance.send(
|
pushInstance.send(
|
||||||
'sendConsoleMessage',
|
'sendConsoleMessage',
|
||||||
{
|
{
|
||||||
source: `[Node: "${source}"]`,
|
source: `[Node: "${source}"]`,
|
||||||
messages,
|
messages,
|
||||||
},
|
},
|
||||||
this.sessionId,
|
sessionId,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.warn(`There was a problem sending message to UI: ${error.message}`);
|
Logger.warn(`There was a problem sending message to UI: ${error.message}`);
|
||||||
|
|
|
@ -44,7 +44,6 @@ import type {
|
||||||
IWorkflowExecutionDataProcessWithExecution,
|
IWorkflowExecutionDataProcessWithExecution,
|
||||||
} from '@/Interfaces';
|
} from '@/Interfaces';
|
||||||
import { NodeTypes } from '@/NodeTypes';
|
import { NodeTypes } from '@/NodeTypes';
|
||||||
import * as Push from '@/Push';
|
|
||||||
import * as Queue from '@/Queue';
|
import * as Queue from '@/Queue';
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
import * as WebhookHelpers from '@/WebhookHelpers';
|
import * as WebhookHelpers from '@/WebhookHelpers';
|
||||||
|
@ -54,16 +53,18 @@ import { InternalHooksManager } from '@/InternalHooksManager';
|
||||||
import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
|
import { generateFailedExecutionFromError } from '@/WorkflowHelpers';
|
||||||
import { initErrorHandling } from '@/ErrorReporting';
|
import { initErrorHandling } from '@/ErrorReporting';
|
||||||
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
|
import { PermissionChecker } from '@/UserManagement/PermissionChecker';
|
||||||
|
import type { Push } from '@/push';
|
||||||
|
import { getPushInstance } from '@/push';
|
||||||
|
|
||||||
export class WorkflowRunner {
|
export class WorkflowRunner {
|
||||||
activeExecutions: ActiveExecutions.ActiveExecutions;
|
activeExecutions: ActiveExecutions.ActiveExecutions;
|
||||||
|
|
||||||
push: Push.Push;
|
push: Push;
|
||||||
|
|
||||||
jobQueue: Queue.JobQueue;
|
jobQueue: Queue.JobQueue;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.push = Push.getInstance();
|
this.push = getPushInstance();
|
||||||
this.activeExecutions = ActiveExecutions.getInstance();
|
this.activeExecutions = ActiveExecutions.getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { InternalHooksManager } from '@/InternalHooksManager';
|
import { InternalHooksManager } from '@/InternalHooksManager';
|
||||||
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials';
|
||||||
import * as Push from '@/Push';
|
|
||||||
import * as ResponseHelper from '@/ResponseHelper';
|
import * as ResponseHelper from '@/ResponseHelper';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -34,6 +33,7 @@ import { isAuthenticatedRequest } from '@/UserManagement/UserManagementHelper';
|
||||||
import type { InstalledPackages } from '@db/entities/InstalledPackages';
|
import type { InstalledPackages } from '@db/entities/InstalledPackages';
|
||||||
import type { CommunityPackages } from '@/Interfaces';
|
import type { CommunityPackages } from '@/Interfaces';
|
||||||
import type { NodeRequest } from '@/requests';
|
import type { NodeRequest } from '@/requests';
|
||||||
|
import { getPushInstance } from '@/push';
|
||||||
|
|
||||||
const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES;
|
const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES;
|
||||||
|
|
||||||
|
@ -141,7 +141,7 @@ nodesController.post(
|
||||||
|
|
||||||
if (!hasLoaded) removePackageFromMissingList(name);
|
if (!hasLoaded) removePackageFromMissingList(name);
|
||||||
|
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
|
|
||||||
// broadcast to connected frontends that node list has been updated
|
// broadcast to connected frontends that node list has been updated
|
||||||
installedPackage.installedNodes.forEach((node) => {
|
installedPackage.installedNodes.forEach((node) => {
|
||||||
|
@ -248,7 +248,7 @@ nodesController.delete(
|
||||||
throw new ResponseHelper.InternalServerError(message);
|
throw new ResponseHelper.InternalServerError(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
|
|
||||||
// broadcast to connected frontends that node list has been updated
|
// broadcast to connected frontends that node list has been updated
|
||||||
installedPackage.installedNodes.forEach((node) => {
|
installedPackage.installedNodes.forEach((node) => {
|
||||||
|
@ -295,7 +295,7 @@ nodesController.patch(
|
||||||
previouslyInstalledPackage,
|
previouslyInstalledPackage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
|
|
||||||
// broadcast to connected frontends that node list has been updated
|
// broadcast to connected frontends that node list has been updated
|
||||||
previouslyInstalledPackage.installedNodes.forEach((node) => {
|
previouslyInstalledPackage.installedNodes.forEach((node) => {
|
||||||
|
@ -325,7 +325,7 @@ nodesController.patch(
|
||||||
return newInstalledPackage;
|
return newInstalledPackage;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
previouslyInstalledPackage.installedNodes.forEach((node) => {
|
previouslyInstalledPackage.installedNodes.forEach((node) => {
|
||||||
const pushInstance = Push.getInstance();
|
const pushInstance = getPushInstance();
|
||||||
pushInstance.send('removeNodeType', {
|
pushInstance.send('removeNodeType', {
|
||||||
name: node.type,
|
name: node.type,
|
||||||
version: node.latestVersion,
|
version: node.latestVersion,
|
||||||
|
|
|
@ -925,6 +925,15 @@ export const schema = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
push: {
|
||||||
|
backend: {
|
||||||
|
format: ['sse', 'websocket'] as const,
|
||||||
|
default: 'sse',
|
||||||
|
env: 'N8N_PUSH_BACKEND',
|
||||||
|
doc: 'Backend to use for push notifications',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
binaryDataManager: {
|
binaryDataManager: {
|
||||||
availableModes: {
|
availableModes: {
|
||||||
format: String,
|
format: String,
|
||||||
|
|
|
@ -16,7 +16,6 @@ import * as ActiveExecutions from './ActiveExecutions';
|
||||||
import * as ActiveWorkflowRunner from './ActiveWorkflowRunner';
|
import * as ActiveWorkflowRunner from './ActiveWorkflowRunner';
|
||||||
import * as Db from './Db';
|
import * as Db from './Db';
|
||||||
import * as GenericHelpers from './GenericHelpers';
|
import * as GenericHelpers from './GenericHelpers';
|
||||||
import * as Push from './Push';
|
|
||||||
import * as ResponseHelper from './ResponseHelper';
|
import * as ResponseHelper from './ResponseHelper';
|
||||||
import * as Server from './Server';
|
import * as Server from './Server';
|
||||||
import * as TestWebhooks from './TestWebhooks';
|
import * as TestWebhooks from './TestWebhooks';
|
||||||
|
@ -30,7 +29,6 @@ export {
|
||||||
ActiveWorkflowRunner,
|
ActiveWorkflowRunner,
|
||||||
Db,
|
Db,
|
||||||
GenericHelpers,
|
GenericHelpers,
|
||||||
Push,
|
|
||||||
ResponseHelper,
|
ResponseHelper,
|
||||||
Server,
|
Server,
|
||||||
TestWebhooks,
|
TestWebhooks,
|
||||||
|
|
49
packages/cli/src/push/abstract.push.ts
Normal file
49
packages/cli/src/push/abstract.push.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { LoggerProxy as Logger } from 'n8n-workflow';
|
||||||
|
import type { IPushDataType } from '@/Interfaces';
|
||||||
|
|
||||||
|
export abstract class AbstractPush<T> {
|
||||||
|
protected connections: Record<string, T> = {};
|
||||||
|
|
||||||
|
protected abstract close(connection: T): void;
|
||||||
|
protected abstract sendToOne(connection: T, data: string): void;
|
||||||
|
|
||||||
|
protected add(sessionId: string, connection: T): void {
|
||||||
|
const { connections } = this;
|
||||||
|
Logger.debug('Add editor-UI session', { sessionId });
|
||||||
|
|
||||||
|
const existingConnection = connections[sessionId];
|
||||||
|
if (existingConnection) {
|
||||||
|
// Make sure to remove existing connection with the same id
|
||||||
|
this.close(existingConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
connections[sessionId] = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected remove(sessionId?: string): void {
|
||||||
|
if (sessionId !== undefined) {
|
||||||
|
Logger.debug('Remove editor-UI session', { sessionId });
|
||||||
|
delete this.connections[sessionId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send<D>(type: IPushDataType, data: D, sessionId: string | undefined = undefined) {
|
||||||
|
const { connections } = this;
|
||||||
|
if (sessionId !== undefined && connections[sessionId] === undefined) {
|
||||||
|
Logger.error(`The session "${sessionId}" is not registered.`, { sessionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`Send data of type "${type}" to editor-UI`, { dataType: type, sessionId });
|
||||||
|
|
||||||
|
const sendData = JSON.stringify({ type, data });
|
||||||
|
|
||||||
|
if (sessionId === undefined) {
|
||||||
|
// Send to all connected clients
|
||||||
|
Object.values(connections).forEach((connection) => this.sendToOne(connection, sendData));
|
||||||
|
} else {
|
||||||
|
// Send only to a specific client
|
||||||
|
this.sendToOne(connections[sessionId], sendData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
105
packages/cli/src/push/index.ts
Normal file
105
packages/cli/src/push/index.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import { ServerResponse } from 'http';
|
||||||
|
import type { Server } from 'http';
|
||||||
|
import type { Socket } from 'net';
|
||||||
|
import type { Application, RequestHandler } from 'express';
|
||||||
|
import { Server as WSServer } from 'ws';
|
||||||
|
import { parse as parseUrl } from 'url';
|
||||||
|
import config from '@/config';
|
||||||
|
import { resolveJwt } from '@/auth/jwt';
|
||||||
|
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||||
|
import { SSEPush } from './sse.push';
|
||||||
|
import { WebSocketPush } from './websocket.push';
|
||||||
|
import type { Push, PushResponse, SSEPushRequest, WebSocketPushRequest } from './types';
|
||||||
|
export type { Push } from './types';
|
||||||
|
|
||||||
|
const useWebSockets = config.getEnv('push.backend') === 'websocket';
|
||||||
|
|
||||||
|
let pushInstance: Push;
|
||||||
|
export const getPushInstance = () => {
|
||||||
|
if (!pushInstance) pushInstance = useWebSockets ? new WebSocketPush() : new SSEPush();
|
||||||
|
return pushInstance;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupPushServer = (restEndpoint: string, server: Server, app: Application) => {
|
||||||
|
if (useWebSockets) {
|
||||||
|
const wsServer = new WSServer({ noServer: true });
|
||||||
|
server.on('upgrade', (request: WebSocketPushRequest, socket: Socket, head) => {
|
||||||
|
if (parseUrl(request.url).pathname === `/${restEndpoint}/push`) {
|
||||||
|
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
request.ws = ws;
|
||||||
|
|
||||||
|
const response = new ServerResponse(request);
|
||||||
|
response.writeHead = (statusCode) => {
|
||||||
|
if (statusCode > 200) ws.close();
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||||
|
app.handle(request, response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupPushHandler = (
|
||||||
|
restEndpoint: string,
|
||||||
|
app: Application,
|
||||||
|
isUserManagementEnabled: boolean,
|
||||||
|
) => {
|
||||||
|
const push = getPushInstance();
|
||||||
|
const endpoint = `/${restEndpoint}/push`;
|
||||||
|
|
||||||
|
const pushValidationMiddleware: RequestHandler = async (
|
||||||
|
req: SSEPushRequest | WebSocketPushRequest,
|
||||||
|
res,
|
||||||
|
next,
|
||||||
|
) => {
|
||||||
|
const ws = req.ws;
|
||||||
|
|
||||||
|
const { sessionId } = req.query;
|
||||||
|
if (sessionId === undefined) {
|
||||||
|
if (ws) {
|
||||||
|
ws.send('The query parameter "sessionId" is missing!');
|
||||||
|
ws.close(400);
|
||||||
|
} else {
|
||||||
|
next(new Error('The query parameter "sessionId" is missing!'));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle authentication
|
||||||
|
if (isUserManagementEnabled) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
const authCookie: string = req.cookies?.[AUTH_COOKIE_NAME] ?? '';
|
||||||
|
await resolveJwt(authCookie);
|
||||||
|
} catch (error) {
|
||||||
|
if (ws) {
|
||||||
|
ws.send(`Unauthorized: ${(error as Error).message}`);
|
||||||
|
ws.close(401);
|
||||||
|
} else {
|
||||||
|
res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
endpoint,
|
||||||
|
pushValidationMiddleware,
|
||||||
|
(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) => {
|
||||||
|
if (req.ws) {
|
||||||
|
(push as WebSocketPush).add(req.query.sessionId, req.ws);
|
||||||
|
} else if (!useWebSockets) {
|
||||||
|
(push as SSEPush).add(req.query.sessionId, { req, res });
|
||||||
|
} else {
|
||||||
|
res.status(401).send('Unauthorized');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
32
packages/cli/src/push/sse.push.ts
Normal file
32
packages/cli/src/push/sse.push.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import SSEChannel from 'sse-channel';
|
||||||
|
import { AbstractPush } from './abstract.push';
|
||||||
|
import type { PushRequest, PushResponse } from './types';
|
||||||
|
|
||||||
|
type Connection = { req: PushRequest; res: PushResponse };
|
||||||
|
|
||||||
|
export class SSEPush extends AbstractPush<Connection> {
|
||||||
|
readonly channel = new SSEChannel();
|
||||||
|
|
||||||
|
readonly connections: Record<string, Connection> = {};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.channel.on('disconnect', (channel, { req }) => {
|
||||||
|
this.remove(req?.query?.sessionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
add(sessionId: string, connection: Connection) {
|
||||||
|
super.add(sessionId, connection);
|
||||||
|
this.channel.addClient(connection.req, connection.res);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected close({ res }: Connection): void {
|
||||||
|
res.end();
|
||||||
|
this.channel.removeClient(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sendToOne(connection: Connection, data: string): void {
|
||||||
|
this.channel.send(data, [connection.res]);
|
||||||
|
}
|
||||||
|
}
|
15
packages/cli/src/push/types.ts
Normal file
15
packages/cli/src/push/types.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import type { SSEPush } from './sse.push';
|
||||||
|
import type { WebSocketPush } from './websocket.push';
|
||||||
|
|
||||||
|
// TODO: move all push related types here
|
||||||
|
|
||||||
|
export type Push = SSEPush | WebSocketPush;
|
||||||
|
|
||||||
|
export type PushRequest = Request<{}, {}, {}, { sessionId: string }>;
|
||||||
|
|
||||||
|
export type SSEPushRequest = PushRequest & { ws: undefined };
|
||||||
|
export type WebSocketPushRequest = PushRequest & { ws: WebSocket };
|
||||||
|
|
||||||
|
export type PushResponse = Response & { req: PushRequest };
|
19
packages/cli/src/push/websocket.push.ts
Normal file
19
packages/cli/src/push/websocket.push.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import type WebSocket from 'ws';
|
||||||
|
import { AbstractPush } from './abstract.push';
|
||||||
|
|
||||||
|
export class WebSocketPush extends AbstractPush<WebSocket> {
|
||||||
|
add(sessionId: string, connection: WebSocket) {
|
||||||
|
super.add(sessionId, connection);
|
||||||
|
|
||||||
|
// Makes sure to remove the session if the connection is closed
|
||||||
|
connection.once('close', () => this.remove(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected close(connection: WebSocket): void {
|
||||||
|
connection.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected sendToOne(connection: WebSocket, data: string): void {
|
||||||
|
connection.send(data);
|
||||||
|
}
|
||||||
|
}
|
10
packages/cli/src/sse-channel.d.ts
vendored
10
packages/cli/src/sse-channel.d.ts
vendored
|
@ -1,16 +1,16 @@
|
||||||
import type { Request, Response } from 'express';
|
import type { PushRequest, PushResponse } from './push/types';
|
||||||
|
|
||||||
declare module 'sse-channel' {
|
declare module 'sse-channel' {
|
||||||
declare class Channel {
|
declare class Channel {
|
||||||
constructor();
|
constructor();
|
||||||
|
|
||||||
on(event: string, handler: (channel: string, res: Response) => void): void;
|
on(event: string, handler: (channel: string, res: PushResponse) => void): void;
|
||||||
|
|
||||||
removeClient: (res: Response) => void;
|
removeClient: (res: PushResponse) => void;
|
||||||
|
|
||||||
addClient: (req: Request, res: Response) => void;
|
addClient: (req: PushRequest, res: PushResponse) => void;
|
||||||
|
|
||||||
send: (msg: string, clients?: Response[]) => void;
|
send: (msg: string, clients?: PushResponse[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export = Channel;
|
export = Channel;
|
||||||
|
|
|
@ -2,4 +2,4 @@ jest.mock('@sentry/node');
|
||||||
jest.mock('@n8n_io/license-sdk');
|
jest.mock('@n8n_io/license-sdk');
|
||||||
jest.mock('@/telemetry');
|
jest.mock('@/telemetry');
|
||||||
jest.mock('@/eventbus/MessageEventBus/MessageEventBus');
|
jest.mock('@/eventbus/MessageEventBus/MessageEventBus');
|
||||||
jest.mock('@/Push');
|
jest.mock('@/push');
|
||||||
|
|
|
@ -690,6 +690,7 @@ export interface IN8nUISettings {
|
||||||
host: string;
|
host: string;
|
||||||
};
|
};
|
||||||
executionMode: string;
|
executionMode: string;
|
||||||
|
pushBackend: 'sse' | 'websocket';
|
||||||
communityNodesEnabled: boolean;
|
communityNodesEnabled: boolean;
|
||||||
isNpmAvailable: boolean;
|
isNpmAvailable: boolean;
|
||||||
publicApi: {
|
publicApi: {
|
||||||
|
|
|
@ -24,6 +24,7 @@ import { useUIStore } from '@/stores/ui';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows';
|
import { useWorkflowsStore } from '@/stores/workflows';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
||||||
import { useCredentialsStore } from '@/stores/credentials';
|
import { useCredentialsStore } from '@/stores/credentials';
|
||||||
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
|
|
||||||
export const pushConnection = mixins(
|
export const pushConnection = mixins(
|
||||||
externalHooks,
|
externalHooks,
|
||||||
|
@ -34,7 +35,7 @@ export const pushConnection = mixins(
|
||||||
).extend({
|
).extend({
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
eventSource: null as EventSource | null,
|
pushSource: null as WebSocket | EventSource | null,
|
||||||
reconnectTimeout: null as NodeJS.Timeout | null,
|
reconnectTimeout: null as NodeJS.Timeout | null,
|
||||||
retryTimeout: null as NodeJS.Timeout | null,
|
retryTimeout: null as NodeJS.Timeout | null,
|
||||||
pushMessageQueue: [] as Array<{ event: Event; retriesLeft: number }>,
|
pushMessageQueue: [] as Array<{ event: Event; retriesLeft: number }>,
|
||||||
|
@ -43,92 +44,99 @@ export const pushConnection = mixins(
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapStores(useCredentialsStore, useNodeTypesStore, useUIStore, useWorkflowsStore),
|
...mapStores(
|
||||||
|
useCredentialsStore,
|
||||||
|
useNodeTypesStore,
|
||||||
|
useUIStore,
|
||||||
|
useWorkflowsStore,
|
||||||
|
useSettingsStore,
|
||||||
|
),
|
||||||
sessionId(): string {
|
sessionId(): string {
|
||||||
return this.rootStore.sessionId;
|
return this.rootStore.sessionId;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
pushAutomaticReconnect(): void {
|
attemptReconnect() {
|
||||||
if (this.reconnectTimeout !== null) {
|
const isWorkflowRunning = this.uiStore.isActionActive('workflowRunning');
|
||||||
return;
|
if (this.connectRetries > 3 && !this.lostConnection && isWorkflowRunning) {
|
||||||
|
this.lostConnection = true;
|
||||||
|
|
||||||
|
this.workflowsStore.executingNode = null;
|
||||||
|
this.uiStore.removeActiveAction('workflowRunning');
|
||||||
|
|
||||||
|
this.$showMessage({
|
||||||
|
title: this.$locale.baseText('pushConnection.executionFailed'),
|
||||||
|
message: this.$locale.baseText('pushConnection.executionFailed.message'),
|
||||||
|
type: 'error',
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reconnectTimeout = setTimeout(() => {
|
this.pushConnect();
|
||||||
this.connectRetries++;
|
|
||||||
const isWorkflowRunning = this.uiStore.isActionActive('workflowRunning');
|
|
||||||
if (this.connectRetries > 3 && !this.lostConnection && isWorkflowRunning) {
|
|
||||||
this.lostConnection = true;
|
|
||||||
|
|
||||||
this.workflowsStore.executingNode = null;
|
|
||||||
this.uiStore.removeActiveAction('workflowRunning');
|
|
||||||
|
|
||||||
this.$showMessage({
|
|
||||||
title: this.$locale.baseText('pushConnection.executionFailed'),
|
|
||||||
message: this.$locale.baseText('pushConnection.executionFailed.message'),
|
|
||||||
type: 'error',
|
|
||||||
duration: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.pushConnect();
|
|
||||||
}, 3000);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to server to receive data via EventSource
|
* Connect to server to receive data via a WebSocket or EventSource
|
||||||
*/
|
*/
|
||||||
pushConnect(): void {
|
pushConnect(): void {
|
||||||
// Make sure existing event-source instances get
|
// always close the previous connection so that we do not end up with multiple connections
|
||||||
// always removed that we do not end up with multiple ones
|
|
||||||
this.pushDisconnect();
|
this.pushDisconnect();
|
||||||
|
|
||||||
const connectionUrl = `${this.rootStore.getRestUrl}/push?sessionId=${this.sessionId}`;
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.eventSource = new EventSource(connectionUrl, { withCredentials: true });
|
const useWebSockets = this.settingsStore.pushBackend === 'websocket';
|
||||||
this.eventSource.addEventListener('message', this.pushMessageReceived, false);
|
|
||||||
|
|
||||||
this.eventSource.addEventListener(
|
const { getRestUrl: restUrl } = this.rootStore;
|
||||||
'open',
|
const url = `/push?sessionId=${this.sessionId}`;
|
||||||
() => {
|
|
||||||
this.connectRetries = 0;
|
|
||||||
this.lostConnection = false;
|
|
||||||
|
|
||||||
this.rootStore.pushConnectionActive = true;
|
if (useWebSockets) {
|
||||||
if (this.reconnectTimeout !== null) {
|
const { protocol, host } = window.location;
|
||||||
clearTimeout(this.reconnectTimeout);
|
const baseUrl = restUrl.startsWith('http')
|
||||||
this.reconnectTimeout = null;
|
? restUrl.replace(/^http/, 'ws')
|
||||||
}
|
: `${protocol === 'https:' ? 'wss' : 'ws'}://${host + restUrl}`;
|
||||||
},
|
this.pushSource = new WebSocket(`${baseUrl}${url}`);
|
||||||
|
} else {
|
||||||
|
this.pushSource = new EventSource(`${restUrl}${url}`, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushSource.addEventListener('open', this.onConnectionSuccess, false);
|
||||||
|
this.pushSource.addEventListener('message', this.pushMessageReceived, false);
|
||||||
|
this.pushSource.addEventListener(
|
||||||
|
useWebSockets ? 'close' : 'error',
|
||||||
|
this.onConnectionError,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
|
||||||
this.eventSource.addEventListener(
|
onConnectionSuccess() {
|
||||||
'error',
|
this.connectRetries = 0;
|
||||||
() => {
|
this.lostConnection = false;
|
||||||
this.pushDisconnect();
|
this.rootStore.pushConnectionActive = true;
|
||||||
|
this.pushSource?.removeEventListener('open', this.onConnectionSuccess);
|
||||||
|
},
|
||||||
|
|
||||||
if (this.reconnectTimeout !== null) {
|
onConnectionError() {
|
||||||
clearTimeout(this.reconnectTimeout);
|
this.pushDisconnect();
|
||||||
this.reconnectTimeout = null;
|
this.connectRetries++;
|
||||||
}
|
this.reconnectTimeout = setTimeout(this.attemptReconnect, this.connectRetries * 5000);
|
||||||
|
|
||||||
this.rootStore.pushConnectionActive = false;
|
|
||||||
this.pushAutomaticReconnect();
|
|
||||||
},
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close connection to server
|
* Close connection to server
|
||||||
*/
|
*/
|
||||||
pushDisconnect(): void {
|
pushDisconnect(): void {
|
||||||
if (this.eventSource !== null) {
|
if (this.pushSource !== null) {
|
||||||
this.eventSource.close();
|
this.pushSource.removeEventListener('error', this.onConnectionError);
|
||||||
this.eventSource = null;
|
this.pushSource.removeEventListener('close', this.onConnectionError);
|
||||||
|
this.pushSource.removeEventListener('message', this.pushMessageReceived);
|
||||||
this.rootStore.pushConnectionActive = false;
|
if (this.pushSource.readyState < 2) this.pushSource.close();
|
||||||
|
this.pushSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.rootStore.pushConnectionActive = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -136,7 +144,6 @@ export const pushConnection = mixins(
|
||||||
* the REST API so we do not know yet what execution ID
|
* the REST API so we do not know yet what execution ID
|
||||||
* is currently active. So internally resend the message
|
* is currently active. So internally resend the message
|
||||||
* a few more times
|
* a few more times
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
queuePushMessage(event: Event, retryAttempts: number) {
|
queuePushMessage(event: Event, retryAttempts: number) {
|
||||||
this.pushMessageQueue.push({ event, retriesLeft: retryAttempts });
|
this.pushMessageQueue.push({ event, retriesLeft: retryAttempts });
|
||||||
|
@ -178,9 +185,6 @@ export const pushConnection = mixins(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a newly received message
|
* Process a newly received message
|
||||||
*
|
|
||||||
* @param {Event} event The event data with the message data
|
|
||||||
* @param {boolean} [isRetry] If it is a retry
|
|
||||||
*/
|
*/
|
||||||
pushMessageReceived(event: Event, isRetry?: boolean): boolean {
|
pushMessageReceived(event: Event, isRetry?: boolean): boolean {
|
||||||
const retryAttempts = 5;
|
const retryAttempts = 5;
|
||||||
|
|
|
@ -141,6 +141,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
|
||||||
templatesHost(): string {
|
templatesHost(): string {
|
||||||
return this.settings.templates.host;
|
return this.settings.templates.host;
|
||||||
},
|
},
|
||||||
|
pushBackend(): IN8nUISettings['pushBackend'] {
|
||||||
|
return this.settings.pushBackend;
|
||||||
|
},
|
||||||
isCommunityNodesFeatureEnabled(): boolean {
|
isCommunityNodesFeatureEnabled(): boolean {
|
||||||
return this.settings.communityNodesEnabled;
|
return this.settings.communityNodesEnabled;
|
||||||
},
|
},
|
||||||
|
|
|
@ -148,6 +148,7 @@ importers:
|
||||||
'@types/syslog-client': ^1.1.2
|
'@types/syslog-client': ^1.1.2
|
||||||
'@types/uuid': ^8.3.2
|
'@types/uuid': ^8.3.2
|
||||||
'@types/validator': ^13.7.0
|
'@types/validator': ^13.7.0
|
||||||
|
'@types/ws': ^8.5.4
|
||||||
'@types/yamljs': ^0.2.31
|
'@types/yamljs': ^0.2.31
|
||||||
axios: ^0.21.1
|
axios: ^0.21.1
|
||||||
basic-auth: ^2.0.1
|
basic-auth: ^2.0.1
|
||||||
|
@ -236,6 +237,7 @@ importers:
|
||||||
uuid: ^8.3.2
|
uuid: ^8.3.2
|
||||||
validator: 13.7.0
|
validator: 13.7.0
|
||||||
winston: ^3.3.3
|
winston: ^3.3.3
|
||||||
|
ws: ^8.12.0
|
||||||
yamljs: ^0.3.0
|
yamljs: ^0.3.0
|
||||||
dependencies:
|
dependencies:
|
||||||
'@n8n_io/license-sdk': 1.8.0
|
'@n8n_io/license-sdk': 1.8.0
|
||||||
|
@ -324,6 +326,7 @@ importers:
|
||||||
uuid: 8.3.2
|
uuid: 8.3.2
|
||||||
validator: 13.7.0
|
validator: 13.7.0
|
||||||
winston: 3.8.2
|
winston: 3.8.2
|
||||||
|
ws: 8.12.0
|
||||||
yamljs: 0.3.0
|
yamljs: 0.3.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@apidevtools/swagger-cli': 4.0.0
|
'@apidevtools/swagger-cli': 4.0.0
|
||||||
|
@ -364,6 +367,7 @@ importers:
|
||||||
'@types/syslog-client': 1.1.2
|
'@types/syslog-client': 1.1.2
|
||||||
'@types/uuid': 8.3.4
|
'@types/uuid': 8.3.4
|
||||||
'@types/validator': 13.7.7
|
'@types/validator': 13.7.7
|
||||||
|
'@types/ws': 8.5.4
|
||||||
'@types/yamljs': 0.2.31
|
'@types/yamljs': 0.2.31
|
||||||
chokidar: 3.5.2
|
chokidar: 3.5.2
|
||||||
concurrently: 5.3.0
|
concurrently: 5.3.0
|
||||||
|
@ -6482,6 +6486,12 @@ packages:
|
||||||
'@types/webidl-conversions': 7.0.0
|
'@types/webidl-conversions': 7.0.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/ws/8.5.4:
|
||||||
|
resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==}
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 16.18.12
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/xml2js/0.4.11:
|
/@types/xml2js/0.4.11:
|
||||||
resolution: {integrity: sha512-JdigeAKmCyoJUiQljjr7tQG3if9NkqGUgwEUqBvV0N7LM4HyQk7UXCnusRa1lnvXAEYJ8mw8GtZWioagNztOwA==}
|
resolution: {integrity: sha512-JdigeAKmCyoJUiQljjr7tQG3if9NkqGUgwEUqBvV0N7LM4HyQk7UXCnusRa1lnvXAEYJ8mw8GtZWioagNztOwA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -22840,7 +22850,6 @@ packages:
|
||||||
optional: true
|
optional: true
|
||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
optional: true
|
||||||
dev: true
|
|
||||||
|
|
||||||
/x-default-browser/0.4.0:
|
/x-default-browser/0.4.0:
|
||||||
resolution: {integrity: sha512-7LKo7RtWfoFN/rHx1UELv/2zHGMx8MkZKDq1xENmOCTkfIqZJ0zZ26NEJX8czhnPXVcqS0ARjjfJB+eJ0/5Cvw==}
|
resolution: {integrity: sha512-7LKo7RtWfoFN/rHx1UELv/2zHGMx8MkZKDq1xENmOCTkfIqZJ0zZ26NEJX8czhnPXVcqS0ARjjfJB+eJ0/5Cvw==}
|
||||||
|
|
Loading…
Reference in a new issue