Merge branch 'ADO-2728/feature-change-auto-add-of-chattrigger' of https://github.com/n8n-io/n8n into ADO-2728/feature-change-auto-add-of-chattrigger

This commit is contained in:
Charlie Kolb 2024-11-06 10:27:09 +01:00
commit b980d62150
No known key found for this signature in database
120 changed files with 6350 additions and 3166 deletions

View file

@ -49,6 +49,9 @@ jobs:
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
cacheKey: ${{ github.sha }}-base:build
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
name: Lint

View file

@ -88,8 +88,7 @@
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch",
"@langchain/core@0.3.3": "patches/@langchain__core@0.3.3.patch"
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch"
}
}
}

View file

@ -0,0 +1,35 @@
import { Config, Env } from '../decorators';
@Config
export class PruningConfig {
/** Whether to delete past executions on a rolling basis. */
@Env('EXECUTIONS_DATA_PRUNE')
isEnabled: boolean = true;
/** How old (hours) a finished execution must be to qualify for soft-deletion. */
@Env('EXECUTIONS_DATA_MAX_AGE')
maxAge: number = 336;
/**
* Max number of finished executions to keep in database. Does not necessarily
* prune to the exact max number. `0` for unlimited.
*/
@Env('EXECUTIONS_DATA_PRUNE_MAX_COUNT')
maxCount: number = 10_000;
/**
* How old (hours) a finished execution must be to qualify for hard-deletion.
* This buffer by default excludes recent executions as the user may need
* them while building a workflow.
*/
@Env('EXECUTIONS_DATA_HARD_DELETE_BUFFER')
hardDeleteBuffer: number = 1;
/** How often (minutes) execution data should be hard-deleted. */
@Env('EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL')
hardDeleteInterval: number = 15;
/** How often (minutes) execution data should be soft-deleted */
@Env('EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL')
softDeleteInterval: number = 60;
}

View file

@ -10,6 +10,7 @@ import { LicenseConfig } from './configs/license.config';
import { LoggingConfig } from './configs/logging.config';
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
import { NodesConfig } from './configs/nodes.config';
import { PruningConfig } from './configs/pruning.config';
import { PublicApiConfig } from './configs/public-api.config';
import { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
@ -24,6 +25,7 @@ import { Config, Env, Nested } from './decorators';
export { Config, Env, Nested } from './decorators';
export { TaskRunnersConfig } from './configs/runners.config';
export { SecurityConfig } from './configs/security.config';
export { PruningConfig } from './configs/pruning.config';
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
export { LOG_SCOPES } from './configs/logging.config';
export type { LogScope } from './configs/logging.config';
@ -112,4 +114,7 @@ export class GlobalConfig {
@Nested
security: SecurityConfig;
@Nested
pruning: PruningConfig;
}

View file

@ -271,6 +271,14 @@ describe('GlobalConfig', () => {
blockFileAccessToN8nFiles: true,
daysAbandonedWorkflow: 90,
},
pruning: {
isEnabled: true,
maxAge: 336,
maxCount: 10_000,
hardDeleteBuffer: 1,
hardDeleteInterval: 15,
softDeleteInterval: 60,
},
};
it('should use all default values when no env variables are defined', () => {

View file

@ -137,22 +137,23 @@
"@google-cloud/resource-manager": "5.3.0",
"@google/generative-ai": "0.19.0",
"@huggingface/inference": "2.8.0",
"@langchain/anthropic": "0.3.1",
"@langchain/aws": "0.1.0",
"@langchain/cohere": "0.3.0",
"@langchain/community": "0.3.2",
"@langchain/anthropic": "0.3.7",
"@langchain/aws": "0.1.1",
"@langchain/cohere": "0.3.1",
"@langchain/community": "0.3.11",
"@langchain/core": "catalog:",
"@langchain/google-genai": "0.1.0",
"@langchain/google-genai": "0.1.2",
"@langchain/google-vertexai": "0.1.0",
"@langchain/groq": "0.1.2",
"@langchain/mistralai": "0.1.1",
"@langchain/ollama": "0.1.0",
"@langchain/openai": "0.3.0",
"@langchain/pinecone": "0.1.0",
"@langchain/ollama": "0.1.1",
"@langchain/openai": "0.3.11",
"@langchain/pinecone": "0.1.1",
"@langchain/qdrant": "0.1.0",
"@langchain/redis": "0.1.0",
"@langchain/textsplitters": "0.1.0",
"@mozilla/readability": "0.5.0",
"@n8n/json-schema-to-zod": "workspace:*",
"@n8n/typeorm": "0.3.20-12",
"@n8n/vm2": "3.9.25",
"@pinecone-database/pinecone": "3.0.3",
@ -168,14 +169,13 @@
"generate-schema": "2.6.0",
"html-to-text": "9.0.5",
"jsdom": "23.0.1",
"@n8n/json-schema-to-zod": "workspace:*",
"langchain": "0.3.2",
"langchain": "0.3.5",
"lodash": "catalog:",
"mammoth": "1.7.2",
"mime-types": "2.1.35",
"n8n-nodes-base": "workspace:*",
"n8n-workflow": "workspace:*",
"openai": "4.63.0",
"openai": "4.69.0",
"pdf-parse": "1.1.1",
"pg": "8.12.0",
"redis": "4.6.12",

View file

@ -23,8 +23,10 @@
],
"dependencies": {
"@n8n/config": "workspace:*",
"n8n-workflow": "workspace:*",
"acorn": "8.14.0",
"acorn-walk": "8.3.4",
"n8n-core": "workspace:*",
"n8n-workflow": "workspace:*",
"nanoid": "^3.3.6",
"typedi": "catalog:",
"ws": "^8.18.0"

View file

@ -4,14 +4,11 @@ import fs from 'node:fs';
import { builtinModules } from 'node:module';
import { ValidationError } from '@/js-task-runner/errors/validation-error';
import {
JsTaskRunner,
type AllCodeTaskData,
type JSExecSettings,
} from '@/js-task-runner/js-task-runner';
import type { DataRequestResponse, JSExecSettings } from '@/js-task-runner/js-task-runner';
import { JsTaskRunner } from '@/js-task-runner/js-task-runner';
import type { Task } from '@/task-runner';
import { newAllCodeTaskData, newTaskWithSettings, withPairedItem, wrapIntoJson } from './test-data';
import { newCodeTaskData, newTaskWithSettings, withPairedItem, wrapIntoJson } from './test-data';
import type { JsRunnerConfig } from '../../config/js-runner-config';
import { MainConfig } from '../../config/main-config';
import { ExecutionError } from '../errors/execution-error';
@ -43,7 +40,7 @@ describe('JsTaskRunner', () => {
runner = defaultTaskRunner,
}: {
task: Task<JSExecSettings>;
taskData: AllCodeTaskData;
taskData: DataRequestResponse;
runner?: JsTaskRunner;
}) => {
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
@ -71,7 +68,7 @@ describe('JsTaskRunner', () => {
nodeMode: 'runOnceForAllItems',
...settings,
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)),
taskData: newCodeTaskData(inputItems.map(wrapIntoJson)),
runner,
});
};
@ -94,7 +91,7 @@ describe('JsTaskRunner', () => {
nodeMode: 'runOnceForEachItem',
...settings,
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)),
taskData: newCodeTaskData(inputItems.map(wrapIntoJson)),
runner,
});
};
@ -111,7 +108,7 @@ describe('JsTaskRunner', () => {
await execTaskWithParams({
task,
taskData: newAllCodeTaskData([wrapIntoJson({})]),
taskData: newCodeTaskData([wrapIntoJson({})]),
});
expect(defaultTaskRunner.makeRpcCall).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [
@ -246,7 +243,7 @@ describe('JsTaskRunner', () => {
code: 'return { val: $env.VAR1 }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: {
isEnvAccessBlocked: false,
isProcessAvailable: true,
@ -265,7 +262,7 @@ describe('JsTaskRunner', () => {
code: 'return { val: $env.VAR1 }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: {
isEnvAccessBlocked: true,
isProcessAvailable: true,
@ -282,7 +279,7 @@ describe('JsTaskRunner', () => {
code: 'return Object.values($env).concat(Object.keys($env))',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: {
isEnvAccessBlocked: false,
isProcessAvailable: true,
@ -301,7 +298,7 @@ describe('JsTaskRunner', () => {
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: undefined,
}),
});
@ -316,7 +313,7 @@ describe('JsTaskRunner', () => {
code: 'return { val: Buffer.from("test-buffer").toString() }',
nodeMode: 'runOnceForAllItems',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: undefined,
}),
});
@ -328,7 +325,7 @@ describe('JsTaskRunner', () => {
code: 'return { val: Buffer.from("test-buffer").toString() }',
nodeMode: 'runOnceForEachItem',
}),
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
envProviderState: undefined,
}),
});
@ -774,7 +771,7 @@ describe('JsTaskRunner', () => {
code: 'unknown',
nodeMode,
}),
taskData: newAllCodeTaskData([wrapIntoJson({ a: 1 })]),
taskData: newCodeTaskData([wrapIntoJson({ a: 1 })]),
}),
).rejects.toThrow(ExecutionError);
},
@ -796,7 +793,7 @@ describe('JsTaskRunner', () => {
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
jest
.spyOn(runner, 'requestData')
.mockResolvedValue(newAllCodeTaskData([wrapIntoJson({ a: 1 })]));
.mockResolvedValue(newCodeTaskData([wrapIntoJson({ a: 1 })]));
await runner.receivedSettings(taskId, task.settings);

View file

@ -2,7 +2,7 @@ import type { IDataObject, INode, INodeExecutionData, ITaskData } from 'n8n-work
import { NodeConnectionType } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import type { AllCodeTaskData, JSExecSettings } from '@/js-task-runner/js-task-runner';
import type { DataRequestResponse, JSExecSettings } from '@/js-task-runner/js-task-runner';
import type { Task } from '@/task-runner';
/**
@ -48,10 +48,10 @@ export const newTaskData = (opts: Partial<ITaskData> & Pick<ITaskData, 'source'>
/**
* Creates a new all code task data with the given options
*/
export const newAllCodeTaskData = (
export const newCodeTaskData = (
codeNodeInputData: INodeExecutionData[],
opts: Partial<AllCodeTaskData> = {},
): AllCodeTaskData => {
opts: Partial<DataRequestResponse> = {},
): DataRequestResponse => {
const codeNode = newNode({
name: 'JsCode',
parameters: {

View file

@ -0,0 +1,117 @@
import { BuiltInsParserState } from '../built-ins-parser-state';
describe('BuiltInsParserState', () => {
describe('toDataRequestSpecification', () => {
it('should return empty array when no properties are marked as needed', () => {
const state = new BuiltInsParserState();
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: [],
env: false,
input: false,
prevNode: false,
});
});
it('should return all nodes and input when markNeedsAllNodes is called', () => {
const state = new BuiltInsParserState();
state.markNeedsAllNodes();
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: 'all',
env: false,
input: true,
prevNode: false,
});
});
it('should return specific node names when nodes are marked as needed individually', () => {
const state = new BuiltInsParserState();
state.markNodeAsNeeded('Node1');
state.markNodeAsNeeded('Node2');
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: ['Node1', 'Node2'],
env: false,
input: false,
prevNode: false,
});
});
it('should ignore individual nodes when needsAllNodes is marked as true', () => {
const state = new BuiltInsParserState();
state.markNodeAsNeeded('Node1');
state.markNeedsAllNodes();
state.markNodeAsNeeded('Node2'); // should be ignored since all nodes are needed
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: 'all',
env: false,
input: true,
prevNode: false,
});
});
it('should mark env as needed when markEnvAsNeeded is called', () => {
const state = new BuiltInsParserState();
state.markEnvAsNeeded();
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: [],
env: true,
input: false,
prevNode: false,
});
});
it('should mark input as needed when markInputAsNeeded is called', () => {
const state = new BuiltInsParserState();
state.markInputAsNeeded();
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: [],
env: false,
input: true,
prevNode: false,
});
});
it('should mark prevNode as needed when markPrevNodeAsNeeded is called', () => {
const state = new BuiltInsParserState();
state.markPrevNodeAsNeeded();
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: [],
env: false,
input: false,
prevNode: true,
});
});
it('should return correct specification when multiple properties are marked as needed', () => {
const state = new BuiltInsParserState();
state.markNeedsAllNodes();
state.markEnvAsNeeded();
state.markInputAsNeeded();
state.markPrevNodeAsNeeded();
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: 'all',
env: true,
input: true,
prevNode: true,
});
});
it('should return correct specification when all properties are marked as needed', () => {
const state = BuiltInsParserState.newNeedsAllDataState();
expect(state.toDataRequestParams()).toEqual({
dataOfNodes: 'all',
env: true,
input: true,
prevNode: true,
});
});
});
});

View file

@ -0,0 +1,251 @@
import { getAdditionalKeys } from 'n8n-core';
import type { IDataObject, INodeType, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { Workflow, WorkflowDataProxy } from 'n8n-workflow';
import { newCodeTaskData } from '../../__tests__/test-data';
import { BuiltInsParser } from '../built-ins-parser';
import { BuiltInsParserState } from '../built-ins-parser-state';
describe('BuiltInsParser', () => {
const parser = new BuiltInsParser();
const parseAndExpectOk = (code: string) => {
const result = parser.parseUsedBuiltIns(code);
if (!result.ok) {
fail(result.error);
}
return result.result;
};
describe('Env, input, execution and prevNode', () => {
const cases: Array<[string, BuiltInsParserState]> = [
['$env', new BuiltInsParserState({ needs$env: true })],
['$execution', new BuiltInsParserState({ needs$execution: true })],
['$prevNode', new BuiltInsParserState({ needs$prevNode: true })],
];
test.each(cases)("should identify built-ins in '%s'", (code, expected) => {
const state = parseAndExpectOk(code);
expect(state).toEqual(expected);
});
});
describe('Input', () => {
it('should mark input as needed when $input is used', () => {
const state = parseAndExpectOk(`
$input.item.json.age = 10 + Math.floor(Math.random() * 30);
$input.item.json.password = $input.item.json.password.split('').map(() => '*').join("")
delete $input.item.json.lastname
const emailParts = $input.item.json.email.split("@")
$input.item.json.emailData = {
user: emailParts[0],
domain: emailParts[1]
}
return $input.item;
`);
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
});
it('should mark input as needed when $json is used', () => {
const state = parseAndExpectOk(`
$json.age = 10 + Math.floor(Math.random() * 30);
return $json;
`);
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
});
});
describe('$(...)', () => {
const cases: Array<[string, BuiltInsParserState]> = [
[
'$("nodeName").first()',
new BuiltInsParserState({ neededNodeNames: new Set(['nodeName']) }),
],
[
'$("nodeName").all(); $("secondNode").matchingItem()',
new BuiltInsParserState({ neededNodeNames: new Set(['nodeName', 'secondNode']) }),
],
];
test.each(cases)("should identify nodes in '%s'", (code, expected) => {
const state = parseAndExpectOk(code);
expect(state).toEqual(expected);
});
it('should need all nodes when $() is called with a variable', () => {
const state = parseAndExpectOk('var n = "name"; $(n)');
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
});
it('should require all nodes when there are multiple usages of $() and one is with a variable', () => {
const state = parseAndExpectOk(`
$("nodeName");
$("secondNode");
var n = "name";
$(n)
`);
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
});
test.each([
['without parameters', '$()'],
['number literal', '$(123)'],
])('should ignore when $ is called %s', (_, code) => {
const state = parseAndExpectOk(code);
expect(state).toEqual(new BuiltInsParserState());
});
test.each([
'$("node").item',
'$("node")["item"]',
'$("node").pairedItem()',
'$("node")["pairedItem"]()',
'$("node").itemMatching(0)',
'$("node")["itemMatching"](0)',
'$("node")[variable]',
'var a = $("node")',
'let a = $("node")',
'const a = $("node")',
'a = $("node")',
])('should require all nodes if %s is used', (code) => {
const state = parseAndExpectOk(code);
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
});
test.each(['$("node").first()', '$("node").last()', '$("node").all()', '$("node").params'])(
'should require only accessed node if %s is used',
(code) => {
const state = parseAndExpectOk(code);
expect(state).toEqual(
new BuiltInsParserState({
needsAllNodes: false,
neededNodeNames: new Set(['node']),
}),
);
},
);
});
describe('ECMAScript syntax', () => {
describe('ES2020', () => {
it('should parse optional chaining', () => {
parseAndExpectOk(`
const a = { b: { c: 1 } };
return a.b?.c;
`);
});
it('should parse nullish coalescing', () => {
parseAndExpectOk(`
const a = null;
return a ?? 1;
`);
});
});
describe('ES2021', () => {
it('should parse numeric separators', () => {
parseAndExpectOk(`
const a = 1_000_000;
return a;
`);
});
});
});
describe('WorkflowDataProxy built-ins', () => {
it('should have a known list of built-ins', () => {
const data = newCodeTaskData([]);
const dataProxy = new WorkflowDataProxy(
new Workflow({
...data.workflow,
nodeTypes: {
getByName() {
return undefined as unknown as INodeType;
},
getByNameAndVersion() {
return undefined as unknown as INodeType;
},
getKnownTypes() {
return undefined as unknown as IDataObject;
},
},
}),
data.runExecutionData,
data.runIndex,
0,
data.activeNodeName,
data.connectionInputData,
data.siblingParameters,
data.mode,
getAdditionalKeys(
data.additionalData as IWorkflowExecuteAdditionalData,
data.mode,
data.runExecutionData,
),
data.executeData,
data.defaultReturnRunIndex,
data.selfData,
data.contextNodeName,
// Make sure that even if we don't receive the envProviderState for
// whatever reason, we don't expose the task runner's env to the code
data.envProviderState ?? {
env: {},
isEnvAccessBlocked: false,
isProcessAvailable: true,
},
).getDataProxy({ throwOnMissingExecutionData: false });
/**
* NOTE! If you are adding new built-ins to the WorkflowDataProxy class
* make sure the built-ins parser and Task Runner handle them properly.
*/
expect(Object.keys(dataProxy)).toStrictEqual([
'$',
'$input',
'$binary',
'$data',
'$env',
'$evaluateExpression',
'$item',
'$fromAI',
'$fromai',
'$fromAi',
'$items',
'$json',
'$node',
'$self',
'$parameter',
'$prevNode',
'$runIndex',
'$mode',
'$workflow',
'$itemIndex',
'$now',
'$today',
'$jmesPath',
'DateTime',
'Interval',
'Duration',
'$execution',
'$vars',
'$secrets',
'$executionId',
'$resumeWebhookUrl',
'$getPairedItem',
'$jmespath',
'$position',
'$thisItem',
'$thisItemIndex',
'$thisRunIndex',
'$nodeVersion',
'$nodeId',
'$webhookId',
]);
});
});
});

View file

@ -0,0 +1,28 @@
import type {
AssignmentExpression,
Identifier,
Literal,
MemberExpression,
Node,
VariableDeclarator,
} from 'acorn';
export function isLiteral(node?: Node): node is Literal {
return node?.type === 'Literal';
}
export function isIdentifier(node?: Node): node is Identifier {
return node?.type === 'Identifier';
}
export function isMemberExpression(node?: Node): node is MemberExpression {
return node?.type === 'MemberExpression';
}
export function isVariableDeclarator(node?: Node): node is VariableDeclarator {
return node?.type === 'VariableDeclarator';
}
export function isAssignmentExpression(node?: Node): node is AssignmentExpression {
return node?.type === 'AssignmentExpression';
}

View file

@ -0,0 +1,74 @@
import type { N8nMessage } from '../../runner-types';
/**
* Class to keep track of which built-in variables are accessed in the code
*/
export class BuiltInsParserState {
neededNodeNames: Set<string> = new Set();
needsAllNodes = false;
needs$env = false;
needs$input = false;
needs$execution = false;
needs$prevNode = false;
constructor(opts: Partial<BuiltInsParserState> = {}) {
Object.assign(this, opts);
}
/**
* Marks that all nodes are needed, including input data
*/
markNeedsAllNodes() {
this.needsAllNodes = true;
this.needs$input = true;
this.neededNodeNames = new Set();
}
markNodeAsNeeded(nodeName: string) {
if (this.needsAllNodes) {
return;
}
this.neededNodeNames.add(nodeName);
}
markEnvAsNeeded() {
this.needs$env = true;
}
markInputAsNeeded() {
this.needs$input = true;
}
markExecutionAsNeeded() {
this.needs$execution = true;
}
markPrevNodeAsNeeded() {
this.needs$prevNode = true;
}
toDataRequestParams(): N8nMessage.ToRequester.TaskDataRequest['requestParams'] {
return {
dataOfNodes: this.needsAllNodes ? 'all' : Array.from(this.neededNodeNames),
env: this.needs$env,
input: this.needs$input,
prevNode: this.needs$prevNode,
};
}
static newNeedsAllDataState() {
const obj = new BuiltInsParserState();
obj.markNeedsAllNodes();
obj.markEnvAsNeeded();
obj.markInputAsNeeded();
obj.markExecutionAsNeeded();
obj.markPrevNodeAsNeeded();
return obj;
}
}

View file

@ -0,0 +1,142 @@
import type { CallExpression, Identifier, Node, Program } from 'acorn';
import { parse } from 'acorn';
import { ancestor } from 'acorn-walk';
import type { Result } from 'n8n-workflow';
import { toResult } from 'n8n-workflow';
import {
isAssignmentExpression,
isIdentifier,
isLiteral,
isMemberExpression,
isVariableDeclarator,
} from './acorn-helpers';
import { BuiltInsParserState } from './built-ins-parser-state';
/**
* Class for parsing Code Node code to identify which built-in variables
* are accessed
*/
export class BuiltInsParser {
/**
* Parses which built-in variables are accessed in the given code
*/
public parseUsedBuiltIns(code: string): Result<BuiltInsParserState, Error> {
return toResult(() => {
const wrappedCode = `async function VmCodeWrapper() { ${code} }`;
const ast = parse(wrappedCode, { ecmaVersion: 2025, sourceType: 'module' });
return this.identifyBuiltInsByWalkingAst(ast);
});
}
/** Traverse the AST of the script and mark any data needed for it to run. */
private identifyBuiltInsByWalkingAst(ast: Program) {
const accessedBuiltIns = new BuiltInsParserState();
ancestor(
ast,
{
CallExpression: this.visitCallExpression,
Identifier: this.visitIdentifier,
},
undefined,
accessedBuiltIns,
);
return accessedBuiltIns;
}
private visitCallExpression = (
node: CallExpression,
state: BuiltInsParserState,
ancestors: Node[],
) => {
// $(...)
const isDollar = node.callee.type === 'Identifier' && node.callee.name === '$';
if (!isDollar) return;
// $(): This is not valid, ignore
if (node.arguments.length === 0) {
return;
}
const firstArg = node.arguments[0];
if (!isLiteral(firstArg)) {
// $(variable): Can't easily determine statically, mark all nodes as needed
state.markNeedsAllNodes();
return;
}
if (typeof firstArg.value !== 'string') {
// $(123): Static value, but not a string --> invalid code --> ignore
return;
}
// $("node"): Static value, mark 'nodeName' as needed
state.markNodeAsNeeded(firstArg.value);
// Determine how $("node") is used
this.handlePrevNodeCall(node, state, ancestors);
};
private handlePrevNodeCall(_node: CallExpression, state: BuiltInsParserState, ancestors: Node[]) {
// $("node").item, .pairedItem or .itemMatching: In a case like this, the execution
// engine will traverse back from current node (i.e. the Code Node) to
// the "node" node and use `pairedItem`s to find which item is linked
// to the current item. So, we need to mark all nodes as needed.
// TODO: We could also mark all the nodes between the current node and
// the "node" node as needed, but that would require more complex logic.
const directParent = ancestors[ancestors.length - 2];
if (isMemberExpression(directParent)) {
const accessedProperty = directParent.property;
if (directParent.computed) {
// $("node")["item"], ["pairedItem"] or ["itemMatching"]
if (isLiteral(accessedProperty)) {
if (this.isPairedItemProperty(accessedProperty.value)) {
state.markNeedsAllNodes();
}
// Else: $("node")[123]: Static value, but not any of the ones above --> ignore
}
// $("node")[variable]
else if (isIdentifier(accessedProperty)) {
state.markNeedsAllNodes();
}
}
// $("node").item, .pairedItem or .itemMatching
else if (isIdentifier(accessedProperty) && this.isPairedItemProperty(accessedProperty.name)) {
state.markNeedsAllNodes();
}
} else if (isVariableDeclarator(directParent) || isAssignmentExpression(directParent)) {
// const variable = $("node") or variable = $("node"):
// In this case we would need to track down all the possible use sites
// of 'variable' and determine if `.item` is accessed on it. This is
// more complex and skipped for now.
// TODO: Optimize for this case
state.markNeedsAllNodes();
} else {
// Something else than the cases above. Mark all nodes as needed as it
// could be a dynamic access.
state.markNeedsAllNodes();
}
}
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
if (node.name === '$env') {
state.markEnvAsNeeded();
} else if (node.name === '$input' || node.name === '$json') {
state.markInputAsNeeded();
} else if (node.name === '$execution') {
state.markExecutionAsNeeded();
} else if (node.name === '$prevNode') {
state.markPrevNodeAsNeeded();
}
};
private isPairedItemProperty(
property?: string | boolean | null | number | RegExp | bigint,
): boolean {
return property === 'item' || property === 'pairedItem' || property === 'itemMatching';
}
}

View file

@ -24,6 +24,8 @@ import { runInNewContext, type Context } from 'node:vm';
import type { TaskResultData } from '@/runner-types';
import { type Task, TaskRunner } from '@/task-runner';
import { BuiltInsParser } from './built-ins-parser/built-ins-parser';
import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state';
import { isErrorLike } from './errors/error-like';
import { ExecutionError } from './errors/execution-error';
import { makeSerializable } from './errors/serializable-error';
@ -57,7 +59,7 @@ export interface PartialAdditionalData {
variables: IDataObject;
}
export interface AllCodeTaskData {
export interface DataRequestResponse {
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
inputData: ITaskDataConnections;
node: INode;
@ -84,6 +86,8 @@ type CustomConsole = {
export class JsTaskRunner extends TaskRunner {
private readonly requireResolver: RequireResolver;
private readonly builtInsParser = new BuiltInsParser();
constructor(config: MainConfig, name = 'JS Task Runner') {
super({
taskType: 'javascript',
@ -102,12 +106,20 @@ export class JsTaskRunner extends TaskRunner {
}
async executeTask(task: Task<JSExecSettings>): Promise<TaskResultData> {
const allData = await this.requestData<AllCodeTaskData>(task.taskId, 'all');
const settings = task.settings;
a.ok(settings, 'JS Code not sent to runner');
const workflowParams = allData.workflow;
const neededBuiltInsResult = this.builtInsParser.parseUsedBuiltIns(settings.code);
const neededBuiltIns = neededBuiltInsResult.ok
? neededBuiltInsResult.result
: BuiltInsParserState.newNeedsAllDataState();
const data = await this.requestData<DataRequestResponse>(
task.taskId,
neededBuiltIns.toDataRequestParams(),
);
const workflowParams = data.workflow;
const workflow = new Workflow({
...workflowParams,
nodeTypes: this.nodeTypes,
@ -126,12 +138,12 @@ export class JsTaskRunner extends TaskRunner {
const result =
settings.nodeMode === 'runOnceForAllItems'
? await this.runForAllItems(task.taskId, settings, allData, workflow, customConsole)
: await this.runForEachItem(task.taskId, settings, allData, workflow, customConsole);
? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole)
: await this.runForEachItem(task.taskId, settings, data, workflow, customConsole);
return {
result,
customData: allData.runExecutionData.resultData.metadata,
customData: data.runExecutionData.resultData.metadata,
};
}
@ -165,12 +177,12 @@ export class JsTaskRunner extends TaskRunner {
private async runForAllItems(
taskId: string,
settings: JSExecSettings,
allData: AllCodeTaskData,
data: DataRequestResponse,
workflow: Workflow,
customConsole: CustomConsole,
): Promise<INodeExecutionData[]> {
const dataProxy = this.createDataProxy(allData, workflow, allData.itemIndex);
const inputItems = allData.connectionInputData;
const dataProxy = this.createDataProxy(data, workflow, data.itemIndex);
const inputItems = data.connectionInputData;
const context: Context = {
require: this.requireResolver,
@ -212,16 +224,16 @@ export class JsTaskRunner extends TaskRunner {
private async runForEachItem(
taskId: string,
settings: JSExecSettings,
allData: AllCodeTaskData,
data: DataRequestResponse,
workflow: Workflow,
customConsole: CustomConsole,
): Promise<INodeExecutionData[]> {
const inputItems = allData.connectionInputData;
const inputItems = data.connectionInputData;
const returnData: INodeExecutionData[] = [];
for (let index = 0; index < inputItems.length; index++) {
const item = inputItems[index];
const dataProxy = this.createDataProxy(allData, workflow, index);
const dataProxy = this.createDataProxy(data, workflow, index);
const context: Context = {
require: this.requireResolver,
module: {},
@ -279,33 +291,37 @@ export class JsTaskRunner extends TaskRunner {
return returnData;
}
private createDataProxy(allData: AllCodeTaskData, workflow: Workflow, itemIndex: number) {
private createDataProxy(data: DataRequestResponse, workflow: Workflow, itemIndex: number) {
return new WorkflowDataProxy(
workflow,
allData.runExecutionData,
allData.runIndex,
data.runExecutionData,
data.runIndex,
itemIndex,
allData.activeNodeName,
allData.connectionInputData,
allData.siblingParameters,
allData.mode,
data.activeNodeName,
data.connectionInputData,
data.siblingParameters,
data.mode,
getAdditionalKeys(
allData.additionalData as IWorkflowExecuteAdditionalData,
allData.mode,
allData.runExecutionData,
data.additionalData as IWorkflowExecuteAdditionalData,
data.mode,
data.runExecutionData,
),
allData.executeData,
allData.defaultReturnRunIndex,
allData.selfData,
allData.contextNodeName,
data.executeData,
data.defaultReturnRunIndex,
data.selfData,
data.contextNodeName,
// Make sure that even if we don't receive the envProviderState for
// whatever reason, we don't expose the task runner's env to the code
allData.envProviderState ?? {
data.envProviderState ?? {
env: {},
isEnvAccessBlocked: false,
isProcessAvailable: true,
},
).getDataProxy();
// Because we optimize the needed data, it can be partially available.
// We assign the available built-ins to the execution context, which
// means we run the getter for '$json', and by default $json throws
// if there is no data available.
).getDataProxy({ throwOnMissingExecutionData: false });
}
private toExecutionErrorIfNeeded(error: unknown): Error {

View file

@ -1,6 +1,11 @@
import type { INodeExecutionData, INodeTypeBaseDescription } from 'n8n-workflow';
export type DataRequestType = 'input' | 'node' | 'all';
export interface TaskDataRequestParams {
dataOfNodes: string[] | 'all';
prevNode: boolean;
input: boolean;
env: boolean;
}
export interface TaskResultData {
result: INodeExecutionData[];
@ -89,8 +94,7 @@ export namespace N8nMessage {
type: 'broker:taskdatarequest';
taskId: string;
requestId: string;
requestType: DataRequestType;
param?: string;
requestParams: TaskDataRequestParams;
}
export interface RPC {
@ -186,8 +190,7 @@ export namespace RunnerMessage {
type: 'runner:taskdatarequest';
taskId: string;
requestId: string;
requestType: DataRequestType;
param?: string;
requestParams: TaskDataRequestParams;
}
export interface RPC {

View file

@ -288,8 +288,7 @@ export abstract class TaskRunner {
async requestData<T = unknown>(
taskId: Task['taskId'],
type: RunnerMessage.ToN8n.TaskDataRequest['requestType'],
param?: string,
requestParams: RunnerMessage.ToN8n.TaskDataRequest['requestParams'],
): Promise<T> {
const requestId = nanoid();
@ -305,8 +304,7 @@ export abstract class TaskRunner {
type: 'runner:taskdatarequest',
taskId,
requestId,
requestType: type,
param,
requestParams,
});
try {

View file

@ -4,6 +4,7 @@ import Container from 'typedi';
import { ActiveExecutions } from '@/active-executions';
import config from '@/config';
import type { User } from '@/databases/entities/user';
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
import { Telemetry } from '@/telemetry';
import { WorkflowRunner } from '@/workflow-runner';
import { mockInstance } from '@test/mocking';
@ -64,6 +65,19 @@ test('processError should return early in Bull stalled edge case', async () => {
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should return early if the error is `ExecutionNotFoundError`', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'success', finished: true }, workflow);
await runner.processError(
new ExecutionNotFoundError(execution.id),
new Date(),
'webhook',
execution.id,
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
);
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
});
test('processError should process error', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution(

View file

@ -1,6 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { ActiveWorkflows, InstanceSettings, NodeExecuteFunctions } from 'n8n-core';
import {
ActiveWorkflows,
InstanceSettings,
NodeExecuteFunctions,
PollContext,
TriggerContext,
} from 'n8n-core';
import type {
ExecutionError,
IDeferredPromise,
@ -274,18 +280,11 @@ export class ActiveWorkflowManager {
activation: WorkflowActivateMode,
): IGetExecutePollFunctions {
return (workflow: Workflow, node: INode) => {
const returnFunctions = NodeExecuteFunctions.getExecutePollFunctions(
workflow,
node,
additionalData,
mode,
activation,
);
returnFunctions.__emit = (
const __emit = (
data: INodeExecutionData[][],
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
donePromise?: IDeferredPromise<IRun | undefined>,
): void => {
) => {
this.logger.debug(`Received event to trigger execution for workflow "${workflow.name}"`);
void this.workflowStaticDataService.saveStaticData(workflow);
const executePromise = this.workflowExecutionService.runWorkflow(
@ -309,14 +308,15 @@ export class ActiveWorkflowManager {
}
};
returnFunctions.__emitError = (error: ExecutionError): void => {
const __emitError = (error: ExecutionError) => {
void this.executionService
.createErrorExecution(error, node, workflowData, workflow, mode)
.then(() => {
this.executeErrorWorkflow(error, workflowData, mode);
});
};
return returnFunctions;
return new PollContext(workflow, node, additionalData, mode, activation, __emit, __emitError);
};
}
@ -331,18 +331,11 @@ export class ActiveWorkflowManager {
activation: WorkflowActivateMode,
): IGetExecuteTriggerFunctions {
return (workflow: Workflow, node: INode) => {
const returnFunctions = NodeExecuteFunctions.getExecuteTriggerFunctions(
workflow,
node,
additionalData,
mode,
activation,
);
returnFunctions.emit = (
const emit = (
data: INodeExecutionData[][],
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
donePromise?: IDeferredPromise<IRun | undefined>,
): void => {
) => {
this.logger.debug(`Received trigger for workflow "${workflow.name}"`);
void this.workflowStaticDataService.saveStaticData(workflow);
@ -366,7 +359,7 @@ export class ActiveWorkflowManager {
executePromise.catch((error: Error) => this.logger.error(error.message, { error }));
}
};
returnFunctions.emitError = (error: Error): void => {
const emitError = (error: Error): void => {
this.logger.info(
`The trigger node "${node.name}" of workflow "${workflowData.name}" failed with the error: "${error.message}". Will try to reactivate.`,
{
@ -391,7 +384,7 @@ export class ActiveWorkflowManager {
this.addQueuedWorkflowActivation(activation, workflowData as WorkflowEntity);
};
return returnFunctions;
return new TriggerContext(workflow, node, additionalData, mode, activation, emit, emitError);
};
}

View file

@ -22,8 +22,6 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'
import { EventService } from '@/events/event.service';
import { ExecutionService } from '@/executions/execution.service';
import { License } from '@/license';
import { LocalTaskManager } from '@/runners/task-managers/local-task-manager';
import { TaskManager } from '@/runners/task-managers/task-manager';
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { Server } from '@/server';
@ -224,19 +222,9 @@ export class Start extends BaseCommand {
const { taskRunners: taskRunnerConfig } = this.globalConfig;
if (!taskRunnerConfig.disabled) {
Container.set(TaskManager, new LocalTaskManager());
const { TaskRunnerServer } = await import('@/runners/task-runner-server');
const taskRunnerServer = Container.get(TaskRunnerServer);
await taskRunnerServer.start();
if (
taskRunnerConfig.mode === 'internal_childprocess' ||
taskRunnerConfig.mode === 'internal_launcher'
) {
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
const runnerProcess = Container.get(TaskRunnerProcess);
await runnerProcess.start();
}
const { TaskRunnerModule } = await import('@/runners/task-runner-module');
const taskRunnerModule = Container.get(TaskRunnerModule);
await taskRunnerModule.start();
}
}

View file

@ -8,8 +8,6 @@ import { EventMessageGeneric } from '@/eventbus/event-message-classes/event-mess
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
import { Logger } from '@/logging/logger.service';
import { LocalTaskManager } from '@/runners/task-managers/local-task-manager';
import { TaskManager } from '@/runners/task-managers/task-manager';
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import type { ScalingService } from '@/scaling/scaling.service';
@ -116,19 +114,9 @@ export class Worker extends BaseCommand {
const { taskRunners: taskRunnerConfig } = this.globalConfig;
if (!taskRunnerConfig.disabled) {
Container.set(TaskManager, new LocalTaskManager());
const { TaskRunnerServer } = await import('@/runners/task-runner-server');
const taskRunnerServer = Container.get(TaskRunnerServer);
await taskRunnerServer.start();
if (
taskRunnerConfig.mode === 'internal_childprocess' ||
taskRunnerConfig.mode === 'internal_launcher'
) {
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
const runnerProcess = Container.get(TaskRunnerProcess);
await runnerProcess.start();
}
const { TaskRunnerModule } = await import('@/runners/task-runner-module');
const taskRunnerModule = Container.get(TaskRunnerModule);
await taskRunnerModule.start();
}
}

View file

@ -98,54 +98,6 @@ export const schema = {
env: 'EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS',
},
// To not exceed the database's capacity and keep its size moderate
// the execution data gets pruned regularly (default: 15 minute interval).
// All saved execution data older than the max age will be deleted.
// Pruning is currently not activated by default, which will change in
// a future version.
pruneData: {
doc: 'Delete data of past executions on a rolling basis',
format: Boolean,
default: true,
env: 'EXECUTIONS_DATA_PRUNE',
},
pruneDataMaxAge: {
doc: 'How old (hours) the finished execution data has to be to get soft-deleted',
format: Number,
default: 336,
env: 'EXECUTIONS_DATA_MAX_AGE',
},
pruneDataHardDeleteBuffer: {
doc: 'How old (hours) the finished execution data has to be to get hard-deleted. By default, this buffer excludes recent executions as the user may need them while building a workflow.',
format: Number,
default: 1,
env: 'EXECUTIONS_DATA_HARD_DELETE_BUFFER',
},
pruneDataIntervals: {
hardDelete: {
doc: 'How often (minutes) execution data should be hard-deleted',
format: Number,
default: 15,
env: 'EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL',
},
softDelete: {
doc: 'How often (minutes) execution data should be soft-deleted',
format: Number,
default: 60,
env: 'EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL',
},
},
// Additional pruning option to delete executions if total count exceeds the configured max.
// Deletes the oldest entries first
// Set to 0 for No limit
pruneDataMaxCount: {
doc: "Maximum number of finished executions to keep in DB. Doesn't necessarily prune exactly to max number. 0 = no limit",
format: Number,
default: 10000,
env: 'EXECUTIONS_DATA_PRUNE_MAX_COUNT',
},
queueRecovery: {
interval: {
doc: 'How often (minutes) to check for queue recovery',

View file

@ -35,7 +35,6 @@ import type {
} from 'n8n-workflow';
import { Service } from 'typedi';
import config from '@/config';
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
@ -460,8 +459,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
}
async softDeletePrunableExecutions() {
const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h
const maxCount = config.getEnv('executions.pruneDataMaxCount');
const { maxAge, maxCount } = this.globalConfig.pruning;
// Sub-query to exclude executions having annotations
const annotatedExecutionsSubQuery = this.manager
@ -517,7 +515,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
async hardDeleteSoftDeletedExecutions() {
const date = new Date();
date.setHours(date.getHours() - config.getEnv('executions.pruneDataHardDeleteBuffer'));
date.setHours(date.getHours() - this.globalConfig.pruning.hardDeleteBuffer);
const workflowIdsAndExecutionIds = (
await this.find({

View file

@ -771,8 +771,8 @@ export class TelemetryEventRelay extends EventRelay {
executions_data_save_manual_executions: config.getEnv(
'executions.saveDataManualExecutions',
),
executions_data_prune: config.getEnv('executions.pruneData'),
executions_data_max_age: config.getEnv('executions.pruneDataMaxAge'),
executions_data_prune: this.globalConfig.pruning.isEnabled,
executions_data_max_age: this.globalConfig.pruning.maxAge,
},
n8n_deployment_type: config.getEnv('deployment.type'),
n8n_binary_data_mode: binaryDataConfig.mode,

View file

@ -1,4 +1,5 @@
import {
deepCopy,
ErrorReporterProxy,
type IRunExecutionData,
type ITaskData,
@ -57,7 +58,7 @@ test('should ignore on leftover async call', async () => {
expect(executionRepository.updateExistingExecution).not.toHaveBeenCalled();
});
test('should update execution', async () => {
test('should update execution when saving progress is enabled', async () => {
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
...commonSettings,
progress: true,
@ -86,6 +87,37 @@ test('should update execution', async () => {
expect(reporterSpy).not.toHaveBeenCalled();
});
test('should update execution when saving progress is disabled, but waitTill is defined', async () => {
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
...commonSettings,
progress: false,
});
const reporterSpy = jest.spyOn(ErrorReporterProxy, 'error');
executionRepository.findSingleExecution.mockResolvedValue({} as IExecutionResponse);
const args = deepCopy(commonArgs);
args[4].waitTill = new Date();
await saveExecutionProgress(...args);
expect(executionRepository.updateExistingExecution).toHaveBeenCalledWith('some-execution-id', {
data: {
executionData: undefined,
resultData: {
lastNodeExecuted: 'My Node',
runData: {
'My Node': [{}],
},
},
startData: {},
},
status: 'running',
});
expect(reporterSpy).not.toHaveBeenCalled();
});
test('should report error on failure', async () => {
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
...commonSettings,

View file

@ -16,7 +16,7 @@ export async function saveExecutionProgress(
) {
const saveSettings = toSaveSettings(workflowData.settings);
if (!saveSettings.progress) return;
if (!saveSettings.progress && !executionData.waitTill) return;
const logger = Container.get(Logger);

View file

@ -494,15 +494,18 @@ describe('TaskBroker', () => {
const taskId = 'task1';
const requesterId = 'requester1';
const requestId = 'request1';
const requestType = 'input';
const param = 'test_param';
const requestParams: RunnerMessage.ToN8n.TaskDataRequest['requestParams'] = {
dataOfNodes: 'all',
env: true,
input: true,
prevNode: true,
};
const message: RunnerMessage.ToN8n.TaskDataRequest = {
type: 'runner:taskdatarequest',
taskId,
requestId,
requestType,
param,
requestParams,
};
const requesterMessageCallback = jest.fn();
@ -519,8 +522,7 @@ describe('TaskBroker', () => {
type: 'broker:taskdatarequest',
taskId,
requestId,
requestType,
param,
requestParams,
});
});

View file

@ -0,0 +1,16 @@
import { Service } from 'typedi';
import { TaskRunnerDisconnectedError } from './errors/task-runner-disconnected-error';
import type { DisconnectAnalyzer } from './runner-types';
import type { TaskRunner } from './task-broker.service';
/**
* Analyzes the disconnect reason of a task runner to provide a more
* meaningful error message to the user.
*/
@Service()
export class DefaultTaskRunnerDisconnectAnalyzer implements DisconnectAnalyzer {
async determineDisconnectReason(runnerId: TaskRunner['id']): Promise<Error> {
return new TaskRunnerDisconnectedError(runnerId);
}
}

View file

@ -3,7 +3,7 @@ import { Service } from 'typedi';
import config from '@/config';
import { TaskRunnerDisconnectedError } from './errors/task-runner-disconnected-error';
import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer';
import { TaskRunnerOomError } from './errors/task-runner-oom-error';
import { SlidingWindowSignal } from './sliding-window-signal';
import type { TaskRunner } from './task-broker.service';
@ -15,13 +15,19 @@ import { TaskRunnerProcess } from './task-runner-process';
* meaningful error message to the user.
*/
@Service()
export class TaskRunnerDisconnectAnalyzer {
export class InternalTaskRunnerDisconnectAnalyzer extends DefaultTaskRunnerDisconnectAnalyzer {
private get isCloudDeployment() {
return config.get('deployment.type') === 'cloud';
}
private readonly exitReasonSignal: SlidingWindowSignal<TaskRunnerProcessEventMap, 'exit'>;
constructor(
private readonly runnerConfig: TaskRunnersConfig,
private readonly taskRunnerProcess: TaskRunnerProcess,
) {
super();
// When the task runner process is running as a child process, there's
// no determinate time when it exits compared to when the runner disconnects
// (i.e. it's a race condition). Hence we use a sliding window to determine
@ -32,17 +38,13 @@ export class TaskRunnerDisconnectAnalyzer {
});
}
private get isCloudDeployment() {
return config.get('deployment.type') === 'cloud';
}
async determineDisconnectReason(runnerId: TaskRunner['id']): Promise<Error> {
const exitCode = await this.awaitExitSignal();
if (exitCode === 'oom') {
return new TaskRunnerOomError(runnerId, this.isCloudDeployment);
}
return new TaskRunnerDisconnectedError(runnerId);
return await super.determineDisconnectReason(runnerId);
}
private async awaitExitSignal(): Promise<ExitReason> {

View file

@ -5,6 +5,22 @@ import type WebSocket from 'ws';
import type { TaskRunner } from './task-broker.service';
import type { AuthlessRequest } from '../requests';
/**
* Specifies what data should be included for a task data request.
*/
export interface TaskDataRequestParams {
dataOfNodes: string[] | 'all';
prevNode: boolean;
/** Whether input data for the node should be included */
input: boolean;
/** Whether env provider's state should be included */
env: boolean;
}
export interface DisconnectAnalyzer {
determineDisconnectReason(runnerId: TaskRunner['id']): Promise<Error>;
}
export type DataRequestType = 'input' | 'node' | 'all';
export interface TaskResultData {
@ -101,8 +117,7 @@ export namespace N8nMessage {
type: 'broker:taskdatarequest';
taskId: string;
requestId: string;
requestType: DataRequestType;
param?: string;
requestParams: TaskDataRequestParams;
}
export interface RPC {
@ -198,8 +213,7 @@ export namespace RunnerMessage {
type: 'runner:taskdatarequest';
taskId: string;
requestId: string;
requestType: DataRequestType;
param?: string;
requestParams: TaskDataRequestParams;
}
export interface RPC {

View file

@ -3,29 +3,38 @@ import type WebSocket from 'ws';
import { Logger } from '@/logging/logger.service';
import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer';
import type {
RunnerMessage,
N8nMessage,
TaskRunnerServerInitRequest,
TaskRunnerServerInitResponse,
DisconnectAnalyzer,
} from './runner-types';
import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service';
import { TaskRunnerDisconnectAnalyzer } from './task-runner-disconnect-analyzer';
function heartbeat(this: WebSocket) {
this.isAlive = true;
}
@Service()
export class TaskRunnerService {
export class TaskRunnerWsServer {
runnerConnections: Map<TaskRunner['id'], WebSocket> = new Map();
constructor(
private readonly logger: Logger,
private readonly taskBroker: TaskBroker,
private readonly disconnectAnalyzer: TaskRunnerDisconnectAnalyzer,
private disconnectAnalyzer: DefaultTaskRunnerDisconnectAnalyzer,
) {}
setDisconnectAnalyzer(disconnectAnalyzer: DisconnectAnalyzer) {
this.disconnectAnalyzer = disconnectAnalyzer;
}
getDisconnectAnalyzer() {
return this.disconnectAnalyzer;
}
sendMessage(id: TaskRunner['id'], message: N8nMessage.ToRunner.All) {
this.runnerConnections.get(id)?.send(JSON.stringify(message));
}

View file

@ -178,12 +178,7 @@ export class TaskBroker {
await this.taskErrorHandler(message.taskId, message.error);
break;
case 'runner:taskdatarequest':
await this.handleDataRequest(
message.taskId,
message.requestId,
message.requestType,
message.param,
);
await this.handleDataRequest(message.taskId, message.requestId, message.requestParams);
break;
case 'runner:rpc':
@ -233,8 +228,7 @@ export class TaskBroker {
async handleDataRequest(
taskId: Task['id'],
requestId: RunnerMessage.ToN8n.TaskDataRequest['requestId'],
requestType: RunnerMessage.ToN8n.TaskDataRequest['requestType'],
param?: string,
requestParams: RunnerMessage.ToN8n.TaskDataRequest['requestParams'],
) {
const task = this.tasks.get(taskId);
if (!task) {
@ -244,8 +238,7 @@ export class TaskBroker {
type: 'broker:taskdatarequest',
taskId,
requestId,
requestType,
param,
requestParams,
});
}

View file

@ -0,0 +1,324 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { type INode, type INodeExecutionData, type Workflow } from 'n8n-workflow';
import { DataRequestResponseBuilder } from '../data-request-response-builder';
import type { TaskData } from '../task-manager';
const triggerNode: INode = mock<INode>({
name: 'Trigger',
});
const debugHelperNode: INode = mock<INode>({
name: 'DebugHelper',
});
const codeNode: INode = mock<INode>({
name: 'Code',
});
const workflow: TaskData['workflow'] = mock<Workflow>();
const debugHelperNodeOutItems: INodeExecutionData[] = [
{
json: {
uid: 'abb74fd4-bef2-4fae-9d53-ea24e9eb3032',
email: 'Dan.Schmidt31@yahoo.com',
firstname: 'Toni',
lastname: 'Schuster',
password: 'Q!D6C2',
},
pairedItem: {
item: 0,
},
},
];
const codeNodeInputItems: INodeExecutionData[] = debugHelperNodeOutItems;
const connectionInputData: TaskData['connectionInputData'] = codeNodeInputItems;
const envProviderState: TaskData['envProviderState'] = mock<TaskData['envProviderState']>({
env: {},
isEnvAccessBlocked: false,
isProcessAvailable: true,
});
const additionalData = mock<IWorkflowExecuteAdditionalData>({
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
instanceBaseUrl: 'http://localhost:5678/',
restApiUrl: 'http://localhost:5678/rest',
variables: {},
webhookBaseUrl: 'http://localhost:5678/webhook',
webhookTestBaseUrl: 'http://localhost:5678/webhook-test',
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
executionId: '45844',
userId: '114984bc-44b3-4dd4-9b54-a4a8d34d51d5',
currentNodeParameters: undefined,
executionTimeoutTimestamp: undefined,
restartExecutionId: undefined,
});
const executeFunctions = mock<IExecuteFunctions>();
/**
* Drawn with https://asciiflow.com/#/
* Task data for an execution of the following WF:
* where denotes the currently being executing node.
*
*
* Trigger DebugHelper Code
*
*/
const taskData: TaskData = {
executeFunctions,
workflow,
connectionInputData,
inputData: {
main: [codeNodeInputItems],
},
itemIndex: 0,
activeNodeName: codeNode.name,
contextNodeName: codeNode.name,
defaultReturnRunIndex: -1,
mode: 'manual',
envProviderState,
node: codeNode,
runExecutionData: {
startData: {
destinationNode: codeNode.name,
runNodeFilter: [triggerNode.name, debugHelperNode.name, codeNode.name],
},
resultData: {
runData: {
[triggerNode.name]: [
{
hints: [],
startTime: 1730313407328,
executionTime: 1,
source: [],
executionStatus: 'success',
data: {
main: [[]],
},
},
],
[debugHelperNode.name]: [
{
hints: [],
startTime: 1730313407330,
executionTime: 1,
source: [
{
previousNode: triggerNode.name,
},
],
executionStatus: 'success',
data: {
main: [debugHelperNodeOutItems],
},
},
],
},
pinData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {
[codeNode.name]: {
'0': {
main: [codeNodeInputItems],
},
},
},
waitingExecutionSource: {
[codeNode.name]: {
'0': {
main: [
{
previousNode: debugHelperNode.name,
},
],
},
},
},
},
},
runIndex: 0,
selfData: {},
siblingParameters: {},
executeData: {
node: codeNode,
data: {
main: [codeNodeInputItems],
},
source: {
main: [
{
previousNode: debugHelperNode.name,
previousNodeOutput: 0,
},
],
},
},
additionalData,
} as const;
describe('DataRequestResponseBuilder', () => {
const allDataParam: DataRequestResponseBuilder['requestParams'] = {
dataOfNodes: 'all',
env: true,
input: true,
prevNode: true,
};
const newRequestParam = (opts: Partial<DataRequestResponseBuilder['requestParams']>) => ({
...allDataParam,
...opts,
});
describe('all data', () => {
it('should build the runExecutionData as is when everything is requested', () => {
const dataRequestResponseBuilder = new DataRequestResponseBuilder(taskData, allDataParam);
const { runExecutionData } = dataRequestResponseBuilder.build();
expect(runExecutionData).toStrictEqual(taskData.runExecutionData);
});
});
describe('envProviderState', () => {
it("should filter out envProviderState when it's not requested", () => {
const dataRequestResponseBuilder = new DataRequestResponseBuilder(
taskData,
newRequestParam({
env: false,
}),
);
const result = dataRequestResponseBuilder.build();
expect(result.envProviderState).toStrictEqual({
env: {},
isEnvAccessBlocked: false,
isProcessAvailable: true,
});
});
});
describe('additionalData', () => {
it('picks only specific properties for additional data', () => {
const dataRequestResponseBuilder = new DataRequestResponseBuilder(taskData, allDataParam);
const result = dataRequestResponseBuilder.build();
expect(result.additionalData).toStrictEqual({
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
instanceBaseUrl: 'http://localhost:5678/',
restApiUrl: 'http://localhost:5678/rest',
webhookBaseUrl: 'http://localhost:5678/webhook',
webhookTestBaseUrl: 'http://localhost:5678/webhook-test',
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
executionId: '45844',
userId: '114984bc-44b3-4dd4-9b54-a4a8d34d51d5',
currentNodeParameters: undefined,
executionTimeoutTimestamp: undefined,
restartExecutionId: undefined,
variables: additionalData.variables,
});
});
});
describe('input data', () => {
const allExceptInputParam = newRequestParam({
input: false,
});
it('drops input data from executeData', () => {
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
expect(result.executeData).toStrictEqual({
node: taskData.executeData!.node,
source: taskData.executeData!.source,
data: {},
});
});
it('drops input data from result', () => {
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
expect(result.inputData).toStrictEqual({});
});
it('drops input data from result', () => {
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
expect(result.inputData).toStrictEqual({});
});
it('drops input data from connectionInputData', () => {
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
expect(result.connectionInputData).toStrictEqual([]);
});
});
describe('nodes', () => {
it('should return empty run data when only Code node is requested', () => {
const result = new DataRequestResponseBuilder(
taskData,
newRequestParam({ dataOfNodes: ['Code'], prevNode: false }),
).build();
expect(result.runExecutionData.resultData.runData).toStrictEqual({});
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
// executionData & startData contain only metadata --> returned as is
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
expect(result.runExecutionData.executionData).toStrictEqual(
taskData.runExecutionData.executionData,
);
});
it('should return empty run data when only Code node is requested', () => {
const result = new DataRequestResponseBuilder(
taskData,
newRequestParam({ dataOfNodes: [codeNode.name], prevNode: false }),
).build();
expect(result.runExecutionData.resultData.runData).toStrictEqual({});
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
// executionData & startData contain only metadata --> returned as is
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
expect(result.runExecutionData.executionData).toStrictEqual(
taskData.runExecutionData.executionData,
);
});
it("should return only DebugHelper's data when only DebugHelper node is requested", () => {
const result = new DataRequestResponseBuilder(
taskData,
newRequestParam({ dataOfNodes: [debugHelperNode.name], prevNode: false }),
).build();
expect(result.runExecutionData.resultData.runData).toStrictEqual({
[debugHelperNode.name]: taskData.runExecutionData.resultData.runData[debugHelperNode.name],
});
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
// executionData & startData contain only metadata --> returned as is
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
expect(result.runExecutionData.executionData).toStrictEqual(
taskData.runExecutionData.executionData,
);
});
it("should return DebugHelper's data when only prevNode node is requested", () => {
const result = new DataRequestResponseBuilder(
taskData,
newRequestParam({ dataOfNodes: [], prevNode: true }),
).build();
expect(result.runExecutionData.resultData.runData).toStrictEqual({
[debugHelperNode.name]: taskData.runExecutionData.resultData.runData[debugHelperNode.name],
});
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
// executionData & startData contain only metadata --> returned as is
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
expect(result.runExecutionData.executionData).toStrictEqual(
taskData.runExecutionData.executionData,
);
});
});
});

View file

@ -0,0 +1,205 @@
import type {
EnvProviderState,
IExecuteData,
INodeExecutionData,
IPinData,
IRunData,
IRunExecutionData,
ITaskDataConnections,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowParameters,
} from 'n8n-workflow';
import type { DataRequestResponse, PartialAdditionalData, TaskData } from './task-manager';
import type { N8nMessage } from '../runner-types';
/**
* Builds the response to a data request coming from a Task Runner. Tries to minimize
* the amount of data that is sent to the runner by only providing what is requested.
*/
export class DataRequestResponseBuilder {
private requestedNodeNames = new Set<string>();
constructor(
private readonly taskData: TaskData,
private readonly requestParams: N8nMessage.ToRequester.TaskDataRequest['requestParams'],
) {
this.requestedNodeNames = new Set(requestParams.dataOfNodes);
if (this.requestParams.prevNode && this.requestParams.dataOfNodes !== 'all') {
this.requestedNodeNames.add(this.determinePrevNodeName());
}
}
/**
* Builds a response to the data request
*/
build(): DataRequestResponse {
const { taskData: td } = this;
return {
workflow: this.buildWorkflow(td.workflow),
connectionInputData: this.buildConnectionInputData(td.connectionInputData),
inputData: this.buildInputData(td.inputData),
itemIndex: td.itemIndex,
activeNodeName: td.activeNodeName,
contextNodeName: td.contextNodeName,
defaultReturnRunIndex: td.defaultReturnRunIndex,
mode: td.mode,
envProviderState: this.buildEnvProviderState(td.envProviderState),
node: td.node, // The current node being executed
runExecutionData: this.buildRunExecutionData(td.runExecutionData),
runIndex: td.runIndex,
selfData: td.selfData,
siblingParameters: td.siblingParameters,
executeData: this.buildExecuteData(td.executeData),
additionalData: this.buildAdditionalData(td.additionalData),
};
}
private buildAdditionalData(
additionalData: IWorkflowExecuteAdditionalData,
): PartialAdditionalData {
return {
formWaitingBaseUrl: additionalData.formWaitingBaseUrl,
instanceBaseUrl: additionalData.instanceBaseUrl,
restApiUrl: additionalData.restApiUrl,
variables: additionalData.variables,
webhookBaseUrl: additionalData.webhookBaseUrl,
webhookTestBaseUrl: additionalData.webhookTestBaseUrl,
webhookWaitingBaseUrl: additionalData.webhookWaitingBaseUrl,
currentNodeParameters: additionalData.currentNodeParameters,
executionId: additionalData.executionId,
executionTimeoutTimestamp: additionalData.executionTimeoutTimestamp,
restartExecutionId: additionalData.restartExecutionId,
userId: additionalData.userId,
};
}
private buildExecuteData(executeData: IExecuteData | undefined): IExecuteData | undefined {
if (executeData === undefined) {
return undefined;
}
return {
node: executeData.node, // The current node being executed
data: this.requestParams.input ? executeData.data : {},
source: executeData.source,
};
}
private buildRunExecutionData(runExecutionData: IRunExecutionData): IRunExecutionData {
if (this.requestParams.dataOfNodes === 'all') {
return runExecutionData;
}
return {
startData: runExecutionData.startData,
resultData: {
error: runExecutionData.resultData.error,
lastNodeExecuted: runExecutionData.resultData.lastNodeExecuted,
metadata: runExecutionData.resultData.metadata,
runData: this.buildRunData(runExecutionData.resultData.runData),
pinData: this.buildPinData(runExecutionData.resultData.pinData),
},
executionData: runExecutionData.executionData
? {
// TODO: Figure out what these two are and can they be filtered
contextData: runExecutionData.executionData?.contextData,
nodeExecutionStack: runExecutionData.executionData.nodeExecutionStack,
metadata: runExecutionData.executionData.metadata,
waitingExecution: runExecutionData.executionData.waitingExecution,
waitingExecutionSource: runExecutionData.executionData.waitingExecutionSource,
}
: undefined,
};
}
private buildRunData(runData: IRunData): IRunData {
return this.filterObjectByNodeNames(runData);
}
private buildPinData(pinData: IPinData | undefined): IPinData | undefined {
return pinData ? this.filterObjectByNodeNames(pinData) : undefined;
}
private buildEnvProviderState(envProviderState: EnvProviderState): EnvProviderState {
if (this.requestParams.env) {
// In case `isEnvAccessBlocked` = true, the provider state has already sanitized
// the environment variables and we can return it as is.
return envProviderState;
}
return {
env: {},
isEnvAccessBlocked: envProviderState.isEnvAccessBlocked,
isProcessAvailable: envProviderState.isProcessAvailable,
};
}
private buildInputData(inputData: ITaskDataConnections): ITaskDataConnections {
if (this.requestParams.input) {
return inputData;
}
return {};
}
private buildConnectionInputData(
connectionInputData: INodeExecutionData[],
): INodeExecutionData[] {
if (this.requestParams.input) {
return connectionInputData;
}
return [];
}
private buildWorkflow(workflow: Workflow): Omit<WorkflowParameters, 'nodeTypes'> {
return {
id: workflow.id,
name: workflow.name,
active: workflow.active,
connections: workflow.connectionsBySourceNode,
nodes: Object.values(workflow.nodes),
pinData: workflow.pinData,
settings: workflow.settings,
staticData: workflow.staticData,
};
}
/**
* Assuming the given `obj` is an object where the keys are node names,
* filters the object to only include the node names that are requested.
*/
private filterObjectByNodeNames<T extends Record<string, unknown>>(obj: T): T {
if (this.requestParams.dataOfNodes === 'all') {
return obj;
}
const filteredObj: T = {} as T;
for (const nodeName in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, nodeName)) {
continue;
}
if (this.requestedNodeNames.has(nodeName)) {
filteredObj[nodeName] = obj[nodeName];
}
}
return filteredObj;
}
private determinePrevNodeName(): string {
const sourceData = this.taskData.executeData?.source?.main?.[0];
if (!sourceData) {
return '';
}
return sourceData.previousNode;
}
}

View file

@ -18,6 +18,7 @@ import {
} from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { DataRequestResponseBuilder } from './data-request-response-builder';
import {
RPC_ALLOW_LIST,
type TaskResultData,
@ -67,7 +68,7 @@ export interface PartialAdditionalData {
variables: IDataObject;
}
export interface AllCodeTaskData {
export interface DataRequestResponse {
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
inputData: ITaskDataConnections;
node: INode;
@ -104,19 +105,6 @@ interface ExecuteFunctionObject {
[name: string]: ((...args: unknown[]) => unknown) | ExecuteFunctionObject;
}
const workflowToParameters = (workflow: Workflow): Omit<WorkflowParameters, 'nodeTypes'> => {
return {
id: workflow.id,
name: workflow.name,
active: workflow.active,
connections: workflow.connectionsBySourceNode,
nodes: Object.values(workflow.nodes),
pinData: workflow.pinData,
settings: workflow.settings,
staticData: workflow.staticData,
};
};
export class TaskManager {
requestAcceptRejects: Map<string, { accept: RequestAccept; reject: RequestReject }> = new Map();
@ -245,7 +233,7 @@ export class TaskManager {
this.taskError(message.taskId, message.error);
break;
case 'broker:taskdatarequest':
this.sendTaskData(message.taskId, message.requestId, message.requestType);
this.sendTaskData(message.taskId, message.requestId, message.requestParams);
break;
case 'broker:rpc':
void this.handleRpc(message.taskId, message.callId, message.name, message.params);
@ -294,54 +282,23 @@ export class TaskManager {
sendTaskData(
taskId: string,
requestId: string,
requestType: N8nMessage.ToRequester.TaskDataRequest['requestType'],
requestParams: N8nMessage.ToRequester.TaskDataRequest['requestParams'],
) {
const job = this.tasks.get(taskId);
if (!job) {
// TODO: logging
return;
}
if (requestType === 'all') {
const jd = job.data;
const ad = jd.additionalData;
const data: AllCodeTaskData = {
workflow: workflowToParameters(jd.workflow),
connectionInputData: jd.connectionInputData,
inputData: jd.inputData,
itemIndex: jd.itemIndex,
activeNodeName: jd.activeNodeName,
contextNodeName: jd.contextNodeName,
defaultReturnRunIndex: jd.defaultReturnRunIndex,
mode: jd.mode,
envProviderState: jd.envProviderState,
node: jd.node,
runExecutionData: jd.runExecutionData,
runIndex: jd.runIndex,
selfData: jd.selfData,
siblingParameters: jd.siblingParameters,
executeData: jd.executeData,
additionalData: {
formWaitingBaseUrl: ad.formWaitingBaseUrl,
instanceBaseUrl: ad.instanceBaseUrl,
restApiUrl: ad.restApiUrl,
variables: ad.variables,
webhookBaseUrl: ad.webhookBaseUrl,
webhookTestBaseUrl: ad.webhookTestBaseUrl,
webhookWaitingBaseUrl: ad.webhookWaitingBaseUrl,
currentNodeParameters: ad.currentNodeParameters,
executionId: ad.executionId,
executionTimeoutTimestamp: ad.executionTimeoutTimestamp,
restartExecutionId: ad.restartExecutionId,
userId: ad.userId,
},
};
this.sendMessage({
type: 'requester:taskdataresponse',
taskId,
requestId,
data,
});
}
const dataRequestResponseBuilder = new DataRequestResponseBuilder(job.data, requestParams);
const requestedData = dataRequestResponseBuilder.build();
this.sendMessage({
type: 'requester:taskdataresponse',
taskId,
requestId,
data: requestedData,
});
}
async handleRpc(

View file

@ -0,0 +1,85 @@
import { TaskRunnersConfig } from '@n8n/config';
import * as a from 'node:assert/strict';
import Container, { Service } from 'typedi';
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
import { TaskRunnerWsServer } from './runner-ws-server';
import type { LocalTaskManager } from './task-managers/local-task-manager';
import type { TaskRunnerServer } from './task-runner-server';
/**
* Module responsible for loading and starting task runner. Task runner can be
* run either internally (=launched by n8n as a child process) or externally
* (=launched by some other orchestrator)
*/
@Service()
export class TaskRunnerModule {
private taskRunnerHttpServer: TaskRunnerServer | undefined;
private taskRunnerWsServer: TaskRunnerWsServer | undefined;
private taskManager: LocalTaskManager | undefined;
private taskRunnerProcess: TaskRunnerProcess | undefined;
constructor(private readonly runnerConfig: TaskRunnersConfig) {}
async start() {
a.ok(!this.runnerConfig.disabled, 'Task runner is disabled');
await this.loadTaskManager();
await this.loadTaskRunnerServer();
if (
this.runnerConfig.mode === 'internal_childprocess' ||
this.runnerConfig.mode === 'internal_launcher'
) {
await this.startInternalTaskRunner();
}
}
async stop() {
if (this.taskRunnerProcess) {
await this.taskRunnerProcess.stop();
this.taskRunnerProcess = undefined;
}
if (this.taskRunnerHttpServer) {
await this.taskRunnerHttpServer.stop();
this.taskRunnerHttpServer = undefined;
}
}
private async loadTaskManager() {
const { TaskManager } = await import('@/runners/task-managers/task-manager');
const { LocalTaskManager } = await import('@/runners/task-managers/local-task-manager');
this.taskManager = new LocalTaskManager();
Container.set(TaskManager, this.taskManager);
}
private async loadTaskRunnerServer() {
// These are imported dynamically because we need to set the task manager
// instance before importing them
const { TaskRunnerServer } = await import('@/runners/task-runner-server');
this.taskRunnerHttpServer = Container.get(TaskRunnerServer);
this.taskRunnerWsServer = Container.get(TaskRunnerWsServer);
await this.taskRunnerHttpServer.start();
}
private async startInternalTaskRunner() {
a.ok(this.taskRunnerWsServer, 'Task Runner WS Server not loaded');
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
this.taskRunnerProcess = Container.get(TaskRunnerProcess);
await this.taskRunnerProcess.start();
const { InternalTaskRunnerDisconnectAnalyzer } = await import(
'@/runners/internal-task-runner-disconnect-analyzer'
);
this.taskRunnerWsServer.setDisconnectAnalyzer(
Container.get(InternalTaskRunnerDisconnectAnalyzer),
);
}
}

View file

@ -69,8 +69,8 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
super();
a.ok(
this.runnerConfig.mode === 'internal_childprocess' ||
this.runnerConfig.mode === 'internal_launcher',
this.runnerConfig.mode !== 'external',
'Task Runner Process cannot be used in external mode',
);
this.logger = logger.scoped('task-runner');

View file

@ -19,7 +19,7 @@ import type {
TaskRunnerServerInitRequest,
TaskRunnerServerInitResponse,
} from '@/runners/runner-types';
import { TaskRunnerService } from '@/runners/runner-ws-server';
import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
/**
* Task Runner HTTP & WS server
@ -44,7 +44,7 @@ export class TaskRunnerServer {
private readonly logger: Logger,
private readonly globalConfig: GlobalConfig,
private readonly taskRunnerAuthController: TaskRunnerAuthController,
private readonly taskRunnerService: TaskRunnerService,
private readonly taskRunnerService: TaskRunnerWsServer,
) {
this.app = express();
this.app.disable('x-powered-by');

View file

@ -1,4 +1,4 @@
import { NodeExecuteFunctions } from 'n8n-core';
import { LoadOptionsContext, NodeExecuteFunctions } from 'n8n-core';
import type {
ILoadOptions,
ILoadOptionsFunctions,
@ -253,6 +253,6 @@ export class DynamicNodeParametersService {
workflow: Workflow,
) {
const node = workflow.nodes['Temp-Node'];
return NodeExecuteFunctions.getLoadOptionsFunctions(workflow, node, path, additionalData);
return new LoadOptionsContext(workflow, node, additionalData, path);
}
}

View file

@ -222,9 +222,9 @@ export class FrontendService {
licensePruneTime: -1,
},
pruning: {
isEnabled: config.getEnv('executions.pruneData'),
maxAge: config.getEnv('executions.pruneDataMaxAge'),
maxCount: config.getEnv('executions.pruneDataMaxCount'),
isEnabled: this.globalConfig.pruning.isEnabled,
maxAge: this.globalConfig.pruning.maxAge,
maxCount: this.globalConfig.pruning.maxCount,
},
security: {
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,

View file

@ -3,7 +3,6 @@ import { BinaryDataService, InstanceSettings } from 'n8n-core';
import { jsonStringify } from 'n8n-workflow';
import { Service } from 'typedi';
import config from '@/config';
import { inTest, TIME } from '@/constants';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import { OnShutdown } from '@/decorators/on-shutdown';
@ -16,8 +15,8 @@ export class PruningService {
private hardDeletionBatchSize = 100;
private rates: Record<string, number> = {
softDeletion: config.getEnv('executions.pruneDataIntervals.softDelete') * TIME.MINUTE,
hardDeletion: config.getEnv('executions.pruneDataIntervals.hardDelete') * TIME.MINUTE,
softDeletion: this.globalConfig.pruning.softDeleteInterval * TIME.MINUTE,
hardDeletion: this.globalConfig.pruning.hardDeleteInterval * TIME.MINUTE,
};
public softDeletionInterval: NodeJS.Timer | undefined;
@ -52,7 +51,7 @@ export class PruningService {
private isPruningEnabled() {
const { instanceType, isFollower } = this.instanceSettings;
if (!config.getEnv('executions.pruneData') || inTest || instanceType !== 'main') {
if (!this.globalConfig.pruning.isEnabled || inTest || instanceType !== 'main') {
return false;
}

View file

@ -468,7 +468,7 @@ function hookFunctionsSave(): IWorkflowExecuteHooks {
(executionStatus === 'success' && !saveSettings.success) ||
(executionStatus !== 'success' && !saveSettings.error);
if (shouldNotSave) {
if (shouldNotSave && !fullRunData.waitTill) {
if (!fullRunData.waitTill && !isManualMode) {
executeErrorWorkflow(
this.workflowData,

View file

@ -35,6 +35,7 @@ import * as WorkflowHelpers from '@/workflow-helpers';
import { generateFailedExecutionFromError } from '@/workflow-helpers';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
import { ExecutionNotFoundError } from './errors/execution-not-found-error';
import { EventService } from './events/event.service';
@Service()
@ -57,12 +58,21 @@ export class WorkflowRunner {
/** The process did error */
async processError(
error: ExecutionError,
error: ExecutionError | ExecutionNotFoundError,
startedAt: Date,
executionMode: WorkflowExecuteMode,
executionId: string,
hooks?: WorkflowHooks,
) {
// This means the execution was probably cancelled and has already
// been cleaned up.
//
// FIXME: This is a quick fix. The proper fix would be to not remove
// the execution from the active executions while it's still running.
if (error instanceof ExecutionNotFoundError) {
return;
}
ErrorReporter.error(error, { executionId });
const isQueueMode = config.getEnv('executions.mode') === 'queue';

View file

@ -1,9 +1,9 @@
import { GlobalConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import { BinaryDataService, InstanceSettings } from 'n8n-core';
import type { ExecutionStatus } from 'n8n-workflow';
import Container from 'typedi';
import config from '@/config';
import { TIME } from '@/constants';
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
@ -28,17 +28,19 @@ describe('softDeleteOnPruningCycle()', () => {
const now = new Date();
const yesterday = new Date(Date.now() - TIME.DAY);
let workflow: WorkflowEntity;
let globalConfig: GlobalConfig;
beforeAll(async () => {
await testDb.init();
globalConfig = Container.get(GlobalConfig);
pruningService = new PruningService(
mockInstance(Logger),
instanceSettings,
Container.get(ExecutionRepository),
mockInstance(BinaryDataService),
mock(),
mock(),
globalConfig,
);
workflow = await createWorkflow();
@ -52,10 +54,6 @@ describe('softDeleteOnPruningCycle()', () => {
await testDb.terminate();
});
afterEach(() => {
config.load(config.default);
});
async function findAllExecutions() {
return await Container.get(ExecutionRepository).find({
order: { id: 'asc' },
@ -64,9 +62,9 @@ describe('softDeleteOnPruningCycle()', () => {
}
describe('when EXECUTIONS_DATA_PRUNE_MAX_COUNT is set', () => {
beforeEach(() => {
config.set('executions.pruneDataMaxCount', 1);
config.set('executions.pruneDataMaxAge', 336);
beforeAll(() => {
globalConfig.pruning.maxAge = 336;
globalConfig.pruning.maxCount = 1;
});
test('should mark as deleted based on EXECUTIONS_DATA_PRUNE_MAX_COUNT', async () => {
@ -165,9 +163,9 @@ describe('softDeleteOnPruningCycle()', () => {
});
describe('when EXECUTIONS_DATA_MAX_AGE is set', () => {
beforeEach(() => {
config.set('executions.pruneDataMaxAge', 1); // 1h
config.set('executions.pruneDataMaxCount', 0);
beforeAll(() => {
globalConfig.pruning.maxAge = 1;
globalConfig.pruning.maxCount = 0;
});
test('should mark as deleted based on EXECUTIONS_DATA_MAX_AGE', async () => {

View file

@ -0,0 +1,40 @@
import { TaskRunnersConfig } from '@n8n/config';
import Container from 'typedi';
import { TaskRunnerModule } from '@/runners/task-runner-module';
import { DefaultTaskRunnerDisconnectAnalyzer } from '../../../src/runners/default-task-runner-disconnect-analyzer';
import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server';
describe('TaskRunnerModule in external mode', () => {
const runnerConfig = Container.get(TaskRunnersConfig);
runnerConfig.mode = 'external';
runnerConfig.port = 0;
const module = Container.get(TaskRunnerModule);
afterEach(async () => {
await module.stop();
});
describe('start', () => {
it('should throw if the task runner is disabled', async () => {
runnerConfig.disabled = true;
// Act
await expect(module.start()).rejects.toThrow('Task runner is disabled');
});
it('should start the task runner', async () => {
runnerConfig.disabled = false;
// Act
await module.start();
});
it('should use DefaultTaskRunnerDisconnectAnalyzer', () => {
const wsServer = Container.get(TaskRunnerWsServer);
expect(wsServer.getDisconnectAnalyzer()).toBeInstanceOf(DefaultTaskRunnerDisconnectAnalyzer);
});
});
});

View file

@ -0,0 +1,39 @@
import { TaskRunnersConfig } from '@n8n/config';
import Container from 'typedi';
import { TaskRunnerModule } from '@/runners/task-runner-module';
import { InternalTaskRunnerDisconnectAnalyzer } from '../../../src/runners/internal-task-runner-disconnect-analyzer';
import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server';
describe('TaskRunnerModule in internal_childprocess mode', () => {
const runnerConfig = Container.get(TaskRunnersConfig);
runnerConfig.mode = 'internal_childprocess';
const module = Container.get(TaskRunnerModule);
afterEach(async () => {
await module.stop();
});
describe('start', () => {
it('should throw if the task runner is disabled', async () => {
runnerConfig.disabled = true;
// Act
await expect(module.start()).rejects.toThrow('Task runner is disabled');
});
it('should start the task runner', async () => {
runnerConfig.disabled = false;
// Act
await module.start();
});
it('should use InternalTaskRunnerDisconnectAnalyzer', () => {
const wsServer = Container.get(TaskRunnerWsServer);
expect(wsServer.getDisconnectAnalyzer()).toBeInstanceOf(InternalTaskRunnerDisconnectAnalyzer);
});
});
});

View file

@ -1,7 +1,7 @@
import { TaskRunnersConfig } from '@n8n/config';
import Container from 'typedi';
import { TaskRunnerService } from '@/runners/runner-ws-server';
import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
import { TaskBroker } from '@/runners/task-broker.service';
import { TaskRunnerProcess } from '@/runners/task-runner-process';
import { TaskRunnerServer } from '@/runners/task-runner-server';
@ -18,7 +18,7 @@ describe('TaskRunnerProcess', () => {
const runnerProcess = Container.get(TaskRunnerProcess);
const taskBroker = Container.get(TaskBroker);
const taskRunnerService = Container.get(TaskRunnerService);
const taskRunnerService = Container.get(TaskRunnerWsServer);
const startLauncherSpy = jest.spyOn(runnerProcess, 'startLauncher');
const startNodeSpy = jest.spyOn(runnerProcess, 'startNode');

View file

@ -13,13 +13,13 @@
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"dev": "pnpm watch",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"",
"test": "jest"
},
"files": [

View file

@ -23,12 +23,11 @@ import type {
} from 'axios';
import axios from 'axios';
import crypto, { createHmac } from 'crypto';
import type { Request, Response } from 'express';
import FileType from 'file-type';
import FormData from 'form-data';
import { createReadStream } from 'fs';
import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises';
import { IncomingMessage, type IncomingHttpHeaders } from 'http';
import { IncomingMessage } from 'http';
import { Agent, type AgentOptions } from 'https';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
@ -60,7 +59,6 @@ import type {
IGetNodeParameterOptions,
IHookFunctions,
IHttpRequestOptions,
ILoadOptionsFunctions,
IN8nHttpFullResponse,
IN8nHttpResponse,
INode,
@ -101,7 +99,6 @@ import type {
INodeParameters,
EnsureTypeOptions,
SSHTunnelFunctions,
SchedulingFunctions,
DeduplicationHelperFunctions,
IDeduplicationOutput,
IDeduplicationOutputItems,
@ -111,6 +108,7 @@ import type {
ICheckProcessedContextData,
AiEvent,
ISupplyDataFunctions,
WebhookType,
} from 'n8n-workflow';
import {
NodeConnectionType,
@ -167,7 +165,8 @@ import {
import { extractValue } from './ExtractValue';
import { InstanceSettings } from './InstanceSettings';
import type { ExtendedValidationResult, IResponseError } from './Interfaces';
import { ScheduledTaskManager } from './ScheduledTaskManager';
// eslint-disable-next-line import/no-cycle
import { HookContext, PollContext, TriggerContext, WebhookContext } from './node-execution-context';
import { getSecretsProxy } from './Secrets';
import { SSHClientsManager } from './SSHClientsManager';
@ -215,7 +214,7 @@ const createFormDataObject = (data: Record<string, unknown>) => {
return formData;
};
const validateUrl = (url?: string): boolean => {
export const validateUrl = (url?: string): boolean => {
if (!url) return false;
try {
@ -776,7 +775,7 @@ export function parseIncomingMessage(message: IncomingMessage) {
}
}
async function binaryToString(body: Buffer | Readable, encoding?: BufferEncoding) {
export async function binaryToString(body: Buffer | Readable, encoding?: BufferEncoding) {
const buffer = await binaryToBuffer(body);
if (!encoding && body instanceof IncomingMessage) {
parseIncomingMessage(body);
@ -1010,7 +1009,7 @@ export const removeEmptyBody = (requestOptions: IHttpRequestOptions | IRequestOp
}
};
async function httpRequest(
export async function httpRequest(
requestOptions: IHttpRequestOptions,
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
removeEmptyBody(requestOptions);
@ -1205,7 +1204,7 @@ export async function copyBinaryFile(
* base64 and adds metadata.
*/
// eslint-disable-next-line complexity
async function prepareBinaryData(
export async function prepareBinaryData(
binaryData: Buffer | Readable,
executionId: string,
workflowId: string,
@ -1348,6 +1347,7 @@ export async function clearAllProcessedItems(
options,
);
}
export async function getProcessedDataCount(
scope: DeduplicationScope,
contextData: ICheckProcessedContextData,
@ -1359,7 +1359,8 @@ export async function getProcessedDataCount(
options,
);
}
function applyPaginationRequestData(
export function applyPaginationRequestData(
requestData: IRequestOptions,
paginationRequestData: PaginationOptions['request'],
): IRequestOptions {
@ -2628,7 +2629,7 @@ export function continueOnFail(node: INode): boolean {
*
*/
export function getNodeWebhookUrl(
name: string,
name: WebhookType,
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
@ -2673,7 +2674,7 @@ export function getNodeWebhookUrl(
*
*/
export function getWebhookDescription(
name: string,
name: WebhookType,
workflow: Workflow,
node: INode,
): IWebhookDescription | undefined {
@ -2798,7 +2799,7 @@ const addExecutionDataFunctions = async (
}
};
async function getInputConnectionData(
export async function getInputConnectionData(
this: IAllExecuteFunctions,
workflow: Workflow,
runExecutionData: IRunExecutionData,
@ -3342,14 +3343,6 @@ const getSSHTunnelFunctions = (): SSHTunnelFunctions => ({
await Container.get(SSHClientsManager).getClient(credentials),
});
const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => {
const scheduledTaskManager = Container.get(ScheduledTaskManager);
return {
registerCron: (cronExpression, onTick) =>
scheduledTaskManager.registerCron(workflow, cronExpression, onTick),
};
};
const getAllowedPaths = () => {
const restrictFileAccessTo = process.env[RESTRICT_FILE_ACCESS_TO];
if (!restrictFileAccessTo) {
@ -3553,57 +3546,7 @@ export function getExecutePollFunctions(
mode: WorkflowExecuteMode,
activation: WorkflowActivateMode,
): IPollFunctions {
return ((workflow: Workflow, node: INode) => {
return {
...getCommonWorkflowFunctions(workflow, node, additionalData),
__emit: (): void => {
throw new ApplicationError(
'Overwrite NodeExecuteFunctions.getExecutePollFunctions.__emit function',
);
},
__emitError() {
throw new ApplicationError(
'Overwrite NodeExecuteFunctions.getExecutePollFunctions.__emitError function',
);
},
getMode: () => mode,
getActivationMode: () => activation,
getCredentials: async (type) =>
await getCredentials(workflow, node, type, additionalData, mode),
getNodeParameter: (
parameterName: string,
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object => {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
workflow,
runExecutionData,
runIndex,
connectionInputData,
node,
parameterName,
itemIndex,
mode,
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
);
},
helpers: {
createDeferredPromise,
...getRequestHelperFunctions(workflow, node, additionalData),
...getBinaryHelperFunctions(additionalData, workflow.id),
...getSchedulingFunctions(workflow),
returnJsonArray,
},
};
})(workflow, node);
return new PollContext(workflow, node, additionalData, mode, activation);
}
/**
@ -3617,58 +3560,7 @@ export function getExecuteTriggerFunctions(
mode: WorkflowExecuteMode,
activation: WorkflowActivateMode,
): ITriggerFunctions {
return ((workflow: Workflow, node: INode) => {
return {
...getCommonWorkflowFunctions(workflow, node, additionalData),
emit: (): void => {
throw new ApplicationError(
'Overwrite NodeExecuteFunctions.getExecuteTriggerFunctions.emit function',
);
},
emitError: (): void => {
throw new ApplicationError(
'Overwrite NodeExecuteFunctions.getExecuteTriggerFunctions.emit function',
);
},
getMode: () => mode,
getActivationMode: () => activation,
getCredentials: async (type) =>
await getCredentials(workflow, node, type, additionalData, mode),
getNodeParameter: (
parameterName: string,
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object => {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
workflow,
runExecutionData,
runIndex,
connectionInputData,
node,
parameterName,
itemIndex,
mode,
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
);
},
helpers: {
createDeferredPromise,
...getSSHTunnelFunctions(),
...getRequestHelperFunctions(workflow, node, additionalData),
...getBinaryHelperFunctions(additionalData, workflow.id),
...getSchedulingFunctions(workflow),
returnJsonArray,
},
};
})(workflow, node);
return new TriggerContext(workflow, node, additionalData, mode, activation);
}
/**
@ -4400,6 +4292,7 @@ export function getExecuteSingleFunctions(
},
helpers: {
createDeferredPromise,
returnJsonArray,
...getRequestHelperFunctions(
workflow,
node,
@ -4439,85 +4332,6 @@ export function getCredentialTestFunctions(): ICredentialTestFunctions {
};
}
/**
* Returns the execute functions regular nodes have access to in load-options-function.
*/
export function getLoadOptionsFunctions(
workflow: Workflow,
node: INode,
path: string,
additionalData: IWorkflowExecuteAdditionalData,
): ILoadOptionsFunctions {
return ((workflow: Workflow, node: INode, path: string) => {
return {
...getCommonWorkflowFunctions(workflow, node, additionalData),
getCredentials: async (type) =>
await getCredentials(workflow, node, type, additionalData, 'internal'),
getCurrentNodeParameter: (
parameterPath: string,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object | undefined => {
const nodeParameters = additionalData.currentNodeParameters;
if (parameterPath.charAt(0) === '&') {
parameterPath = `${path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
}
let returnData = get(nodeParameters, parameterPath);
// This is outside the try/catch because it throws errors with proper messages
if (options?.extractValue) {
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (nodeType === undefined) {
throw new ApplicationError('Node type is not known so cannot return parameter value', {
tags: { nodeType: node.type },
});
}
returnData = extractValue(
returnData,
parameterPath,
node,
nodeType,
) as NodeParameterValueType;
}
return returnData;
},
getCurrentNodeParameters: () => additionalData.currentNodeParameters,
getNodeParameter: (
parameterName: string,
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object => {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const mode = 'internal' as WorkflowExecuteMode;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
workflow,
runExecutionData,
runIndex,
connectionInputData,
node,
parameterName,
itemIndex,
mode,
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
);
},
helpers: {
...getSSHTunnelFunctions(),
...getRequestHelperFunctions(workflow, node, additionalData),
},
};
})(workflow, node, path);
}
/**
* Returns the execute functions regular nodes have access to in hook-function.
*/
@ -4529,59 +4343,7 @@ export function getExecuteHookFunctions(
activation: WorkflowActivateMode,
webhookData?: IWebhookData,
): IHookFunctions {
return ((workflow: Workflow, node: INode) => {
return {
...getCommonWorkflowFunctions(workflow, node, additionalData),
getCredentials: async (type) =>
await getCredentials(workflow, node, type, additionalData, mode),
getMode: () => mode,
getActivationMode: () => activation,
getNodeParameter: (
parameterName: string,
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object => {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
workflow,
runExecutionData,
runIndex,
connectionInputData,
node,
parameterName,
itemIndex,
mode,
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
);
},
getNodeWebhookUrl: (name: string): string | undefined => {
return getNodeWebhookUrl(
name,
workflow,
node,
additionalData,
mode,
getAdditionalKeys(additionalData, mode, null),
webhookData?.isTest,
);
},
getWebhookName(): string {
if (webhookData === undefined) {
throw new ApplicationError('Only supported in webhook functions');
}
return webhookData.webhookDescription.name;
},
getWebhookDescription: (name) => getWebhookDescription(name, workflow, node),
helpers: getRequestHelperFunctions(workflow, node, additionalData),
};
})(workflow, node);
return new HookContext(workflow, node, additionalData, mode, activation, webhookData);
}
/**
@ -4597,170 +4359,13 @@ export function getExecuteWebhookFunctions(
closeFunctions: CloseFunction[],
runExecutionData: IRunExecutionData | null,
): IWebhookFunctions {
return ((workflow: Workflow, node: INode, runExecutionData: IRunExecutionData | null) => {
return {
...getCommonWorkflowFunctions(workflow, node, additionalData),
getBodyData(): IDataObject {
if (additionalData.httpRequest === undefined) {
throw new ApplicationError('Request is missing');
}
return additionalData.httpRequest.body;
},
getCredentials: async (type) =>
await getCredentials(workflow, node, type, additionalData, mode),
getHeaderData(): IncomingHttpHeaders {
if (additionalData.httpRequest === undefined) {
throw new ApplicationError('Request is missing');
}
return additionalData.httpRequest.headers;
},
async getInputConnectionData(
inputName: NodeConnectionType,
itemIndex: number,
): Promise<unknown> {
// To be able to use expressions like "$json.sessionId" set the
// body data the webhook received to what is normally used for
// incoming node data.
const connectionInputData: INodeExecutionData[] = [
{ json: additionalData.httpRequest?.body || {} },
];
const runExecutionData: IRunExecutionData = {
resultData: {
runData: {},
},
};
const executeData: IExecuteData = {
data: {
main: [connectionInputData],
},
node,
source: null,
};
const runIndex = 0;
return await getInputConnectionData.call(
this,
workflow,
runExecutionData,
runIndex,
connectionInputData,
{} as ITaskDataConnections,
additionalData,
executeData,
mode,
closeFunctions,
inputName,
itemIndex,
);
},
getMode: () => mode,
evaluateExpression: (expression: string, evaluateItemIndex?: number) => {
const itemIndex = evaluateItemIndex === undefined ? 0 : evaluateItemIndex;
const runIndex = 0;
let connectionInputData: INodeExecutionData[] = [];
let executionData: IExecuteData | undefined;
if (runExecutionData?.executionData !== undefined) {
executionData = runExecutionData.executionData.nodeExecutionStack[0];
if (executionData !== undefined) {
connectionInputData = executionData.data.main[0]!;
}
}
const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData);
return workflow.expression.resolveSimpleParameterValue(
`=${expression}`,
{},
runExecutionData,
runIndex,
itemIndex,
node.name,
connectionInputData,
mode,
additionalKeys,
executionData,
);
},
getNodeParameter: (
parameterName: string,
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object => {
const itemIndex = 0;
const runIndex = 0;
let connectionInputData: INodeExecutionData[] = [];
let executionData: IExecuteData | undefined;
if (runExecutionData?.executionData !== undefined) {
executionData = runExecutionData.executionData.nodeExecutionStack[0];
if (executionData !== undefined) {
connectionInputData = executionData.data.main[0]!;
}
}
const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData);
return getNodeParameter(
workflow,
runExecutionData,
runIndex,
connectionInputData,
node,
parameterName,
itemIndex,
mode,
additionalKeys,
executionData,
fallbackValue,
options,
);
},
getParamsData(): object {
if (additionalData.httpRequest === undefined) {
throw new ApplicationError('Request is missing');
}
return additionalData.httpRequest.params;
},
getQueryData(): object {
if (additionalData.httpRequest === undefined) {
throw new ApplicationError('Request is missing');
}
return additionalData.httpRequest.query;
},
getRequestObject(): Request {
if (additionalData.httpRequest === undefined) {
throw new ApplicationError('Request is missing');
}
return additionalData.httpRequest;
},
getResponseObject(): Response {
if (additionalData.httpResponse === undefined) {
throw new ApplicationError('Response is missing');
}
return additionalData.httpResponse;
},
getNodeWebhookUrl: (name: string): string | undefined =>
getNodeWebhookUrl(
name,
workflow,
node,
additionalData,
mode,
getAdditionalKeys(additionalData, mode, null),
),
getWebhookName: () => webhookData.webhookDescription.name,
helpers: {
createDeferredPromise,
...getRequestHelperFunctions(workflow, node, additionalData),
...getBinaryHelperFunctions(additionalData, workflow.id),
returnJsonArray,
},
nodeHelpers: getNodeHelperFunctions(additionalData, workflow.id),
};
})(workflow, node, runExecutionData);
return new WebhookContext(
workflow,
node,
additionalData,
mode,
webhookData,
closeFunctions,
runExecutionData,
);
}

View file

@ -45,6 +45,7 @@ import {
NodeExecutionOutput,
sleep,
ErrorReporterProxy,
ExecutionCancelledError,
} from 'n8n-workflow';
import PCancelable from 'p-cancelable';
@ -154,10 +155,6 @@ export class WorkflowExecute {
return this.processRunExecutionData(workflow);
}
static isAbortError(e?: ExecutionBaseError) {
return e?.message === 'AbortError';
}
forceInputNodeExecution(workflow: Workflow): boolean {
return workflow.settings.executionOrder !== 'v1';
}
@ -1479,7 +1476,7 @@ export class WorkflowExecute {
// Add the execution data again so that it can get restarted
this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData);
// Only execute the nodeExecuteAfter hook if the node did not get aborted
if (!WorkflowExecute.isAbortError(executionError)) {
if (!this.isCancelled) {
await this.executeHook('nodeExecuteAfter', [
executionNode.name,
taskData,
@ -1827,7 +1824,7 @@ export class WorkflowExecute {
return await this.processSuccessExecution(
startedAt,
workflow,
new WorkflowOperationError('Workflow has been canceled or timed out'),
new ExecutionCancelledError(this.additionalData.executionId ?? 'unknown'),
closeFunction,
);
}
@ -1928,7 +1925,7 @@ export class WorkflowExecute {
this.moveNodeMetadata();
// Prevent from running the hook if the error is an abort error as it was already handled
if (!WorkflowExecute.isAbortError(executionError)) {
if (!this.isCancelled) {
await this.executeHook('workflowExecuteAfter', [fullRunData, newStaticData]);
}
@ -1959,4 +1956,8 @@ export class WorkflowExecute {
return fullRunData;
}
private get isCancelled() {
return this.abortController.signal.aborted;
}
}

View file

@ -20,3 +20,4 @@ export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee';
export { BinaryData } from './BinaryData/types';
export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
export * from './ExecutionMetadata';
export * from './node-execution-context';

View file

@ -0,0 +1,168 @@
import { mock } from 'jest-mock-extended';
import type {
INode,
INodeExecutionData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { Container } from 'typedi';
import { InstanceSettings } from '@/InstanceSettings';
import { NodeExecutionContext } from '../node-execution-context';
class TestContext extends NodeExecutionContext {}
describe('BaseContext', () => {
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
Container.set(InstanceSettings, instanceSettings);
const workflow = mock<Workflow>({
id: '123',
name: 'Test Workflow',
active: true,
nodeTypes: mock(),
timezone: 'UTC',
});
const node = mock<INode>();
let additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper: mock(),
});
const mode: WorkflowExecuteMode = 'manual';
const testContext = new TestContext(workflow, node, additionalData, mode);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getNode', () => {
it('should return a deep copy of the node', () => {
const result = testContext.getNode();
expect(result).not.toBe(node);
expect(JSON.stringify(result)).toEqual(JSON.stringify(node));
});
});
describe('getWorkflow', () => {
it('should return the id, name, and active properties of the workflow', () => {
const result = testContext.getWorkflow();
expect(result).toEqual({ id: '123', name: 'Test Workflow', active: true });
});
});
describe('getMode', () => {
it('should return the mode property', () => {
const result = testContext.getMode();
expect(result).toBe(mode);
});
});
describe('getWorkflowStaticData', () => {
it('should call getStaticData method of workflow', () => {
testContext.getWorkflowStaticData('testType');
expect(workflow.getStaticData).toHaveBeenCalledWith('testType', node);
});
});
describe('getChildNodes', () => {
it('should return an array of NodeTypeAndVersion objects for the child nodes of the given node', () => {
const childNode1 = mock<INode>({ name: 'Child Node 1', type: 'testType1', typeVersion: 1 });
const childNode2 = mock<INode>({ name: 'Child Node 2', type: 'testType2', typeVersion: 2 });
workflow.getChildNodes.mockReturnValue(['Child Node 1', 'Child Node 2']);
workflow.nodes = {
'Child Node 1': childNode1,
'Child Node 2': childNode2,
};
const result = testContext.getChildNodes('Test Node');
expect(result).toEqual([
{ name: 'Child Node 1', type: 'testType1', typeVersion: 1 },
{ name: 'Child Node 2', type: 'testType2', typeVersion: 2 },
]);
});
});
describe('getParentNodes', () => {
it('should return an array of NodeTypeAndVersion objects for the parent nodes of the given node', () => {
const parentNode1 = mock<INode>({ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 });
const parentNode2 = mock<INode>({ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 });
workflow.getParentNodes.mockReturnValue(['Parent Node 1', 'Parent Node 2']);
workflow.nodes = {
'Parent Node 1': parentNode1,
'Parent Node 2': parentNode2,
};
const result = testContext.getParentNodes('Test Node');
expect(result).toEqual([
{ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 },
{ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 },
]);
});
});
describe('getKnownNodeTypes', () => {
it('should call getKnownTypes method of workflow.nodeTypes', () => {
testContext.getKnownNodeTypes();
expect(workflow.nodeTypes.getKnownTypes).toHaveBeenCalled();
});
});
describe('getRestApiUrl', () => {
it('should return the restApiUrl property of additionalData', () => {
additionalData.restApiUrl = 'https://example.com/api';
const result = testContext.getRestApiUrl();
expect(result).toBe('https://example.com/api');
});
});
describe('getInstanceBaseUrl', () => {
it('should return the instanceBaseUrl property of additionalData', () => {
additionalData.instanceBaseUrl = 'https://example.com';
const result = testContext.getInstanceBaseUrl();
expect(result).toBe('https://example.com');
});
});
describe('getInstanceId', () => {
it('should return the instanceId property of instanceSettings', () => {
const result = testContext.getInstanceId();
expect(result).toBe('abc123');
});
});
describe('getTimezone', () => {
it('should return the timezone property of workflow', () => {
const result = testContext.getTimezone();
expect(result).toBe('UTC');
});
});
describe('getCredentialsProperties', () => {
it('should call getCredentialsProperties method of additionalData.credentialsHelper', () => {
testContext.getCredentialsProperties('testType');
expect(additionalData.credentialsHelper.getCredentialsProperties).toHaveBeenCalledWith(
'testType',
);
});
});
describe('prepareOutputData', () => {
it('should return the input array wrapped in another array', async () => {
const outputData = [mock<INodeExecutionData>(), mock<INodeExecutionData>()];
const result = await testContext.prepareOutputData(outputData);
expect(result).toEqual([outputData]);
});
});
});

View file

@ -0,0 +1,147 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWebhookDescription,
IWebhookData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { HookContext } from '../hook-context';
describe('HookContext', () => {
const testCredentialType = 'testCredential';
const webhookDescription: IWebhookDescription = {
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
path: 'testPath',
};
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
nodeType.description.webhooks = [webhookDescription];
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const activation: WorkflowActivateMode = 'init';
const webhookData = mock<IWebhookData>({
webhookDescription: {
name: 'default',
isFullPath: true,
},
});
const hookContext = new HookContext(
workflow,
node,
additionalData,
mode,
activation,
webhookData,
);
beforeEach(() => {
jest.clearAllMocks();
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
expression.getSimpleParameterValue.mockImplementation((_, value) => value);
});
describe('getActivationMode', () => {
it('should return the activation property', () => {
const result = hookContext.getActivationMode();
expect(result).toBe(activation);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await hookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getNodeParameter', () => {
it('should return parameter value when it exists', () => {
const parameter = hookContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
});
describe('getNodeWebhookUrl', () => {
it('should return node webhook url', () => {
const url = hookContext.getNodeWebhookUrl('default');
expect(url).toContain('testPath');
});
});
describe('getWebhookName', () => {
it('should return webhook name', () => {
const name = hookContext.getWebhookName();
expect(name).toBe('default');
});
it('should throw an error if webhookData is undefined', () => {
const hookContextWithoutWebhookData = new HookContext(
workflow,
node,
additionalData,
mode,
activation,
);
expect(() => hookContextWithoutWebhookData.getWebhookName()).toThrow(ApplicationError);
});
});
describe('getWebhookDescription', () => {
it('should return webhook description', () => {
const description = hookContext.getWebhookDescription('default');
expect(description).toEqual<IWebhookDescription>(webhookDescription);
});
});
});

View file

@ -0,0 +1,102 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import { LoadOptionsContext } from '../load-options-context';
describe('LoadOptionsContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const path = 'testPath';
const loadOptionsContext = new LoadOptionsContext(workflow, node, additionalData, path);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await loadOptionsContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getCurrentNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
});
it('should return parameter value when it exists', () => {
additionalData.currentNodeParameters = {
testParameter: 'testValue',
};
const parameter = loadOptionsContext.getCurrentNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = loadOptionsContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = loadOptionsContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View file

@ -0,0 +1,96 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { PollContext } from '../poll-context';
describe('PollContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const activation: WorkflowActivateMode = 'init';
const pollContext = new PollContext(workflow, node, additionalData, mode, activation);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getActivationMode', () => {
it('should return the activation property', () => {
const result = pollContext.getActivationMode();
expect(result).toBe(activation);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await pollContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = pollContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = pollContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View file

@ -0,0 +1,96 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { TriggerContext } from '../trigger-context';
describe('TriggerContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const activation: WorkflowActivateMode = 'init';
const triggerContext = new TriggerContext(workflow, node, additionalData, mode, activation);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getActivationMode', () => {
it('should return the activation property', () => {
const result = triggerContext.getActivationMode();
expect(result).toBe(activation);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await triggerContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = triggerContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = triggerContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View file

@ -0,0 +1,161 @@
import type { Request, Response } from 'express';
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWebhookData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { WebhookContext } from '../webhook-context';
describe('WebhookContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
});
additionalData.httpRequest = {
body: { test: 'body' },
headers: { test: 'header' },
params: { test: 'param' },
query: { test: 'query' },
} as unknown as Request;
additionalData.httpResponse = mock<Response>();
const mode: WorkflowExecuteMode = 'manual';
const webhookData = mock<IWebhookData>({
webhookDescription: {
name: 'default',
},
});
const runExecutionData = null;
const webhookContext = new WebhookContext(
workflow,
node,
additionalData,
mode,
webhookData,
[],
runExecutionData,
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getBodyData', () => {
it('should return the body data of the request', () => {
const bodyData = webhookContext.getBodyData();
expect(bodyData).toEqual({ test: 'body' });
});
});
describe('getHeaderData', () => {
it('should return the header data of the request', () => {
const headerData = webhookContext.getHeaderData();
expect(headerData).toEqual({ test: 'header' });
});
});
describe('getParamsData', () => {
it('should return the params data of the request', () => {
const paramsData = webhookContext.getParamsData();
expect(paramsData).toEqual({ test: 'param' });
});
});
describe('getQueryData', () => {
it('should return the query data of the request', () => {
const queryData = webhookContext.getQueryData();
expect(queryData).toEqual({ test: 'query' });
});
});
describe('getRequestObject', () => {
it('should return the request object', () => {
const request = webhookContext.getRequestObject();
expect(request).toBe(additionalData.httpRequest);
});
});
describe('getResponseObject', () => {
it('should return the response object', () => {
const response = webhookContext.getResponseObject();
expect(response).toBe(additionalData.httpResponse);
});
});
describe('getWebhookName', () => {
it('should return the name of the webhook', () => {
const webhookName = webhookContext.getWebhookName();
expect(webhookName).toBe('default');
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = webhookContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = webhookContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View file

@ -0,0 +1,136 @@
import FileType from 'file-type';
import { IncomingMessage, type ClientRequest } from 'http';
import { mock } from 'jest-mock-extended';
import type { Workflow, IWorkflowExecuteAdditionalData, IBinaryData } from 'n8n-workflow';
import type { Socket } from 'net';
import { Container } from 'typedi';
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
import { BinaryHelpers } from '../binary-helpers';
jest.mock('file-type');
describe('BinaryHelpers', () => {
let binaryDataService = mock<BinaryDataService>();
Container.set(BinaryDataService, binaryDataService);
const workflow = mock<Workflow>({ id: '123' });
const additionalData = mock<IWorkflowExecuteAdditionalData>({ executionId: '456' });
const binaryHelpers = new BinaryHelpers(workflow, additionalData);
beforeEach(() => {
jest.clearAllMocks();
binaryDataService.store.mockImplementation(
async (_workflowId, _executionId, _buffer, value) => value,
);
});
describe('getBinaryPath', () => {
it('should call getPath method of BinaryDataService', () => {
binaryHelpers.getBinaryPath('mock-binary-data-id');
expect(binaryDataService.getPath).toHaveBeenCalledWith('mock-binary-data-id');
});
});
describe('getBinaryMetadata', () => {
it('should call getMetadata method of BinaryDataService', async () => {
await binaryHelpers.getBinaryMetadata('mock-binary-data-id');
expect(binaryDataService.getMetadata).toHaveBeenCalledWith('mock-binary-data-id');
});
});
describe('getBinaryStream', () => {
it('should call getStream method of BinaryDataService', async () => {
await binaryHelpers.getBinaryStream('mock-binary-data-id');
expect(binaryDataService.getAsStream).toHaveBeenCalledWith('mock-binary-data-id', undefined);
});
});
describe('prepareBinaryData', () => {
it('should guess the mime type and file extension if not provided', async () => {
const buffer = Buffer.from('test');
const fileTypeData = { mime: 'application/pdf', ext: 'pdf' };
(FileType.fromBuffer as jest.Mock).mockResolvedValue(fileTypeData);
const binaryData = await binaryHelpers.prepareBinaryData(buffer);
expect(binaryData.mimeType).toEqual('application/pdf');
expect(binaryData.fileExtension).toEqual('pdf');
expect(binaryData.fileType).toEqual('pdf');
expect(binaryData.fileName).toBeUndefined();
expect(binaryData.directory).toBeUndefined();
expect(binaryDataService.store).toHaveBeenCalledWith(
workflow.id,
additionalData.executionId!,
buffer,
binaryData,
);
});
it('should use the provided mime type and file extension if provided', async () => {
const buffer = Buffer.from('test');
const mimeType = 'application/octet-stream';
const binaryData = await binaryHelpers.prepareBinaryData(buffer, undefined, mimeType);
expect(binaryData.mimeType).toEqual(mimeType);
expect(binaryData.fileExtension).toEqual('bin');
expect(binaryData.fileType).toBeUndefined();
expect(binaryData.fileName).toBeUndefined();
expect(binaryData.directory).toBeUndefined();
expect(binaryDataService.store).toHaveBeenCalledWith(
workflow.id,
additionalData.executionId!,
buffer,
binaryData,
);
});
const mockSocket = mock<Socket>({ readableHighWaterMark: 0 });
it('should use the contentDisposition.filename, responseUrl, and contentType properties to set the fileName, directory, and mimeType properties of the binaryData object', async () => {
const incomingMessage = new IncomingMessage(mockSocket);
incomingMessage.contentDisposition = { filename: 'test.txt', type: 'attachment' };
incomingMessage.contentType = 'text/plain';
incomingMessage.responseUrl = 'https://example.com/test.txt';
const binaryData = await binaryHelpers.prepareBinaryData(incomingMessage);
expect(binaryData.fileName).toEqual('test.txt');
expect(binaryData.fileType).toEqual('text');
expect(binaryData.directory).toBeUndefined();
expect(binaryData.mimeType).toEqual('text/plain');
expect(binaryData.fileExtension).toEqual('txt');
});
it('should use the req.path property to set the fileName property of the binaryData object if contentDisposition.filename and responseUrl are not provided', async () => {
const incomingMessage = new IncomingMessage(mockSocket);
incomingMessage.contentType = 'text/plain';
incomingMessage.req = mock<ClientRequest>({ path: '/test.txt' });
const binaryData = await binaryHelpers.prepareBinaryData(incomingMessage);
expect(binaryData.fileName).toEqual('test.txt');
expect(binaryData.directory).toBeUndefined();
expect(binaryData.mimeType).toEqual('text/plain');
expect(binaryData.fileExtension).toEqual('txt');
});
});
describe('setBinaryDataBuffer', () => {
it('should call store method of BinaryDataService', async () => {
const binaryData = mock<IBinaryData>();
const bufferOrStream = mock<Buffer>();
await binaryHelpers.setBinaryDataBuffer(binaryData, bufferOrStream);
expect(binaryDataService.store).toHaveBeenCalledWith(
workflow.id,
additionalData.executionId,
bufferOrStream,
binaryData,
);
});
});
});

View file

@ -0,0 +1,33 @@
import { mock } from 'jest-mock-extended';
import type { Workflow } from 'n8n-workflow';
import { Container } from 'typedi';
import { ScheduledTaskManager } from '@/ScheduledTaskManager';
import { SchedulingHelpers } from '../scheduling-helpers';
describe('SchedulingHelpers', () => {
const scheduledTaskManager = mock<ScheduledTaskManager>();
Container.set(ScheduledTaskManager, scheduledTaskManager);
const workflow = mock<Workflow>();
const schedulingHelpers = new SchedulingHelpers(workflow);
beforeEach(() => {
jest.clearAllMocks();
});
describe('registerCron', () => {
it('should call registerCron method of ScheduledTaskManager', () => {
const cronExpression = '* * * * * *';
const onTick = jest.fn();
schedulingHelpers.registerCron(cronExpression, onTick);
expect(scheduledTaskManager.registerCron).toHaveBeenCalledWith(
workflow,
cronExpression,
onTick,
);
});
});
});

View file

@ -0,0 +1,32 @@
import { mock } from 'jest-mock-extended';
import type { SSHCredentials } from 'n8n-workflow';
import type { Client } from 'ssh2';
import { Container } from 'typedi';
import { SSHClientsManager } from '@/SSHClientsManager';
import { SSHTunnelHelpers } from '../ssh-tunnel-helpers';
describe('SSHTunnelHelpers', () => {
const sshClientsManager = mock<SSHClientsManager>();
Container.set(SSHClientsManager, sshClientsManager);
const sshTunnelHelpers = new SSHTunnelHelpers();
beforeEach(() => {
jest.clearAllMocks();
});
describe('getSSHClient', () => {
const credentials = mock<SSHCredentials>();
it('should call SSHClientsManager.getClient with the given credentials', async () => {
const mockClient = mock<Client>();
sshClientsManager.getClient.mockResolvedValue(mockClient);
const client = await sshTunnelHelpers.getSSHClient(credentials);
expect(sshClientsManager.getClient).toHaveBeenCalledWith(credentials);
expect(client).toBe(mockClient);
});
});
});

View file

@ -0,0 +1,148 @@
import FileType from 'file-type';
import { IncomingMessage } from 'http';
import MimeTypes from 'mime-types';
import { ApplicationError, fileTypeFromMimeType } from 'n8n-workflow';
import type {
BinaryHelperFunctions,
IWorkflowExecuteAdditionalData,
Workflow,
IBinaryData,
} from 'n8n-workflow';
import path from 'path';
import type { Readable } from 'stream';
import Container from 'typedi';
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
import { binaryToBuffer } from '@/BinaryData/utils';
// eslint-disable-next-line import/no-cycle
import { binaryToString } from '@/NodeExecuteFunctions';
export class BinaryHelpers {
private readonly binaryDataService = Container.get(BinaryDataService);
constructor(
private readonly workflow: Workflow,
private readonly additionalData: IWorkflowExecuteAdditionalData,
) {}
get exported(): BinaryHelperFunctions {
return {
getBinaryPath: this.getBinaryPath.bind(this),
getBinaryMetadata: this.getBinaryMetadata.bind(this),
getBinaryStream: this.getBinaryStream.bind(this),
binaryToBuffer,
binaryToString,
prepareBinaryData: this.prepareBinaryData.bind(this),
setBinaryDataBuffer: this.setBinaryDataBuffer.bind(this),
copyBinaryFile: this.copyBinaryFile.bind(this),
};
}
getBinaryPath(binaryDataId: string) {
return this.binaryDataService.getPath(binaryDataId);
}
async getBinaryMetadata(binaryDataId: string) {
return await this.binaryDataService.getMetadata(binaryDataId);
}
async getBinaryStream(binaryDataId: string, chunkSize?: number) {
return await this.binaryDataService.getAsStream(binaryDataId, chunkSize);
}
// eslint-disable-next-line complexity
async prepareBinaryData(binaryData: Buffer | Readable, filePath?: string, mimeType?: string) {
let fileExtension: string | undefined;
if (binaryData instanceof IncomingMessage) {
if (!filePath) {
try {
const { responseUrl } = binaryData;
filePath =
binaryData.contentDisposition?.filename ??
((responseUrl && new URL(responseUrl).pathname) ?? binaryData.req?.path)?.slice(1);
} catch {}
}
if (!mimeType) {
mimeType = binaryData.contentType;
}
}
if (!mimeType) {
// If no mime type is given figure it out
if (filePath) {
// Use file path to guess mime type
const mimeTypeLookup = MimeTypes.lookup(filePath);
if (mimeTypeLookup) {
mimeType = mimeTypeLookup;
}
}
if (!mimeType) {
if (Buffer.isBuffer(binaryData)) {
// Use buffer to guess mime type
const fileTypeData = await FileType.fromBuffer(binaryData);
if (fileTypeData) {
mimeType = fileTypeData.mime;
fileExtension = fileTypeData.ext;
}
} else if (binaryData instanceof IncomingMessage) {
mimeType = binaryData.headers['content-type'];
} else {
// TODO: detect filetype from other kind of streams
}
}
}
if (!fileExtension && mimeType) {
fileExtension = MimeTypes.extension(mimeType) || undefined;
}
if (!mimeType) {
// Fall back to text
mimeType = 'text/plain';
}
const returnData: IBinaryData = {
mimeType,
fileType: fileTypeFromMimeType(mimeType),
fileExtension,
data: '',
};
if (filePath) {
if (filePath.includes('?')) {
// Remove maybe present query parameters
filePath = filePath.split('?').shift();
}
const filePathParts = path.parse(filePath as string);
if (filePathParts.dir !== '') {
returnData.directory = filePathParts.dir;
}
returnData.fileName = filePathParts.base;
// Remove the dot
const extractedFileExtension = filePathParts.ext.slice(1);
if (extractedFileExtension) {
returnData.fileExtension = extractedFileExtension;
}
}
return await this.setBinaryDataBuffer(returnData, binaryData);
}
async setBinaryDataBuffer(binaryData: IBinaryData, bufferOrStream: Buffer | Readable) {
return await this.binaryDataService.store(
this.workflow.id,
this.additionalData.executionId!,
bufferOrStream,
binaryData,
);
}
async copyBinaryFile(): Promise<never> {
throw new ApplicationError('`copyBinaryFile` has been removed. Please upgrade this node.');
}
}

View file

@ -0,0 +1,381 @@
import { createHash } from 'crypto';
import { pick } from 'lodash';
import { jsonParse, NodeOperationError, sleep } from 'n8n-workflow';
import type {
RequestHelperFunctions,
IAdditionalCredentialOptions,
IAllExecuteFunctions,
IExecuteData,
IHttpRequestOptions,
IN8nHttpFullResponse,
IN8nHttpResponse,
INode,
INodeExecutionData,
IOAuth2Options,
IRequestOptions,
IRunExecutionData,
IWorkflowDataProxyAdditionalKeys,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
PaginationOptions,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { Readable } from 'stream';
// eslint-disable-next-line import/no-cycle
import {
applyPaginationRequestData,
binaryToString,
httpRequest,
httpRequestWithAuthentication,
proxyRequestToAxios,
requestOAuth1,
requestOAuth2,
requestWithAuthentication,
validateUrl,
} from '@/NodeExecuteFunctions';
export class RequestHelpers {
constructor(
private readonly context: IAllExecuteFunctions,
private readonly workflow: Workflow,
private readonly node: INode,
private readonly additionalData: IWorkflowExecuteAdditionalData,
private readonly runExecutionData: IRunExecutionData | null = null,
private readonly connectionInputData: INodeExecutionData[] = [],
) {}
get exported(): RequestHelperFunctions {
return {
httpRequest,
httpRequestWithAuthentication: this.httpRequestWithAuthentication.bind(this),
requestWithAuthenticationPaginated: this.requestWithAuthenticationPaginated.bind(this),
request: this.request.bind(this),
requestWithAuthentication: this.requestWithAuthentication.bind(this),
requestOAuth1: this.requestOAuth1.bind(this),
requestOAuth2: this.requestOAuth2.bind(this),
};
}
get httpRequest() {
return httpRequest;
}
async httpRequestWithAuthentication(
credentialsType: string,
requestOptions: IHttpRequestOptions,
additionalCredentialOptions?: IAdditionalCredentialOptions,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await httpRequestWithAuthentication.call(
this.context,
credentialsType,
requestOptions,
this.workflow,
this.node,
this.additionalData,
additionalCredentialOptions,
);
}
// eslint-disable-next-line complexity
async requestWithAuthenticationPaginated(
requestOptions: IRequestOptions,
itemIndex: number,
paginationOptions: PaginationOptions,
credentialsType?: string,
additionalCredentialOptions?: IAdditionalCredentialOptions,
): Promise<unknown[]> {
const responseData = [];
if (!requestOptions.qs) {
requestOptions.qs = {};
}
requestOptions.resolveWithFullResponse = true;
requestOptions.simple = false;
let tempResponseData: IN8nHttpFullResponse;
let makeAdditionalRequest: boolean;
let paginateRequestData: PaginationOptions['request'];
const runIndex = 0;
const additionalKeys = {
$request: requestOptions,
$response: {} as IN8nHttpFullResponse,
$version: this.node.typeVersion,
$pageCount: 0,
};
const executeData: IExecuteData = {
data: {},
node: this.node,
source: null,
};
const hashData = {
identicalCount: 0,
previousLength: 0,
previousHash: '',
};
do {
paginateRequestData = this.getResolvedValue(
paginationOptions.request as unknown as NodeParameterValueType,
itemIndex,
runIndex,
executeData,
additionalKeys,
false,
) as object as PaginationOptions['request'];
const tempRequestOptions = applyPaginationRequestData(requestOptions, paginateRequestData);
if (!validateUrl(tempRequestOptions.uri as string)) {
throw new NodeOperationError(
this.node,
`'${paginateRequestData.url}' is not a valid URL.`,
{
itemIndex,
runIndex,
type: 'invalid_url',
},
);
}
if (credentialsType) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
tempResponseData = await this.requestWithAuthentication(
credentialsType,
tempRequestOptions,
additionalCredentialOptions,
);
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
tempResponseData = await this.request(tempRequestOptions);
}
const newResponse: IN8nHttpFullResponse = Object.assign(
{
body: {},
headers: {},
statusCode: 0,
},
pick(tempResponseData, ['body', 'headers', 'statusCode']),
);
let contentBody: Exclude<IN8nHttpResponse, Buffer>;
if (newResponse.body instanceof Readable && paginationOptions.binaryResult !== true) {
// Keep the original string version that we can use it to hash if needed
contentBody = await binaryToString(newResponse.body as Buffer | Readable);
const responseContentType = newResponse.headers['content-type']?.toString() ?? '';
if (responseContentType.includes('application/json')) {
newResponse.body = jsonParse(contentBody, { fallbackValue: {} });
} else {
newResponse.body = contentBody;
}
tempResponseData.__bodyResolved = true;
tempResponseData.body = newResponse.body;
} else {
contentBody = newResponse.body;
}
if (paginationOptions.binaryResult !== true || tempResponseData.headers.etag) {
// If the data is not binary (and so not a stream), or an etag is present,
// we check via etag or hash if identical data is received
let contentLength = 0;
if ('content-length' in tempResponseData.headers) {
contentLength = parseInt(tempResponseData.headers['content-length'] as string) || 0;
}
if (hashData.previousLength === contentLength) {
let hash: string;
if (tempResponseData.headers.etag) {
// If an etag is provided, we use it as "hash"
hash = tempResponseData.headers.etag as string;
} else {
// If there is no etag, we calculate a hash from the data in the body
if (typeof contentBody !== 'string') {
contentBody = JSON.stringify(contentBody);
}
hash = createHash('md5').update(contentBody).digest('base64');
}
if (hashData.previousHash === hash) {
hashData.identicalCount += 1;
if (hashData.identicalCount > 2) {
// Length was identical 5x and hash 3x
throw new NodeOperationError(
this.node,
'The returned response was identical 5x, so requests got stopped',
{
itemIndex,
description:
'Check if "Pagination Completed When" has been configured correctly.',
},
);
}
} else {
hashData.identicalCount = 0;
}
hashData.previousHash = hash;
} else {
hashData.identicalCount = 0;
}
hashData.previousLength = contentLength;
}
responseData.push(tempResponseData);
additionalKeys.$response = newResponse;
additionalKeys.$pageCount = additionalKeys.$pageCount + 1;
const maxRequests = this.getResolvedValue(
paginationOptions.maxRequests,
itemIndex,
runIndex,
executeData,
additionalKeys,
false,
) as number;
if (maxRequests && additionalKeys.$pageCount >= maxRequests) {
break;
}
makeAdditionalRequest = this.getResolvedValue(
paginationOptions.continue,
itemIndex,
runIndex,
executeData,
additionalKeys,
false,
) as boolean;
if (makeAdditionalRequest) {
if (paginationOptions.requestInterval) {
const requestInterval = this.getResolvedValue(
paginationOptions.requestInterval,
itemIndex,
runIndex,
executeData,
additionalKeys,
false,
) as number;
await sleep(requestInterval);
}
if (tempResponseData.statusCode < 200 || tempResponseData.statusCode >= 300) {
// We have it configured to let all requests pass no matter the response code
// via "requestOptions.simple = false" to not by default fail if it is for example
// configured to stop on 404 response codes. For that reason we have to throw here
// now an error manually if the response code is not a success one.
let data = tempResponseData.body;
if (data instanceof Readable && paginationOptions.binaryResult !== true) {
data = await binaryToString(data as Buffer | Readable);
} else if (typeof data === 'object') {
data = JSON.stringify(data);
}
throw Object.assign(new Error(`${tempResponseData.statusCode} - "${data?.toString()}"`), {
statusCode: tempResponseData.statusCode,
error: data,
isAxiosError: true,
response: {
headers: tempResponseData.headers,
status: tempResponseData.statusCode,
statusText: tempResponseData.statusMessage,
},
});
}
}
} while (makeAdditionalRequest);
return responseData;
}
async request(uriOrObject: string | IRequestOptions, options?: IRequestOptions) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await proxyRequestToAxios(
this.workflow,
this.additionalData,
this.node,
uriOrObject,
options,
);
}
async requestWithAuthentication(
credentialsType: string,
requestOptions: IRequestOptions,
additionalCredentialOptions?: IAdditionalCredentialOptions,
itemIndex?: number,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await requestWithAuthentication.call(
this.context,
credentialsType,
requestOptions,
this.workflow,
this.node,
this.additionalData,
additionalCredentialOptions,
itemIndex,
);
}
async requestOAuth1(credentialsType: string, requestOptions: IRequestOptions) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await requestOAuth1.call(this.context, credentialsType, requestOptions);
}
async requestOAuth2(
credentialsType: string,
requestOptions: IRequestOptions,
oAuth2Options?: IOAuth2Options,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await requestOAuth2.call(
this.context,
credentialsType,
requestOptions,
this.node,
this.additionalData,
oAuth2Options,
);
}
private getResolvedValue(
parameterValue: NodeParameterValueType,
itemIndex: number,
runIndex: number,
executeData: IExecuteData,
additionalKeys?: IWorkflowDataProxyAdditionalKeys,
returnObjectAsString = false,
): NodeParameterValueType {
const mode: WorkflowExecuteMode = 'internal';
if (
typeof parameterValue === 'object' ||
(typeof parameterValue === 'string' && parameterValue.charAt(0) === '=')
) {
return this.workflow.expression.getParameterValue(
parameterValue,
this.runExecutionData,
runIndex,
itemIndex,
this.node.name,
this.connectionInputData,
mode,
additionalKeys ?? {},
executeData,
returnObjectAsString,
);
}
return parameterValue;
}
}

View file

@ -0,0 +1,20 @@
import type { CronExpression, Workflow, SchedulingFunctions } from 'n8n-workflow';
import { Container } from 'typedi';
import { ScheduledTaskManager } from '@/ScheduledTaskManager';
export class SchedulingHelpers {
private readonly scheduledTaskManager = Container.get(ScheduledTaskManager);
constructor(private readonly workflow: Workflow) {}
get exported(): SchedulingFunctions {
return {
registerCron: this.registerCron.bind(this),
};
}
registerCron(cronExpression: CronExpression, onTick: () => void) {
this.scheduledTaskManager.registerCron(this.workflow, cronExpression, onTick);
}
}

View file

@ -0,0 +1,18 @@
import type { SSHCredentials, SSHTunnelFunctions } from 'n8n-workflow';
import { Container } from 'typedi';
import { SSHClientsManager } from '@/SSHClientsManager';
export class SSHTunnelHelpers {
private readonly sshClientsManager = Container.get(SSHClientsManager);
get exported(): SSHTunnelFunctions {
return {
getSSHClient: this.getSSHClient.bind(this),
};
}
async getSSHClient(credentials: SSHCredentials) {
return await this.sshClientsManager.getClient(credentials);
}
}

View file

@ -0,0 +1,103 @@
import type {
ICredentialDataDecryptedObject,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
IHookFunctions,
IRunExecutionData,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
IWebhookData,
WebhookType,
} from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getAdditionalKeys,
getCredentials,
getNodeParameter,
getNodeWebhookUrl,
getWebhookDescription,
} from '@/NodeExecuteFunctions';
import { RequestHelpers } from './helpers/request-helpers';
import { NodeExecutionContext } from './node-execution-context';
export class HookContext extends NodeExecutionContext implements IHookFunctions {
readonly helpers: IHookFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly activation: WorkflowActivateMode,
private readonly webhookData?: IWebhookData,
) {
super(workflow, node, additionalData, mode);
this.helpers = new RequestHelpers(this, workflow, node, additionalData);
}
getActivationMode() {
return this.activation;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
}
getNodeParameter(
parameterName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
this.workflow,
runExecutionData,
runIndex,
connectionInputData,
this.node,
parameterName,
itemIndex,
this.mode,
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
undefined,
fallbackValue,
options,
);
}
getNodeWebhookUrl(name: WebhookType): string | undefined {
return getNodeWebhookUrl(
name,
this.workflow,
this.node,
this.additionalData,
this.mode,
getAdditionalKeys(this.additionalData, this.mode, null),
this.webhookData?.isTest,
);
}
getWebhookName(): string {
if (this.webhookData === undefined) {
throw new ApplicationError('Only supported in webhook functions');
}
return this.webhookData.webhookDescription.name;
}
getWebhookDescription(name: WebhookType) {
return getWebhookDescription(name, this.workflow, this.node);
}
}

View file

@ -0,0 +1,6 @@
// eslint-disable-next-line import/no-cycle
export { HookContext } from './hook-context';
export { LoadOptionsContext } from './load-options-context';
export { PollContext } from './poll-context';
export { TriggerContext } from './trigger-context';
export { WebhookContext } from './webhook-context';

View file

@ -0,0 +1,102 @@
import { get } from 'lodash';
import type {
ICredentialDataDecryptedObject,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
ILoadOptionsFunctions,
IRunExecutionData,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
Workflow,
} from 'n8n-workflow';
import { extractValue } from '@/ExtractValue';
// eslint-disable-next-line import/no-cycle
import { getAdditionalKeys, getCredentials, getNodeParameter } from '@/NodeExecuteFunctions';
import { RequestHelpers } from './helpers/request-helpers';
import { SSHTunnelHelpers } from './helpers/ssh-tunnel-helpers';
import { NodeExecutionContext } from './node-execution-context';
export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions {
readonly helpers: ILoadOptionsFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
private readonly path: string,
) {
super(workflow, node, additionalData, 'internal');
this.helpers = {
...new RequestHelpers(this, workflow, node, additionalData).exported,
...new SSHTunnelHelpers().exported,
};
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
}
getCurrentNodeParameter(
parameterPath: string,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object | undefined {
const nodeParameters = this.additionalData.currentNodeParameters;
if (parameterPath.charAt(0) === '&') {
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
}
let returnData = get(nodeParameters, parameterPath);
// This is outside the try/catch because it throws errors with proper messages
if (options?.extractValue) {
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
this.node.type,
this.node.typeVersion,
);
returnData = extractValue(
returnData,
parameterPath,
this.node,
nodeType,
) as NodeParameterValueType;
}
return returnData;
}
getCurrentNodeParameters() {
return this.additionalData.currentNodeParameters;
}
getNodeParameter(
parameterName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
this.workflow,
runExecutionData,
runIndex,
connectionInputData,
this.node,
parameterName,
itemIndex,
this.mode,
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
undefined,
fallbackValue,
options,
);
}
}

View file

@ -0,0 +1,107 @@
import type {
FunctionsBase,
INode,
INodeExecutionData,
IWorkflowExecuteAdditionalData,
NodeTypeAndVersion,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { deepCopy, LoggerProxy } from 'n8n-workflow';
import { Container } from 'typedi';
import { InstanceSettings } from '@/InstanceSettings';
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
protected readonly instanceSettings = Container.get(InstanceSettings);
constructor(
protected readonly workflow: Workflow,
protected readonly node: INode,
protected readonly additionalData: IWorkflowExecuteAdditionalData,
protected readonly mode: WorkflowExecuteMode,
) {}
get logger() {
return LoggerProxy;
}
getExecutionId() {
return this.additionalData.executionId!;
}
getNode(): INode {
return deepCopy(this.node);
}
getWorkflow() {
const { id, name, active } = this.workflow;
return { id, name, active };
}
getMode() {
return this.mode;
}
getWorkflowStaticData(type: string) {
return this.workflow.getStaticData(type, this.node);
}
getChildNodes(nodeName: string) {
const output: NodeTypeAndVersion[] = [];
const nodeNames = this.workflow.getChildNodes(nodeName);
for (const n of nodeNames) {
const node = this.workflow.nodes[n];
output.push({
name: node.name,
type: node.type,
typeVersion: node.typeVersion,
});
}
return output;
}
getParentNodes(nodeName: string) {
const output: NodeTypeAndVersion[] = [];
const nodeNames = this.workflow.getParentNodes(nodeName);
for (const n of nodeNames) {
const node = this.workflow.nodes[n];
output.push({
name: node.name,
type: node.type,
typeVersion: node.typeVersion,
});
}
return output;
}
getKnownNodeTypes() {
return this.workflow.nodeTypes.getKnownTypes();
}
getRestApiUrl() {
return this.additionalData.restApiUrl;
}
getInstanceBaseUrl() {
return this.additionalData.instanceBaseUrl;
}
getInstanceId() {
return this.instanceSettings.instanceId;
}
getTimezone() {
return this.workflow.timezone;
}
getCredentialsProperties(type: string) {
return this.additionalData.credentialsHelper.getCredentialsProperties(type);
}
async prepareOutputData(outputData: INodeExecutionData[]) {
return [outputData];
}
}

View file

@ -0,0 +1,94 @@
import type {
ICredentialDataDecryptedObject,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
IPollFunctions,
IRunExecutionData,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getAdditionalKeys,
getCredentials,
getNodeParameter,
returnJsonArray,
} from '@/NodeExecuteFunctions';
import { BinaryHelpers } from './helpers/binary-helpers';
import { RequestHelpers } from './helpers/request-helpers';
import { SchedulingHelpers } from './helpers/scheduling-helpers';
import { NodeExecutionContext } from './node-execution-context';
const throwOnEmit = () => {
throw new ApplicationError('Overwrite PollContext.__emit function');
};
const throwOnEmitError = () => {
throw new ApplicationError('Overwrite PollContext.__emitError function');
};
export class PollContext extends NodeExecutionContext implements IPollFunctions {
readonly helpers: IPollFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly activation: WorkflowActivateMode,
readonly __emit: IPollFunctions['__emit'] = throwOnEmit,
readonly __emitError: IPollFunctions['__emitError'] = throwOnEmitError,
) {
super(workflow, node, additionalData, mode);
this.helpers = {
createDeferredPromise,
returnJsonArray,
...new BinaryHelpers(workflow, additionalData).exported,
...new RequestHelpers(this, workflow, node, additionalData).exported,
...new SchedulingHelpers(workflow).exported,
};
}
getActivationMode() {
return this.activation;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
}
getNodeParameter(
parameterName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
this.workflow,
runExecutionData,
runIndex,
connectionInputData,
this.node,
parameterName,
itemIndex,
this.mode,
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
undefined,
fallbackValue,
options,
);
}
}

View file

@ -0,0 +1,96 @@
import type {
ICredentialDataDecryptedObject,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
IRunExecutionData,
ITriggerFunctions,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getAdditionalKeys,
getCredentials,
getNodeParameter,
returnJsonArray,
} from '@/NodeExecuteFunctions';
import { BinaryHelpers } from './helpers/binary-helpers';
import { RequestHelpers } from './helpers/request-helpers';
import { SchedulingHelpers } from './helpers/scheduling-helpers';
import { SSHTunnelHelpers } from './helpers/ssh-tunnel-helpers';
import { NodeExecutionContext } from './node-execution-context';
const throwOnEmit = () => {
throw new ApplicationError('Overwrite TriggerContext.emit function');
};
const throwOnEmitError = () => {
throw new ApplicationError('Overwrite TriggerContext.emitError function');
};
export class TriggerContext extends NodeExecutionContext implements ITriggerFunctions {
readonly helpers: ITriggerFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly activation: WorkflowActivateMode,
readonly emit: ITriggerFunctions['emit'] = throwOnEmit,
readonly emitError: ITriggerFunctions['emitError'] = throwOnEmitError,
) {
super(workflow, node, additionalData, mode);
this.helpers = {
createDeferredPromise,
returnJsonArray,
...new BinaryHelpers(workflow, additionalData).exported,
...new RequestHelpers(this, workflow, node, additionalData).exported,
...new SchedulingHelpers(workflow).exported,
...new SSHTunnelHelpers().exported,
};
}
getActivationMode() {
return this.activation;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
}
getNodeParameter(
parameterName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object {
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(
this.workflow,
runExecutionData,
runIndex,
connectionInputData,
this.node,
parameterName,
itemIndex,
this.mode,
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
undefined,
fallbackValue,
options,
);
}
}

View file

@ -0,0 +1,233 @@
import type { Request, Response } from 'express';
import type {
CloseFunction,
ICredentialDataDecryptedObject,
IDataObject,
IExecuteData,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
IRunExecutionData,
ITaskDataConnections,
IWebhookData,
IWebhookFunctions,
IWorkflowExecuteAdditionalData,
NodeConnectionType,
NodeParameterValueType,
WebhookType,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
copyBinaryFile,
getAdditionalKeys,
getCredentials,
getInputConnectionData,
getNodeParameter,
getNodeWebhookUrl,
returnJsonArray,
} from '@/NodeExecuteFunctions';
import { BinaryHelpers } from './helpers/binary-helpers';
import { RequestHelpers } from './helpers/request-helpers';
import { NodeExecutionContext } from './node-execution-context';
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
readonly helpers: IWebhookFunctions['helpers'];
readonly nodeHelpers: IWebhookFunctions['nodeHelpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly webhookData: IWebhookData,
private readonly closeFunctions: CloseFunction[],
private readonly runExecutionData: IRunExecutionData | null,
) {
super(workflow, node, additionalData, mode);
this.helpers = {
createDeferredPromise,
returnJsonArray,
...new BinaryHelpers(workflow, additionalData).exported,
...new RequestHelpers(this, workflow, node, additionalData).exported,
};
this.nodeHelpers = {
copyBinaryFile: async (filePath, fileName, mimeType) =>
await copyBinaryFile(
this.workflow.id,
this.additionalData.executionId!,
filePath,
fileName,
mimeType,
),
};
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
}
getBodyData() {
return this.assertHttpRequest().body as IDataObject;
}
getHeaderData() {
return this.assertHttpRequest().headers;
}
getParamsData(): object {
return this.assertHttpRequest().params;
}
getQueryData(): object {
return this.assertHttpRequest().query;
}
getRequestObject(): Request {
return this.assertHttpRequest();
}
getResponseObject(): Response {
if (this.additionalData.httpResponse === undefined) {
throw new ApplicationError('Response is missing');
}
return this.additionalData.httpResponse;
}
private assertHttpRequest() {
const { httpRequest } = this.additionalData;
if (httpRequest === undefined) {
throw new ApplicationError('Request is missing');
}
return httpRequest;
}
getNodeWebhookUrl(name: WebhookType): string | undefined {
return getNodeWebhookUrl(
name,
this.workflow,
this.node,
this.additionalData,
this.mode,
getAdditionalKeys(this.additionalData, this.mode, null),
);
}
getWebhookName() {
return this.webhookData.webhookDescription.name;
}
async getInputConnectionData(inputName: NodeConnectionType, itemIndex: number): Promise<unknown> {
// To be able to use expressions like "$json.sessionId" set the
// body data the webhook received to what is normally used for
// incoming node data.
const connectionInputData: INodeExecutionData[] = [
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ json: this.additionalData.httpRequest?.body || {} },
];
const runExecutionData: IRunExecutionData = {
resultData: {
runData: {},
},
};
const executeData: IExecuteData = {
data: {
main: [connectionInputData],
},
node: this.node,
source: null,
};
const runIndex = 0;
return await getInputConnectionData.call(
this,
this.workflow,
runExecutionData,
runIndex,
connectionInputData,
{} as ITaskDataConnections,
this.additionalData,
executeData,
this.mode,
this.closeFunctions,
inputName,
itemIndex,
);
}
evaluateExpression(expression: string, evaluateItemIndex?: number) {
const itemIndex = evaluateItemIndex ?? 0;
const runIndex = 0;
let connectionInputData: INodeExecutionData[] = [];
let executionData: IExecuteData | undefined;
if (this.runExecutionData?.executionData !== undefined) {
executionData = this.runExecutionData.executionData.nodeExecutionStack[0];
if (executionData !== undefined) {
connectionInputData = executionData.data.main[0]!;
}
}
const additionalKeys = getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
return this.workflow.expression.resolveSimpleParameterValue(
`=${expression}`,
{},
this.runExecutionData,
runIndex,
itemIndex,
this.node.name,
connectionInputData,
this.mode,
additionalKeys,
executionData,
);
}
getNodeParameter(
parameterName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object {
const itemIndex = 0;
const runIndex = 0;
let connectionInputData: INodeExecutionData[] = [];
let executionData: IExecuteData | undefined;
if (this.runExecutionData?.executionData !== undefined) {
executionData = this.runExecutionData.executionData.nodeExecutionStack[0];
if (executionData !== undefined) {
connectionInputData = executionData.data.main[0]!;
}
}
const additionalKeys = getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
return getNodeParameter(
this.workflow,
this.runExecutionData,
runIndex,
connectionInputData,
this.node,
parameterName,
itemIndex,
this.mode,
additionalKeys,
executionData,
fallbackValue,
options,
);
}
}

View file

@ -5,6 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<style>@media (prefers-color-scheme: dark) { body { background-color: rgb(45, 46, 46) } }</style>
<script type="text/javascript">
window.BASE_PATH = '/{{BASE_PATH}}/';
window.REST_ENDPOINT = '{{REST_ENDPOINT}}';

View file

@ -39,9 +39,9 @@
"@n8n/codemirror-lang-sql": "^1.0.2",
"@n8n/permissions": "workspace:*",
"@sentry/vue": "catalog:frontend",
"@vue-flow/background": "^1.3.0",
"@vue-flow/background": "^1.3.1",
"@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.41.2",
"@vue-flow/core": "^1.41.4",
"@vue-flow/minimap": "^1.5.0",
"@vue-flow/node-resizer": "^1.4.0",
"@vueuse/components": "^10.11.0",

View file

@ -1,65 +1,59 @@
<script lang="ts">
<script lang="ts" setup>
import { useUIStore } from '@/stores/ui.store';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
export default defineComponent({
name: 'CommunityPackageCard',
props: {
communityPackage: {
type: Object as () => PublicInstalledPackage | null,
required: false,
default: null,
},
loading: {
type: Boolean,
default: false,
},
},
data() {
return {
packageActions: [
{
label: this.$locale.baseText('settings.communityNodes.viewDocsAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
type: 'external-link',
},
{
label: this.$locale.baseText('settings.communityNodes.uninstallAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
},
],
};
},
computed: {
...mapStores(useUIStore),
},
methods: {
async onAction(value: string) {
if (!this.communityPackage) return;
switch (value) {
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
this.$telemetry.track('user clicked to browse the cnr package documentation', {
package_name: this.communityPackage.packageName,
package_version: this.communityPackage.installedVersion,
});
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${this.communityPackage.packageName}`, '_blank');
break;
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
this.uiStore.openCommunityPackageUninstallConfirmModal(this.communityPackage.packageName);
break;
default:
break;
}
},
onUpdateClick() {
if (!this.communityPackage) return;
this.uiStore.openCommunityPackageUpdateConfirmModal(this.communityPackage.packageName);
},
},
interface Props {
communityPackage?: PublicInstalledPackage | null;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
communityPackage: null,
loading: false,
});
const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallConfirmModal } =
useUIStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const packageActions = [
{
label: i18n.baseText('settings.communityNodes.viewDocsAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
type: 'external-link',
},
{
label: i18n.baseText('settings.communityNodes.uninstallAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
},
];
async function onAction(value: string) {
if (!props.communityPackage) return;
switch (value) {
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
telemetry.track('user clicked to browse the cnr package documentation', {
package_name: props.communityPackage.packageName,
package_version: props.communityPackage.installedVersion,
});
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${props.communityPackage.packageName}`, '_blank');
break;
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
openCommunityPackageUninstallConfirmModal(props.communityPackage.packageName);
break;
default:
break;
}
}
function onUpdateClick() {
if (!props.communityPackage) return;
openCommunityPackageUpdateConfirmModal(props.communityPackage.packageName);
}
</script>
<template>
@ -76,7 +70,7 @@ export default defineComponent({
<div :class="$style.cardSubtitle">
<n8n-text :bold="true" size="small" color="text-light">
{{
$locale.baseText('settings.communityNodes.packageNodes.label', {
i18n.baseText('settings.communityNodes.packageNodes.label', {
adjustToNumber: communityPackage.installedNodes.length,
})
}}:&nbsp;
@ -96,7 +90,7 @@ export default defineComponent({
<n8n-tooltip v-if="communityPackage.failedLoading === true" placement="top">
<template #content>
<div>
{{ $locale.baseText('settings.communityNodes.failedToLoad.tooltip') }}
{{ i18n.baseText('settings.communityNodes.failedToLoad.tooltip') }}
</div>
</template>
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
@ -104,7 +98,7 @@ export default defineComponent({
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
<template #content>
<div>
{{ $locale.baseText('settings.communityNodes.updateAvailable.tooltip') }}
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}
</div>
</template>
<n8n-button outline label="Update" @click="onUpdateClick" />
@ -112,7 +106,7 @@ export default defineComponent({
<n8n-tooltip v-else placement="top">
<template #content>
<div>
{{ $locale.baseText('settings.communityNodes.upToDate.tooltip') }}
{{ i18n.baseText('settings.communityNodes.upToDate.tooltip') }}
</div>
</template>
<n8n-icon icon="check-circle" color="text-light" size="large" />

View file

@ -1,7 +1,5 @@
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { IN8nPromptResponse, ModalKey } from '@/Interface';
import { VALID_EMAIL_REGEX } from '@/constants';
import Modal from '@/components/Modal.vue';
@ -10,78 +8,69 @@ import { useRootStore } from '@/stores/root.store';
import { createEventBus } from 'n8n-design-system/utils';
import { useToast } from '@/composables/useToast';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { useTelemetry } from '@/composables/useTelemetry';
export default defineComponent({
name: 'ContactPromptModal',
components: { Modal },
props: {
modalName: {
type: String as PropType<ModalKey>,
required: true,
},
},
setup() {
return {
...useToast(),
};
},
data() {
return {
email: '',
modalBus: createEventBus(),
};
},
computed: {
...mapStores(useRootStore, useSettingsStore, useNpsSurveyStore),
title(): string {
if (this.npsSurveyStore.promptsData?.title) {
return this.npsSurveyStore.promptsData.title;
}
defineProps<{
modalName: ModalKey;
}>();
return 'Youre a power user 💪';
},
description(): string {
if (this.npsSurveyStore.promptsData?.message) {
return this.npsSurveyStore.promptsData.message;
}
const email = ref('');
const modalBus = createEventBus();
return 'Your experience with n8n can help us improve — for you and our entire community.';
},
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.email).toLowerCase());
},
},
methods: {
closeDialog(): void {
if (!this.isEmailValid) {
this.$telemetry.track('User closed email modal', {
instance_id: this.rootStore.instanceId,
email: null,
});
}
},
async send() {
if (this.isEmailValid) {
const response = (await this.settingsStore.submitContactInfo(
this.email,
)) as IN8nPromptResponse;
const npsSurveyStore = useNpsSurveyStore();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
if (response.updated) {
this.$telemetry.track('User closed email modal', {
instance_id: this.rootStore.instanceId,
email: this.email,
});
this.showMessage({
title: 'Thanks!',
message: "It's people like you that help make n8n better",
type: 'success',
});
}
this.modalBus.emit('close');
}
},
},
const toast = useToast();
const telemetry = useTelemetry();
const title = computed(() => {
if (npsSurveyStore.promptsData?.title) {
return npsSurveyStore.promptsData.title;
}
return 'Youre a power user 💪';
});
const description = computed(() => {
if (npsSurveyStore.promptsData?.message) {
return npsSurveyStore.promptsData.message;
}
return 'Your experience with n8n can help us improve — for you and our entire community.';
});
const isEmailValid = computed(() => {
return VALID_EMAIL_REGEX.test(String(email.value).toLowerCase());
});
const closeDialog = () => {
if (!isEmailValid.value) {
telemetry.track('User closed email modal', {
instance_id: rootStore.instanceId,
email: null,
});
}
};
const send = async () => {
if (isEmailValid.value) {
const response = (await settingsStore.submitContactInfo(email.value)) as IN8nPromptResponse;
if (response.updated) {
telemetry.track('User closed email modal', {
instance_id: rootStore.instanceId,
email: email.value,
});
toast.showMessage({
title: 'Thanks!',
message: "It's people like you that help make n8n better",
type: 'success',
});
}
modalBus.emit('close');
}
};
</script>
<template>

View file

@ -1,26 +1,25 @@
<script lang="ts">
import { type PropType, defineComponent } from 'vue';
<script setup lang="ts">
import { computed } from 'vue';
import type { EnterpriseEditionFeatureValue } from '@/Interface';
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/settings.store';
export default defineComponent({
name: 'EnterpriseEdition',
props: {
features: {
type: Array as PropType<EnterpriseEditionFeatureValue[]>,
default: () => [],
},
const props = withDefaults(
defineProps<{
features: EnterpriseEditionFeatureValue[];
}>(),
{
features: () => [],
},
computed: {
...mapStores(useSettingsStore),
canAccess(): boolean {
return this.features.reduce((acc: boolean, feature) => {
return acc && !!this.settingsStore.isEnterpriseFeatureEnabled[feature];
}, true);
},
},
});
);
const settingsStore = useSettingsStore();
const canAccess = computed(() =>
props.features.reduce(
(acc: boolean, feature) => acc && !!settingsStore.isEnterpriseFeatureEnabled[feature],
true,
),
);
</script>
<template>

View file

@ -51,6 +51,7 @@ export default defineComponent({
},
pushRef: {
type: String,
required: true,
},
readOnly: {
type: Boolean,

View file

@ -1,34 +1,36 @@
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
<script setup lang="ts">
import { nextTick } from 'vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
export default defineComponent({
name: 'IntersectionObserved',
props: {
enabled: {
type: Boolean,
default: false,
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
const props = withDefaults(
defineProps<{
enabled: boolean;
eventBus: EventBus;
}>(),
{
enabled: false,
default: () => createEventBus(),
},
async mounted() {
if (!this.enabled) {
return;
}
);
await this.$nextTick();
this.eventBus.emit('observe', this.$refs.observed);
},
beforeUnmount() {
if (this.enabled) {
this.eventBus.emit('unobserve', this.$refs.observed);
}
},
const observed = ref<IntersectionObserver | null>(null);
onMounted(async () => {
if (!props.enabled) {
return;
}
await nextTick();
props.eventBus.emit('observe', observed.value);
});
onBeforeUnmount(() => {
if (props.enabled) {
props.eventBus.emit('unobserve', observed.value);
}
});
</script>

View file

@ -1,67 +1,65 @@
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
export default defineComponent({
name: 'IntersectionObserver',
props: {
threshold: {
type: Number,
default: 0,
},
enabled: {
type: Boolean,
default: false,
},
eventBus: {
type: Object as PropType<EventBus>,
default: () => createEventBus(),
},
const props = withDefaults(
defineProps<{
threshold: number;
enabled: boolean;
eventBus: EventBus;
}>(),
{
threshold: 0,
enabled: false,
default: () => createEventBus(),
},
data() {
return {
observer: null as IntersectionObserver | null,
};
},
mounted() {
if (!this.enabled) {
return;
}
);
const options = {
root: this.$refs.root as Element,
rootMargin: '0px',
threshold: this.threshold,
};
const emit = defineEmits<{
observed: [{ el: HTMLElement; isIntersecting: boolean }];
}>();
const observer = new IntersectionObserver((entries) => {
entries.forEach(({ target, isIntersecting }) => {
this.$emit('observed', {
el: target,
isIntersecting,
});
const observer = ref<IntersectionObserver | null>(null);
const root = ref<HTMLElement | null>(null);
onBeforeUnmount(() => {
if (props.enabled && observer.value) {
observer.value.disconnect();
}
});
onMounted(() => {
if (!props.enabled) {
return;
}
const options = {
root: root.value,
rootMargin: '0px',
threshold: props.threshold,
};
const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(({ target, isIntersecting }) => {
emit('observed', {
el: target as HTMLElement,
isIntersecting,
});
}, options);
this.observer = observer;
this.eventBus.on('observe', (observed: Element) => {
if (observed) {
observer.observe(observed);
}
});
}, options);
this.eventBus.on('unobserve', (observed: Element) => {
observer.unobserve(observed);
});
},
beforeUnmount() {
if (this.enabled && this.observer) {
this.observer.disconnect();
observer.value = intersectionObserver;
props.eventBus.on('observe', (observed: Element) => {
if (observed) {
intersectionObserver.observe(observed);
}
},
});
props.eventBus.on('unobserve', (observed: Element) => {
intersectionObserver.unobserve(observed);
});
});
</script>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useToast } from '@/composables/useToast';
import Modal from './Modal.vue';
import type { IFormInputs, IInviteResponse, IUser, InvitableRoleName } from '@/Interface';
@ -12,13 +11,256 @@ import {
} from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { createFormEventBus, createEventBus } from 'n8n-design-system/utils';
import { useClipboard } from '@/composables/useClipboard';
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const clipboard = useClipboard();
const { showMessage, showError } = useToast();
const i18n = useI18n();
const { goToUpgrade } = usePageRedirectionHelper();
const formBus = createFormEventBus();
const modalBus = createEventBus();
const config = ref<IFormInputs | null>();
const emails = ref('');
const role = ref<InvitableRoleName>(ROLE.Member);
const showInviteUrls = ref<IInviteResponse[] | null>(null);
const loading = ref(false);
onMounted(() => {
config.value = [
{
name: 'emails',
properties: {
label: i18n.baseText('settings.users.newEmailsToInvite'),
required: true,
validationRules: [{ name: 'VALID_EMAILS' }],
validators: {
VALID_EMAILS: {
validate: validateEmails,
},
},
placeholder: 'name1@email.com, name2@email.com, ...',
capitalize: true,
focusInitially: true,
},
},
{
name: 'role',
initialValue: ROLE.Member,
properties: {
label: i18n.baseText('auth.role'),
required: true,
type: 'select',
options: [
{
value: ROLE.Member,
label: i18n.baseText('auth.roles.member'),
},
{
value: ROLE.Admin,
label: i18n.baseText('auth.roles.admin'),
disabled: !isAdvancedPermissionsEnabled.value,
},
],
capitalize: true,
},
},
];
});
const emailsCount = computed((): number => {
return emails.value.split(',').filter((email: string) => !!email.trim()).length;
});
const buttonLabel = computed((): string => {
if (emailsCount.value > 1) {
return i18n.baseText(
`settings.users.inviteXUser${settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
{
interpolate: { count: emailsCount.value.toString() },
},
);
}
return i18n.baseText(`settings.users.inviteUser${settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`);
});
const enabledButton = computed((): boolean => {
return emailsCount.value >= 1;
});
const invitedUsers = computed((): IUser[] => {
return showInviteUrls.value
? usersStore.allUsers.filter((user) =>
showInviteUrls.value?.find((invite) => invite.user.id === user.id),
)
: [];
});
const isAdvancedPermissionsEnabled = computed((): boolean => {
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions];
});
const validateEmails = (value: string | number | boolean | null | undefined) => {
if (typeof value !== 'string') {
return false;
}
const emails = value.split(',');
for (let i = 0; i < emails.length; i++) {
const email = emails[i];
const parsed = getEmail(email);
if (!!parsed.trim() && !VALID_EMAIL_REGEX.test(String(parsed).trim().toLowerCase())) {
return {
messageKey: 'settings.users.invalidEmailError',
options: { interpolate: { email: parsed } },
};
}
}
return false;
};
function onInput(e: { name: string; value: InvitableRoleName }) {
if (e.name === 'emails') {
emails.value = e.value;
}
if (e.name === 'role') {
role.value = e.value;
}
}
async function onSubmit() {
try {
loading.value = true;
const emailList = emails.value
.split(',')
.map((email) => ({ email: getEmail(email), role: role.value }))
.filter((invite) => !!invite.email);
if (emailList.length === 0) {
throw new Error(i18n.baseText('settings.users.noUsersToInvite'));
}
const invited = await usersStore.inviteUsers(emailList);
const erroredInvites = invited.filter((invite) => invite.error);
const successfulEmailInvites = invited.filter(
(invite) => !invite.error && invite.user.emailSent,
);
const successfulUrlInvites = invited.filter(
(invite) => !invite.error && !invite.user.emailSent,
);
if (successfulEmailInvites.length) {
showMessage({
type: 'success',
title: i18n.baseText(
successfulEmailInvites.length > 1
? 'settings.users.usersInvited'
: 'settings.users.userInvited',
),
message: i18n.baseText('settings.users.emailInvitesSent', {
interpolate: {
emails: successfulEmailInvites.map(({ user }) => user.email).join(', '),
},
}),
});
}
if (successfulUrlInvites.length) {
if (successfulUrlInvites.length === 1) {
void clipboard.copy(successfulUrlInvites[0].user.inviteAcceptUrl);
}
showMessage({
type: 'success',
title: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
}
if (erroredInvites.length) {
setTimeout(() => {
showMessage({
type: 'error',
title: i18n.baseText('settings.users.usersEmailedError'),
message: i18n.baseText('settings.users.emailInvitesSentError', {
interpolate: { emails: erroredInvites.map(({ error }) => error).join(', ') },
}),
});
}, 0); // notifications stack on top of each other otherwise
}
if (successfulUrlInvites.length > 1) {
showInviteUrls.value = successfulUrlInvites;
} else {
modalBus.emit('close');
}
} catch (error) {
showError(error, i18n.baseText('settings.users.usersInvitedError'));
}
loading.value = false;
}
function showCopyInviteLinkToast(successfulUrlInvites: IInviteResponse[]) {
showMessage({
type: 'success',
title: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
}
function onSubmitClick() {
formBus.emit('submit');
}
function onCopyInviteLink(user: IUser) {
if (user.inviteAcceptUrl && showInviteUrls.value) {
void clipboard.copy(user.inviteAcceptUrl);
showCopyInviteLinkToast([]);
}
}
function goToUpgradeAdvancedPermissions() {
void goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions');
}
function getEmail(email: string): string {
let parsed = email.trim();
if (NAME_EMAIL_FORMAT_REGEX.test(parsed)) {
@ -29,267 +271,13 @@ function getEmail(email: string): string {
}
return parsed;
}
export default defineComponent({
name: 'InviteUsersModal',
components: { Modal },
props: {
modalName: {
type: String,
},
},
setup() {
const clipboard = useClipboard();
return {
clipboard,
...useToast(),
...usePageRedirectionHelper(),
};
},
data() {
return {
config: null as IFormInputs | null,
formBus: createFormEventBus(),
modalBus: createEventBus(),
emails: '',
role: ROLE.Member as InvitableRoleName,
showInviteUrls: null as IInviteResponse[] | null,
loading: false,
INVITE_USER_MODAL_KEY,
};
},
mounted() {
this.config = [
{
name: 'emails',
properties: {
label: this.$locale.baseText('settings.users.newEmailsToInvite'),
required: true,
validationRules: [{ name: 'VALID_EMAILS' }],
validators: {
VALID_EMAILS: {
validate: this.validateEmails,
},
},
placeholder: 'name1@email.com, name2@email.com, ...',
capitalize: true,
focusInitially: true,
},
},
{
name: 'role',
initialValue: ROLE.Member,
properties: {
label: this.$locale.baseText('auth.role'),
required: true,
type: 'select',
options: [
{
value: ROLE.Member,
label: this.$locale.baseText('auth.roles.member'),
},
{
value: ROLE.Admin,
label: this.$locale.baseText('auth.roles.admin'),
disabled: !this.isAdvancedPermissionsEnabled,
},
],
capitalize: true,
},
},
];
},
computed: {
...mapStores(useUsersStore, useSettingsStore, useUIStore),
emailsCount(): number {
return this.emails.split(',').filter((email: string) => !!email.trim()).length;
},
buttonLabel(): string {
if (this.emailsCount > 1) {
return this.$locale.baseText(
`settings.users.inviteXUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
{
interpolate: { count: this.emailsCount.toString() },
},
);
}
return this.$locale.baseText(
`settings.users.inviteUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
);
},
enabledButton(): boolean {
return this.emailsCount >= 1;
},
invitedUsers(): IUser[] {
return this.showInviteUrls
? this.usersStore.allUsers.filter((user) =>
this.showInviteUrls!.find((invite) => invite.user.id === user.id),
)
: [];
},
isAdvancedPermissionsEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled[
EnterpriseEditionFeature.AdvancedPermissions
];
},
},
methods: {
validateEmails(value: string | number | boolean | null | undefined) {
if (typeof value !== 'string') {
return false;
}
const emails = value.split(',');
for (let i = 0; i < emails.length; i++) {
const email = emails[i];
const parsed = getEmail(email);
if (!!parsed.trim() && !VALID_EMAIL_REGEX.test(String(parsed).trim().toLowerCase())) {
return {
messageKey: 'settings.users.invalidEmailError',
options: { interpolate: { email: parsed } },
};
}
}
return false;
},
onInput(e: { name: string; value: InvitableRoleName }) {
if (e.name === 'emails') {
this.emails = e.value;
}
if (e.name === 'role') {
this.role = e.value;
}
},
async onSubmit() {
try {
this.loading = true;
const emails = this.emails
.split(',')
.map((email) => ({ email: getEmail(email), role: this.role }))
.filter((invite) => !!invite.email);
if (emails.length === 0) {
throw new Error(this.$locale.baseText('settings.users.noUsersToInvite'));
}
const invited = await this.usersStore.inviteUsers(emails);
const erroredInvites = invited.filter((invite) => invite.error);
const successfulEmailInvites = invited.filter(
(invite) => !invite.error && invite.user.emailSent,
);
const successfulUrlInvites = invited.filter(
(invite) => !invite.error && !invite.user.emailSent,
);
if (successfulEmailInvites.length) {
this.showMessage({
type: 'success',
title: this.$locale.baseText(
successfulEmailInvites.length > 1
? 'settings.users.usersInvited'
: 'settings.users.userInvited',
),
message: this.$locale.baseText('settings.users.emailInvitesSent', {
interpolate: {
emails: successfulEmailInvites.map(({ user }) => user.email).join(', '),
},
}),
});
}
if (successfulUrlInvites.length) {
if (successfulUrlInvites.length === 1) {
void this.clipboard.copy(successfulUrlInvites[0].user.inviteAcceptUrl);
}
this.showMessage({
type: 'success',
title: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
}
if (erroredInvites.length) {
setTimeout(() => {
this.showMessage({
type: 'error',
title: this.$locale.baseText('settings.users.usersEmailedError'),
message: this.$locale.baseText('settings.users.emailInvitesSentError', {
interpolate: { emails: erroredInvites.map(({ error }) => error).join(', ') },
}),
});
}, 0); // notifications stack on top of each other otherwise
}
if (successfulUrlInvites.length > 1) {
this.showInviteUrls = successfulUrlInvites;
} else {
this.modalBus.emit('close');
}
} catch (error) {
this.showError(error, this.$locale.baseText('settings.users.usersInvitedError'));
}
this.loading = false;
},
showCopyInviteLinkToast(successfulUrlInvites: IInviteResponse[]) {
this.showMessage({
type: 'success',
title: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
},
onSubmitClick() {
this.formBus.emit('submit');
},
onCopyInviteLink(user: IUser) {
if (user.inviteAcceptUrl && this.showInviteUrls) {
void this.clipboard.copy(user.inviteAcceptUrl);
this.showCopyInviteLinkToast([]);
}
},
goToUpgradeAdvancedPermissions() {
void this.goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions');
},
},
});
</script>
<template>
<Modal
:name="INVITE_USER_MODAL_KEY"
:title="
$locale.baseText(
i18n.baseText(
showInviteUrls ? 'settings.users.copyInviteUrls' : 'settings.users.inviteNewUsers',
)
"
@ -303,7 +291,7 @@ export default defineComponent({
<i18n-t keypath="settings.users.advancedPermissions.warning">
<template #link>
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
</n8n-link>
</template>
</i18n-t>
@ -313,7 +301,7 @@ export default defineComponent({
<template #actions="{ user }">
<n8n-tooltip>
<template #content>
{{ $locale.baseText('settings.users.inviteLink.copy') }}
{{ i18n.baseText('settings.users.inviteLink.copy') }}
</template>
<n8n-icon-button
icon="link"

View file

@ -1,20 +1,14 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
<script setup lang="ts">
import { computed } from 'vue';
import { useRootStore } from '@/stores/root.store';
import { useUIStore } from '@/stores/ui.store';
export default defineComponent({
computed: {
...mapStores(useRootStore, useUIStore),
basePath(): string {
return this.rootStore.baseUrl;
},
logoPath(): string {
return this.basePath + this.uiStore.logo;
},
},
});
const rootStore = useRootStore();
const uiStore = useUIStore();
const basePath = computed(() => rootStore.baseUrl);
const logoPath = computed(() => basePath.value + uiStore.logo);
</script>
<template>

View file

@ -1,85 +1,71 @@
<script lang="ts">
<script setup lang="ts">
import { useUIStore } from '@/stores/ui.store';
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { onBeforeUnmount, onMounted } from 'vue';
import type { EventBus } from 'n8n-design-system';
import { ElDrawer } from 'element-plus';
export default defineComponent({
name: 'ModalDrawer',
components: {
ElDrawer,
const props = withDefaults(
defineProps<{
name: string;
beforeClose?: Function;
eventBus?: EventBus;
direction: 'ltr' | 'rtl' | 'ttb' | 'btt';
modal?: boolean;
width: string;
wrapperClosable?: boolean;
}>(),
{
modal: true,
wrapperClosable: true,
},
props: {
name: {
type: String,
required: true,
},
beforeClose: {
type: Function,
},
eventBus: {
type: Object as PropType<EventBus>,
},
direction: {
type: String as PropType<'ltr' | 'rtl' | 'ttb' | 'btt'>,
required: true,
},
modal: {
type: Boolean,
default: true,
},
width: {
type: String,
},
wrapperClosable: {
type: Boolean,
default: true,
},
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
this.eventBus?.on('close', this.close);
);
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
const emit = defineEmits<{
enter: [];
}>();
const uiStore = useUIStore();
const handleEnter = () => {
if (uiStore.isModalActiveById[props.name]) {
emit('enter');
}
};
const onWindowKeydown = (event: KeyboardEvent) => {
if (!uiStore.isModalActiveById[props.name]) {
return;
}
if (event && event.keyCode === 13) {
handleEnter();
}
};
const close = async () => {
if (props.beforeClose) {
const shouldClose = await props.beforeClose();
if (shouldClose === false) {
// must be strictly false to stop modal from closing
return;
}
},
beforeUnmount() {
this.eventBus?.off('close', this.close);
window.removeEventListener('keydown', this.onWindowKeydown);
},
computed: {
...mapStores(useUIStore),
},
methods: {
onWindowKeydown(event: KeyboardEvent) {
if (!this.uiStore.isModalActiveById[this.name]) {
return;
}
}
uiStore.closeModal(props.name);
};
if (event && event.keyCode === 13) {
this.handleEnter();
}
},
handleEnter() {
if (this.uiStore.isModalActiveById[this.name]) {
this.$emit('enter');
}
},
async close() {
if (this.beforeClose) {
const shouldClose = await this.beforeClose();
if (shouldClose === false) {
// must be strictly false to stop modal from closing
return;
}
}
this.uiStore.closeModal(this.name);
},
},
onMounted(() => {
window.addEventListener('keydown', onWindowKeydown);
props.eventBus?.on('close', close);
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
});
onBeforeUnmount(() => {
props.eventBus?.off('close', close);
window.removeEventListener('keydown', onWindowKeydown);
});
</script>

View file

@ -1,25 +1,23 @@
<script lang="ts">
import type { PropType } from 'vue';
import { defineComponent } from 'vue';
<script setup lang="ts">
import { useUIStore } from '@/stores/ui.store';
import { mapStores } from 'pinia';
import type { ModalKey } from '@/Interface';
export default defineComponent({
name: 'ModalRoot',
props: {
name: {
type: String as PropType<ModalKey>,
required: true,
},
keepAlive: {
type: Boolean,
},
},
computed: {
...mapStores(useUIStore),
},
});
defineProps<{
name: string;
keepAlive?: boolean;
}>();
defineSlots<{
default: {
modalName: string;
active: boolean;
open: boolean;
activeId: string;
mode: string;
data: Record<string, unknown>;
};
}>();
const uiStore = useUIStore();
</script>
<template>

View file

@ -69,6 +69,7 @@ import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceM
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
import type { EventBus } from 'n8n-design-system';
</script>
<template>
@ -183,7 +184,15 @@ import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentMo
</ModalRoot>
<ModalRoot :name="LOG_STREAM_MODAL_KEY">
<template #default="{ modalName, data }">
<template
#default="{
modalName,
data,
}: {
modalName: string;
data: { destination: Object; isNew: boolean; eventBus: EventBus };
}"
>
<EventDestinationSettingsModal
:modal-name="modalName"
:destination="data.destination"

View file

@ -122,7 +122,7 @@ const badge = computed(() => {
:disabled="disabled"
:size="size"
:circle="circle"
:node-type-name="nodeName ?? nodeType?.displayName ?? ''"
:node-type-name="nodeName ? nodeName : nodeType?.displayName"
:show-tooltip="showTooltip"
:tooltip-position="tooltipPosition"
:badge="badge"

View file

@ -1,48 +1,40 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { computed } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import type { ITemplatesNode } from '@/Interface';
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
export default defineComponent({
name: 'NodeList',
components: {
NodeIcon,
},
props: {
nodes: {
type: Array,
},
limit: {
type: Number,
default: 4,
},
size: {
type: String,
default: 'sm',
},
},
computed: {
filteredCoreNodes() {
return filterTemplateNodes(this.nodes as ITemplatesNode[]);
},
hiddenNodes(): number {
return this.filteredCoreNodes.length - this.countNodesToBeSliced(this.filteredCoreNodes);
},
slicedNodes(): ITemplatesNode[] {
return this.filteredCoreNodes.slice(0, this.countNodesToBeSliced(this.filteredCoreNodes));
},
},
methods: {
countNodesToBeSliced(nodes: ITemplatesNode[]): number {
if (nodes.length > this.limit) {
return this.limit - 1;
} else {
return this.limit;
}
},
const props = withDefaults(
defineProps<{
nodes: ITemplatesNode[];
limit?: number;
size?: string;
}>(),
{
limit: 4,
size: 'sm',
},
);
const filteredCoreNodes = computed(() => {
return filterTemplateNodes(props.nodes);
});
const hiddenNodes = computed(() => {
return filteredCoreNodes.value.length - countNodesToBeSliced(filteredCoreNodes.value);
});
const slicedNodes = computed(() => {
return filteredCoreNodes.value.slice(0, countNodesToBeSliced(filteredCoreNodes.value));
});
const countNodesToBeSliced = (nodes: ITemplatesNode[]): number => {
if (nodes.length > props.limit) {
return props.limit - 1;
} else {
return props.limit;
}
};
</script>
<template>

View file

@ -35,7 +35,7 @@ type Props = {
isReadOnly?: boolean;
linkedRuns?: boolean;
canLinkRuns?: boolean;
pushRef?: string;
pushRef: string;
blockUI?: boolean;
isProductionExecutionPreview?: boolean;
isPaneActive?: boolean;

View file

@ -1,32 +1,29 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import Draggable from './Draggable.vue';
import type { XYPosition } from '@/Interface';
export default defineComponent({
components: {
Draggable,
},
props: {
canMoveRight: {
type: Boolean,
},
canMoveLeft: {
type: Boolean,
},
},
methods: {
onDrag(e: XYPosition) {
this.$emit('drag', e);
},
onDragStart() {
this.$emit('dragstart');
},
onDragEnd() {
this.$emit('dragend');
},
},
});
defineProps<{
canMoveRight: boolean;
canMoveLeft: boolean;
}>();
const emit = defineEmits<{
drag: [e: XYPosition];
dragstart: [];
dragend: [];
}>();
const onDrag = (e: XYPosition) => {
emit('drag', e);
};
const onDragEnd = () => {
emit('dragend');
};
const onDragStart = () => {
emit('dragstart');
};
</script>
<template>

View file

@ -1,14 +1,9 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import TitledList from '@/components/TitledList.vue';
export default defineComponent({
name: 'ParameterIssues',
components: {
TitledList,
},
props: ['issues'],
});
defineProps<{
issues: string[];
}>();
</script>
<template>

View file

@ -1,26 +1,23 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
<script setup lang="ts">
import { computed } from 'vue';
import { useRootStore } from '@/stores/root.store';
import { useI18n } from '@/composables/useI18n';
export default defineComponent({
name: 'PushConnectionTracker',
computed: {
...mapStores(useRootStore),
},
});
const rootStore = useRootStore();
const pushConnectionActive = computed(() => rootStore.pushConnectionActive);
const i18n = useI18n();
</script>
<template>
<span>
<div v-if="!rootStore.pushConnectionActive" class="push-connection-lost primary-color">
<div v-if="!pushConnectionActive" class="push-connection-lost primary-color">
<n8n-tooltip placement="bottom-end">
<template #content>
<div v-n8n-html="$locale.baseText('pushConnectionTracker.cannotConnectToServer')"></div>
<div v-n8n-html="i18n.baseText('pushConnectionTracker.cannotConnectToServer')"></div>
</template>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp;
{{ $locale.baseText('pushConnectionTracker.connectionLost') }}
{{ i18n.baseText('pushConnectionTracker.connectionLost') }}
</span>
</n8n-tooltip>
</div>

View file

@ -135,6 +135,7 @@ export default defineComponent({
},
pushRef: {
type: String,
required: true,
},
paneType: {
type: String as PropType<NodePanelType>,

View file

@ -23,15 +23,15 @@ const LazyRunDataJsonActions = defineAsyncComponent(
const props = withDefaults(
defineProps<{
editMode: { enabled?: boolean; value?: string };
pushRef?: string;
paneType?: string;
pushRef: string;
paneType: string;
node: INodeUi;
inputData: INodeExecutionData[];
mappingEnabled?: boolean;
distanceFromActive: number;
runIndex?: number;
totalRuns?: number;
search?: string;
runIndex: number | undefined;
totalRuns: number | undefined;
search: string | undefined;
}>(),
{
editMode: () => ({}),

View file

@ -1,7 +1,4 @@
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores, storeToRefs } from 'pinia';
<script lang="ts" setup>
import jp from 'jsonpath';
import type { INodeUi } from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
@ -14,192 +11,168 @@ import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import { nonExistingJsonPath } from '@/constants';
import { useClipboard } from '@/composables/useClipboard';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { usePinnedData } from '@/composables/usePinnedData';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
type JsonPathData = {
path: string;
startPath: string;
};
export default defineComponent({
name: 'RunDataJsonActions',
props: {
node: {
type: Object as PropType<INodeUi>,
required: true,
},
paneType: {
type: String,
},
pushRef: {
type: String,
},
currentOutputIndex: {
type: Number,
},
runIndex: {
type: Number,
},
displayMode: {
type: String,
},
distanceFromActive: {
type: Number,
},
selectedJsonPath: {
type: String,
default: nonExistingJsonPath,
},
jsonData: {
type: Array as PropType<IDataObject[]>,
required: true,
},
const props = withDefaults(
defineProps<{
node: INodeUi;
paneType: string;
pushRef: string;
displayMode: string;
distanceFromActive: number;
selectedJsonPath: string;
jsonData: IDataObject[];
currentOutputIndex?: number;
runIndex?: number;
}>(),
{
selectedJsonPath: nonExistingJsonPath,
},
setup() {
const ndvStore = useNDVStore();
const i18n = useI18n();
const nodeHelpers = useNodeHelpers();
const clipboard = useClipboard();
const { activeNode } = storeToRefs(ndvStore);
const pinnedData = usePinnedData(activeNode);
);
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
return {
i18n,
nodeHelpers,
clipboard,
pinnedData,
...useToast(),
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore, useSourceControlStore),
isReadOnlyRoute() {
return this.$route?.meta?.readOnlyCanvas === true;
},
activeNode(): INodeUi | null {
return this.ndvStore.activeNode;
},
noSelection() {
return this.selectedJsonPath === nonExistingJsonPath;
},
normalisedJsonPath(): string {
return this.noSelection ? '[""]' : this.selectedJsonPath;
},
},
methods: {
getJsonValue(): string {
let selectedValue = jp.query(this.jsonData, `$${this.normalisedJsonPath}`)[0];
if (this.noSelection) {
const inExecutionsFrame =
window !== window.parent && window.parent.location.pathname.includes('/executions');
const i18n = useI18n();
const nodeHelpers = useNodeHelpers();
const clipboard = useClipboard();
const { activeNode } = ndvStore;
const pinnedData = usePinnedData(activeNode);
const { showToast } = useToast();
const telemetry = useTelemetry();
if (this.pinnedData.hasData.value && !inExecutionsFrame) {
selectedValue = clearJsonKey(this.pinnedData.data.value as object);
} else {
selectedValue = executionDataToJson(
this.nodeHelpers.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex),
);
}
}
const route = useRoute();
let value = '';
if (typeof selectedValue === 'object') {
value = JSON.stringify(selectedValue, null, 2);
} else {
value = selectedValue.toString();
}
return value;
},
getJsonItemPath(): JsonPathData {
const newPath = convertPath(this.normalisedJsonPath);
let startPath = '';
let path = '';
const pathParts = newPath.split(']');
const index = pathParts[0].slice(1);
path = pathParts.slice(1).join(']');
startPath = `$item(${index}).$node["${this.node.name}"].json`;
return { path, startPath };
},
getJsonParameterPath(): JsonPathData {
const newPath = convertPath(this.normalisedJsonPath);
const path = newPath.split(']').slice(1).join(']');
let startPath = `$node["${this.node.name}"].json`;
if (this.distanceFromActive === 1) {
startPath = '$json';
}
return { path, startPath };
},
handleCopyClick(commandData: { command: string }) {
let value: string;
if (commandData.command === 'value') {
value = this.getJsonValue();
this.showToast({
title: this.i18n.baseText('runData.copyValue.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else {
let startPath = '';
let path = '';
if (commandData.command === 'itemPath') {
const jsonItemPath = this.getJsonItemPath();
startPath = jsonItemPath.startPath;
path = jsonItemPath.path;
this.showToast({
title: this.i18n.baseText('runData.copyItemPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else if (commandData.command === 'parameterPath') {
const jsonParameterPath = this.getJsonParameterPath();
startPath = jsonParameterPath.startPath;
path = jsonParameterPath.path;
this.showToast({
title: this.i18n.baseText('runData.copyParameterPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
}
if (!path.startsWith('[') && !path.startsWith('.') && path) {
path += '.';
}
value = `{{ ${startPath + path} }}`;
}
const copyType = {
value: 'selection',
itemPath: 'item_path',
parameterPath: 'parameter_path',
}[commandData.command];
this.$telemetry.track('User copied ndv data', {
node_type: this.activeNode?.type,
push_ref: this.pushRef,
run_index: this.runIndex,
view: 'json',
copy_type: copyType,
workflow_id: this.workflowsStore.workflowId,
pane: this.paneType,
in_execution_log: this.isReadOnlyRoute,
});
void this.clipboard.copy(value);
},
},
const isReadOnlyRoute = computed(() => {
return route?.meta?.readOnlyCanvas === true;
});
const noSelection = computed(() => {
return props.selectedJsonPath === nonExistingJsonPath;
});
const normalisedJsonPath = computed((): string => {
return noSelection.value ? '[""]' : props.selectedJsonPath;
});
function getJsonValue(): string {
let selectedValue = jp.query(props.jsonData, `$${normalisedJsonPath.value}`)[0];
if (noSelection.value) {
const inExecutionsFrame =
window !== window.parent && window.parent.location.pathname.includes('/executions');
if (pinnedData.hasData.value && !inExecutionsFrame) {
selectedValue = clearJsonKey(pinnedData.data.value as object);
} else {
selectedValue = executionDataToJson(
nodeHelpers.getNodeInputData(props.node, props.runIndex, props.currentOutputIndex),
);
}
}
let value = '';
if (typeof selectedValue === 'object') {
value = JSON.stringify(selectedValue, null, 2);
} else {
value = selectedValue.toString();
}
return value;
}
function getJsonItemPath(): JsonPathData {
const newPath = convertPath(normalisedJsonPath.value);
let startPath = '';
let path = '';
const pathParts = newPath.split(']');
const index = pathParts[0].slice(1);
path = pathParts.slice(1).join(']');
startPath = `$item(${index}).$node["${props.node.name}"].json`;
return { path, startPath };
}
function getJsonParameterPath(): JsonPathData {
const newPath = convertPath(normalisedJsonPath.value);
const path = newPath.split(']').slice(1).join(']');
let startPath = `$node["${props.node.name}"].json`;
if (props.distanceFromActive === 1) {
startPath = '$json';
}
return { path, startPath };
}
function handleCopyClick(commandData: { command: string }) {
let value: string;
if (commandData.command === 'value') {
value = getJsonValue();
showToast({
title: i18n.baseText('runData.copyValue.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else {
let startPath = '';
let path = '';
if (commandData.command === 'itemPath') {
const jsonItemPath = getJsonItemPath();
startPath = jsonItemPath.startPath;
path = jsonItemPath.path;
showToast({
title: i18n.baseText('runData.copyItemPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else if (commandData.command === 'parameterPath') {
const jsonParameterPath = getJsonParameterPath();
startPath = jsonParameterPath.startPath;
path = jsonParameterPath.path;
showToast({
title: i18n.baseText('runData.copyParameterPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
}
if (!path.startsWith('[') && !path.startsWith('.') && path) {
path += '.';
}
value = `{{ ${startPath + path} }}`;
}
const copyType = {
value: 'selection',
itemPath: 'item_path',
parameterPath: 'parameter_path',
}[commandData.command];
telemetry.track('User copied ndv data', {
node_type: activeNode?.type,
push_ref: props.pushRef,
run_index: props.runIndex,
view: 'json',
copy_type: copyType,
workflow_id: workflowsStore.workflowId,
pane: props.paneType,
in_execution_log: isReadOnlyRoute.value,
});
void clipboard.copy(value);
}
</script>
<template>

View file

@ -1,134 +1,135 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
<script lang="ts" setup>
import { computed, onBeforeMount, onMounted, ref } from 'vue';
import { MODAL_CONFIRM } from '@/constants';
import { useMessage } from '@/composables/useMessage';
import { useLogStreamingStore } from '@/stores/logStreaming.store';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { MessageEventBusDestinationOptions } from 'n8n-workflow';
import { deepCopy, defaultMessageEventBusDestinationOptions } from 'n8n-workflow';
import type { BaseTextKey } from '@/plugins/i18n';
import type { EventBus } from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n';
import { assert } from '@/utils/assert';
export const DESTINATION_LIST_ITEM_ACTIONS = {
const DESTINATION_LIST_ITEM_ACTIONS = {
OPEN: 'open',
DELETE: 'delete',
};
export default defineComponent({
components: {},
setup() {
return {
...useMessage(),
};
},
data() {
return {
EnterpriseEditionFeature,
nodeParameters: {} as MessageEventBusDestinationOptions,
};
},
props: {
eventBus: {
type: Object as PropType<EventBus>,
},
destination: {
type: Object,
required: true,
default: deepCopy(defaultMessageEventBusDestinationOptions),
},
readonly: Boolean,
},
mounted() {
this.nodeParameters = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
this.destination,
);
this.eventBus?.on('destinationWasSaved', this.onDestinationWasSaved);
},
beforeUnmount() {
this.eventBus?.off('destinationWasSaved', this.onDestinationWasSaved);
},
computed: {
...mapStores(useLogStreamingStore),
actions(): Array<{ label: string; value: string }> {
const actions = [
{
label: this.$locale.baseText('workflows.item.open'),
value: DESTINATION_LIST_ITEM_ACTIONS.OPEN,
},
];
if (!this.readonly) {
actions.push({
label: this.$locale.baseText('workflows.item.delete'),
value: DESTINATION_LIST_ITEM_ACTIONS.DELETE,
});
}
return actions;
},
typeLabelName(): BaseTextKey {
return `settings.log-streaming.${this.destination.__type}` as BaseTextKey;
},
},
methods: {
onDestinationWasSaved() {
const updatedDestination = this.logStreamingStore.getDestination(this.destination.id);
if (updatedDestination) {
this.nodeParameters = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
this.destination,
);
}
},
async onClick(event: Event) {
const cardActions = this.$refs.cardActions as HTMLDivElement | null;
const target = event.target as HTMLDivElement | null;
if (
cardActions === target ||
cardActions?.contains(target) ||
target?.contains(cardActions)
) {
return;
}
const { confirm } = useMessage();
const i18n = useI18n();
const logStreamingStore = useLogStreamingStore();
this.$emit('edit', this.destination.id);
},
onEnabledSwitched(state: boolean) {
this.nodeParameters.enabled = state;
void this.saveDestination();
},
async saveDestination() {
await this.logStreamingStore.saveDestination(this.nodeParameters);
},
async onAction(action: string) {
if (action === DESTINATION_LIST_ITEM_ACTIONS.OPEN) {
this.$emit('edit', this.destination.id);
} else if (action === DESTINATION_LIST_ITEM_ACTIONS.DELETE) {
const deleteConfirmed = await this.confirm(
this.$locale.baseText('settings.log-streaming.destinationDelete.message', {
interpolate: { destinationName: this.destination.label },
}),
this.$locale.baseText('settings.log-streaming.destinationDelete.headline'),
{
type: 'warning',
confirmButtonText: this.$locale.baseText(
'settings.log-streaming.destinationDelete.confirmButtonText',
),
cancelButtonText: this.$locale.baseText(
'settings.log-streaming.destinationDelete.cancelButtonText',
),
},
);
const nodeParameters = ref<MessageEventBusDestinationOptions>({});
const cardActions = ref<HTMLDivElement | null>(null);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
this.$emit('remove', this.destination.id);
}
},
const props = withDefaults(
defineProps<{
eventBus: EventBus;
destination: MessageEventBusDestinationOptions;
readonly: boolean;
}>(),
{
destination: () => deepCopy(defaultMessageEventBusDestinationOptions),
},
);
const emit = defineEmits<{
edit: [id: string | undefined];
remove: [id: string | undefined];
}>();
onMounted(() => {
nodeParameters.value = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
props.destination,
);
props.eventBus?.on('destinationWasSaved', onDestinationWasSaved);
});
onBeforeMount(() => {
props.eventBus?.off('destinationWasSaved', onDestinationWasSaved);
});
const actions = computed((): Array<{ label: string; value: string }> => {
const actionList = [
{
label: i18n.baseText('workflows.item.open'),
value: DESTINATION_LIST_ITEM_ACTIONS.OPEN,
},
];
if (!props.readonly) {
actionList.push({
label: i18n.baseText('workflows.item.delete'),
value: DESTINATION_LIST_ITEM_ACTIONS.DELETE,
});
}
return actionList;
});
const typeLabelName = computed((): BaseTextKey => {
return `settings.log-streaming.${props.destination.__type}` as BaseTextKey;
});
function onDestinationWasSaved() {
assert(props.destination.id);
const updatedDestination = logStreamingStore.getDestination(props.destination.id);
if (updatedDestination) {
nodeParameters.value = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
props.destination,
);
}
}
async function onClick(event: Event) {
const target = event.target as HTMLDivElement | null;
if (
cardActions.value === target ||
cardActions.value?.contains(target) ||
target?.contains(cardActions.value)
) {
return;
}
emit('edit', props.destination.id);
}
function onEnabledSwitched(state: boolean) {
nodeParameters.value.enabled = state;
void saveDestination();
}
async function saveDestination() {
await logStreamingStore.saveDestination(nodeParameters.value);
}
async function onAction(action: string) {
if (action === DESTINATION_LIST_ITEM_ACTIONS.OPEN) {
emit('edit', props.destination.id);
} else if (action === DESTINATION_LIST_ITEM_ACTIONS.DELETE) {
const deleteConfirmed = await confirm(
i18n.baseText('settings.log-streaming.destinationDelete.message', {
interpolate: { destinationName: props.destination.label ?? '' },
}),
i18n.baseText('settings.log-streaming.destinationDelete.headline'),
{
type: 'warning',
confirmButtonText: i18n.baseText(
'settings.log-streaming.destinationDelete.confirmButtonText',
),
cancelButtonText: i18n.baseText(
'settings.log-streaming.destinationDelete.cancelButtonText',
),
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
emit('remove', props.destination.id);
}
}
</script>
<template>
@ -140,7 +141,7 @@ export default defineComponent({
</n8n-heading>
<div :class="$style.cardDescription">
<n8n-text color="text-light" size="small">
<span>{{ $locale.baseText(typeLabelName) }}</span>
<span>{{ i18n.baseText(typeLabelName) }}</span>
</n8n-text>
</div>
</div>
@ -149,10 +150,10 @@ export default defineComponent({
<div ref="cardActions" :class="$style.cardActions">
<div :class="$style.activeStatusText" data-test-id="destination-activator-status">
<n8n-text v-if="nodeParameters.enabled" :color="'success'" size="small" bold>
{{ $locale.baseText('workflowActivator.active') }}
{{ i18n.baseText('workflowActivator.active') }}
</n8n-text>
<n8n-text v-else color="text-base" size="small" bold>
{{ $locale.baseText('workflowActivator.inactive') }}
{{ i18n.baseText('workflowActivator.inactive') }}
</n8n-text>
</div>
@ -162,8 +163,8 @@ export default defineComponent({
:model-value="nodeParameters.enabled"
:title="
nodeParameters.enabled
? $locale.baseText('workflowActivator.deactivateWorkflow')
: $locale.baseText('workflowActivator.activateWorkflow')
? i18n.baseText('workflowActivator.deactivateWorkflow')
: i18n.baseText('workflowActivator.activateWorkflow')
"
active-color="#13ce66"
inactive-color="#8899AA"

View file

@ -1,63 +1,45 @@
<script lang="ts">
import { type PropType, defineComponent } from 'vue';
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
<script lang="ts" setup>
import { abbreviateNumber } from '@/utils/typesUtils';
import NodeList from './NodeList.vue';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ITemplatesWorkflow } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import type { BaseTextKey } from '@/plugins/i18n';
export default defineComponent({
name: 'TemplateCard',
components: {
TimeAgo,
NodeList,
const i18n = useI18n();
const nodesToBeShown = 5;
withDefaults(
defineProps<{
workflow?: ITemplatesWorkflow;
lastItem?: boolean;
firstItem?: boolean;
useWorkflowButton?: boolean;
loading?: boolean;
simpleView?: boolean;
}>(),
{
lastItem: false,
firstItem: false,
useWorkflowButton: false,
loading: false,
simpleView: false,
},
props: {
workflow: {
type: Object as PropType<ITemplatesWorkflow>,
},
lastItem: {
type: Boolean,
default: false,
},
firstItem: {
type: Boolean,
default: false,
},
useWorkflowButton: {
type: Boolean,
},
loading: {
type: Boolean,
},
simpleView: {
type: Boolean,
default: false,
},
},
data() {
return {
nodesToBeShown: 5,
};
},
methods: {
filterTemplateNodes,
abbreviateNumber,
countNodesToBeSliced(nodes: []): number {
if (nodes.length > this.nodesToBeShown) {
return this.nodesToBeShown - 1;
} else {
return this.nodesToBeShown;
}
},
onUseWorkflowClick(e: MouseEvent) {
this.$emit('useWorkflow', e);
},
onCardClick(e: MouseEvent) {
this.$emit('click', e);
},
},
});
);
const emit = defineEmits<{
useWorkflow: [e: MouseEvent];
click: [e: MouseEvent];
}>();
function onUseWorkflowClick(e: MouseEvent) {
emit('useWorkflow', e);
}
function onCardClick(e: MouseEvent) {
emit('click', e);
}
</script>
<template>
@ -88,8 +70,12 @@ export default defineComponent({
<TimeAgo :date="workflow.createdAt" />
</n8n-text>
<div v-if="workflow.user" :class="$style.line" v-text="'|'" />
<n8n-text v-if="workflow.user" size="small" color="text-light"
>By {{ workflow.user.username }}</n8n-text
<n8n-text v-if="workflow.user" size="small" color="text-light">
{{
i18n.baseText('template.byAuthor' as BaseTextKey, {
interpolate: { name: workflow.user.username },
})
}}</n8n-text
>
</div>
</div>

View file

@ -1,6 +1,4 @@
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
<script setup lang="ts">
import TemplateDetailsBlock from '@/components/TemplateDetailsBlock.vue';
import NodeIcon from '@/components/NodeIcon.vue';
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
@ -11,51 +9,32 @@ import type {
ITemplatesNode,
ITemplatesWorkflow,
} from '@/Interface';
import { mapStores } from 'pinia';
import { useTemplatesStore } from '@/stores/templates.store';
import TimeAgo from '@/components/TimeAgo.vue';
import { isFullTemplatesCollection, isTemplatesWorkflow } from '@/utils/templates/typeGuards';
import { useRouter } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
export default defineComponent({
name: 'TemplateDetails',
components: {
NodeIcon,
TemplateDetailsBlock,
TimeAgo,
},
props: {
template: {
type: Object as PropType<
ITemplatesWorkflow | ITemplatesCollection | ITemplatesCollectionFull | null
>,
required: true,
},
blockTitle: {
type: String,
required: true,
},
loading: {
type: Boolean,
},
},
computed: {
...mapStores(useTemplatesStore),
},
methods: {
abbreviateNumber,
filterTemplateNodes,
redirectToCategory(id: string) {
this.templatesStore.resetSessionId();
void this.$router.push(`/templates?categories=${id}`);
},
redirectToSearchPage(node: ITemplatesNode) {
this.templatesStore.resetSessionId();
void this.$router.push(`/templates?search=${node.displayName}`);
},
isFullTemplatesCollection,
isTemplatesWorkflow,
},
});
defineProps<{
template: ITemplatesWorkflow | ITemplatesCollection | ITemplatesCollectionFull | null;
blockTitle: string;
loading: boolean;
}>();
const router = useRouter();
const i18n = useI18n();
const templatesStore = useTemplatesStore();
const redirectToCategory = (id: string) => {
templatesStore.resetSessionId();
void router.push(`/templates?categories=${id}`);
};
const redirectToSearchPage = (node: ITemplatesNode) => {
templatesStore.resetSessionId();
void router.push(`/templates?search=${node.displayName}`);
};
</script>
<template>
@ -91,13 +70,13 @@ export default defineComponent({
<TemplateDetailsBlock
v-if="!loading && template"
:title="$locale.baseText('template.details.details')"
:title="i18n.baseText('template.details.details')"
>
<div :class="$style.text">
<n8n-text v-if="isTemplatesWorkflow(template)" size="small" color="text-base">
{{ $locale.baseText('template.details.created') }}
{{ i18n.baseText('template.details.created') }}
<TimeAgo :date="template.createdAt" />
{{ $locale.baseText('template.details.by') }}
{{ i18n.baseText('template.details.by') }}
{{ template.user ? template.user.username : 'n8n team' }}
</n8n-text>
</div>
@ -107,9 +86,9 @@ export default defineComponent({
size="small"
color="text-base"
>
{{ $locale.baseText('template.details.viewed') }}
{{ i18n.baseText('template.details.viewed') }}
{{ abbreviateNumber(template.totalViews) }}
{{ $locale.baseText('template.details.times') }}
{{ i18n.baseText('template.details.times') }}
</n8n-text>
</div>
</TemplateDetailsBlock>

View file

@ -1,101 +1,104 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import type { ITemplatesCategory } from '@/Interface';
import type { PropType } from 'vue';
import { useTemplatesStore } from '@/stores/templates.store';
import { mapStores } from 'pinia';
import { useI18n } from '@/composables/useI18n';
export default defineComponent({
name: 'TemplateFilters',
props: {
categories: {
type: Array as PropType<ITemplatesCategory[]>,
default: () => [],
},
sortOnPopulate: {
type: Boolean,
default: false,
},
expandLimit: {
type: Number,
default: 12,
},
loading: {
type: Boolean,
},
selected: {
type: Array as PropType<ITemplatesCategory[]>,
default: () => [],
},
},
emits: ['clearAll', 'select', 'clear'],
data() {
return {
collapsed: true,
sortedCategories: [] as ITemplatesCategory[],
};
},
computed: {
...mapStores(useTemplatesStore),
allSelected(): boolean {
return this.selected.length === 0;
},
},
watch: {
sortOnPopulate: {
handler(value: boolean) {
if (value) {
this.sortCategories();
}
},
immediate: true,
},
categories: {
handler(categories: ITemplatesCategory[]) {
if (categories.length > 0) {
this.sortCategories();
}
},
immediate: true,
},
},
methods: {
sortCategories() {
if (!this.sortOnPopulate) {
this.sortedCategories = this.categories;
} else {
const selected = this.selected || [];
const selectedCategories = this.categories.filter((cat) => selected.includes(cat));
const notSelectedCategories = this.categories.filter((cat) => !selected.includes(cat));
this.sortedCategories = selectedCategories.concat(notSelectedCategories);
}
},
collapseAction() {
this.collapsed = false;
},
handleCheckboxChanged(value: boolean, selectedCategory: ITemplatesCategory) {
this.$emit(value ? 'select' : 'clear', selectedCategory);
},
isSelected(category: ITemplatesCategory) {
return this.selected.includes(category);
},
resetCategories() {
this.$emit('clearAll');
},
},
interface Props {
categories?: ITemplatesCategory[];
sortOnPopulate?: boolean;
expandLimit?: number;
loading?: boolean;
selected?: ITemplatesCategory[];
}
const props = withDefaults(defineProps<Props>(), {
categories: () => [],
sortOnPopulate: false,
expandLimit: 12,
loading: false,
selected: () => [],
});
const emit = defineEmits<{
clearAll: [];
select: [category: ITemplatesCategory];
clear: [category: ITemplatesCategory];
}>();
const i18n = useI18n();
const collapsed = ref(true);
const sortedCategories = ref<ITemplatesCategory[]>([]);
const allSelected = computed((): boolean => {
return props.selected.length === 0;
});
function sortCategories() {
if (!props.sortOnPopulate) {
sortedCategories.value = props.categories;
} else {
const selected = props.selected || [];
const selectedCategories = props.categories.filter((cat) => selected.includes(cat));
const notSelectedCategories = props.categories.filter((cat) => !selected.includes(cat));
sortedCategories.value = selectedCategories.concat(notSelectedCategories);
}
}
function collapseAction() {
collapsed.value = false;
}
function handleCheckboxChanged(value: boolean, selectedCategory: ITemplatesCategory) {
if (value) {
emit('select', selectedCategory);
} else {
emit('clear', selectedCategory);
}
}
function isSelected(category: ITemplatesCategory) {
return props.selected.includes(category);
}
function resetCategories() {
emit('clearAll');
}
watch(
() => props.sortOnPopulate,
(value: boolean) => {
if (value) {
sortCategories();
}
},
{
immediate: true,
},
);
watch(
() => props.categories,
(categories: ITemplatesCategory[]) => {
if (categories.length > 0) {
sortCategories();
}
},
{
immediate: true,
},
);
</script>
<template>
<div :class="$style.filters" class="template-filters" data-test-id="templates-filter-container">
<div :class="$style.title" v-text="$locale.baseText('templates.categoriesHeading')" />
<div :class="$style.title" v-text="i18n.baseText('templates.categoriesHeading')" />
<div v-if="loading" :class="$style.list">
<n8n-loading :loading="loading" :rows="expandLimit" />
</div>
<ul v-if="!loading" :class="$style.categories">
<li :class="$style.item" data-test-id="template-filter-all-categories">
<el-checkbox :model-value="allSelected" @update:model-value="() => resetCategories()">
{{ $locale.baseText('templates.allCategories') }}
{{ i18n.baseText('templates.allCategories') }}
</el-checkbox>
</li>
<li

View file

@ -1,33 +1,23 @@
<script lang="ts">
import { type PropType, defineComponent } from 'vue';
<script lang="ts" setup>
import Card from '@/components/CollectionWorkflowCard.vue';
import NodeList from '@/components/NodeList.vue';
import { useI18n } from '@/composables/useI18n';
import type { ITemplatesCollection } from '@/Interface';
export default defineComponent({
name: 'TemplatesInfoCard',
components: {
Card,
NodeList,
withDefaults(
defineProps<{
collection: ITemplatesCollection;
loading?: boolean;
showItemCount?: boolean;
width: string;
}>(),
{
loading: false,
showItemCount: true,
},
props: {
collection: {
type: Object as PropType<ITemplatesCollection>,
required: true,
},
loading: {
type: Boolean,
},
showItemCount: {
type: Boolean,
default: true,
},
width: {
type: String,
required: true,
},
},
});
);
const i18n = useI18n();
</script>
<template>
@ -36,7 +26,7 @@ export default defineComponent({
<span>
<n8n-text v-show="showItemCount" size="small" color="text-light">
{{ collection.workflows.length }}
{{ $locale.baseText('templates.workflows') }}
{{ i18n.baseText('templates.workflows') }}
</n8n-text>
</span>
<NodeList :nodes="collection.nodes" :show-more="false" />

View file

@ -1,103 +1,66 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script setup lang="ts">
import { computed, onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { mapStores } from 'pinia';
import type { WorkerStatus } from '@n8n/api-types';
import type { ExecutionStatus } from 'n8n-workflow';
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
import { useI18n } from '@/composables/useI18n';
import { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
import { useOrchestrationStore } from '@/stores/orchestration.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import WorkerCard from './Workers/WorkerCard.ee.vue';
import { usePushConnection } from '@/composables/usePushConnection';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useRootStore } from '@/stores/root.store';
import { useTelemetry } from '@/composables/useTelemetry';
// eslint-disable-next-line import/no-default-export
export default defineComponent({
name: 'WorkerList',
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/naming-convention
components: { PushConnectionTracker, WorkerCard },
props: {
autoRefreshEnabled: {
type: Boolean,
default: true,
},
withDefaults(
defineProps<{
autoRefreshEnabled?: boolean;
}>(),
{
autoRefreshEnabled: true,
},
setup() {
const router = useRouter();
const i18n = useI18n();
const pushConnection = usePushConnection({ router });
);
return {
i18n,
pushConnection,
...useToast(),
documentTitle: useDocumentTitle(),
};
},
computed: {
...mapStores(useRootStore, useUIStore, usePushConnectionStore, useOrchestrationStore),
combinedWorkers(): WorkerStatus[] {
const returnData: WorkerStatus[] = [];
for (const workerId in this.orchestrationManagerStore.workers) {
returnData.push(this.orchestrationManagerStore.workers[workerId]);
}
return returnData;
},
initialStatusReceived(): boolean {
return this.orchestrationManagerStore.initialStatusReceived;
},
workerIds(): string[] {
return Object.keys(this.orchestrationManagerStore.workers);
},
pageTitle() {
return this.i18n.baseText('workerList.pageTitle');
},
},
mounted() {
this.documentTitle.set(this.pageTitle);
const router = useRouter();
const i18n = useI18n();
const pushConnection = usePushConnection({ router });
const documentTitle = useDocumentTitle();
const telemetry = useTelemetry();
this.$telemetry.track('User viewed worker view', {
instance_id: this.rootStore.instanceId,
});
},
beforeMount() {
if (window.Cypress !== undefined) {
return;
}
const orchestrationManagerStore = useOrchestrationStore();
const rootStore = useRootStore();
const pushStore = usePushConnectionStore();
this.pushConnection.initialize();
this.pushStore.pushConnect();
this.orchestrationManagerStore.startWorkerStatusPolling();
},
beforeUnmount() {
if (window.Cypress !== undefined) {
return;
}
const initialStatusReceived = computed(() => orchestrationManagerStore.initialStatusReceived);
this.orchestrationManagerStore.stopWorkerStatusPolling();
this.pushStore.pushDisconnect();
this.pushConnection.terminate();
},
methods: {
averageLoadAvg(loads: number[]) {
return (loads.reduce((prev, curr) => prev + curr, 0) / loads.length).toFixed(2);
},
getStatus(payload: WorkerStatus): ExecutionStatus {
if (payload.runningJobsSummary.length > 0) {
return 'running';
} else {
return 'success';
}
},
getRowClass(payload: WorkerStatus): string {
return [this.$style.execRow, this.$style[this.getStatus(payload)]].join(' ');
},
},
const workerIds = computed(() => Object.keys(orchestrationManagerStore.workers));
const pageTitle = computed(() => i18n.baseText('workerList.pageTitle'));
onMounted(() => {
documentTitle.set(pageTitle.value);
telemetry.track('User viewed worker view', {
instance_id: rootStore.instanceId,
});
});
onBeforeMount(() => {
if (window.Cypress !== undefined) {
return;
}
pushConnection.initialize();
pushStore.pushConnect();
orchestrationManagerStore.startWorkerStatusPolling();
});
onBeforeUnmount(() => {
if (window.Cypress !== undefined) {
return;
}
orchestrationManagerStore.stopWorkerStatusPolling();
pushStore.pushDisconnect();
pushConnection.terminate();
});
</script>
@ -111,7 +74,7 @@ export default defineComponent({
<n8n-spinner />
</div>
<div v-else>
<div v-if="workerIds.length === 0">{{ $locale.baseText('workerList.empty') }}</div>
<div v-if="workerIds.length === 0">{{ i18n.baseText('workerList.empty') }}</div>
<div v-else>
<div v-for="workerId in workerIds" :key="workerId" :class="$style.card">
<WorkerCard :worker-id="workerId" data-test-id="worker-card" />

View file

@ -220,20 +220,4 @@ describe('Canvas', () => {
expect(container.querySelector('#diagonalHatch')).toBeInTheDocument();
});
});
describe('pane', () => {
describe('onPaneMouseDown', () => {
it('should enable panning when middle mouse button is pressed', async () => {
const { getByTestId } = renderComponent();
const canvas = getByTestId('canvas');
const pane = canvas.querySelector('.vue-flow__pane');
if (!pane) throw new Error('VueFlow pane not in the document');
await fireEvent.mouseDown(canvas, { button: 1, view: window });
expect(canvas).toHaveClass('draggable');
});
});
});
});

Some files were not shown because too many files have changed in this diff Show more