first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.code",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "The Code node allows you to execute JavaScript in your workflow.",
"categories": ["Development", "Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code/"
}
]
},
"alias": ["cpde", "Javascript", "JS", "Python", "Script", "Custom Code", "Function"],
"subcategories": {
"Core Nodes": ["Helpers", "Data Transformation"]
}
}
+205
View File
@@ -0,0 +1,205 @@
/* eslint-disable n8n-nodes-base/node-execute-block-wrong-error-thrown */
import { NodesConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import {
NodeConnectionTypes,
UnexpectedError,
UserError,
type CodeExecutionMode,
type CodeNodeEditorLanguage,
type IExecuteFunctions,
type INodeType,
type INodeTypeDescription,
} from 'n8n-workflow';
type CodeNodeLanguageOption = CodeNodeEditorLanguage | 'pythonNative';
import { javascriptCodeDescription } from './descriptions/JavascriptCodeDescription';
import { pythonCodeDescription } from './descriptions/PythonCodeDescription';
import { JsTaskRunnerSandbox } from './JsTaskRunnerSandbox';
import { PythonRunnerUnavailableError } from './python-runner-unavailable.error';
import { PythonTaskRunnerSandbox } from './PythonTaskRunnerSandbox';
class PythonDisabledError extends UserError {
constructor() {
super(
'This instance disallows Python execution because it has the environment variable `N8N_PYTHON_ENABLED` set to `false`. To restore Python execution, remove this environment variable or set it to `true` and restart the instance.',
);
}
}
export class Code implements INodeType {
description: INodeTypeDescription = {
displayName: 'Code',
name: 'code',
icon: 'file:code.svg',
group: ['transform'],
version: [1, 2],
defaultVersion: 2,
description: 'Run custom JavaScript or Python code',
defaults: {
name: 'Code',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
builderHint: {
message:
'Use Code node as a LAST RESORT — it runs in a sandboxed environment and is slower than native nodes. Code node is ONLY appropriate for complex multi-step algorithms that cannot be expressed in single expressions, or operations requiring complex data structures.',
relatedNodes: [
{
nodeType: 'n8n-nodes-base.set',
relationHint:
'Use this instead for data manipulation: add/modify/rename fields, set values, map data',
},
{
nodeType: 'n8n-nodes-base.filter',
relationHint: 'Use this instead for filtering items by condition',
},
{
nodeType: 'n8n-nodes-base.if',
relationHint: 'Use this instead for routing by condition',
},
{
nodeType: 'n8n-nodes-base.switch',
relationHint: 'Use this instead for multi-way routing by condition',
},
{
nodeType: 'n8n-nodes-base.splitOut',
relationHint: 'Use this instead for splitting arrays into separate items',
},
{
nodeType: 'n8n-nodes-base.aggregate',
relationHint: 'Use this instead for combining multiple items into one',
},
{
nodeType: 'n8n-nodes-base.summarize',
relationHint: 'Use this instead for summarizing or pivoting data',
},
{
nodeType: 'n8n-nodes-base.removeDuplicates',
relationHint: 'Use this instead for removing duplicates',
},
{
nodeType: 'n8n-nodes-base.limit',
relationHint: 'Use this instead to reduce the number of items returned',
},
{
nodeType: 'n8n-nodes-base.merge',
relationHint: 'Use this instead for merging data from multiple branches',
},
{
nodeType: 'n8n-nodes-base.dateTime',
relationHint: 'Use this instead for date time operations',
},
{
nodeType: 'n8n-nodes-base.html',
relationHint: 'Use this instead for creating html pages',
},
],
},
parameterPane: 'wide',
properties: [
{
displayName: 'Mode',
name: 'mode',
type: 'options',
noDataExpression: true,
options: [
{
name: 'Run Once for All Items',
value: 'runOnceForAllItems',
description: 'Run this code only once, no matter how many input items there are',
},
{
name: 'Run Once for Each Item',
value: 'runOnceForEachItem',
description: 'Run this code as many times as there are input items',
},
],
default: 'runOnceForAllItems',
},
{
displayName: 'Language',
name: 'language',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
'@version': [2],
},
},
options: [
{
name: 'JavaScript',
value: 'javaScript',
action: 'Code in JavaScript',
},
{
name: 'Python',
value: 'pythonNative',
action: 'Code in Python',
},
],
default: 'javaScript',
},
{
displayName: 'Language',
name: 'language',
type: 'hidden',
displayOptions: {
show: {
'@version': [1],
},
},
default: 'javaScript',
},
...javascriptCodeDescription,
...pythonCodeDescription,
],
};
async execute(this: IExecuteFunctions) {
const node = this.getNode();
const language: CodeNodeLanguageOption =
node.typeVersion === 2
? (this.getNodeParameter('language', 0) as CodeNodeLanguageOption)
: 'javaScript';
const isJsLang = language === 'javaScript';
const isPyLang = language === 'python' || language === 'pythonNative'; // keep legacy `python` for backwards compatibility
if (isPyLang && !Container.get(NodesConfig).pythonEnabled) {
throw new PythonDisabledError();
}
const nodeMode = this.getNodeParameter('mode', 0) as CodeExecutionMode;
const workflowMode = this.getMode();
const codeParameterName = isPyLang ? 'pythonCode' : 'jsCode';
if (isJsLang) {
const code = this.getNodeParameter(codeParameterName, 0) as string;
const sandbox = new JsTaskRunnerSandbox(workflowMode, this);
const numInputItems = this.getInputData().length;
return nodeMode === 'runOnceForAllItems'
? [await sandbox.runCodeAllItems(code)]
: [await sandbox.runCodeForEachItem(code, numInputItems)];
}
if (isPyLang) {
const runnerStatus = this.getRunnerStatus('python');
if (!runnerStatus.available) {
throw new PythonRunnerUnavailableError(
runnerStatus.reason as 'python' | 'venv' | undefined,
);
}
const code = this.getNodeParameter(codeParameterName, 0) as string;
const sandbox = new PythonTaskRunnerSandbox(code, nodeMode, workflowMode, this);
return [await sandbox.runUsingIncomingItems()];
}
throw new UnexpectedError(`Unsupported language: ${language}`);
}
}
@@ -0,0 +1,91 @@
import { ApplicationError } from '@n8n/errors';
export class ExecutionError extends ApplicationError {
description: string | null = null;
itemIndex: number | undefined = undefined;
context: { itemIndex: number } | undefined = undefined;
stack = '';
lineNumber: number | undefined = undefined;
constructor(error: Error & { stack?: string }, itemIndex?: number) {
super(error.message);
this.itemIndex = itemIndex;
if (this.itemIndex !== undefined) {
this.context = { itemIndex: this.itemIndex };
}
this.stack = error.stack ?? '';
this.populateFromStack();
}
/**
* Populate error `message` and `description` from error `stack`.
*/
private populateFromStack() {
const stackRows = this.stack && typeof this.stack === 'string' ? this.stack.split('\n') : [];
if (stackRows.length === 0) {
this.message = 'Unknown error';
return;
}
const messageRow = stackRows.find((line) => line.includes('Error:'));
const lineNumberRow = stackRows.find((line) => line.includes('Code:'));
const lineNumberDisplay = this.toLineNumberDisplay(lineNumberRow) || '';
if (!messageRow) {
this.message = `Unknown error ${lineNumberDisplay}`;
return;
}
const [errorDetails, errorType] = this.toErrorDetailsAndType(messageRow);
if (errorType) this.description = errorType;
if (!errorDetails) {
this.message = `Unknown error ${lineNumberDisplay}`;
return;
}
this.message = `${errorDetails} ${lineNumberDisplay}`.trim();
}
private toLineNumberDisplay(lineNumberRow?: string) {
const errorLineNumberMatch = lineNumberRow?.match(/Code:(?<lineNumber>\d+)/);
if (!errorLineNumberMatch?.groups?.lineNumber) return null;
const lineNumber = errorLineNumberMatch.groups.lineNumber;
this.lineNumber = Number(lineNumber);
if (!lineNumber) return '';
return this.itemIndex === undefined
? `[line ${lineNumber}]`
: `[line ${lineNumber}, for item ${this.itemIndex}]`;
}
private toErrorDetailsAndType(messageRow?: string) {
if (!messageRow) return [null, null];
// Remove "Error: " prefix added by stacktrace formatting
messageRow = messageRow.replace(/^Error: /, '');
const colonIndex = messageRow.indexOf(': ');
if (colonIndex === -1) {
return [messageRow.trim(), null];
}
const errorType = messageRow.substring(0, colonIndex).trim();
const errorDetails = messageRow.substring(colonIndex + 2).trim();
return [errorDetails, errorType === 'Error' ? null : errorType];
}
}
@@ -0,0 +1,123 @@
import { NodeVM, makeResolverFromLegacyOptions, type Resolver } from 'vm2';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { ExecutionError } from './ExecutionError';
import {
mapItemNotDefinedErrorIfNeededForRunForEach,
mapItemsNotDefinedErrorIfNeededForRunForAll,
validateNoDisallowedMethodsInRunForEach,
} from './JsCodeValidator';
import type { SandboxContext } from './Sandbox';
import { Sandbox } from './Sandbox';
import { ValidationError } from './ValidationError';
const { NODE_FUNCTION_ALLOW_BUILTIN: builtIn, NODE_FUNCTION_ALLOW_EXTERNAL: external } =
process.env;
export const vmResolver = makeResolverFromLegacyOptions({
external: external
? {
modules: external.split(','),
transitive: false,
}
: false,
builtin: builtIn?.split(',') ?? [],
});
export class JavaScriptSandbox extends Sandbox {
private readonly vm: NodeVM;
constructor(
context: SandboxContext,
private jsCode: string,
helpers: IExecuteFunctions['helpers'],
options?: { resolver?: Resolver },
) {
super(
{
object: {
singular: 'object',
plural: 'objects',
},
},
helpers,
);
this.vm = new NodeVM({
console: 'redirect',
sandbox: context,
require: options?.resolver ?? vmResolver,
wasm: false,
});
this.vm.on('console.log', (...args: unknown[]) => this.emit('output', ...args));
}
async runCode<T = unknown>(): Promise<T> {
const script = `module.exports = async function() {${this.jsCode}\n}()`;
try {
const executionResult = (await this.vm.run(script, __dirname)) as T;
return executionResult;
} catch (error) {
throw new ExecutionError(error);
}
}
async runCodeAllItems(options?: {
multiOutput?: boolean;
}): Promise<INodeExecutionData[] | INodeExecutionData[][]> {
const script = `module.exports = async function() {${this.jsCode}\n}()`;
let executionResult: INodeExecutionData | INodeExecutionData[] | INodeExecutionData[][];
try {
executionResult = await this.vm.run(script, __dirname);
} catch (error) {
// anticipate user expecting `items` to pre-exist as in Function Item node
mapItemsNotDefinedErrorIfNeededForRunForAll(this.jsCode, error);
throw new ExecutionError(error);
}
if (executionResult === null) return [];
if (options?.multiOutput === true) {
// Check if executionResult is an array of arrays
if (!Array.isArray(executionResult) || executionResult.some((item) => !Array.isArray(item))) {
throw new ValidationError({
message: "The code doesn't return an array of arrays",
description:
'Please return an array of arrays. One array for the different outputs and one for the different items that get returned.',
});
}
return executionResult.map((data) => {
return this.validateRunCodeAllItems(data);
});
}
return this.validateRunCodeAllItems(
executionResult as INodeExecutionData | INodeExecutionData[],
);
}
async runCodeEachItem(itemIndex: number): Promise<INodeExecutionData | undefined> {
const script = `module.exports = async function() {${this.jsCode}\n}()`;
validateNoDisallowedMethodsInRunForEach(this.jsCode, itemIndex);
let executionResult: INodeExecutionData;
try {
executionResult = await this.vm.run(script, __dirname);
} catch (error) {
// anticipate user expecting `item` to pre-exist as in Function Item node
mapItemNotDefinedErrorIfNeededForRunForEach(this.jsCode, error);
throw new ExecutionError(error, itemIndex);
}
if (executionResult === null) return undefined;
return this.validateRunCodeEachItem(executionResult, itemIndex);
}
}
@@ -0,0 +1,60 @@
import { ValidationError } from './ValidationError';
/**
* Validates that no disallowed methods are used in the
* runCodeForEachItem JS code. Throws `ValidationError` if
* a disallowed method is found.
*/
export function validateNoDisallowedMethodsInRunForEach(code: string, itemIndex: number) {
const match = code.match(/\$input\.(?<disallowedMethod>first|last|all|itemMatching)/);
if (match?.groups?.disallowedMethod) {
const { disallowedMethod } = match.groups;
const lineNumber =
code.split('\n').findIndex((line) => {
line = line.trimStart();
return (
line.includes(disallowedMethod) &&
!line.startsWith('//') &&
!line.startsWith('/*') &&
!line.startsWith('*')
);
}) + 1;
const disallowedMethodFound = lineNumber !== 0;
if (disallowedMethodFound) {
throw new ValidationError({
message: `Can't use .${disallowedMethod}() here`,
description: "This is only available in 'Run Once for All Items' mode",
itemIndex,
lineNumber,
});
}
}
}
/**
* Checks if the error message indicates that `items` is not defined and
* modifies the error message to suggest using `$input.all()`.
*/
export function mapItemsNotDefinedErrorIfNeededForRunForAll(code: string, error: Error) {
// anticipate user expecting `items` to pre-exist as in Function Item node
if (error.message === 'items is not defined' && !/(let|const|var) +items +=/.test(code)) {
const quoted = error.message.replace('items', '`items`');
error.message = quoted + '. Did you mean `$input.all()`?';
}
}
/**
* Maps the "item is not defined" error message to provide a more helpful suggestion
* for users who may expect `items` to pre-exist
*/
export function mapItemNotDefinedErrorIfNeededForRunForEach(code: string, error: Error) {
// anticipate user expecting `items` to pre-exist as in Function Item node
if (error.message === 'item is not defined' && !/(let|const|var) +item +=/.test(code)) {
const quoted = error.message.replace('item', '`item`');
error.message = quoted + '. Did you mean `$input.item.json`?';
}
}
@@ -0,0 +1,161 @@
import {
type IExecuteFunctions,
type INodeExecutionData,
type WorkflowExecuteMode,
} from 'n8n-workflow';
import { validateNoDisallowedMethodsInRunForEach } from './JsCodeValidator';
import type { TextKeys } from './result-validation';
import { validateRunCodeAllItems, validateRunCodeEachItem } from './result-validation';
import { throwExecutionError } from './throw-execution-error';
const JS_TEXT_KEYS: TextKeys = {
object: { singular: 'object', plural: 'objects' },
};
/**
* JS Code execution sandbox that executes the JS code using task runner.
*/
export class JsTaskRunnerSandbox {
constructor(
private readonly workflowMode: WorkflowExecuteMode,
private readonly executeFunctions: Pick<
IExecuteFunctions,
'startJob' | 'continueOnFail' | 'helpers'
>,
private readonly chunkSize = 1000,
private readonly additionalProperties: Record<string, unknown> = {},
) {}
async runCodeAllItems(code: string): Promise<INodeExecutionData[]> {
const itemIndex = 0;
const executionResult = await this.executeFunctions.startJob<INodeExecutionData[]>(
'javascript',
{
code,
nodeMode: 'runOnceForAllItems',
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
additionalProperties: this.additionalProperties,
},
itemIndex,
);
if (!executionResult.ok) {
throwExecutionError('error' in executionResult ? executionResult.error : {});
}
return validateRunCodeAllItems(
executionResult.result,
JS_TEXT_KEYS,
this.executeFunctions.helpers.normalizeItems.bind(this.executeFunctions.helpers),
);
}
async runCodeForTool(code: string): Promise<unknown> {
const itemIndex = 0;
const executionResult = await this.executeFunctions.startJob(
'javascript',
{
code,
nodeMode: 'runOnceForAllItems',
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
additionalProperties: this.additionalProperties,
},
itemIndex,
);
if (!executionResult.ok) {
throwExecutionError('error' in executionResult ? executionResult.error : {});
}
return executionResult.result;
}
async runCodeForEachItem(code: string, numInputItems: number): Promise<INodeExecutionData[]> {
validateNoDisallowedMethodsInRunForEach(code, 0);
const itemIndex = 0;
const chunks = this.chunkInputItems(numInputItems);
let executionResults: INodeExecutionData[] = [];
for (const chunk of chunks) {
const executionResult = await this.executeFunctions.startJob<INodeExecutionData[]>(
'javascript',
{
code,
nodeMode: 'runOnceForEachItem',
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
chunk: {
startIndex: chunk.startIdx,
count: chunk.count,
},
additionalProperties: this.additionalProperties,
},
itemIndex,
);
if (!executionResult.ok) {
return throwExecutionError('error' in executionResult ? executionResult.error : {});
}
for (let i = 0; i < executionResult.result.length; i++) {
const actualItemIndex = chunk.startIdx + i;
const validatedItem = validateRunCodeEachItem(
executionResult.result[i],
actualItemIndex,
JS_TEXT_KEYS,
this.executeFunctions.helpers.normalizeItems.bind(this.executeFunctions.helpers),
);
executionResult.result[i] = validatedItem;
}
executionResults = executionResults.concat(executionResult.result);
}
return executionResults;
}
async runCode<T = unknown>(code: string): Promise<T> {
const executionResult = await this.executeFunctions.startJob(
'javascript',
{
code,
nodeMode: 'runCode',
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
additionalProperties: this.additionalProperties,
},
0,
);
if (!executionResult.ok) {
throwExecutionError('error' in executionResult ? executionResult.error : {});
}
// We just assume the caller types the result correctly to match the code
return executionResult.result as T;
}
/** Chunks the input items into chunks of 1000 items each */
private chunkInputItems(numInputItems: number) {
const numChunks = Math.ceil(numInputItems / this.chunkSize);
const chunks = [];
for (let i = 0; i < numChunks; i++) {
const startIdx = i * this.chunkSize;
const isLastChunk = i === numChunks - 1;
const count = isLastChunk ? numInputItems - startIdx : this.chunkSize;
chunks.push({
startIdx,
count,
});
}
return chunks;
}
}
@@ -0,0 +1,127 @@
import {
type CodeExecutionMode,
type IExecuteFunctions,
type INodeExecutionData,
type WorkflowExecuteMode,
NodeOperationError,
} from 'n8n-workflow';
import type { TextKeys } from './result-validation';
import { validateRunCodeAllItems, validateRunCodeEachItem } from './result-validation';
import { throwExecutionError } from './throw-execution-error';
const PYTHON_TEXT_KEYS: TextKeys = {
object: { singular: 'dictionary', plural: 'dictionaries' },
};
export class PythonTaskRunnerSandbox {
constructor(
private readonly pythonCode: string,
private readonly nodeMode: CodeExecutionMode,
private readonly workflowMode: WorkflowExecuteMode,
private readonly executeFunctions: IExecuteFunctions,
private readonly additionalProperties: Record<string, unknown> = {},
) {}
private validateCode(): void {
if (typeof this.pythonCode !== 'string') {
throw new NodeOperationError(
this.executeFunctions.getNode(),
'No Python code found to execute. Please add code to the Code node.',
);
}
}
/**
* Run a script by forwarding it to a Python task runner, together with input items.
*
* The Python runner receives input items together with the task, whereas the
* JavaScript runner does _not_ receive input items together with the task and
* instead retrieves them later, only if needed, via an RPC request.
*/
async runUsingIncomingItems() {
this.validateCode();
const itemIndex = 0;
const node = this.executeFunctions.getNode();
const workflow = this.executeFunctions.getWorkflow();
const taskSettings: Record<string, unknown> = {
code: this.pythonCode,
nodeMode: this.nodeMode,
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
items: this.executeFunctions.getInputData(),
nodeId: node.id,
nodeName: node.name,
workflowId: workflow.id,
workflowName: workflow.name,
};
const executionResult = await this.executeFunctions.startJob<INodeExecutionData[]>(
'python',
taskSettings,
itemIndex,
);
if (!executionResult.ok) {
return throwExecutionError('error' in executionResult ? executionResult.error : {});
}
if (this.nodeMode === 'runOnceForAllItems') {
return validateRunCodeAllItems(
executionResult.result,
PYTHON_TEXT_KEYS,
this.executeFunctions.helpers.normalizeItems.bind(this.executeFunctions.helpers),
);
}
return executionResult.result.map((item, index) =>
validateRunCodeEachItem(
item,
index,
PYTHON_TEXT_KEYS,
this.executeFunctions.helpers.normalizeItems.bind(this.executeFunctions.helpers),
),
);
}
/**
* Run a script for tool execution.
*
* Unlike `runUsingIncomingItems`, this method:
* - Sends empty items (tools don't process workflow items)
* - Passes `query` from `additionalProperties` to the runner
* - Does not validate the result from the runner (tools can return any type)
*/
async runCodeForTool(): Promise<unknown> {
this.validateCode();
const itemIndex = 0;
const node = this.executeFunctions.getNode();
const workflow = this.executeFunctions.getWorkflow();
const taskSettings: Record<string, unknown> = {
code: this.pythonCode,
nodeMode: 'runOnceForAllItems',
workflowMode: this.workflowMode,
continueOnFail: this.executeFunctions.continueOnFail(),
items: [],
nodeId: node.id,
nodeName: node.name,
workflowId: workflow.id,
workflowName: workflow.name,
query: this.additionalProperties.query,
};
const executionResult = await this.executeFunctions.startJob('python', taskSettings, itemIndex);
if (!executionResult.ok) {
return throwExecutionError('error' in executionResult ? executionResult.error : {});
}
return executionResult.result;
}
}
+80
View File
@@ -0,0 +1,80 @@
import { EventEmitter } from 'events';
import type {
IExecuteFunctions,
INodeExecutionData,
ISupplyDataFunctions,
IWorkflowDataProxyData,
} from 'n8n-workflow';
import { validateRunCodeAllItems, validateRunCodeEachItem } from './result-validation';
interface SandboxTextKeys {
object: {
singular: string;
plural: string;
};
}
export interface SandboxContext extends IWorkflowDataProxyData {
$getNodeParameter: IExecuteFunctions['getNodeParameter'];
$getWorkflowStaticData: IExecuteFunctions['getWorkflowStaticData'];
helpers: IExecuteFunctions['helpers'];
}
export function getSandboxContext(
this: IExecuteFunctions | ISupplyDataFunctions,
index: number,
): SandboxContext {
const helpers = {
...this.helpers,
httpRequestWithAuthentication: this.helpers.httpRequestWithAuthentication.bind(this),
requestWithAuthenticationPaginated: this.helpers.requestWithAuthenticationPaginated.bind(this),
};
return {
// from NodeExecuteFunctions
$getNodeParameter: this.getNodeParameter.bind(this),
$getWorkflowStaticData: this.getWorkflowStaticData.bind(this),
helpers,
// to bring in all $-prefixed vars and methods from WorkflowDataProxy
// $node, $items(), $parameter, $json, $env, etc.
...this.getWorkflowDataProxy(index),
};
}
export abstract class Sandbox extends EventEmitter {
constructor(
private textKeys: SandboxTextKeys,
protected helpers: IExecuteFunctions['helpers'],
) {
super();
}
abstract runCode<T = unknown>(): Promise<T>;
abstract runCodeAllItems(): Promise<INodeExecutionData[] | INodeExecutionData[][]>;
abstract runCodeEachItem(itemIndex: number): Promise<INodeExecutionData | undefined>;
validateRunCodeEachItem(
executionResult: INodeExecutionData | undefined,
itemIndex: number,
): INodeExecutionData {
return validateRunCodeEachItem(
executionResult,
itemIndex,
this.textKeys,
this.helpers.normalizeItems.bind(this.helpers),
);
}
validateRunCodeAllItems(
executionResult: INodeExecutionData | INodeExecutionData[] | undefined,
): INodeExecutionData[] {
return validateRunCodeAllItems(
executionResult,
this.textKeys,
this.helpers.normalizeItems.bind(this.helpers),
);
}
}
@@ -0,0 +1,44 @@
import { ApplicationError } from '@n8n/errors';
export class ValidationError extends ApplicationError {
description = '';
itemIndex: number | undefined = undefined;
context: { itemIndex: number } | undefined = undefined;
lineNumber: number | undefined = undefined;
constructor({
message,
description,
itemIndex,
lineNumber,
}: {
message: string;
description: string;
itemIndex?: number;
lineNumber?: number;
}) {
super(message);
this.lineNumber = lineNumber;
this.itemIndex = itemIndex;
if (this.lineNumber !== undefined && this.itemIndex !== undefined) {
this.message = `${message} [line ${lineNumber}, for item ${itemIndex}]`;
} else if (this.lineNumber !== undefined) {
this.message = `${message} [line ${lineNumber}]`;
} else if (this.itemIndex !== undefined) {
this.message = `${message} [item ${itemIndex}]`;
} else {
this.message = message;
}
this.description = description;
if (this.itemIndex !== undefined) {
this.context = { itemIndex: this.itemIndex };
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1171_441)">
<path d="M170.283 48H196.5C203.127 48 208.5 42.6274 208.5 36V12C208.5 5.37258 203.127 0 196.5 0H170.283C126.1 0 90.283 35.8172 90.283 80V176C90.283 206.928 65.2109 232 34.283 232H23C16.3726 232 11 237.372 11 244V268C11 274.627 16.3724 280 22.9996 280L34.283 280C65.2109 280 90.283 305.072 90.283 336V440C90.283 479.764 122.518 512 162.283 512H196.5C203.127 512 208.5 506.627 208.5 500V476C208.5 469.373 203.127 464 196.5 464H162.283C149.028 464 138.283 453.255 138.283 440V336C138.283 309.022 128.011 284.443 111.164 265.961C106.109 260.416 106.109 251.584 111.164 246.039C128.011 227.557 138.283 202.978 138.283 176V80C138.283 62.3269 152.61 48 170.283 48Z" fill="#FF9922"/>
<path d="M305 36C305 42.6274 310.373 48 317 48H342.979C360.652 48 374.978 62.3269 374.978 80V176C374.978 202.978 385.251 227.557 402.098 246.039C407.153 251.584 407.153 260.416 402.098 265.961C385.251 284.443 374.978 309.022 374.978 336V432C374.978 449.673 360.652 464 342.979 464H317C310.373 464 305 469.373 305 476V500C305 506.627 310.373 512 317 512H342.979C387.161 512 422.978 476.183 422.978 432V336C422.978 305.072 448.051 280 478.979 280H490C496.627 280 502 274.628 502 268V244C502 237.373 496.628 232 490 232L478.979 232C448.051 232 422.978 206.928 422.978 176V80C422.978 35.8172 387.161 0 342.979 0H317C310.373 0 305 5.37258 305 12V36Z" fill="#FF9922"/>
</g>
<defs>
<clipPath id="clip0_1171_441">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,76 @@
import type { INodeProperties } from 'n8n-workflow';
const commonDescription: INodeProperties = {
displayName: 'JavaScript',
name: 'jsCode',
type: 'string',
typeOptions: {
editor: 'codeNodeEditor',
editorLanguage: 'javaScript',
},
default: '',
description:
'JavaScript code to execute.<br><br>Tip: You can use luxon vars like <code>$today</code> for dates and <code>$jmespath</code> for querying JSON structures. <a href="https://docs.n8n.io/nodes/n8n-nodes-base.function">Learn more</a>.',
noDataExpression: true,
};
const v1Properties: INodeProperties[] = [
{
...commonDescription,
displayOptions: {
show: {
'@version': [1],
mode: ['runOnceForAllItems'],
},
},
},
{
...commonDescription,
displayOptions: {
show: {
'@version': [1],
mode: ['runOnceForEachItem'],
},
},
},
];
const v2Properties: INodeProperties[] = [
{
...commonDescription,
displayOptions: {
show: {
'@version': [2],
language: ['javaScript'],
mode: ['runOnceForAllItems'],
},
},
},
{
...commonDescription,
displayOptions: {
show: {
'@version': [2],
language: ['javaScript'],
mode: ['runOnceForEachItem'],
},
},
},
];
export const javascriptCodeDescription: INodeProperties[] = [
...v1Properties,
...v2Properties,
{
displayName:
'Type <code>$</code> for a list of <a target="_blank" href="https://docs.n8n.io/code-examples/methods-variables-reference/">special vars/methods</a>. Debug by using <code>console.log()</code> statements and viewing their output in the browser console.',
name: 'notice',
type: 'notice',
displayOptions: {
show: {
language: ['javaScript'],
},
},
default: '',
},
];
@@ -0,0 +1,50 @@
import type { INodeProperties } from 'n8n-workflow';
const commonDescription: INodeProperties = {
displayName: 'Python',
name: 'pythonCode',
type: 'string',
typeOptions: {
editor: 'codeNodeEditor',
editorLanguage: 'python',
},
default: '',
description:
'Python code to execute.<br><br>Tip: You can use built-in methods and variables like <code>_today</code> for dates and <code>_jmespath</code> for querying JSON structures. <a href="https://docs.n8n.io/code/builtin/">Learn more</a>.',
noDataExpression: true,
};
const PRINT_INSTRUCTION =
'Debug by using <code>print()</code> statements and viewing their output in the browser console.';
export const pythonCodeDescription: INodeProperties[] = [
{
...commonDescription,
displayOptions: {
show: {
language: ['python', 'pythonNative'],
mode: ['runOnceForAllItems'],
},
},
},
{
...commonDescription,
displayOptions: {
show: {
language: ['python', 'pythonNative'],
mode: ['runOnceForEachItem'],
},
},
},
{
displayName: `${PRINT_INSTRUCTION}<br><br>The Python option does not support <code>_</code> syntax and helpers, except for <code>_items</code> in all-items mode and <code>_item</code> in per-item mode.`,
name: 'notice',
type: 'notice',
displayOptions: {
show: {
language: ['python', 'pythonNative'],
},
},
default: '',
},
];
@@ -0,0 +1,31 @@
import { ApplicationError } from '@n8n/errors';
export type WrappableError = Record<string, unknown>;
/**
* Errors received from the task runner are not instances of Error.
* This class wraps them in an Error instance and makes all their
* properties available.
*/
export class WrappedExecutionError extends ApplicationError {
[key: string]: unknown;
constructor(error: WrappableError) {
const message = typeof error.message === 'string' ? error.message : 'Unknown error';
super(message, {
cause: error,
});
this.copyErrorProperties(error);
}
private copyErrorProperties(error: WrappableError) {
for (const key of Object.getOwnPropertyNames(error)) {
this[key] = error[key];
}
}
}
export function isWrappableError(error: unknown): error is WrappableError {
return typeof error === 'object' && error !== null;
}
@@ -0,0 +1,21 @@
import { UserError } from 'n8n-workflow';
type FailureReason = 'python' | 'venv';
const REASONS: Record<FailureReason, string> = {
python: 'Python 3 is missing from this system',
venv: 'Virtual environment is missing from this system',
};
export class PythonRunnerUnavailableError extends UserError {
constructor(reason?: FailureReason) {
const message = reason
? `Python runner unavailable: ${REASONS[reason]}`
: 'Python runner unavailable';
super(message, {
description:
'Internal mode is intended only for debugging. For production, deploy in external mode: https://docs.n8n.io/hosting/configuration/task-runners/#setting-up-external-mode',
});
}
}
@@ -0,0 +1,11 @@
import { ValidationError } from './ValidationError';
export class ReservedKeyFoundError extends ValidationError {
constructor(reservedKey: string, itemIndex: number) {
super({
message: 'Invalid output format',
description: `An output item contains the reserved key <code>${reservedKey}</code>. To get around this, please wrap each item in an object, under a key called <code>json</code>. <a href="https://docs.n8n.io/data/data-structure/#data-structure" target="_blank">Example</a>`,
itemIndex,
});
}
}
@@ -0,0 +1,174 @@
import type { INodeExecutionData } from 'n8n-workflow';
import { ReservedKeyFoundError } from './reserved-key-found-error';
import { isObject } from './utils';
import { ValidationError } from './ValidationError';
export interface TextKeys {
object: {
singular: string;
plural: string;
};
}
export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', 'error', 'index']);
export function getTextKey(
textKeys: TextKeys,
key: keyof TextKeys,
options?: { includeArticle?: boolean; plural?: boolean },
) {
const response = textKeys[key][options?.plural ? 'plural' : 'singular'];
if (!options?.includeArticle) {
return response;
}
if (['a', 'e', 'i', 'o', 'u'].some((value) => response.startsWith(value))) {
return `an ${response}`;
}
return `a ${response}`;
}
export function validateItem(
{ json, binary }: INodeExecutionData,
itemIndex: number,
textKeys: TextKeys,
) {
if (json === undefined || !isObject(json)) {
throw new ValidationError({
message: `A 'json' property isn't ${getTextKey(textKeys, 'object', { includeArticle: true })}`,
description: `In the returned data, every key named 'json' must point to ${getTextKey(
textKeys,
'object',
{ includeArticle: true },
)}.`,
itemIndex,
});
}
if (binary !== undefined && !isObject(binary)) {
throw new ValidationError({
message: `A 'binary' property isn't ${getTextKey(textKeys, 'object', { includeArticle: true })}`,
description: `In the returned data, every key named 'binary' must point to ${getTextKey(
textKeys,
'object',
{ includeArticle: true },
)}.`,
itemIndex,
});
}
}
export function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) {
let foundReservedKey: string | null = null;
const unknownKeys: string[] = [];
for (const key in item) {
if (!Object.prototype.hasOwnProperty.call(item, key)) continue;
if (REQUIRED_N8N_ITEM_KEYS.has(key)) {
foundReservedKey ??= key;
} else {
unknownKeys.push(key);
}
}
if (unknownKeys.length > 0) {
if (foundReservedKey) throw new ReservedKeyFoundError(foundReservedKey, itemIndex);
throw new ValidationError({
message: `Unknown top-level item key: ${unknownKeys[0]}`,
description: 'Access the properties of an item under `.json`, e.g. `item.json`',
itemIndex,
});
}
}
export function validateRunCodeEachItem(
executionResult: INodeExecutionData | undefined,
itemIndex: number,
textKeys: TextKeys,
normalizeItems: (items: INodeExecutionData[]) => INodeExecutionData[],
): INodeExecutionData {
if (typeof executionResult !== 'object') {
throw new ValidationError({
message: `Code doesn't return ${getTextKey(textKeys, 'object', { includeArticle: true })}`,
description: `Please return ${getTextKey(textKeys, 'object', {
includeArticle: true,
})} representing the output item. ('${executionResult}' was returned instead.)`,
itemIndex,
});
}
if (Array.isArray(executionResult)) {
const firstSentence =
executionResult.length > 0
? `An array of ${typeof executionResult[0]}s was returned.`
: 'An empty array was returned.';
throw new ValidationError({
message: `Code doesn't return a single ${getTextKey(textKeys, 'object')}`,
description: `${firstSentence} If you need to output multiple items, please use the 'Run Once for All Items' mode instead.`,
itemIndex,
});
}
const [returnData] = normalizeItems([executionResult]);
validateItem(returnData, itemIndex, textKeys);
// If at least one top-level key is a supported item key (`json`, `binary`, etc.),
// and another top-level key is unrecognized, then the user mis-added a property
// directly on the item, when they intended to add it on the `json` property
validateTopLevelKeys(returnData, itemIndex);
return returnData;
}
export function validateRunCodeAllItems(
executionResult: INodeExecutionData | INodeExecutionData[] | undefined,
textKeys: TextKeys,
normalizeItems: (items: INodeExecutionData | INodeExecutionData[]) => INodeExecutionData[],
): INodeExecutionData[] {
if (typeof executionResult !== 'object') {
throw new ValidationError({
message: "Code doesn't return items properly",
description: `Please return an array of ${getTextKey(textKeys, 'object', {
plural: true,
})}, one for each item you would like to output.`,
});
}
if (Array.isArray(executionResult)) {
/**
* If at least one top-level key is an n8n item key (`json`, `binary`, etc.),
* then require all item keys to be an n8n item key.
*
* If no top-level key is an n8n key, then skip this check, allowing non-n8n
* item keys to be wrapped in `json` when normalizing items below.
*/
for (const item of executionResult) {
if (!isObject(item)) {
throw new ValidationError({
message: "Code doesn't return items properly",
description: `Please return an array of ${getTextKey(textKeys, 'object', {
plural: true,
})}, one for each item you would like to output.`,
});
}
}
const mustHaveTopLevelN8nKey = executionResult.some((item) =>
Object.keys(item).find((key) => REQUIRED_N8N_ITEM_KEYS.has(key)),
);
if (mustHaveTopLevelN8nKey) {
for (let index = 0; index < executionResult.length; index++) {
const item = executionResult[index];
validateTopLevelKeys(item, index);
}
}
}
const returnData = normalizeItems(executionResult);
returnData.forEach((item, index) => validateItem(item, index, textKeys));
return returnData;
}
@@ -0,0 +1,35 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import { normalizeItems } from 'n8n-core';
import type { IExecuteFunctions, INode, IWorkflowDataProxyData } from 'n8n-workflow';
import { Code } from '../Code.node';
import { PythonTaskRunnerSandbox } from '../PythonTaskRunnerSandbox';
describe('Code Node unit test', () => {
const workflowDataProxy = mock<IWorkflowDataProxyData>({ $input: mock() });
it('should route legacy `python` language to native Python runner', async () => {
const node = new Code();
const pythonThisArg: MockProxy<IExecuteFunctions> = mock<IExecuteFunctions>();
pythonThisArg.helpers = { normalizeItems } as IExecuteFunctions['helpers'];
pythonThisArg.getNode.mockReturnValue(mock<INode>({ typeVersion: 2 }));
pythonThisArg.getWorkflowDataProxy.mockReturnValue(workflowDataProxy);
pythonThisArg.getMode.mockReturnValue('manual');
pythonThisArg.getRunnerStatus.mockReturnValue({ available: true });
pythonThisArg.getNodeParameter.calledWith('language', 0).mockReturnValue('python');
pythonThisArg.getNodeParameter.calledWith('mode', 0).mockReturnValue('runOnceForAllItems');
pythonThisArg.getNodeParameter.calledWith('pythonCode', 0).mockReturnValue('return []');
pythonThisArg.getInputData.mockReturnValue([{ json: {} }]);
const runSpy = jest
.spyOn(PythonTaskRunnerSandbox.prototype, 'runUsingIncomingItems')
.mockResolvedValue([]);
await node.execute.call(pythonThisArg);
expect(runSpy).toHaveBeenCalled();
runSpy.mockRestore();
});
});
@@ -0,0 +1,213 @@
{
"nodes": [
{
"parameters": {},
"id": "33eede8d-2ab0-42ab-b79a-a069d8549ab0",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [-40, 580]
},
{
"parameters": {
"jsCode": "return[\n { value: 1 },\n { value: 2 },\n]"
},
"id": "a5913b52-24dc-4f81-bb7f-f90e61dad978",
"name": "Sample Data",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [200, 580]
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nlet sum = 0;\nfor (const item of $input.all()) {\n sum += item.json.value;\n}\n\nreturn [ {sum} ];"
},
"id": "c4ad4913-5af3-42bc-a784-69182f1facdd",
"name": "Run Once for All Items",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [460, 320]
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field\n// called 'myNewField' to the JSON of each one\nlet sum = 0;\nfor (const item of items) {\n sum += item.json.value;\n}\n\nreturn [ {sum} ];"
},
"id": "34cbd204-4335-4790-92cd-c3df617eee21",
"name": "Run Once for All Items (Legacy Syntax)",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [460, 500]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add a new field called 'myNewField' to the\n// JSON of the item\n$input.item.json.myNewField = $input.item.json.value;\n\nreturn $input.item;"
},
"id": "f67d29bf-554a-4572-8867-4456182dec24",
"name": "Run Once for Each Item",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [460, 680]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add a new field called 'myNewField' to the\n// JSON of the item\nitem.json.myNewField = item.json.value;\n\nreturn item;"
},
"id": "6f4bf149-e84e-4e0d-802a-7eaf7a42b18c",
"name": "Run Once for Each Item (Legacy Syntax)",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [460, 860]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const json = $input.item.json\njson.myNewField = await (async () => json.value)();\n\nreturn $input.item;"
},
"id": "3cff4a64-c3fd-47d3-a33e-3c446846138f",
"name": "With Async Functions",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
460,
1200
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "const json = $input.item.json\njson.myNewField = await new Promise((resolve) => resolve(json.value));\n\nreturn $input.item;"
},
"id": "947e4e3e-2da3-40c5-97da-830c4572fc05",
"name": "With Promises",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
460,
1380
]
}
],
"pinData": {
"Run Once for All Items": [
{
"json": {
"sum": 3
}
}
],
"Run Once for Each Item": [
{
"json": {
"value": 1,
"myNewField": 1
}
},
{
"json": {
"value": 2,
"myNewField": 2
}
}
],
"Run Once for All Items (Legacy Syntax)": [
{
"json": {
"sum": 3
}
}
],
"Run Once for Each Item (Legacy Syntax)": [
{
"json": {
"value": 1,
"myNewField": 1
}
},
{
"json": {
"value": 2,
"myNewField": 2
}
}
],
"With Async Functions": [
{
"json": {
"value": 1,
"myNewField": 1
}
},
{
"json": {
"value": 2,
"myNewField": 2
}
}
],
"With Promises": [
{
"json": {
"value": 1,
"myNewField": 1
}
},
{
"json": {
"value": 2,
"myNewField": 2
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Sample Data",
"type": "main",
"index": 0
}
]
]
},
"Sample Data": {
"main": [
[
{
"node": "Run Once for All Items",
"type": "main",
"index": 0
},
{
"node": "Run Once for Each Item",
"type": "main",
"index": 0
},
{
"node": "Run Once for All Items (Legacy Syntax)",
"type": "main",
"index": 0
},
{
"node": "Run Once for Each Item (Legacy Syntax)",
"type": "main",
"index": 0
},
{
"node": "With Async Functions",
"type": "main",
"index": 0
},
{
"node": "With Promises",
"type": "main",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,64 @@
import { ExecutionError } from '../ExecutionError';
describe('ExecutionError', () => {
describe('constructor', () => {
it('should set message to "Unknown error" when stack is empty', () => {
const error = new Error('test');
error.stack = '';
const executionError = new ExecutionError(error);
expect(executionError.message).toBe('Unknown error');
});
it('should extract error details and type from stack', () => {
const error = new Error('ErrorType: Error Details');
error.stack = 'Error: ErrorType: Error Details\n at Code:123';
const executionError = new ExecutionError(error);
expect(executionError.message).toBe('Error Details [line 123]');
expect(executionError.description).toBe('ErrorType');
});
it('should extract error details when no error type is present', () => {
const error = new Error('Error Details');
error.stack = 'Error: Error Details\n at Code:123';
const executionError = new ExecutionError(error);
expect(executionError.message).toBe('Error Details [line 123]');
expect(executionError.description).toBe(null);
});
it('should handle stack with only "Error: " prefix', () => {
const error = new Error('Error: ');
error.stack = 'Error: Error: \n at Code:123';
const executionError = new ExecutionError(error);
expect(executionError.message).toBe('Unknown error [line 123]');
expect(executionError.description).toBe(null);
});
it('should handle stack with colon and space', () => {
const error = new Error(': ');
error.stack = 'Error: : \n at Code:123';
const executionError = new ExecutionError(error);
expect(executionError.message).toBe('Unknown error [line 123]');
expect(executionError.description).toBe(null);
});
it('should handle itemIndex', () => {
const error = new Error('ErrorType: Error Details');
error.stack = 'Error: ErrorType: Error Details\n at Code:123';
const executionError = new ExecutionError(error, 1);
expect(executionError.message).toBe('Error Details [line 123, for item 1]');
expect(executionError.description).toBe('ErrorType');
expect(executionError.itemIndex).toBe(1);
expect(executionError.context).toEqual({ itemIndex: 1 });
});
it('should handle stack without line number', () => {
const error = new Error('ErrorType: Error Details');
error.stack = 'Error: ErrorType: Error Details';
const executionError = new ExecutionError(error, 1);
expect(executionError.message).toBe('Error Details');
expect(executionError.description).toBe('ErrorType');
expect(executionError.itemIndex).toBe(1);
expect(executionError.context).toEqual({ itemIndex: 1 });
});
});
});
@@ -0,0 +1,51 @@
import { validateNoDisallowedMethodsInRunForEach } from '../JsCodeValidator';
describe('JsCodeValidator', () => {
describe('validateNoDisallowedMethodsInRunForEach', () => {
it('should not throw error if disallow method is used within single line comments', () => {
const code = [
"// Add a new field called 'myNewField' to the JSON of the item",
'$input.item.json.myNewField = 1;',
' // const xxx = $input.all()',
'return $input.item;',
].join('\n');
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).not.toThrow();
});
it('should not throw error if disallow method is used in single multi line comments', () => {
const code = [
"// Add a new field called 'myNewField' to the JSON of the item",
'$input.item.json.myNewField = 1;',
'/** const xxx = $input.all()*/',
'return $input.item;',
].join('\n');
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).not.toThrow();
});
it('should not throw error if disallow method is used within multi line comments', () => {
const code = [
"// Add a new field called 'myNewField' to the JSON of the item",
'$input.item.json.myNewField = 1;',
'/**',
'*const xxx = $input.all()',
'*/',
'return $input.item;',
].join('\n');
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).not.toThrow();
});
it('should throw error if disallow method is used', () => {
const code = [
"// Add a new field called 'myNewField' to the JSON of the item",
'$input.item.json.myNewField = 1;',
'const xxx = $input.all()',
'return $input.item;',
].join('\n');
expect(() => validateNoDisallowedMethodsInRunForEach(code, 0)).toThrow();
});
});
});
@@ -0,0 +1,247 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import { createResultOk, createResultError } from 'n8n-workflow';
import { JsTaskRunnerSandbox } from '../JsTaskRunnerSandbox';
describe('JsTaskRunnerSandbox', () => {
describe('runCodeForEachItem', () => {
it('should chunk the input items and execute the code for each chunk', async () => {
const jsCode = 'console.log($item);';
const workflowMode = 'manual';
const executeFunctions = mock<IExecuteFunctions>();
executeFunctions.helpers = {
...executeFunctions.helpers,
normalizeItems: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
.mockImplementation((items: any) => (Array.isArray(items) ? items : [items])),
};
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions, 2);
let i = 1;
executeFunctions.startJob.mockResolvedValue(createResultOk([{ json: { item: i++ } }]));
const numInputItems = 5;
await sandbox.runCodeForEachItem(jsCode, numInputItems);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(executeFunctions.startJob).toHaveBeenCalledTimes(3);
const calls = executeFunctions.startJob.mock.calls;
expect(calls).toEqual([
[
'javascript',
{
code: jsCode,
workflowMode,
nodeMode: 'runOnceForEachItem',
continueOnFail: executeFunctions.continueOnFail(),
chunk: { startIndex: 0, count: 2 },
additionalProperties: {},
},
0,
],
[
'javascript',
{
code: jsCode,
workflowMode,
nodeMode: 'runOnceForEachItem',
continueOnFail: executeFunctions.continueOnFail(),
chunk: { startIndex: 2, count: 2 },
additionalProperties: {},
},
0,
],
[
'javascript',
{
code: jsCode,
workflowMode,
nodeMode: 'runOnceForEachItem',
continueOnFail: executeFunctions.continueOnFail(),
chunk: { startIndex: 4, count: 1 },
additionalProperties: {},
},
0,
],
]);
});
});
describe('runCodeForTool', () => {
it('should execute code and return string result', async () => {
const jsCode = 'return "Hello World";';
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = mock<IExecuteFunctions>();
executeFunctions.helpers = {
...executeFunctions.helpers,
normalizeItems: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
.mockImplementation((items: any) => (Array.isArray(items) ? items : [items])),
};
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
const expectedResult = 'Hello World';
executeFunctions.startJob.mockResolvedValue(createResultOk(expectedResult));
const result = await sandbox.runCodeForTool(jsCode);
expect(result).toBe(expectedResult);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(executeFunctions.startJob).toHaveBeenCalledWith(
'javascript',
{
code: jsCode,
nodeMode,
workflowMode,
continueOnFail: executeFunctions.continueOnFail(),
additionalProperties: {},
},
0,
);
});
it('should handle execution errors by calling throwExecutionError', async () => {
const jsCode = 'throw new Error("execution failed");';
const workflowMode = 'manual';
const executeFunctions = mock<IExecuteFunctions>();
executeFunctions.helpers = {
...executeFunctions.helpers,
normalizeItems: jest
.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
.mockImplementation((items: any) => (Array.isArray(items) ? items : [items])),
};
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
const executionError = { message: 'execution failed', stack: 'error stack' };
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
// Mock throwExecutionError to throw an error for testing
const throwExecutionErrorModule = await import('../throw-execution-error');
const throwExecutionErrorSpy = jest
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
.mockImplementation(() => {
throw new Error('Execution failed');
});
await expect(sandbox.runCodeForTool(jsCode)).rejects.toThrow('Execution failed');
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
});
});
describe('runCode', () => {
it('should execute code and return typed result', async () => {
const jsCode = 'return { sorted: [3, 2, 1].sort() };';
const workflowMode = 'manual';
const executeFunctions = mock<IExecuteFunctions>();
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
const expectedResult = { sorted: [1, 2, 3] };
executeFunctions.startJob.mockResolvedValue(createResultOk(expectedResult));
const result = await sandbox.runCode<{ sorted: number[] }>(jsCode);
expect(result).toEqual(expectedResult);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(executeFunctions.startJob).toHaveBeenCalledWith(
'javascript',
{
code: jsCode,
nodeMode: 'runCode',
workflowMode,
continueOnFail: executeFunctions.continueOnFail(),
additionalProperties: {},
},
0,
);
});
it('should pass additionalProperties to the job', async () => {
const jsCode = 'return items.sort();';
const workflowMode = 'manual';
const executeFunctions = mock<IExecuteFunctions>();
const additionalProperties = { items: [3, 1, 2], customOption: true };
const sandbox = new JsTaskRunnerSandbox(
workflowMode,
executeFunctions,
1000,
additionalProperties,
);
executeFunctions.startJob.mockResolvedValue(createResultOk([1, 2, 3]));
await sandbox.runCode<number[]>(jsCode);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(executeFunctions.startJob).toHaveBeenCalledWith(
'javascript',
{
code: jsCode,
nodeMode: 'runCode',
workflowMode,
continueOnFail: executeFunctions.continueOnFail(),
additionalProperties,
},
0,
);
});
it('should handle execution errors by calling throwExecutionError', async () => {
const jsCode = 'throw new Error("sort failed");';
const workflowMode = 'manual';
const executeFunctions = mock<IExecuteFunctions>();
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
const executionError = { message: 'sort failed', stack: 'error stack' };
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
// Mock throwExecutionError to throw an error for testing
const throwExecutionErrorModule = await import('../throw-execution-error');
const throwExecutionErrorSpy = jest
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
.mockImplementation(() => {
throw new Error('Execution failed');
});
await expect(sandbox.runCode(jsCode)).rejects.toThrow('Execution failed');
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
});
it('should handle error result without error property', async () => {
const jsCode = 'return null;';
const workflowMode = 'manual';
const executeFunctions = mock<IExecuteFunctions>();
const sandbox = new JsTaskRunnerSandbox(workflowMode, executeFunctions);
// Simulate an error result without the 'error' property
executeFunctions.startJob.mockResolvedValue({ ok: false } as ReturnType<
typeof createResultError
>);
// Mock throwExecutionError to throw an error for testing
const throwExecutionErrorModule = await import('../throw-execution-error');
const throwExecutionErrorSpy = jest
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
.mockImplementation(() => {
throw new Error('Execution failed');
});
await expect(sandbox.runCode(jsCode)).rejects.toThrow('Execution failed');
expect(throwExecutionErrorSpy).toHaveBeenCalledWith({});
});
});
});
@@ -0,0 +1,295 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import { createResultOk, createResultError, NodeOperationError } from 'n8n-workflow';
import { PythonTaskRunnerSandbox } from '../PythonTaskRunnerSandbox';
const createNormalizeItemsMock = () =>
jest.fn().mockImplementation((items: any) => {
const itemsArray = Array.isArray(items) ? items : [items];
return itemsArray.map((item: any) => {
if (item.json !== undefined) {
return item;
}
return { json: item };
});
});
const createMockExecuteFunctions = (inputData: any[] = []) => {
const executeFunctions = mock<IExecuteFunctions>();
executeFunctions.helpers = {
...executeFunctions.helpers,
normalizeItems: createNormalizeItemsMock(),
};
executeFunctions.getNode.mockReturnValue({
id: 'node-id',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
executeFunctions.getWorkflow.mockReturnValue({
id: 'workflow-id',
name: 'Test Workflow',
active: false,
});
executeFunctions.getInputData.mockReturnValue(inputData);
return executeFunctions;
};
describe('PythonTaskRunnerSandbox', () => {
describe('runUsingIncomingItems', () => {
it('should call validateRunCodeAllItems for runOnceForAllItems mode', async () => {
const pythonCode = 'return [{"foo": "bar"}]';
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([{ json: { test: 'data' } }]);
const sandbox = new PythonTaskRunnerSandbox(
pythonCode,
nodeMode,
workflowMode,
executeFunctions,
);
const mockResult = [{ foo: 'bar' }];
executeFunctions.startJob.mockResolvedValue(createResultOk(mockResult));
const result = await sandbox.runUsingIncomingItems();
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
expect(executeFunctions.startJob).toHaveBeenCalledWith(
'python',
{
code: pythonCode,
nodeMode,
workflowMode,
continueOnFail: executeFunctions.continueOnFail(),
items: [{ json: { test: 'data' } }],
nodeId: 'node-id',
nodeName: 'Code',
workflowId: 'workflow-id',
workflowName: 'Test Workflow',
},
0,
);
expect(executeFunctions.helpers.normalizeItems).toHaveBeenCalledWith(mockResult);
expect(result).toEqual([{ json: { foo: 'bar' } }]);
});
it('should call validateRunCodeEachItem for runOnceForEachItem mode', async () => {
const pythonCode = 'return {"foo": "bar"}';
const nodeMode = 'runOnceForEachItem';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([
{ json: { test: 'data1' } },
{ json: { test: 'data2' } },
]);
const sandbox = new PythonTaskRunnerSandbox(
pythonCode,
nodeMode,
workflowMode,
executeFunctions,
);
const mockResult = [
{ json: { foo: 'bar' }, pairedItem: { item: 0 } },
{ json: { foo: 'bar' }, pairedItem: { item: 1 } },
];
executeFunctions.startJob.mockResolvedValue(createResultOk(mockResult));
const result = await sandbox.runUsingIncomingItems();
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
expect(executeFunctions.helpers.normalizeItems).toHaveBeenCalledTimes(2);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty('json');
expect(result[0]).toHaveProperty('pairedItem');
});
it('should handle execution errors by calling throwExecutionError', async () => {
const pythonCode = 'raise ValueError("test error")';
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([]);
const sandbox = new PythonTaskRunnerSandbox(
pythonCode,
nodeMode,
workflowMode,
executeFunctions,
);
const executionError = { message: 'test error', stack: 'error stack' };
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
const throwExecutionErrorModule = await import('../throw-execution-error');
const throwExecutionErrorSpy = jest
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
.mockImplementation(() => {
throw new Error('Execution failed');
});
await expect(sandbox.runUsingIncomingItems()).rejects.toThrow('Execution failed');
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
});
it('should throw NodeOperationError when pythonCode is undefined', async () => {
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([]);
const sandbox = new PythonTaskRunnerSandbox(
undefined as unknown as string,
nodeMode,
workflowMode,
executeFunctions,
);
await expect(sandbox.runUsingIncomingItems()).rejects.toThrow(NodeOperationError);
await expect(sandbox.runUsingIncomingItems()).rejects.toThrow(
'No Python code found to execute',
);
expect(executeFunctions.startJob).not.toHaveBeenCalled();
});
});
describe('runCodeForTool', () => {
it('should pass query and empty items to the runner', async () => {
const pythonCode = 'return _query.upper()';
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([]);
const query = 'hello world';
const sandbox = new PythonTaskRunnerSandbox(
pythonCode,
nodeMode,
workflowMode,
executeFunctions,
{ query },
);
executeFunctions.startJob.mockResolvedValue(createResultOk('HELLO WORLD'));
const result = await sandbox.runCodeForTool();
expect(executeFunctions.startJob).toHaveBeenCalledTimes(1);
expect(executeFunctions.startJob).toHaveBeenCalledWith(
'python',
{
code: pythonCode,
nodeMode: 'runOnceForAllItems',
workflowMode,
continueOnFail: executeFunctions.continueOnFail(),
items: [],
nodeId: 'node-id',
nodeName: 'Code',
workflowId: 'workflow-id',
workflowName: 'Test Workflow',
query,
},
0,
);
expect(result).toBe('HELLO WORLD');
});
it('should pass structured query object to the runner', async () => {
const pythonCode = 'return f"{_query["name"]} is {_query["age"]}"';
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([]);
const query = { name: 'Alice', age: 30 };
const sandbox = new PythonTaskRunnerSandbox(
pythonCode,
nodeMode,
workflowMode,
executeFunctions,
{ query },
);
executeFunctions.startJob.mockResolvedValue(createResultOk('Alice is 30'));
const result = await sandbox.runCodeForTool();
expect(executeFunctions.startJob).toHaveBeenCalledWith(
'python',
expect.objectContaining({ query, items: [] }),
0,
);
expect(result).toBe('Alice is 30');
});
it('should return result without validation', async () => {
const pythonCode = 'return 42';
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([]);
const sandbox = new PythonTaskRunnerSandbox(
pythonCode,
nodeMode,
workflowMode,
executeFunctions,
{ query: 'test' },
);
executeFunctions.startJob.mockResolvedValue(createResultOk(42));
const result = await sandbox.runCodeForTool();
// Should return raw number, not wrapped in INodeExecutionData
expect(result).toBe(42);
expect(executeFunctions.helpers.normalizeItems).not.toHaveBeenCalled();
});
it('should handle execution errors by calling throwExecutionError', async () => {
const pythonCode = 'raise ValueError("tool error")';
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([]);
const sandbox = new PythonTaskRunnerSandbox(
pythonCode,
nodeMode,
workflowMode,
executeFunctions,
{ query: 'test' },
);
const executionError = { message: 'tool error', stack: 'error stack' };
executeFunctions.startJob.mockResolvedValue(createResultError(executionError));
const throwExecutionErrorModule = await import('../throw-execution-error');
const throwExecutionErrorSpy = jest
.spyOn(throwExecutionErrorModule, 'throwExecutionError')
.mockImplementation(() => {
throw new Error('Tool execution failed');
});
await expect(sandbox.runCodeForTool()).rejects.toThrow('Tool execution failed');
expect(throwExecutionErrorSpy).toHaveBeenCalledWith(executionError);
});
it('should throw NodeOperationError when pythonCode is undefined', async () => {
const nodeMode = 'runOnceForAllItems';
const workflowMode = 'manual';
const executeFunctions = createMockExecuteFunctions([]);
const sandbox = new PythonTaskRunnerSandbox(
undefined as unknown as string,
nodeMode,
workflowMode,
executeFunctions,
{ query: 'test' },
);
await expect(sandbox.runCodeForTool()).rejects.toThrow(NodeOperationError);
await expect(sandbox.runCodeForTool()).rejects.toThrow('No Python code found to execute');
expect(executeFunctions.startJob).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,46 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { addPostExecutionWarning } from '../utils';
describe('addPostExecutionWarning', () => {
const context = mock<IExecuteFunctions>();
const inputItemsLength = 2;
beforeEach(() => jest.resetAllMocks());
it('should add execution hints when returnData length differs from inputItemsLength', () => {
const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }];
addPostExecutionWarning(context, returnData, inputItemsLength);
expect(context.addExecutionHints).toHaveBeenCalledWith({
message:
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
location: 'outputPane',
});
});
it('should add execution hints when any item has undefined pairedItem', () => {
const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }, { json: {} }];
addPostExecutionWarning(context, returnData, inputItemsLength);
expect(context.addExecutionHints).toHaveBeenCalledWith({
message:
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
location: 'outputPane',
});
});
it('should not add execution hints when all items match inputItemsLength and have defined pairedItem', () => {
const returnData: INodeExecutionData[] = [
{ json: {}, pairedItem: 0 },
{ json: {}, pairedItem: 1 },
];
addPostExecutionWarning(context, returnData, inputItemsLength);
expect(context.addExecutionHints).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
{
"name": "errors falsely flagged as internal",
"nodes": [
{
"parameters": {},
"id": "d2ff695c-4ca0-457d-ae9e-666d2dc53a53",
"name": "When clicking Execute workflow",
"type": "n8n-nodes-base.manualTrigger",
"position": [360, 420],
"typeVersion": 1
},
{
"parameters": {
"jsCode": "return {json: \"test\"}"
},
"id": "fc540f62-d671-49a2-b35d-aead4ed8bd10",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [620, 420],
"onError": "continueErrorOutput"
},
{
"parameters": {},
"id": "d8335277-61af-42d8-9cf5-02a8b85df42b",
"name": "No Operation, do nothing",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [900, 500]
}
],
"pinData": {
"No Operation, do nothing": [
{
"json": {
"error": "A 'json' property isn't an object [item 0]"
}
}
]
},
"connections": {
"When clicking Execute workflow": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[],
[
{
"node": "No Operation, do nothing",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "1aa448f4-ac5f-497b-ae3c-62a5e9db63d4",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "be251a83c052a9862eeac953816fbb1464f89dfbf79d7ac490a8e336a8cc8bfd"
},
"id": "TlJvElz9tvmByCh3",
"tags": []
}
@@ -0,0 +1,15 @@
import { ApplicationError } from 'n8n-workflow';
import { isWrappableError, WrappedExecutionError } from './errors/WrappedExecutionError';
export function throwExecutionError(error: unknown): never {
if (error instanceof Error) {
throw error;
} else if (isWrappableError(error)) {
// The error coming from task runner is not an instance of error,
// so we need to wrap it in an error instance.
throw new WrappedExecutionError(error);
}
throw new ApplicationError(`Unknown error: ${JSON.stringify(error)}`);
}
+55
View File
@@ -0,0 +1,55 @@
import type { INodeExecutionData, IDataObject, IExecuteFunctions } from 'n8n-workflow';
export function isObject(maybe: unknown): maybe is { [key: string]: unknown } {
return (
typeof maybe === 'object' && maybe !== null && !Array.isArray(maybe) && !(maybe instanceof Date)
);
}
function isTraversable(maybe: unknown): maybe is IDataObject {
return isObject(maybe) && typeof maybe.toJSON !== 'function' && Object.keys(maybe).length > 0;
}
/**
* Stringify any non-standard JS objects (e.g. `Date`, `RegExp`) inside output items at any depth.
*/
export function standardizeOutput(output: IDataObject) {
function standardizeOutputRecursive(obj: IDataObject, knownObjects = new WeakSet()): IDataObject {
for (const [key, value] of Object.entries(obj)) {
if (!isTraversable(value)) continue;
if (typeof value === 'object' && value !== null) {
if (knownObjects.has(value)) {
// Found circular reference
continue;
}
knownObjects.add(value);
}
obj[key] =
value.constructor.name !== 'Object'
? JSON.stringify(value) // Date, RegExp, etc.
: standardizeOutputRecursive(value, knownObjects);
}
return obj;
}
standardizeOutputRecursive(output);
return output;
}
export const addPostExecutionWarning = (
context: IExecuteFunctions,
returnData: INodeExecutionData[],
inputItemsLength: number,
): void => {
if (
returnData.length !== inputItemsLength ||
returnData.some((item) => item.pairedItem === undefined)
) {
context.addExecutionHints({
message:
'To make sure expressions after this node work, return the input items that produced each output item. <a target="_blank" href="https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/">More info</a>',
location: 'outputPane',
});
}
};