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
+13
View File
@@ -0,0 +1,13 @@
![n8n.io - Workflow Automation](https://user-images.githubusercontent.com/65276001/173571060-9f2f6d7b-bac0-43b6-bdb2-001da9694058.png)
# n8n-workflow
Workflow base code for n8n
```
npm install n8n-workflow
```
## License
You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)
+45
View File
@@ -0,0 +1,45 @@
import { defineConfig } from 'eslint/config';
import { baseConfig } from '@n8n/eslint-config/base';
export default defineConfig(
baseConfig,
{
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
complexity: ['error', 23],
// TODO: remove these
'no-empty': 'warn',
'id-denylist': 'warn',
'no-fallthrough': 'warn',
'no-useless-escape': 'warn',
'import-x/order': 'warn',
'no-extra-boolean-cast': 'warn',
'no-case-declarations': 'warn',
'no-prototype-builtins': 'warn',
'@typescript-eslint/naming-convention': 'warn',
'@typescript-eslint/no-base-to-string': 'warn',
'@typescript-eslint/no-redundant-type-constituents': 'warn',
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
'@typescript-eslint/prefer-optional-chain': 'warn',
'@typescript-eslint/return-await': ['error', 'always'],
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-unsafe-function-type': 'warn',
'@typescript-eslint/no-duplicate-type-constituents': 'warn',
'@typescript-eslint/no-unsafe-call': 'warn',
},
},
{
files: ['**/*.test.ts'],
rules: {
// TODO: remove these
'prefer-const': 'warn',
'@typescript-eslint/no-unused-expressions': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unsafe-member-access': 'warn',
'@typescript-eslint/no-unsafe-assignment': 'warn',
'@typescript-eslint/no-unsafe-return': 'warn',
'@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }],
},
},
);
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../jest.config'),
};
+73
View File
@@ -0,0 +1,73 @@
{
"name": "n8n-workflow",
"version": "2.11.0",
"description": "Workflow base code of n8n",
"types": "dist/esm/index.d.ts",
"module": "dist/esm/index.js",
"main": "dist/cjs/index.js",
"exports": {
".": {
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./common": {
"types": "./dist/esm/common/index.d.ts",
"import": "./dist/esm/common/index.js",
"require": "./dist/cjs/common/index.js"
},
"./*": "./*"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build:vite": "vite build",
"build": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint src --quiet",
"lint:fix": "eslint src --fix",
"watch": "tsc --build tsconfig.build.esm.json tsconfig.build.cjs.json --watch",
"test": "vitest run",
"test:unit": "vitest run",
"test:dev": "vitest --watch"
},
"files": [
"dist/**/*"
],
"devDependencies": {
"@langchain/core": "catalog:",
"@n8n/config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@types/express": "catalog:",
"@types/jmespath": "^0.15.0",
"@types/lodash": "catalog:",
"@types/luxon": "3.2.0",
"@types/md5": "^2.3.5",
"@types/xml2js": "catalog:",
"vitest": "catalog:",
"vitest-mock-extended": "catalog:"
},
"dependencies": {
"@n8n/errors": "workspace:*",
"@n8n/tournament": "1.0.6",
"ast-types": "0.16.1",
"callsites": "catalog:",
"esprima-next": "5.8.4",
"form-data": "catalog:",
"jmespath": "0.16.0",
"js-base64": "catalog:",
"jssha": "3.3.1",
"lodash": "catalog:",
"luxon": "catalog:",
"md5": "2.3.0",
"recast": "0.22.0",
"title-case": "3.0.3",
"transliteration": "2.3.5",
"xml2js": "catalog:",
"zod": "catalog:",
"jsonrepair": "catalog:"
}
}
+160
View File
@@ -0,0 +1,160 @@
import type { IDataObject } from './interfaces';
const defaultPropertyDescriptor = Object.freeze({ enumerable: true, configurable: true });
// eslint-disable-next-line @typescript-eslint/unbound-method
const { hasOwnProperty } = Object.prototype;
const augmentedObjects = new WeakSet<object>();
function augment<T>(value: T): T {
if (typeof value !== 'object' || value === null || value instanceof RegExp) return value;
if (value instanceof Date) return new Date(value.valueOf()) as T;
if (value instanceof Uint8Array) return value.slice() as T;
if (Array.isArray(value)) return augmentArray(value) as T;
return augmentObject(value) as T;
}
export function augmentArray<T>(data: T[]): T[] {
if (augmentedObjects.has(data)) return data;
let newData: unknown[] | undefined = undefined;
function getData(): unknown[] {
if (newData === undefined) {
newData = [...data];
}
return newData;
}
const proxy = new Proxy(data, {
deleteProperty(_target, key: string) {
return Reflect.deleteProperty(getData(), key);
},
get(target, key: string, receiver): unknown {
if (key === 'constructor') return Array;
const value = Reflect.get(newData ?? target, key, receiver) as unknown;
const newValue = augment(value);
if (newValue !== value) {
newData = getData();
Reflect.set(newData, key, newValue);
return newValue;
}
return value;
},
getOwnPropertyDescriptor(target, key) {
if (newData === undefined) {
return Reflect.getOwnPropertyDescriptor(target, key);
}
if (key === 'length') {
return Reflect.getOwnPropertyDescriptor(newData, key);
}
return Object.getOwnPropertyDescriptor(data, key) ?? defaultPropertyDescriptor;
},
has(target, key) {
return Reflect.has(newData ?? target, key);
},
ownKeys(target) {
return Reflect.ownKeys(newData ?? target);
},
set(_target, key: string, newValue: unknown) {
// Always proxy all objects. Like that we can check in get simply if it
// is a proxy and it does then not matter if it was already there from the
// beginning and it got proxied at some point or set later and so theoretically
// does not have to get proxied
return Reflect.set(getData(), key, augment(newValue));
},
});
augmentedObjects.add(proxy);
return proxy;
}
export function augmentObject<T extends object>(data: T): T {
if (augmentedObjects.has(data)) return data;
const newData = {} as IDataObject;
const deletedProperties = new Set<string | symbol>();
const proxy = new Proxy(data, {
get(target, key: string, receiver): unknown {
if (key === 'constructor') return Object;
if (deletedProperties.has(key)) {
return undefined;
}
if (hasOwnProperty.call(newData, key)) {
return newData[key];
}
const value = Reflect.get(target, key, receiver);
if (typeof value !== 'object' || value === null) return value;
if (value instanceof RegExp) return value.toString();
if ('toJSON' in value && typeof value.toJSON === 'function') return value.toJSON() as T;
const newValue = augment(value);
if (newValue !== value) {
Object.assign(newData, { [key]: newValue });
return newValue;
}
return value;
},
deleteProperty(_target, key: string) {
if (hasOwnProperty.call(newData, key)) {
delete newData[key];
}
if (hasOwnProperty.call(data, key)) {
deletedProperties.add(key);
}
return true;
},
set(target, key: string, newValue: unknown) {
if (newValue === undefined) {
if (key in newData) {
delete newData[key];
}
if (key in target) {
deletedProperties.add(key);
}
return true;
}
newData[key] = newValue as IDataObject;
if (deletedProperties.has(key)) {
deletedProperties.delete(key);
}
return true;
},
has(_target, key) {
if (deletedProperties.has(key)) return false;
const target = hasOwnProperty.call(newData, key) ? newData : data;
return Reflect.has(target, key);
},
ownKeys(target) {
const originalKeys = Reflect.ownKeys(target);
const newKeys = Object.keys(newData);
return [...new Set([...originalKeys, ...newKeys])].filter(
(key) => !deletedProperties.has(key),
);
},
getOwnPropertyDescriptor(_target, key) {
if (deletedProperties.has(key)) return undefined;
const target = hasOwnProperty.call(newData, key) ? newData : data;
return Object.getOwnPropertyDescriptor(target, key);
},
});
augmentedObjects.add(proxy);
return proxy;
}
@@ -0,0 +1,12 @@
import { getConnectedNodes } from './get-connected-nodes';
import { NodeConnectionTypes } from '../interfaces';
import type { IConnections, NodeConnectionType } from '../interfaces';
export function getChildNodes(
connectionsBySourceNode: IConnections,
nodeName: string,
type: NodeConnectionType | 'ALL' | 'ALL_NON_MAIN' = NodeConnectionTypes.Main,
depth = -1,
): string[] {
return getConnectedNodes(connectionsBySourceNode, nodeName, type, depth);
}
@@ -0,0 +1,98 @@
import { NodeConnectionTypes } from '../interfaces';
import type { IConnections, NodeConnectionType } from '../interfaces';
/**
* Gets all the nodes which are connected nodes starting from
* the given one
*
* @param {NodeConnectionType} [type='main']
* @param {*} [depth=-1]
*/
export function getConnectedNodes(
connections: IConnections,
nodeName: string,
connectionType: NodeConnectionType | 'ALL' | 'ALL_NON_MAIN' = NodeConnectionTypes.Main,
depth = -1,
checkedNodesIncoming?: string[],
): string[] {
const newDepth = depth === -1 ? depth : depth - 1;
if (depth === 0) {
// Reached max depth
return [];
}
if (!connections.hasOwnProperty(nodeName)) {
// Node does not have incoming connections
return [];
}
let types: NodeConnectionType[];
if (connectionType === 'ALL') {
types = Object.keys(connections[nodeName]) as NodeConnectionType[];
} else if (connectionType === 'ALL_NON_MAIN') {
types = Object.keys(connections[nodeName]).filter(
(type) => type !== 'main',
) as NodeConnectionType[];
} else {
types = [connectionType];
}
let addNodes: string[];
let nodeIndex: number;
let i: number;
let parentNodeName: string;
const returnNodes: string[] = [];
types.forEach((type) => {
if (!connections[nodeName].hasOwnProperty(type)) {
// Node does not have incoming connections of given type
return;
}
const checkedNodes = checkedNodesIncoming ? [...checkedNodesIncoming] : [];
if (checkedNodes.includes(nodeName)) {
// Node got checked already before
return;
}
checkedNodes.push(nodeName);
connections[nodeName][type].forEach((connectionsByIndex) => {
connectionsByIndex?.forEach((connection) => {
if (checkedNodes.includes(connection.node)) {
// Node got checked already before
return;
}
returnNodes.unshift(connection.node);
addNodes = getConnectedNodes(
connections,
connection.node,
connectionType,
newDepth,
checkedNodes,
);
for (i = addNodes.length; i--; i > 0) {
// Because nodes can have multiple parents it is possible that
// parts of the tree is parent of both and to not add nodes
// twice check first if they already got added before.
parentNodeName = addNodes[i];
nodeIndex = returnNodes.indexOf(parentNodeName);
if (nodeIndex !== -1) {
// Node got found before so remove it from current location
// that node-order stays correct
returnNodes.splice(nodeIndex, 1);
}
returnNodes.unshift(parentNodeName);
}
});
});
});
return returnNodes;
}
@@ -0,0 +1,19 @@
import type { INode, INodes } from '../interfaces';
/**
* Returns the node with the given name if it exists else null
*
* @param {INodes} nodes Nodes to search in
* @param {string} name Name of the node to return
*/
export function getNodeByName(nodes: INodes | INode[], name: string) {
if (Array.isArray(nodes)) {
return nodes.find((node) => node.name === name) || null;
}
if (nodes.hasOwnProperty(name)) {
return nodes[name];
}
return null;
}
@@ -0,0 +1,18 @@
import { getConnectedNodes } from './get-connected-nodes';
import { NodeConnectionTypes } from '../interfaces';
import type { IConnections, NodeConnectionType } from '../interfaces';
/**
* Returns all the nodes before the given one
*
* @param {NodeConnectionType} [type='main']
* @param {*} [depth=-1]
*/
export function getParentNodes(
connectionsByDestinationNode: IConnections,
nodeName: string,
type: NodeConnectionType | 'ALL' | 'ALL_NON_MAIN' = NodeConnectionTypes.Main,
depth = -1,
): string[] {
return getConnectedNodes(connectionsByDestinationNode, nodeName, type, depth);
}
+5
View File
@@ -0,0 +1,5 @@
export * from './get-child-nodes';
export * from './get-connected-nodes';
export * from './get-node-by-name';
export * from './get-parent-nodes';
export * from './map-connections-by-destination';
@@ -0,0 +1,49 @@
/* eslint-disable @typescript-eslint/no-for-in-array */
import type { IConnections, NodeConnectionType } from '../interfaces';
export function mapConnectionsByDestination(connections: IConnections) {
const returnConnection: IConnections = {};
let connectionInfo;
let maxIndex: number;
for (const sourceNode in connections) {
if (!connections.hasOwnProperty(sourceNode)) {
continue;
}
for (const type of Object.keys(connections[sourceNode]) as NodeConnectionType[]) {
if (!connections[sourceNode].hasOwnProperty(type)) {
continue;
}
for (const inputIndex in connections[sourceNode][type]) {
if (!connections[sourceNode][type].hasOwnProperty(inputIndex)) {
continue;
}
for (connectionInfo of connections[sourceNode][type][inputIndex] ?? []) {
if (!returnConnection.hasOwnProperty(connectionInfo.node)) {
returnConnection[connectionInfo.node] = {};
}
if (!returnConnection[connectionInfo.node].hasOwnProperty(connectionInfo.type)) {
returnConnection[connectionInfo.node][connectionInfo.type] = [];
}
maxIndex = returnConnection[connectionInfo.node][connectionInfo.type].length - 1;
for (let j = maxIndex; j < connectionInfo.index; j++) {
returnConnection[connectionInfo.node][connectionInfo.type].push([]);
}
returnConnection[connectionInfo.node][connectionInfo.type][connectionInfo.index]?.push({
node: sourceNode,
type,
index: parseInt(inputIndex, 10),
});
}
}
}
}
return returnConnection;
}
+87
View File
@@ -0,0 +1,87 @@
import type { IConnection, IConnections } from '.';
type ConnectionEntry = {
sourceIndex: number;
value: { index: number; connection: IConnection } | null;
};
export type INodeConnectionsDiff = Record<string, ConnectionEntry[]>;
export type ConnectionsDiff = {
added: Record<string, INodeConnectionsDiff>;
removed: Record<string, INodeConnectionsDiff>;
};
export function compareConnections(prev: IConnections, next: IConnections): ConnectionsDiff {
const added: Record<string, INodeConnectionsDiff> = {};
const removed: Record<string, INodeConnectionsDiff> = {};
// Get all unique node names from both connection objects
const allNodeNames = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const nodeName of allNodeNames) {
const prevNodeConnections = prev[nodeName] ?? {};
const nextNodeConnections = next[nodeName] ?? {};
// Get all unique input names for this node
const allInputNames = new Set([
...Object.keys(prevNodeConnections),
...Object.keys(nextNodeConnections),
]);
for (const inputName of allInputNames) {
const prevInputConnections = prevNodeConnections[inputName] ?? [];
const nextInputConnections = nextNodeConnections[inputName] ?? [];
// Compare each source index
const maxLength = Math.max(prevInputConnections.length, nextInputConnections.length);
for (let sourceIndex = 0; sourceIndex < maxLength; sourceIndex++) {
const prevConnections = prevInputConnections[sourceIndex] ?? [];
const nextConnections = nextInputConnections[sourceIndex] ?? [];
// Build maps for easier comparison
const prevMap = new Map(
prevConnections.map((conn, idx) => [
JSON.stringify(conn),
{ index: idx, connection: conn },
]),
);
const nextMap = new Map(
nextConnections.map((conn, idx) => [
JSON.stringify(conn),
{ index: idx, connection: conn },
]),
);
// Find added connections
for (const [key, value] of nextMap) {
if (!prevMap.has(key)) {
if (!added[nodeName]) added[nodeName] = {};
if (!added[nodeName][inputName]) added[nodeName][inputName] = [];
added[nodeName][inputName].push({
sourceIndex,
value,
});
}
}
// Find removed connections
for (const [key, value] of prevMap) {
if (!nextMap.has(key)) {
if (!removed[nodeName]) removed[nodeName] = {};
if (!removed[nodeName][inputName]) removed[nodeName][inputName] = [];
removed[nodeName][inputName].push({
sourceIndex,
value,
});
}
}
}
}
}
return { added, removed };
}
+152
View File
@@ -0,0 +1,152 @@
export const DIGITS = '0123456789';
export const UPPERCASE_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export const LOWERCASE_LETTERS = UPPERCASE_LETTERS.toLowerCase();
export const ALPHABET = [DIGITS, UPPERCASE_LETTERS, LOWERCASE_LETTERS].join('');
export const BINARY_ENCODING = 'base64';
export const WAIT_INDEFINITELY = new Date('3000-01-01T00:00:00.000Z');
export const LOG_LEVELS = ['silent', 'error', 'warn', 'info', 'debug'] as const;
export const CODE_LANGUAGES = ['javaScript', 'python', 'json', 'html'] as const;
export const CODE_EXECUTION_MODES = ['runOnceForAllItems', 'runOnceForEachItem'] as const;
// Arbitrary value to represent an empty credential value
export const CREDENTIAL_EMPTY_VALUE = '__n8n_EMPTY_VALUE_7b1af746-3729-4c60-9b9b-e08eb29e58da';
export const FORM_TRIGGER_PATH_IDENTIFIER = 'n8n-form';
export const UNKNOWN_ERROR_MESSAGE = 'There was an unknown issue while executing the node';
export const UNKNOWN_ERROR_DESCRIPTION =
'Double-check the node configuration and the service it connects to. Check the error details below and refer to the <a href="https://docs.n8n.io" target="_blank">n8n documentation</a> to troubleshoot the issue.';
export const UNKNOWN_ERROR_MESSAGE_CRED = 'UNKNOWN ERROR';
//n8n-nodes-base
export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote';
export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp';
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook';
export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger';
export const EVALUATION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.evaluationTrigger';
export const EVALUATION_NODE_TYPE = 'n8n-nodes-base.evaluation';
export const ERROR_TRIGGER_NODE_TYPE = 'n8n-nodes-base.errorTrigger';
export const EXECUTE_WORKFLOW_NODE_TYPE = 'n8n-nodes-base.executeWorkflow';
export const EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE = 'n8n-nodes-base.executeWorkflowTrigger';
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
export const FUNCTION_NODE_TYPE = 'n8n-nodes-base.function';
export const FUNCTION_ITEM_NODE_TYPE = 'n8n-nodes-base.functionItem';
export const MERGE_NODE_TYPE = 'n8n-nodes-base.merge';
export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform';
export const FORM_NODE_TYPE = 'n8n-nodes-base.form';
export const FORM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.formTrigger';
export const WAIT_NODE_TYPE = 'n8n-nodes-base.wait';
export const RESPOND_TO_WEBHOOK_NODE_TYPE = 'n8n-nodes-base.respondToWebhook';
export const HTML_NODE_TYPE = 'n8n-nodes-base.html';
export const MAILGUN_NODE_TYPE = 'n8n-nodes-base.mailgun';
export const POSTGRES_NODE_TYPE = 'n8n-nodes-base.postgres';
export const MYSQL_NODE_TYPE = 'n8n-nodes-base.mySql';
export const MICROSOFT_AGENT365_TRIGGER_NODE_TYPE =
'@n8n/n8n-nodes-langchain.microsoftAgent365Trigger';
export const SCHEDULE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.scheduleTrigger';
export const DATA_TABLE_TOOL_NODE_TYPE = 'n8n-nodes-base.dataTableTool';
export const STARTING_NODE_TYPES = [
MANUAL_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
ERROR_TRIGGER_NODE_TYPE,
EVALUATION_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
];
export const SCRIPTING_NODE_TYPES = [
FUNCTION_NODE_TYPE,
FUNCTION_ITEM_NODE_TYPE,
CODE_NODE_TYPE,
AI_TRANSFORM_NODE_TYPE,
];
export const ADD_FORM_NOTICE = 'addFormPage';
/**
* Nodes whose parameter values may refer to other nodes without expressions.
* Their content may need to be updated when the referenced node is renamed.
*/
export const NODES_WITH_RENAMABLE_CONTENT = new Set([
CODE_NODE_TYPE,
FUNCTION_NODE_TYPE,
FUNCTION_ITEM_NODE_TYPE,
AI_TRANSFORM_NODE_TYPE,
]);
export const NODES_WITH_RENAMABLE_FORM_HTML_CONTENT = new Set([FORM_NODE_TYPE]);
export const NODES_WITH_RENAMEABLE_TOPLEVEL_HTML_CONTENT = new Set([
MAILGUN_NODE_TYPE,
HTML_NODE_TYPE,
]);
//@n8n/n8n-nodes-langchain
export const MANUAL_CHAT_TRIGGER_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.manualChatTrigger';
export const AGENT_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent';
export const CHAIN_LLM_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.chainLlm';
export const OPENAI_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.openAi';
export const OPENAI_CHAT_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
export const CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE =
'@n8n/n8n-nodes-langchain.chainSummarization';
export const AGENT_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.agentTool';
export const CODE_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolCode';
export const WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWorkflow';
export const HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolHttpRequest';
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
export const CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat';
export const CHAT_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTool';
export const MEMORY_MANAGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.memoryManager';
export const MEMORY_BUFFER_WINDOW_NODE_TYPE = '@n8n/n8n-nodes-langchain.memoryBufferWindow';
export const GUARDRAILS_NODE_TYPE = '@n8n/n8n-nodes-langchain.guardrails';
export const MCP_CLIENT_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpClientTool';
export const MCP_CLIENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpClient';
export const ANTHROPIC_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.anthropic';
export const OLLAMA_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.ollama';
export const GOOGLE_GEMINI_LANGCHAIN_NODE_TYPE = '@n8n/n8n-nodes-langchain.googleGemini';
export const AI_VENDOR_NODE_TYPES = [
OPENAI_LANGCHAIN_NODE_TYPE,
ANTHROPIC_LANGCHAIN_NODE_TYPE,
OLLAMA_LANGCHAIN_NODE_TYPE,
GOOGLE_GEMINI_LANGCHAIN_NODE_TYPE,
];
export const LANGCHAIN_LM_NODE_TYPE_PREFIX = '@n8n/n8n-nodes-langchain.lm';
export const LANGCHAIN_CUSTOM_TOOLS = [
CODE_TOOL_LANGCHAIN_NODE_TYPE,
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
];
export const SEND_AND_WAIT_OPERATION = 'sendAndWait';
export const AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT = 'codeGeneratedForPrompt';
export const AI_TRANSFORM_JS_CODE = 'jsCode';
/**
* Key for an item standing in for a manual execution data item too large to be
* sent live via pubsub. See {@link TRIMMED_TASK_DATA_CONNECTIONS} in constants
* in `cli` package.
*/
export const TRIMMED_TASK_DATA_CONNECTIONS_KEY = '__isTrimmedManualExecutionDataItem';
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
export const FREE_AI_CREDITS_ERROR_TYPE = 'free_ai_credits_request_error';
export const FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE = 400;
export const FROM_AI_AUTO_GENERATED_MARKER = '/*n8n-auto-generated-fromAI-override*/';
export const PROJECT_ROOT = '0';
export const WAITING_FORMS_EXECUTION_STATUS = 'n8n-execution-status';
export const CHAT_WAIT_USER_REPLY = 'waitUserReply';
export const FREE_TEXT_CHAT_RESPONSE_TYPE = 'freeTextChat';
export const BINARY_IN_JSON_PROPERTY = '_files';
export const BINARY_MODE_SEPARATE = 'separate';
export const BINARY_MODE_COMBINED = 'combined';
+72
View File
@@ -0,0 +1,72 @@
import type { CronExpression } from './interfaces';
import { randomInt } from './utils';
interface BaseTriggerTime<T extends string> {
mode: T;
}
interface CustomTrigger extends BaseTriggerTime<'custom'> {
cronExpression: CronExpression;
}
interface EveryX<U extends string> extends BaseTriggerTime<'everyX'> {
unit: U;
value: number;
}
type EveryMinute = BaseTriggerTime<'everyMinute'>;
type EveryXMinutes = EveryX<'minutes'>;
interface EveryHour extends BaseTriggerTime<'everyHour'> {
minute: number; // 0 - 59
}
type EveryXHours = EveryX<'hours'>;
interface EveryDay extends BaseTriggerTime<'everyDay'> {
hour: number; // 0 - 23
minute: number; // 0 - 59
}
interface EveryWeek extends BaseTriggerTime<'everyWeek'> {
hour: number; // 0 - 23
minute: number; // 0 - 59
weekday: number; // 0 - 6(Sun - Sat)
}
interface EveryMonth extends BaseTriggerTime<'everyMonth'> {
hour: number; // 0 - 23
minute: number; // 0 - 59
dayOfMonth: number; // 1 - 31
}
export type TriggerTime =
| CustomTrigger
| EveryMinute
| EveryXMinutes
| EveryHour
| EveryXHours
| EveryDay
| EveryWeek
| EveryMonth;
export const toCronExpression = (item: TriggerTime): CronExpression => {
const randomSecond = randomInt(60);
if (item.mode === 'everyMinute') return `${randomSecond} * * * * *`;
if (item.mode === 'everyHour') return `${randomSecond} ${item.minute} * * * *`;
if (item.mode === 'everyX') {
if (item.unit === 'minutes') return `${randomSecond} */${item.value} * * * *`;
const randomMinute = randomInt(60);
if (item.unit === 'hours') return `${randomSecond} ${randomMinute} */${item.value} * * *`;
}
if (item.mode === 'everyDay') return `${randomSecond} ${item.minute} ${item.hour} * * *`;
if (item.mode === 'everyWeek')
return `${randomSecond} ${item.minute} ${item.hour} * * ${item.weekday}`;
if (item.mode === 'everyMonth')
return `${randomSecond} ${item.minute} ${item.hour} ${item.dayOfMonth} * *`;
return item.cronExpression.trim() as CronExpression;
};
+216
View File
@@ -0,0 +1,216 @@
export type DataTableColumnType = 'string' | 'number' | 'boolean' | 'date';
/**
* Data Table row operations
* Used by the Data Table node (n8n-nodes-base.dataTable) for row-level CRUD operations
*/
export type DataTableRowOperation =
| 'insert'
| 'get'
| 'rowExists'
| 'rowNotExists'
| 'deleteRows'
| 'update'
| 'upsert';
/**
* Data Table table operations
* Used by the Data Table node for table-level management operations
*/
export type DataTableTableOperation = 'create' | 'delete' | 'list' | 'update';
export type DataTableColumn = {
id: string;
name: string;
type: DataTableColumnType;
index: number;
dataTableId: string;
};
export type DataTable = {
id: string;
name: string;
columns: DataTableColumn[];
createdAt: Date;
updatedAt: Date;
projectId: string;
};
export type CreateDataTableColumnOptions = Pick<DataTableColumn, 'name' | 'type'> &
Partial<Pick<DataTableColumn, 'index'>>;
export type CreateDataTableOptions = Pick<DataTable, 'name'> & {
columns: CreateDataTableColumnOptions[];
};
export type UpdateDataTableOptions = { name: string };
export type ListDataTableOptionsSortByKey = 'name' | 'createdAt' | 'updatedAt';
export type ListDataTableOptions = {
filter?: Record<string, string | string[]>;
sortBy?: `${ListDataTableOptionsSortByKey}:asc` | `${ListDataTableOptionsSortByKey}:desc`;
take?: number;
skip?: number;
};
export type DataTableFilter = {
type: 'and' | 'or';
filters: Array<{
columnName: string;
condition: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte';
value: DataTableColumnJsType;
}>;
};
export type ListDataTableRowsOptions = {
filter?: DataTableFilter;
sortBy?: [string, 'ASC' | 'DESC'];
take?: number;
skip?: number;
};
export type UpdateDataTableRowOptions = {
filter: DataTableFilter;
data: DataTableRow;
dryRun?: boolean;
};
export type UpsertDataTableRowOptions = {
filter: DataTableFilter;
data: DataTableRow;
dryRun?: boolean;
};
export type DeleteDataTableRowsOptions = {
filter: DataTableFilter;
dryRun?: boolean;
};
export type MoveDataTableColumnOptions = {
targetIndex: number;
};
export type AddDataTableColumnOptions = Pick<DataTableColumn, 'name' | 'type'> &
Partial<Pick<DataTableColumn, 'index'>>;
export type DataTableColumnJsType = string | number | boolean | Date | null;
export const DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP: Record<string, DataTableColumnType> = {
id: 'number',
createdAt: 'date',
updatedAt: 'date',
};
export const DATA_TABLE_SYSTEM_COLUMNS = Object.keys(DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP);
export const DATA_TABLE_SYSTEM_TESTING_COLUMN = 'dryRunState';
// Raw database result type (before normalization)
export type DataTableRawRowReturnBase = {
id: number;
createdAt: string | number | Date;
updatedAt: string | number | Date;
};
export type DataTableRowReturnBase = {
id: number;
createdAt: Date;
updatedAt: Date;
};
export type DataTableRow = Record<string, DataTableColumnJsType>;
export type DataTableRows = DataTableRow[];
// Raw database results (before normalization)
export type DataTableRawRowReturn = DataTableRow & DataTableRawRowReturnBase;
export type DataTableRawRowsReturn = DataTableRawRowReturn[];
export type DataTableRowReturn = DataTableRow & DataTableRowReturnBase;
export type DataTableRowsReturn = DataTableRowReturn[];
export type DataTableRowReturnWithState = DataTableRow & {
id: number | null;
createdAt: Date | null;
updatedAt: Date | null;
dryRunState: 'before' | 'after';
};
export type DataTableRowUpdatePair = {
before: DataTableRowReturn;
after: DataTableRowReturn;
};
export type DataTableInsertRowsReturnType = 'all' | 'id' | 'count';
export type DataTableInsertRowsBulkResult = { success: true; insertedRows: number };
export type DataTableInsertRowsResult<
T extends DataTableInsertRowsReturnType = DataTableInsertRowsReturnType,
> = T extends 'all'
? DataTableRowReturn[]
: T extends 'id'
? Array<Pick<DataTableRowReturn, 'id'>>
: DataTableInsertRowsBulkResult;
export type DataTableSizeStatus = 'ok' | 'warn' | 'error';
export type DataTableInfo = {
id: string;
name: string;
projectId: string;
projectName: string;
sizeBytes: number;
};
export type DataTableInfoById = Record<string, DataTableInfo>;
export type DataTablesSizeData = {
totalBytes: number;
dataTables: DataTableInfoById;
};
export type DataTablesSizeResult = DataTablesSizeData & {
quotaStatus: DataTableSizeStatus;
};
// APIs for a data table service operating on a specific projectId
export interface IDataTableProjectAggregateService {
getProjectId(): string;
createDataTable(options: CreateDataTableOptions): Promise<DataTable>;
getManyAndCount(options: ListDataTableOptions): Promise<{ count: number; data: DataTable[] }>;
deleteDataTableAll(): Promise<boolean>;
}
// APIs for a data table service operating on a specific projectId and dataTableId
export interface IDataTableProjectService {
updateDataTable(options: UpdateDataTableOptions): Promise<boolean>;
deleteDataTable(): Promise<boolean>;
getColumns(): Promise<DataTableColumn[]>;
addColumn(options: AddDataTableColumnOptions): Promise<DataTableColumn>;
moveColumn(columnId: string, options: MoveDataTableColumnOptions): Promise<boolean>;
deleteColumn(columnId: string): Promise<boolean>;
getManyRowsAndCount(
dto: Partial<ListDataTableRowsOptions>,
): Promise<{ count: number; data: DataTableRowsReturn }>;
insertRows<T extends DataTableInsertRowsReturnType>(
rows: DataTableRows,
returnType: T,
): Promise<DataTableInsertRowsResult<T>>;
updateRows(
options: UpdateDataTableRowOptions,
): Promise<DataTableRowReturn[] | DataTableRowReturnWithState[]>;
upsertRow(
options: UpsertDataTableRowOptions,
): Promise<DataTableRowReturn[] | DataTableRowReturnWithState[]>;
deleteRows(options: DeleteDataTableRowsOptions): Promise<DataTableRowReturn[]>;
}
+17
View File
@@ -0,0 +1,17 @@
type ResolveFn<T> = (result: T | PromiseLike<T>) => void;
type RejectFn = (error: Error) => void;
export interface IDeferredPromise<T> {
promise: Promise<T>;
resolve: ResolveFn<T>;
reject: RejectFn;
}
export function createDeferredPromise<T = void>(): IDeferredPromise<T> {
const deferred: Partial<IDeferredPromise<T>> = {};
deferred.promise = new Promise<T>((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
return deferred as IDeferredPromise<T>;
}
@@ -0,0 +1,52 @@
import { ApplicationError, type ReportingOptions } from '@n8n/errors';
import type { Functionality, IDataObject, JsonObject } from '../../interfaces';
interface ExecutionBaseErrorOptions extends ReportingOptions {
cause?: Error;
errorResponse?: JsonObject;
}
export abstract class ExecutionBaseError extends ApplicationError {
description: string | null | undefined;
override cause?: Error;
errorResponse?: JsonObject;
timestamp: number;
context: IDataObject = {};
lineNumber: number | undefined;
functionality: Functionality = 'regular';
constructor(message: string, options: ExecutionBaseErrorOptions = {}) {
super(message, options);
this.name = this.constructor.name;
this.timestamp = Date.now();
const { cause, errorResponse } = options;
if (cause instanceof ExecutionBaseError) {
this.context = cause.context;
} else if (cause && !(cause instanceof Error)) {
this.cause = cause;
}
if (errorResponse) this.errorResponse = errorResponse;
}
toJSON?() {
return {
message: this.message,
lineNumber: this.lineNumber,
timestamp: this.timestamp,
name: this.name,
description: this.description,
context: this.context,
cause: this.cause,
};
}
}
@@ -0,0 +1,187 @@
import { ExecutionBaseError } from './execution-base.error';
import type { IDataObject, INode, JsonObject } from '../../interfaces';
import { isTraversableObject, jsonParse } from '../../utils';
/**
* Descriptive messages for common errors.
*/
const COMMON_ERRORS: IDataObject = {
// nodeJS errors
ECONNREFUSED: 'The service refused the connection - perhaps it is offline',
ECONNRESET:
'The connection to the server was closed unexpectedly, perhaps it is offline. You can retry the request immediately or wait and retry later.',
ENOTFOUND:
'The connection cannot be established, this usually occurs due to an incorrect host (domain) value',
ETIMEDOUT:
"The connection timed out, consider setting the 'Retry on Fail' option in the node settings",
ERRADDRINUSE:
'The port is already occupied by some other application, if possible change the port or kill the application that is using it',
EADDRNOTAVAIL: 'The address is not available, ensure that you have the right IP address',
ECONNABORTED: 'The connection was aborted, perhaps the server is offline',
EHOSTUNREACH: 'The host is unreachable, perhaps the server is offline',
EAI_AGAIN: 'The DNS server returned an error, perhaps the server is offline',
ENOENT: 'The file or directory does not exist',
EISDIR: 'The file path was expected but the given path is a directory',
ENOTDIR: 'The directory path was expected but the given path is a file',
EACCES: 'Forbidden by access permissions, make sure you have the right permissions',
EEXIST: 'The file or directory already exists',
EPERM: 'Operation not permitted, make sure you have the right permissions',
// other errors
GETADDRINFO: 'The server closed the connection unexpectedly',
};
/**
* Base class for specific NodeError-types, with functionality for finding
* a value recursively inside an error object.
*/
export abstract class NodeError extends ExecutionBaseError {
messages: string[] = [];
constructor(
readonly node: INode,
error: Error | JsonObject,
) {
const isError = error instanceof Error;
const message = isError ? error.message : '';
const options = isError ? { cause: error } : { errorResponse: error };
super(message, options);
if (error instanceof NodeError) {
this.tags.reWrapped = true;
}
}
/**
* Finds property through exploration based on potential keys and traversal keys.
* Depth-first approach.
*
* This method iterates over `potentialKeys` and, if the value at the key is a
* truthy value, the type of the value is checked:
* (1) if a string or number, the value is returned as a string; or
* (2) if an array,
* its string or number elements are collected as a long string,
* its object elements are traversed recursively (restart this function
* with each object as a starting point), or
* (3) if it is an object, it traverses the object and nested ones recursively
* based on the `potentialKeys` and returns a string if found.
*
* If nothing found via `potentialKeys` this method iterates over `traversalKeys` and
* if the value at the key is a traversable object, it restarts with the object as the
* new starting point (recursion).
* If nothing found for any of the `traversalKeys`, exploration continues with remaining
* `traversalKeys`.
*
* Otherwise, if all the paths have been exhausted and no value is eligible, `null` is
* returned.
*
*/
protected findProperty(
jsonError: JsonObject,
potentialKeys: string[],
traversalKeys: string[] = [],
): string | null {
for (const key of potentialKeys) {
let value = jsonError[key];
if (value) {
if (typeof value === 'string') {
try {
value = jsonParse(value);
} catch (error) {
return value as string;
}
if (typeof value === 'string') return value;
}
if (typeof value === 'number') return value.toString();
if (Array.isArray(value)) {
const resolvedErrors: string[] = value
.map((jsonError) => {
if (typeof jsonError === 'string') return jsonError;
if (typeof jsonError === 'number') return jsonError.toString();
if (isTraversableObject(jsonError)) {
return this.findProperty(jsonError, potentialKeys);
}
return null;
})
.filter((errorValue): errorValue is string => errorValue !== null);
if (resolvedErrors.length === 0) {
return null;
}
return resolvedErrors.join(' | ');
}
if (isTraversableObject(value)) {
const property = this.findProperty(value, potentialKeys);
if (property) {
return property;
}
}
}
}
for (const key of traversalKeys) {
const value = jsonError[key];
if (isTraversableObject(value)) {
const property = this.findProperty(value, potentialKeys, traversalKeys);
if (property) {
return property;
}
}
}
return null;
}
/**
* Preserve the original error message before setting the new one
*/
protected addToMessages(message: string): void {
if (message && !this.messages.includes(message)) {
this.messages.push(message);
}
}
/**
* Set descriptive error message if code is provided or if message contains any of the common errors,
* update description to include original message plus the description
*/
protected setDescriptiveErrorMessage(
message: string,
messages: string[],
code?: string | null,
messageMapping?: { [key: string]: string },
): [string, string[]] {
let newMessage = message;
if (messageMapping) {
for (const [mapKey, mapMessage] of Object.entries(messageMapping)) {
if ((message || '').toUpperCase().includes(mapKey.toUpperCase())) {
newMessage = mapMessage;
messages.push(message);
break;
}
}
if (newMessage !== message) {
return [newMessage, messages];
}
}
// if code is provided and it is in the list of common errors set the message and return early
if (code && typeof code === 'string' && COMMON_ERRORS[code.toUpperCase()]) {
newMessage = COMMON_ERRORS[code] as string;
messages.push(message);
return [newMessage, messages];
}
// check if message contains any of the common errors and set the message and description
for (const [errorCode, errorDescriptiveMessage] of Object.entries(COMMON_ERRORS)) {
if ((message || '').toUpperCase().includes(errorCode.toUpperCase())) {
newMessage = errorDescriptiveMessage as string;
messages.push(message);
break;
}
}
return [newMessage, messages];
}
}
@@ -0,0 +1,58 @@
import type { Event } from '@sentry/node';
import callsites from 'callsites';
import type { ErrorTags, ErrorLevel, ReportingOptions } from '@n8n/errors';
export type BaseErrorOptions = { description?: string | undefined | null } & ErrorOptions &
ReportingOptions;
/**
* Base class for all errors
*/
export abstract class BaseError extends Error {
/**
* Error level. Defines which level the error should be logged/reported
* @default 'error'
*/
level: ErrorLevel;
/**
* Whether the error should be reported to Sentry.
* @default true
*/
readonly shouldReport: boolean;
readonly description: string | null | undefined;
readonly tags: ErrorTags;
readonly extra?: Event['extra'];
readonly packageName?: string;
constructor(
message: string,
{
level = 'error',
description,
shouldReport,
tags = {},
extra,
...rest
}: BaseErrorOptions = {},
) {
super(message, rest);
this.level = level;
this.shouldReport = shouldReport ?? (level === 'error' || level === 'fatal');
this.description = description;
this.tags = tags;
this.extra = extra;
try {
const filePath = callsites()[2].getFileName() ?? '';
const match = /packages\/([^\/]+)\//.exec(filePath)?.[1];
if (match) this.tags.packageName = match;
} catch {}
}
}
@@ -0,0 +1,21 @@
import type { BaseErrorOptions } from './base.error';
import { BaseError } from './base.error';
export type OperationalErrorOptions = Omit<BaseErrorOptions, 'level'> & {
level?: 'info' | 'warning' | 'error';
};
/**
* Error that indicates a transient issue, like a network request failing,
* a database query timing out, etc. These are expected to happen, are
* transient by nature and should be handled gracefully.
*
* Default level: warning
*/
export class OperationalError extends BaseError {
constructor(message: string, opts: OperationalErrorOptions = {}) {
opts.level = opts.level ?? 'warning';
super(message, opts);
}
}
@@ -0,0 +1,21 @@
import type { BaseErrorOptions } from './base.error';
import { BaseError } from './base.error';
export type UnexpectedErrorOptions = Omit<BaseErrorOptions, 'level'> & {
level?: 'error' | 'fatal';
};
/**
* Error that indicates something is wrong in the code: logic mistakes,
* unhandled cases, assertions that fail. These are not recoverable and
* should be brought to developers' attention.
*
* Default level: error
*/
export class UnexpectedError extends BaseError {
constructor(message: string, opts: UnexpectedErrorOptions = {}) {
opts.level = opts.level ?? 'error';
super(message, opts);
}
}
@@ -0,0 +1,24 @@
import type { BaseErrorOptions } from './base.error';
import { BaseError } from './base.error';
export type UserErrorOptions = Omit<BaseErrorOptions, 'level'> & {
level?: 'info' | 'warning';
description?: string | null | undefined;
};
/**
* Error that indicates the user performed an action that caused an error.
* E.g. provided invalid input, tried to access a resource theyre not
* authorized to, or violates a business rule.
*
* Default level: info
*/
export class UserError extends BaseError {
declare readonly description: string | null | undefined;
constructor(message: string, opts: UserErrorOptions = {}) {
opts.level = opts.level ?? 'info';
super(message, opts);
}
}
@@ -0,0 +1,3 @@
import { SubworkflowOperationError } from './subworkflow-operation.error';
export class CliWorkflowOperationError extends SubworkflowOperationError {}
@@ -0,0 +1,14 @@
import { ApplicationError } from '@n8n/errors';
export type DbConnectionTimeoutErrorOpts = {
configuredTimeoutInMs: number;
cause: Error;
};
export class DbConnectionTimeoutError extends ApplicationError {
constructor(opts: DbConnectionTimeoutErrorOpts) {
const numberFormat = Intl.NumberFormat();
const errorMessage = `Could not establish database connection within the configured timeout of ${numberFormat.format(opts.configuredTimeoutInMs)} ms. Please ensure the database is configured correctly and the server is reachable. You can increase the timeout by setting the 'DB_POSTGRESDB_CONNECTION_TIMEOUT' environment variable.`;
super(errorMessage, { cause: opts.cause });
}
}
@@ -0,0 +1,9 @@
/** Ensures `error` is an `Error */
export function ensureError(error: unknown): Error {
return error instanceof Error
? error
: new Error('Error that was not an instance of Error was thrown', {
// We should never throw anything except something that derives from Error
cause: error,
});
}
@@ -0,0 +1,37 @@
import { ExecutionBaseError } from './abstract/execution-base.error';
export type CancellationReason = 'manual' | 'timeout' | 'shutdown';
export abstract class ExecutionCancelledError extends ExecutionBaseError {
readonly reason: CancellationReason;
// NOTE: prefer one of the more specific
constructor(executionId: string, reason: CancellationReason) {
super('The execution was cancelled', {
level: 'warning',
extra: { executionId },
});
this.reason = reason;
}
}
export class ManualExecutionCancelledError extends ExecutionCancelledError {
constructor(executionId: string) {
super(executionId, 'manual');
this.message = 'The execution was cancelled manually';
}
}
export class TimeoutExecutionCancelledError extends ExecutionCancelledError {
constructor(executionId: string) {
super(executionId, 'timeout');
this.message = 'The execution was cancelled because it timed out';
}
}
export class SystemShutdownExecutionCancelledError extends ExecutionCancelledError {
constructor(executionId: string) {
super(executionId, 'shutdown');
this.message = 'The execution was cancelled because the system is shutting down';
}
}
@@ -0,0 +1,7 @@
import { ExpressionError } from './expression.error';
export class ExpressionClassExtensionError extends ExpressionError {
constructor(baseClass: string) {
super(`Cannot extend "${baseClass}" due to security concerns`);
}
}
@@ -0,0 +1,7 @@
import { ExpressionError } from './expression.error';
export class ExpressionComputedDestructuringError extends ExpressionError {
constructor() {
super('Computed property names in destructuring are not allowed due to security concerns');
}
}
@@ -0,0 +1,7 @@
import { ExpressionError } from './expression.error';
export class ExpressionDestructuringError extends ExpressionError {
constructor(property: string) {
super(`Cannot destructure "${property}" due to security concerns`);
}
}
@@ -0,0 +1,3 @@
import { ExpressionError } from './expression.error';
export class ExpressionExtensionError extends ExpressionError {}
@@ -0,0 +1,7 @@
import { ExpressionError } from './expression.error';
export class ExpressionReservedVariableError extends ExpressionError {
constructor(variableName: string) {
super(`Cannot use "${variableName}" due to security concerns`);
}
}
@@ -0,0 +1,7 @@
import { ExpressionError } from './expression.error';
export class ExpressionWithStatementError extends ExpressionError {
constructor() {
super('Cannot use "with" statements due to security concerns');
}
}
@@ -0,0 +1,63 @@
import type { IDataObject } from '../interfaces';
import { ExecutionBaseError } from './abstract/execution-base.error';
export interface ExpressionErrorOptions {
cause?: Error;
causeDetailed?: string;
description?: string;
descriptionKey?: string;
descriptionTemplate?: string;
functionality?: 'pairedItem';
itemIndex?: number;
messageTemplate?: string;
nodeCause?: string;
parameter?: string;
runIndex?: number;
type?:
| 'no_execution_data'
| 'no_node_execution_data'
| 'no_input_connection'
| 'internal'
| 'paired_item_invalid_info'
| 'paired_item_no_info'
| 'paired_item_multiple_matches'
| 'paired_item_no_connection'
| 'paired_item_intermediate_nodes';
}
/**
* Class for instantiating an expression error
*/
export class ExpressionError extends ExecutionBaseError {
constructor(message: string, options?: ExpressionErrorOptions) {
super(message, { cause: options?.cause, level: 'warning' });
if (options?.description !== undefined) {
this.description = options.description;
}
const allowedKeys = [
'causeDetailed',
'descriptionTemplate',
'descriptionKey',
'itemIndex',
'messageTemplate',
'nodeCause',
'parameter',
'runIndex',
'type',
];
if (options !== undefined) {
if (options.functionality !== undefined) {
this.functionality = options.functionality;
}
Object.keys(options as IDataObject).forEach((key) => {
if (allowedKeys.includes(key)) {
this.context[key] = (options as IDataObject)[key];
}
});
}
}
}
+35
View File
@@ -0,0 +1,35 @@
export { BaseError, type BaseErrorOptions } from './base/base.error';
export { OperationalError, type OperationalErrorOptions } from './base/operational.error';
export { UnexpectedError, type UnexpectedErrorOptions } from './base/unexpected.error';
export { UserError, type UserErrorOptions } from './base/user.error';
export { ApplicationError } from '@n8n/errors';
export { ExpressionError } from './expression.error';
export {
ExecutionCancelledError,
ManualExecutionCancelledError,
SystemShutdownExecutionCancelledError,
TimeoutExecutionCancelledError,
type CancellationReason,
} from './execution-cancelled.error';
export { NodeApiError } from './node-api.error';
export { NodeOperationError } from './node-operation.error';
export { WorkflowConfigurationError } from './workflow-configuration.error';
export { NodeSslError } from './node-ssl.error';
export { WebhookPathTakenError } from './webhook-taken.error';
export { WorkflowActivationError } from './workflow-activation.error';
export { WorkflowDeactivationError } from './workflow-deactivation.error';
export { WorkflowOperationError } from './workflow-operation.error';
export { SubworkflowOperationError } from './subworkflow-operation.error';
export { CliWorkflowOperationError } from './cli-subworkflow-operation.error';
export { TriggerCloseError } from './trigger-close.error';
export { NodeError } from './abstract/node.error';
export { ExecutionBaseError } from './abstract/execution-base.error';
export { ExpressionExtensionError } from './expression-extension.error';
export { ExpressionDestructuringError } from './expression-destructuring.error';
export { ExpressionComputedDestructuringError } from './expression-computed-destructuring.error';
export { ExpressionClassExtensionError } from './expression-class-extension.error';
export { ExpressionReservedVariableError } from './expression-reserved-variable.error';
export { ExpressionWithStatementError } from './expression-with-statement.error';
export { DbConnectionTimeoutError } from './db-connection-timeout-error';
export { ensureError } from './ensure-error';
@@ -0,0 +1,344 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import type { AxiosError } from 'axios';
import { parseString } from 'xml2js';
import { NodeError } from './abstract/node.error';
import type { ErrorLevel } from '@n8n/errors';
import {
NO_OP_NODE_TYPE,
UNKNOWN_ERROR_DESCRIPTION,
UNKNOWN_ERROR_MESSAGE,
UNKNOWN_ERROR_MESSAGE_CRED,
} from '../constants';
import type {
INode,
JsonObject,
IDataObject,
IStatusCodeMessages,
Functionality,
RelatedExecution,
} from '../interfaces';
import { removeCircularRefs } from '../utils';
export interface NodeOperationErrorOptions {
message?: string;
description?: string;
runIndex?: number;
itemIndex?: number;
level?: ErrorLevel;
messageMapping?: { [key: string]: string }; // allows to pass custom mapping for error messages scoped to a node
functionality?: Functionality;
type?: string;
metadata?: {
subExecution?: RelatedExecution;
parentExecution?: RelatedExecution;
};
}
interface NodeApiErrorOptions extends NodeOperationErrorOptions {
message?: string;
httpCode?: string;
parseXml?: boolean;
}
/**
* Top-level properties where an error message can be found in an API response.
* order is important, precedence is from top to bottom
*/
const POSSIBLE_ERROR_MESSAGE_KEYS = [
'cause',
'error',
'message',
'Message',
'msg',
'messages',
'description',
'reason',
'detail',
'details',
'errors',
'errorMessage',
'errorMessages',
'ErrorMessage',
'error_message',
'_error_message',
'errorDescription',
'error_description',
'error_summary',
'error_info',
'title',
'text',
'field',
'err',
'type',
];
/**
* Properties where a nested object can be found in an API response.
*/
const POSSIBLE_NESTED_ERROR_OBJECT_KEYS = ['Error', 'error', 'err', 'response', 'body', 'data'];
/**
* Top-level properties where an HTTP error code can be found in an API response.
*/
const POSSIBLE_ERROR_STATUS_KEYS = [
'statusCode',
'status',
'code',
'status_code',
'errorCode',
'error_code',
];
/**
* Descriptive messages for common HTTP status codes
* this is used by NodeApiError class
*/
const STATUS_CODE_MESSAGES: IStatusCodeMessages = {
'4XX': 'Your request is invalid or could not be processed by the service',
'400': 'Bad request - please check your parameters',
'401': 'Authorization failed - please check your credentials',
'402': 'Payment required - perhaps check your payment details?',
'403': 'Forbidden - perhaps check your credentials?',
'404': 'The resource you are requesting could not be found',
'405': 'Method not allowed - please check you are using the right HTTP method',
'429': 'The service is receiving too many requests from you',
'5XX': 'The service failed to process your request',
'500': 'The service was not able to process your request',
'502': 'Bad gateway - the service failed to handle your request',
'503':
'Service unavailable - try again later or consider setting this node to retry automatically (in the node settings)',
'504': 'Gateway timed out - perhaps try again later?',
};
/**
* Class for instantiating an error in an API response, e.g. a 404 Not Found response,
* with an HTTP error code, an error message and a description.
*/
export class NodeApiError extends NodeError {
httpCode: string | null = null;
// eslint-disable-next-line complexity
constructor(
node: INode,
errorResponse: JsonObject,
{
message,
description,
httpCode,
parseXml,
runIndex,
itemIndex,
level,
functionality,
messageMapping,
}: NodeApiErrorOptions = {},
) {
if (errorResponse instanceof NodeApiError) {
return errorResponse;
}
super(node, errorResponse);
this.addToMessages(errorResponse.message as string);
if (
!httpCode &&
errorResponse instanceof Error &&
errorResponse.constructor?.name === 'AxiosError'
) {
httpCode = (errorResponse as unknown as AxiosError).response?.status?.toString();
}
// only for request library error
if (errorResponse.error) {
removeCircularRefs(errorResponse.error as JsonObject);
}
// if not description provided, try to find it in the error object
if (
!description &&
(errorResponse.description || (errorResponse?.reason as IDataObject)?.description)
) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
this.description = (errorResponse.description ||
(errorResponse?.reason as IDataObject)?.description) as string;
}
// if not message provided, try to find it in the error object or set description as message
if (
!message &&
(errorResponse.message || (errorResponse?.reason as IDataObject)?.message || description)
) {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
this.message = (errorResponse.message ||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
(errorResponse?.reason as IDataObject)?.message ||
description) as string;
}
// if it's an error generated by axios
// look for descriptions in the response object
if (errorResponse.reason) {
const reason: IDataObject = errorResponse.reason as unknown as IDataObject;
if (reason.isAxiosError && reason.response) {
errorResponse = reason.response as JsonObject;
}
}
// set http code of this error
if (httpCode) {
this.httpCode = httpCode;
} else if (errorResponse.httpCode) {
this.httpCode = errorResponse.httpCode as string;
} else {
this.httpCode =
this.findProperty(
errorResponse,
POSSIBLE_ERROR_STATUS_KEYS,
POSSIBLE_NESTED_ERROR_OBJECT_KEYS,
) ?? null;
}
this.level = level ?? 'warning';
if (
errorResponse?.response &&
typeof errorResponse?.response === 'object' &&
!Array.isArray(errorResponse.response) &&
errorResponse.response.data &&
typeof errorResponse.response.data === 'object' &&
!Array.isArray(errorResponse.response.data)
) {
const data = errorResponse.response.data;
if (data.message) {
description = data.message as string;
} else if (data.error && ((data.error as IDataObject) || {}).message) {
description = (data.error as IDataObject).message as string;
}
this.context.data = data;
}
// set description of this error
if (description) {
this.description = description;
}
if (!this.description) {
if (parseXml) {
this.setDescriptionFromXml(errorResponse.error as string);
} else {
this.description = this.findProperty(
errorResponse,
POSSIBLE_ERROR_MESSAGE_KEYS,
POSSIBLE_NESTED_ERROR_OBJECT_KEYS,
);
}
}
// set message if provided
// set default message based on http code
// or use raw error message
if (message) {
this.message = message;
} else {
this.setDefaultStatusCodeMessage();
}
// if message and description are the same, unset redundant description
if (this.message === this.description) {
this.description = undefined;
}
// if message contain common error code set descriptive message and update description
[this.message, this.messages] = this.setDescriptiveErrorMessage(
this.message,
this.messages,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
this.httpCode ||
(errorResponse?.code as string) ||
((errorResponse?.reason as JsonObject)?.code as string) ||
undefined,
messageMapping,
);
if (functionality !== undefined) this.functionality = functionality;
if (runIndex !== undefined) this.context.runIndex = runIndex;
if (itemIndex !== undefined) this.context.itemIndex = itemIndex;
}
private setDescriptionFromXml(xml: string) {
parseString(xml, { explicitArray: false }, (_, result) => {
if (!result) return;
const topLevelKey = Object.keys(result)[0];
this.description = this.findProperty(
result[topLevelKey],
POSSIBLE_ERROR_MESSAGE_KEYS,
POSSIBLE_NESTED_ERROR_OBJECT_KEYS,
);
});
}
/**
* Set the error's message based on the HTTP status code.
*/
private setDefaultStatusCodeMessage() {
// Set generic error message for 502 Bad Gateway
if (!this.httpCode && this.message && this.message.toLowerCase().includes('bad gateway')) {
this.httpCode = '502';
}
if (!this.httpCode) {
this.httpCode = null;
if (!this.message) {
if (this.description) {
this.message = this.description;
this.description = undefined;
} else {
this.message = UNKNOWN_ERROR_MESSAGE;
this.description = UNKNOWN_ERROR_DESCRIPTION;
}
}
return;
}
if (STATUS_CODE_MESSAGES[this.httpCode]) {
this.addToMessages(this.message);
this.message = STATUS_CODE_MESSAGES[this.httpCode];
return;
}
switch (this.httpCode.charAt(0)) {
case '4':
this.addToMessages(this.message);
this.message = STATUS_CODE_MESSAGES['4XX'];
break;
case '5':
this.addToMessages(this.message);
this.message = STATUS_CODE_MESSAGES['5XX'];
break;
default:
if (!this.message) {
if (this.description) {
this.message = this.description;
this.description = undefined;
} else {
this.message = UNKNOWN_ERROR_MESSAGE;
this.description = UNKNOWN_ERROR_DESCRIPTION;
}
}
}
if (this.node.type === NO_OP_NODE_TYPE && this.message === UNKNOWN_ERROR_MESSAGE) {
this.message = `${UNKNOWN_ERROR_MESSAGE_CRED} - ${this.httpCode}`;
}
}
}
@@ -0,0 +1,55 @@
import { NodeError } from './abstract/node.error';
import { ApplicationError } from '@n8n/errors';
import type { NodeOperationErrorOptions } from './node-api.error';
import type { INode, JsonObject } from '../interfaces';
/**
* Class for instantiating an operational error, e.g. an invalid credentials error.
*/
export class NodeOperationError extends NodeError {
type: string | undefined;
constructor(
node: INode,
error: Error | string | JsonObject,
options: NodeOperationErrorOptions = {},
) {
if (error instanceof NodeOperationError) {
return error;
}
if (typeof error === 'string') {
error = new ApplicationError(error, { level: options.level ?? 'warning' });
}
super(node, error);
if (error instanceof NodeError && error?.messages?.length) {
error.messages.forEach((message) => this.addToMessages(message));
}
if (options.message) this.message = options.message;
this.level = options.level ?? 'warning';
if (options.functionality) this.functionality = options.functionality;
if (options.type) this.type = options.type;
if (options.description) this.description = options.description;
else if ('description' in error && typeof error.description === 'string')
this.description = error.description;
this.context.runIndex = options.runIndex;
this.context.itemIndex = options.itemIndex;
this.context.metadata = options.metadata;
if (this.message === this.description) {
this.description = undefined;
}
[this.message, this.messages] = this.setDescriptiveErrorMessage(
this.message,
this.messages,
undefined,
options.messageMapping,
);
}
}
@@ -0,0 +1,7 @@
import { ExecutionBaseError } from './abstract/execution-base.error';
export class NodeSslError extends ExecutionBaseError {
constructor(cause: Error) {
super("SSL Issue: consider using the 'Ignore SSL issues' option", { cause });
}
}
@@ -0,0 +1,19 @@
import { WorkflowOperationError } from './workflow-operation.error';
export class SubworkflowOperationError extends WorkflowOperationError {
override description = '';
override cause: Error;
constructor(message: string, description: string) {
super(message);
this.name = this.constructor.name;
this.description = description;
this.cause = {
name: this.name,
message,
stack: this.stack as string,
};
}
}
@@ -0,0 +1,16 @@
import { ApplicationError, type ErrorLevel } from '@n8n/errors';
import type { INode } from '../interfaces';
interface TriggerCloseErrorOptions extends ErrorOptions {
level: ErrorLevel;
}
export class TriggerCloseError extends ApplicationError {
constructor(
readonly node: INode,
{ cause, level }: TriggerCloseErrorOptions,
) {
super('Trigger Close Failed', { cause, extra: { nodeName: node.name } });
this.level = level;
}
}
@@ -0,0 +1,10 @@
import { WorkflowActivationError } from './workflow-activation.error';
export class WebhookPathTakenError extends WorkflowActivationError {
constructor(nodeName: string, cause?: Error) {
super(
`The URL path that the "${nodeName}" node uses is already taken. Please change it to something else.`,
{ level: 'warning', cause },
);
}
}
@@ -0,0 +1,59 @@
import { ExecutionBaseError } from './abstract/execution-base.error';
import type { ApplicationError } from '@n8n/errors';
import type { INode } from '../interfaces';
interface WorkflowActivationErrorOptions {
cause?: Error;
node?: INode;
level?: ApplicationError['level'];
workflowId?: string;
}
/**
* Class for instantiating an workflow activation error
*/
export class WorkflowActivationError extends ExecutionBaseError {
node: INode | undefined;
workflowId: string | undefined;
constructor(
message: string,
{ cause, node, level, workflowId }: WorkflowActivationErrorOptions = {},
) {
let error = cause as Error;
if (cause instanceof ExecutionBaseError) {
error = new Error(cause.message);
error.constructor = cause.constructor;
error.name = cause.name;
error.stack = cause.stack;
}
super(message, { cause: error });
this.node = node;
this.workflowId = workflowId;
this.message = message;
this.setLevel(level);
}
private setLevel(level?: ApplicationError['level']) {
if (level) {
this.level = level;
return;
}
if (
[
'etimedout', // Node.js
'econnrefused', // Node.js
'eauth', // OAuth
'temporary authentication failure', // IMAP server
'invalid credentials',
].some((str) => this.message.toLowerCase().includes(str))
) {
this.level = 'warning';
return;
}
this.level = 'error';
}
}
@@ -0,0 +1,6 @@
import { NodeOperationError } from './node-operation.error';
/**
* A type of NodeOperationError caused by a configuration problem somewhere in workflow.
*/
export class WorkflowConfigurationError extends NodeOperationError {}
@@ -0,0 +1,3 @@
import { WorkflowActivationError } from './workflow-activation.error';
export class WorkflowDeactivationError extends WorkflowActivationError {}
@@ -0,0 +1,20 @@
import type { INode } from '../interfaces';
import { ExecutionBaseError } from './abstract/execution-base.error';
/**
* Class for instantiating an operational error, e.g. a timeout error.
*/
export class WorkflowOperationError extends ExecutionBaseError {
node: INode | undefined;
override timestamp: number;
constructor(message: string, node?: INode, description?: string) {
super(message, { cause: undefined });
this.level = 'warning';
this.name = this.constructor.name;
if (description) this.description = description;
this.node = node;
this.timestamp = Date.now();
}
}
@@ -0,0 +1,25 @@
/**
* Evaluation-related utility functions
*
* This file contains utilities that need to be shared between different packages
* to avoid circular dependencies. For example, the evaluation test-runner (in CLI package)
* and the Evaluation node (in nodes-base package) both need to know which metrics
* require AI model connections, but they can't import from each other directly.
*
* By placing shared utilities here in the workflow package (which both packages depend on),
* we avoid circular dependency issues.
*/
/**
* Default metric type used in evaluations
*/
export const DEFAULT_EVALUATION_METRIC = 'correctness';
/**
* Determines if a given evaluation metric requires an AI model connection
* @param metric The metric name to check
* @returns true if the metric requires an AI model connection
*/
export function metricRequiresModelConnection(metric: string): boolean {
return ['correctness', 'helpfulness'].includes(metric);
}
@@ -0,0 +1,50 @@
import z from 'zod/v4';
const ExecutionContextEstablishmentHookParameterSchemaV1 = z.object({
executionsHooksVersion: z.literal(1),
contextEstablishmentHooks: z.object({
hooks: z
.array(
z
.object({
hookName: z.string(),
isAllowedToFail: z.boolean().optional().default(false),
})
.loose(),
)
.optional()
.default([]),
}),
});
export type ExecutionContextEstablishmentHookParameterV1 = z.output<
typeof ExecutionContextEstablishmentHookParameterSchemaV1
>;
export const ExecutionContextEstablishmentHookParameterSchema = z
.discriminatedUnion('executionsHooksVersion', [
ExecutionContextEstablishmentHookParameterSchemaV1,
])
.meta({
title: 'ExecutionContextEstablishmentHookParameter',
});
export type ExecutionContextEstablishmentHookParameter = z.output<
typeof ExecutionContextEstablishmentHookParameterSchema
>;
/**
* Safely parses an execution context establishment hook parameters
* @param obj
* @returns
*/
export const toExecutionContextEstablishmentHookParameter = (value: unknown) => {
if (value === null || value === undefined || typeof value !== 'object') {
return null;
}
// Quick check to avoid unnecessary parsing attempts
if (!('executionsHooksVersion' in value)) {
return null;
}
return ExecutionContextEstablishmentHookParameterSchema.safeParse(value);
};
+208
View File
@@ -0,0 +1,208 @@
import z, { type ZodType } from 'zod/v4';
import { jsonParse } from './utils';
const CredentialContextSchemaV1 = z.object({
version: z.literal(1),
/**
* Identity token/value used for credential resolution
* Could be JWT, API key, session token, user ID, etc.
*/
identity: z.string(),
/**
* Optional metadata for credential resolution
*/
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type ICredentialContextV1 = z.output<typeof CredentialContextSchemaV1>;
export const CredentialContextSchema = z
.discriminatedUnion('version', [CredentialContextSchemaV1])
.meta({
title: 'ICredentialContext',
});
/**
* Decrypted structure of credentials field
* Never stored in this form - always encrypted in IExecutionContext
*/
export type ICredentialContext = z.output<typeof CredentialContextSchema>;
const WorkflowExecuteModeSchema = z.union([
z.literal('cli'),
z.literal('error'),
z.literal('integrated'),
z.literal('internal'),
z.literal('manual'),
z.literal('retry'),
z.literal('trigger'),
z.literal('webhook'),
z.literal('evaluation'),
z.literal('chat'),
]);
export type WorkflowExecuteModeValues = z.infer<typeof WorkflowExecuteModeSchema>;
const RedactionPolicySchema = z.union([
z.literal('none'),
z.literal('all'),
z.literal('non-manual'),
]);
const RedactionSettingSchemaV1 = z.object({
version: z.literal(1),
policy: RedactionPolicySchema,
});
export type IRedactionSettingV1 = z.output<typeof RedactionSettingSchemaV1>;
const ExecutionContextSchemaV1 = z.object({
version: z.literal(1),
/**
* When the context was established (Unix timestamp in milliseconds)
*/
establishedAt: z.number(),
/**
* The mode in which the workflow is being executed
*/
source: WorkflowExecuteModeSchema,
/**
* Optional node where execution started
*/
triggerNode: z
.object({
name: z.string(),
type: z.string(),
})
.optional(),
/**
* Optional ID of the parent execution, if this is set this
* execution context inherited from the mentioned parent execution context.
*/
parentExecutionId: z.string().optional(),
/**
* Encrypted credential context for dynamic credential resolution
* Always encrypted when stored, decrypted on-demand by credential resolver
* @see ICredentialContext for decrypted structure
*/
credentials: z.string().optional().meta({
description:
'Encrypted credential context for dynamic credential resolution Always encrypted when stored, decrypted on-demand by credential resolver @see ICredentialContext for decrypted structure',
}),
/**
* Redaction setting captured at execution time.
* Persisted so the correct redaction policy is applied when reading execution data,
* regardless of any subsequent changes to the workflow setting.
*/
redaction: RedactionSettingSchemaV1.optional(),
});
export type IExecutionContextV1 = z.output<typeof ExecutionContextSchemaV1>;
export const ExecutionContextSchema = z
.discriminatedUnion('version', [ExecutionContextSchemaV1])
.meta({
title: 'IExecutionContext',
});
/**
* Execution context carries per-execution metadata throughout workflow lifecycle
* Established at execution start and propagated to sub-workflows/error workflows
*/
export type IExecutionContext = z.output<typeof ExecutionContextSchema>;
/**
* Runtime representation of execution context with decrypted credential data.
*
* This type is identical to IExecutionContext except the `credentials` field
* contains the decrypted ICredentialContext object instead of an encrypted string.
*
* **Usage contexts:**
* - Hook execution: Hooks work with plaintext context to extract/merge credential data
* - Credential resolution: Resolvers need decrypted identity tokens
* - Internal processing: Runtime operations that need access to credential context
*
* **Security notes:**
* - Never persist this type to database - use IExecutionContext with encrypted credentials
* - Never expose in API responses or logs
* - Only exists in-memory during workflow execution
* - Should be cleared from memory after use
*
* **Lifecycle:**
* 1. Load IExecutionContext from storage (credentials encrypted)
* 2. Decrypt credentials field → PlaintextExecutionContext (runtime only)
* 3. Use for hook execution, credential resolution, etc.
* 4. Encrypt credentials → IExecutionContext before persistence
*
* @see IExecutionContext - Persisted form with encrypted credentials
* @see ICredentialContext - Decrypted credential structure
* @see IExecutionContextUpdate - Partial updates during hook execution
*
* @example
* ```typescript
* // During hook execution:
* const plaintextContext: PlaintextExecutionContext = {
* ...context,
* credentials: decryptCredentials(context.credentials) // Decrypt for runtime use
* };
*
* // Hook can now access plaintext credential data
* const identity = plaintextContext.credentials?.identity;
*
* // Before storage, re-encrypt:
* const storableContext: IExecutionContext = {
* ...plaintextContext,
* credentials: encryptCredentials(plaintextContext.credentials)
* };
* ```
*/
export type PlaintextExecutionContext = Omit<IExecutionContext, 'credentials'> & {
credentials?: ICredentialContext;
};
export const safeParse = <T extends ZodType>(value: string | object, schema: T) => {
const typeName = schema.meta()?.title ?? 'Object';
try {
const normalizedObject = typeof value === 'string' ? jsonParse(value) : value;
const parseResult = schema.safeParse(normalizedObject);
if (parseResult.error) {
throw parseResult.error;
}
// here we could implement a mgiration policy for migrating old execution context versions to newer ones
return parseResult.data;
} catch (error) {
throw new Error(`Failed to parse to valid ${typeName}`, {
cause: error,
});
}
};
/**
* Safely parses an execution context from an
* @param obj
* @returns
*/
export const toExecutionContext = (value: string | object): IExecutionContext => {
// here we could implement a mgiration policy for migrating old execution context versions to newer ones
return safeParse(value, ExecutionContextSchema);
};
/**
* Safely parses a credential context from either an object or a string to an
* ICredentialContext. This can be used to safely parse a decrypted context for
* example.
* @param value The object or string to be parsed
* @returns ICredentialContext
* @throws Error in case parsing fails for any reason
*/
export const toCredentialContext = (value: string | object): ICredentialContext => {
// here we could implement a mgiration policy for migrating old credential context versions to newer ones
return safeParse(value, CredentialContextSchema);
};
+12
View File
@@ -0,0 +1,12 @@
export const ExecutionStatusList = [
'canceled',
'crashed',
'error',
'new',
'running',
'success',
'unknown',
'waiting',
] as const;
export type ExecutionStatus = (typeof ExecutionStatusList)[number];
@@ -0,0 +1,21 @@
import { Tournament } from '@n8n/tournament';
import { DollarSignValidator, ThisSanitizer, PrototypeSanitizer } from './expression-sandboxing';
type Evaluator = (expr: string, data: unknown) => string | null | (() => unknown);
type ErrorHandler = (error: Error) => void;
const errorHandler: ErrorHandler = () => {};
const tournamentEvaluator = new Tournament(errorHandler, undefined, undefined, {
before: [ThisSanitizer],
after: [PrototypeSanitizer, DollarSignValidator],
});
const evaluator: Evaluator = tournamentEvaluator.execute.bind(tournamentEvaluator);
export const setErrorHandler = (handler: ErrorHandler) => {
tournamentEvaluator.errorHandler = handler;
};
export const evaluateExpression: Evaluator = (expr, data) => {
return evaluator(expr, data);
};
@@ -0,0 +1,563 @@
import { type ASTAfterHook, type ASTBeforeHook, astBuilders as b, astVisit } from '@n8n/tournament';
import {
ExpressionClassExtensionError,
ExpressionComputedDestructuringError,
ExpressionDestructuringError,
ExpressionError,
ExpressionReservedVariableError,
ExpressionWithStatementError,
} from './errors';
import { isSafeObjectProperty } from './utils';
export const sanitizerName = '__sanitize';
const sanitizerIdentifier = b.identifier(sanitizerName);
const DATA_NODE_NAME = '___n8n_data';
const RESERVED_VARIABLE_NAMES = new Set([DATA_NODE_NAME, sanitizerName]);
type AstNode = { type: string } & Record<string, unknown>;
const isAstNode = (value: unknown): value is AstNode =>
typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string';
const getBoundIdentifiers = (node: unknown, acc: string[] = []): string[] => {
if (!isAstNode(node)) return acc;
switch (node.type) {
case 'Identifier': {
if (typeof node.name === 'string') acc.push(node.name);
break;
}
case 'ObjectPattern': {
if (!Array.isArray(node.properties)) break;
for (const property of node.properties) {
if (!isAstNode(property)) continue;
if (property.type === 'Property') {
getBoundIdentifiers(property.value, acc);
} else if (property.type === 'RestElement') {
getBoundIdentifiers(property.argument, acc);
}
}
break;
}
case 'ArrayPattern': {
if (!Array.isArray(node.elements)) break;
for (const element of node.elements) {
getBoundIdentifiers(element, acc);
}
break;
}
case 'AssignmentPattern': {
getBoundIdentifiers(node.left, acc);
break;
}
case 'RestElement': {
getBoundIdentifiers(node.argument, acc);
break;
}
case 'VariableDeclaration': {
if (!Array.isArray(node.declarations)) break;
for (const declaration of node.declarations) {
if (!isAstNode(declaration) || declaration.type !== 'VariableDeclarator') continue;
getBoundIdentifiers(declaration.id, acc);
}
break;
}
}
return acc;
};
const getReservedIdentifier = (node: unknown): string | undefined =>
getBoundIdentifiers(node).find((name) => RESERVED_VARIABLE_NAMES.has(name));
export const DOLLAR_SIGN_ERROR = 'Cannot access "$" without calling it as a function';
const EMPTY_CONTEXT = b.objectExpression([
b.property('init', b.identifier('process'), b.objectExpression([])),
b.property('init', b.identifier('require'), b.objectExpression([])),
b.property('init', b.identifier('module'), b.objectExpression([])),
b.property('init', b.identifier('Buffer'), b.objectExpression([])),
]);
const SAFE_GLOBAL = b.objectExpression([]);
const SAFE_THIS = b.sequenceExpression([b.literal(0), EMPTY_CONTEXT]);
/**
* Helper to check if an expression is a valid property access with $ as the property.
* Returns true for obj.$ or obj.nested.$ but false for bare $ or other expression contexts.
*/
const isValidDollarPropertyAccess = (expr: unknown): boolean => {
if (
typeof expr !== 'object' ||
expr === null ||
!('type' in expr) ||
expr.type !== 'MemberExpression' ||
!('property' in expr) ||
!('object' in expr)
) {
return false;
}
const property = expr.property;
const object = expr.object;
// $ must be the property
const isPropertyDollar =
typeof property === 'object' &&
property !== null &&
'name' in property &&
property.name === '$';
// $ must NOT be the object (to block $.something)
const isObjectDollar =
typeof object === 'object' && object !== null && 'name' in object && object.name === '$';
// Object must be an Identifier (obj) or MemberExpression (obj.nested)
// This excludes bare $ or $ in other expression contexts
const isObjectValid =
typeof object === 'object' &&
object !== null &&
'type' in object &&
(object.type === 'Identifier' || object.type === 'MemberExpression');
return isPropertyDollar && !isObjectDollar && isObjectValid;
};
const GLOBAL_IDENTIFIERS = new Set(['globalThis']);
const BLOCKED_SPREAD_GLOBALS = new Set(['process', 'global', 'globalThis', 'Buffer']);
/**
* Prevents regular functions from binding their `this` to the Node.js global.
*/
export const ThisSanitizer: ASTBeforeHook = (ast, dataNode) => {
astVisit(ast, {
visitCallExpression(path) {
const { node } = path;
if (node.callee.type !== 'FunctionExpression') {
this.traverse(path);
return;
}
const fnExpression = node.callee;
/**
* Called function expressions (IIFEs) - both anonymous and named:
*
* ```js
* (function(x) { return x * 2; })(5)
* (function factorial(n) { return n <= 1 ? 1 : n * factorial(n-1); })(5)
*
* // become
*
* (function(x) { return x * 2; }).call({ process: {} }, 5)
* (function factorial(n) { return n <= 1 ? 1 : n * factorial(n-1); }).call({ process: {} }, 5)
* ```
*/
this.traverse(path); // depth first to transform inside out
const callExpression = b.callExpression(
b.memberExpression(fnExpression, b.identifier('call')),
[EMPTY_CONTEXT, ...node.arguments],
);
path.replace(callExpression);
return false;
},
visitFunctionExpression(path) {
const { node } = path;
/**
* Callable function expressions (callbacks) - both anonymous and named:
*
* ```js
* [1, 2, 3].map(function(n) { return n * 2; })
* [1, 2, 3].map(function factorial(n) { return n <= 1 ? 1 : n * factorial(n-1); })
*
* // become
*
* [1, 2, 3].map((function(n) { return n * 2; }).bind({ process: {} }))
* [1, 2, 3].map((function factorial(n) { return n <= 1 ? 1 : n * factorial(n-1); }).bind({ process: {} }))
* ```
*/
this.traverse(path);
const boundFunction = b.callExpression(b.memberExpression(node, b.identifier('bind')), [
EMPTY_CONTEXT,
]);
path.replace(boundFunction);
return false;
},
visitIdentifier(path) {
this.traverse(path);
const { node } = path;
if (GLOBAL_IDENTIFIERS.has(node.name)) {
const parent: unknown = path.parent;
const isPropertyName =
typeof parent === 'object' &&
parent !== null &&
'name' in parent &&
parent.name === 'property';
if (!isPropertyName) path.replace(SAFE_GLOBAL);
}
},
visitThisExpression(path) {
this.traverse(path);
/**
* Replace `this` with a safe context object.
* This prevents arrow functions from accessing the real global context:
*
* ```js
* (() => this?.process)() // becomes (() => (0, { process: {} })?.process)()
* ```
*
* Arrow functions don't have their own `this` binding - they inherit from
* the outer lexical scope. Without this fix, `this` inside an arrow function
* would resolve to the Node.js global object, exposing process.env and other
* sensitive data.
*
* We use SAFE_THIS (a sequence expression) instead of EMPTY_CONTEXT directly
* to ensure the object literal is unambiguously parsed as an expression.
*/
path.replace(SAFE_THIS);
},
});
};
/**
* Validates that the $ identifier is only used in allowed contexts.
* This prevents user errors like `{{ $ }}` which would return the function object itself.
*
* Allowed contexts:
* - As a function call: $()
* - As a property name: obj.$ (where $ is a valid property name in JavaScript)
*
* Disallowed contexts:
* - Bare identifier: $
* - As object in member expression: $.property
* - In expressions: "prefix" + $, [1, 2, $], etc.
*/
export const DollarSignValidator: ASTAfterHook = (ast, _dataNode) => {
astVisit(ast, {
visitIdentifier(path) {
this.traverse(path);
const node = path.node;
// Only check for the exact identifier '$'
if (node.name !== '$') return;
// Runtime type checking since path properties are typed as 'any'
const parent: unknown = path.parent;
// Check if parent is a path object with a 'name' property
if (typeof parent !== 'object' || parent === null || !('name' in parent)) {
throw new ExpressionError(DOLLAR_SIGN_ERROR);
}
// Allow $ when it's the callee: $()
// parent.name === 'callee' means the parent path represents the callee field
if (parent.name === 'callee') {
return;
}
// Block when $ is the object in a MemberExpression: $.something
// parent.name === 'object' means the parent path represents the object field
if (parent.name === 'object') {
throw new ExpressionError(DOLLAR_SIGN_ERROR);
}
// Check if $ is the property of a MemberExpression: obj.$
// For obj.$: parent.name is 'expression' and grandparent has ExpressionStatement
// The ExpressionStatement should contain a MemberExpression with $ as property
if ('parent' in parent && typeof parent.parent === 'object' && parent.parent !== null) {
const grandparent = parent.parent;
if (
'value' in grandparent &&
typeof grandparent.value === 'object' &&
grandparent.value !== null
) {
const gpNode = grandparent.value;
// ExpressionStatement has an 'expression' field containing the actual expression
if ('type' in gpNode && gpNode.type === 'ExpressionStatement' && 'expression' in gpNode) {
// Check if this is a valid property access like obj.$
if (isValidDollarPropertyAccess(gpNode.expression)) {
return;
}
}
}
}
// Disallow all other cases (bare $, $ in expressions, etc.)
throw new ExpressionError(DOLLAR_SIGN_ERROR);
},
});
};
const blockedBaseClasses = new Set([
'Function',
'GeneratorFunction',
'AsyncFunction',
'AsyncGeneratorFunction',
]);
/**
* Builds an AST node that safely resolves a spread argument like `...process`.
*
* Tournament's VariablePolyfill rewrites plain identifiers (e.g. `process`)
* to look them up from the data context, but it does NOT handle identifiers
* inside SpreadElement / SpreadProperty nodes. Without this fix, `{...process}`
* would resolve to the real Node.js `process` object.
*
* The generated code checks the data context first, falling back to a throw:
*
* ("process" in data) ? data.process : (() => { throw new Error("...") })()
*
* - If the workflow has a variable called "process" → spread that (safe, user-defined)
* - Otherwise → throw at runtime, blocking access to the real global
*/
const buildSafeSpreadArg = (name: string, dataNode: Parameters<ASTAfterHook>[1]) => {
// "process" in ___n8n_data
const isInDataContext = b.binaryExpression('in', b.literal(name), dataNode);
// ___n8n_data.process
const readFromDataContext = b.memberExpression(dataNode, b.identifier(name));
// (() => { throw new Error('Cannot spread "process" ...') })()
//
// This is an IIFE because `throw` is a statement, not an expression,
// so it cannot appear directly inside a ternary's falsy branch.
const throwSecurityError = b.callExpression(
b.arrowFunctionExpression(
[],
b.blockStatement([
b.throwStatement(
b.newExpression(b.identifier('Error'), [
b.literal(`Cannot spread "${name}" due to security concerns`),
]),
),
]),
),
[],
);
// Full result:
// ("process" in ___n8n_data) ? ___n8n_data.process : (() => { throw ... })()
return b.conditionalExpression(isInDataContext, readFromDataContext, throwSecurityError);
};
export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => {
astVisit(ast, {
visitVariableDeclarator(path) {
this.traverse(path);
const node = path.node;
const reservedIdentifier = getReservedIdentifier(node.id);
if (reservedIdentifier === undefined) return;
throw new ExpressionReservedVariableError(reservedIdentifier);
},
visitFunction(path) {
this.traverse(path);
const node = path.node;
const functionName = getReservedIdentifier(node.id);
if (functionName !== undefined) {
throw new ExpressionReservedVariableError(functionName);
}
for (const param of node.params) {
const paramName = getReservedIdentifier(param);
if (paramName !== undefined) {
throw new ExpressionReservedVariableError(paramName);
}
}
},
visitCatchClause(path) {
this.traverse(path);
const node = path.node;
const catchParamName = getReservedIdentifier(node.param);
if (catchParamName === undefined) return;
throw new ExpressionReservedVariableError(catchParamName);
},
visitClassDeclaration(path) {
this.traverse(path);
const node = path.node;
const className = getReservedIdentifier(node.id);
if (className !== undefined) {
throw new ExpressionReservedVariableError(className);
}
if (node.superClass) {
if (node.superClass.type === 'Identifier') {
if (blockedBaseClasses.has(node.superClass.name)) {
throw new ExpressionClassExtensionError(node.superClass.name);
}
} else {
throw new ExpressionError('Cannot use dynamic class extension due to security concerns');
}
}
},
visitClassExpression(path) {
this.traverse(path);
const node = path.node;
const className = getReservedIdentifier(node.id);
if (className !== undefined) {
throw new ExpressionReservedVariableError(className);
}
if (node.superClass) {
if (node.superClass.type === 'Identifier') {
if (blockedBaseClasses.has(node.superClass.name)) {
throw new ExpressionClassExtensionError(node.superClass.name);
}
} else {
throw new ExpressionError('Cannot use dynamic class extension due to security concerns');
}
}
},
visitAssignmentExpression(path) {
this.traverse(path);
const node = path.node;
const assignedIdentifier = getReservedIdentifier(node.left);
if (assignedIdentifier === undefined) return;
throw new ExpressionReservedVariableError(assignedIdentifier);
},
visitUpdateExpression(path) {
this.traverse(path);
const node = path.node;
const updatedIdentifier = getReservedIdentifier(node.argument);
if (updatedIdentifier === undefined) return;
throw new ExpressionReservedVariableError(updatedIdentifier);
},
visitForOfStatement(path) {
this.traverse(path);
const node = path.node;
const loopBinding = getReservedIdentifier(node.left);
if (loopBinding === undefined) return;
throw new ExpressionReservedVariableError(loopBinding);
},
visitForInStatement(path) {
this.traverse(path);
const node = path.node;
const loopBinding = getReservedIdentifier(node.left);
if (loopBinding === undefined) return;
throw new ExpressionReservedVariableError(loopBinding);
},
visitMemberExpression(path) {
this.traverse(path);
const node = path.node;
if (!node.computed) {
// This is static, so we're safe to error here
if (node.property.type !== 'Identifier') {
throw new ExpressionError(
`Unknown property type ${node.property.type} while sanitising expression`,
);
}
if (!isSafeObjectProperty(node.property.name)) {
throw new ExpressionError(
`Cannot access "${node.property.name}" due to security concerns`,
);
}
} else if (node.property.type === 'StringLiteral' || node.property.type === 'Literal') {
// Check any static strings against our forbidden list
if (!isSafeObjectProperty(node.property.value as string)) {
throw new ExpressionError(
`Cannot access "${node.property.value as string}" due to security concerns`,
);
}
} else {
path.replace(
b.memberExpression(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
node.object as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
b.callExpression(b.memberExpression(dataNode, sanitizerIdentifier), [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
node.property as any,
]),
true,
),
);
}
},
visitObjectPattern(path) {
this.traverse(path);
const node = path.node;
for (const prop of node.properties) {
if (prop.type === 'Property') {
if (prop.computed) {
throw new ExpressionComputedDestructuringError();
}
let keyName: string | undefined;
if (prop.key.type === 'Identifier') {
keyName = prop.key.name;
} else if (prop.key.type === 'StringLiteral' || prop.key.type === 'Literal') {
keyName = String(prop.key.value);
}
if (keyName !== undefined && !isSafeObjectProperty(keyName)) {
throw new ExpressionDestructuringError(keyName);
}
}
}
},
visitSpreadElement(path) {
this.traverse(path);
const { argument } = path.node;
if (argument.type === 'Identifier' && BLOCKED_SPREAD_GLOBALS.has(argument.name)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(path.node as any).argument = buildSafeSpreadArg(argument.name, dataNode);
}
},
visitSpreadProperty(path) {
this.traverse(path);
const { argument } = path.node;
if (argument.type === 'Identifier' && BLOCKED_SPREAD_GLOBALS.has(argument.name)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(path.node as any).argument = buildSafeSpreadArg(argument.name, dataNode);
}
},
visitWithStatement() {
throw new ExpressionWithStatementError();
},
});
};
export const sanitizer = (value: unknown): unknown => {
const propertyKey = String(value);
if (!isSafeObjectProperty(propertyKey)) {
throw new ExpressionError(`Cannot access "${propertyKey}" due to security concerns`);
}
return propertyKey;
};
+466
View File
@@ -0,0 +1,466 @@
import { ApplicationError } from '@n8n/errors';
import { DateTime, Duration, Interval } from 'luxon';
import { ExpressionExtensionError } from './errors/expression-extension.error';
import { ExpressionError } from './errors/expression.error';
import { evaluateExpression, setErrorHandler } from './expression-evaluator-proxy';
import { sanitizer, sanitizerName } from './expression-sandboxing';
import { isExpression } from './expressions/expression-helpers';
import { extend, extendOptional } from './extensions';
import { extendSyntax } from './extensions/expression-extension';
import { extendedFunctions } from './extensions/extended-functions';
import type {
IDataObject,
INodeParameters,
IWorkflowDataProxyData,
NodeParameterValue,
} from './interfaces';
const IS_FRONTEND_IN_DEV_MODE =
typeof process === 'object' &&
Object.keys(process).length === 1 &&
'env' in process &&
Object.keys(process.env).length === 0;
const IS_FRONTEND = typeof process === 'undefined' || IS_FRONTEND_IN_DEV_MODE;
const isSyntaxError = (error: unknown): error is SyntaxError =>
error instanceof SyntaxError || (error instanceof Error && error.name === 'SyntaxError');
const isExpressionError = (error: unknown): error is ExpressionError =>
error instanceof ExpressionError || error instanceof ExpressionExtensionError;
const isTypeError = (error: unknown): error is TypeError =>
error instanceof TypeError || (error instanceof Error && error.name === 'TypeError');
// Make sure that error get forwarded
setErrorHandler((error: Error) => {
if (isExpressionError(error)) throw error;
});
/**
* Creates a safe Object wrapper that removes dangerous static methods
* that could be used to bypass property access sanitization.
*
* Blocked methods:
* - defineProperty/defineProperties: Can set properties bypassing access checks
* - setPrototypeOf/getPrototypeOf: Can manipulate prototype chains
* - getOwnPropertyDescriptor(s): Can introspect sensitive properties
* - __defineGetter__/__defineSetter__: Legacy methods that can bypass set traps
* - __lookupGetter__/__lookupSetter__: Can introspect getters/setters
*
* Object.create is wrapped to prevent passing property descriptors (2nd argument)
*/
const createSafeObject = (): typeof Object => {
const safeCreate = (proto: object | null): object => {
// Only allow single-argument create (no property descriptors)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Object.create(proto);
};
// Block dangerous static and prototype methods
const blockedMethods = new Set([
// Static methods that can bypass property access checks
'defineProperty',
'defineProperties',
'setPrototypeOf',
'getPrototypeOf',
'getOwnPropertyDescriptor',
'getOwnPropertyDescriptors',
// Legacy methods that can bypass Proxy set traps
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__',
]);
// Create a proxy that blocks dangerous methods
return new Proxy(Object, {
get(target, prop, receiver) {
if (blockedMethods.has(prop as string)) {
return undefined;
}
// Wrap Object.create to prevent property descriptor argument
if (prop === 'create') {
return safeCreate;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver);
},
// Block defineProperty trap to prevent __defineGetter__ from working
defineProperty() {
return false;
},
});
};
/**
* List of properties that are blocked on Error and all Error subclasses.
* These properties can be exploited for sandbox escape via V8's stack trace API.
*/
const blockedErrorProperties = new Set([
// V8 stack trace manipulation
'captureStackTrace',
'prepareStackTrace',
'stackTraceLimit',
// Legacy methods that can bypass Proxy set traps
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__',
]);
/**
* Creates a safe Error constructor that removes dangerous static methods
* like captureStackTrace and prepareStackTrace which can be exploited for RCE.
*
* The V8 prepareStackTrace attack works by:
* 1. Setting Error.prepareStackTrace to a malicious function
* 2. Creating a new Error and accessing its .stack property
* 3. V8 calls the prepareStackTrace function with CallSite objects
* 4. CallSite.getThis() returns the real global object, escaping the sandbox
*/
const createSafeError = (): typeof Error => {
return new Proxy(Error, {
get(target, prop, receiver) {
if (blockedErrorProperties.has(prop as string)) {
return undefined;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver);
},
set() {
// Prevent setting any properties on Error (like prepareStackTrace)
return false;
},
defineProperty() {
// Prevent defineProperty (blocks __defineGetter__ internally)
return false;
},
});
};
/**
* Creates a safe wrapper for Error subclasses (TypeError, SyntaxError, etc.)
* While prepareStackTrace is only on Error in V8, we wrap subclasses for defense in depth.
*/
const createSafeErrorSubclass = <T extends ErrorConstructor>(ErrorClass: T): T => {
return new Proxy(ErrorClass, {
get(target, prop, receiver) {
if (blockedErrorProperties.has(prop as string)) {
return undefined;
}
return Reflect.get(target, prop, receiver);
},
set() {
return false;
},
defineProperty() {
return false;
},
});
};
export class Expression {
constructor(private readonly timezone: string) {}
static initializeGlobalContext(data: IDataObject) {
/**
* Denylist
*/
data.document = {};
data.global = {};
data.window = {};
data.Window = {};
data.this = {};
data.globalThis = {};
data.self = {};
// Alerts
data.alert = {};
data.prompt = {};
data.confirm = {};
// Prevent Remote Code Execution
data.eval = {};
data.uneval = {};
data.setTimeout = {};
data.setInterval = {};
data.setImmediate = {};
data.clearImmediate = {};
data.queueMicrotask = {};
data.Function = {};
// Prevent Node.js module access
data.require = {};
data.module = {};
data.Buffer = {};
data.__dirname = {};
data.__filename = {};
// Prevent requests
data.fetch = {};
data.XMLHttpRequest = {};
// Prevent control abstraction
data.Promise = {};
data.Generator = {};
data.GeneratorFunction = {};
data.AsyncFunction = {};
data.AsyncGenerator = {};
data.AsyncGeneratorFunction = {};
// Prevent WASM
data.WebAssembly = {};
// Prevent Reflection
data.Reflect = {};
data.Proxy = {};
data.__lookupGetter__ = undefined;
data.__lookupSetter__ = undefined;
data.__defineGetter__ = undefined;
data.__defineSetter__ = undefined;
// Deprecated
data.escape = {};
data.unescape = {};
/**
* Allowlist
*/
// Dates
data.Date = Date;
data.DateTime = DateTime;
data.Interval = Interval;
data.Duration = Duration;
// Objects - use safe wrapper to block dangerous methods like defineProperty
data.Object = createSafeObject();
// Arrays
data.Array = Array;
data.Int8Array = Int8Array;
data.Uint8Array = Uint8Array;
data.Uint8ClampedArray = Uint8ClampedArray;
data.Int16Array = Int16Array;
data.Uint16Array = Uint16Array;
data.Int32Array = Int32Array;
data.Uint32Array = Uint32Array;
data.Float32Array = Float32Array;
data.Float64Array = Float64Array;
data.BigInt64Array = typeof BigInt64Array !== 'undefined' ? BigInt64Array : {};
data.BigUint64Array = typeof BigUint64Array !== 'undefined' ? BigUint64Array : {};
// Collections
data.Map = typeof Map !== 'undefined' ? Map : {};
data.WeakMap = typeof WeakMap !== 'undefined' ? WeakMap : {};
data.Set = typeof Set !== 'undefined' ? Set : {};
data.WeakSet = typeof WeakSet !== 'undefined' ? WeakSet : {};
// Errors - use safe wrappers to block prepareStackTrace, captureStackTrace,
// and other dangerous properties that could enable sandbox escape
data.Error = createSafeError();
data.TypeError = createSafeErrorSubclass(TypeError);
data.SyntaxError = createSafeErrorSubclass(SyntaxError);
data.EvalError = createSafeErrorSubclass(EvalError);
data.RangeError = createSafeErrorSubclass(RangeError);
data.ReferenceError = createSafeErrorSubclass(ReferenceError);
data.URIError = createSafeErrorSubclass(URIError);
// Internationalization
data.Intl = typeof Intl !== 'undefined' ? Intl : {};
// Text
// eslint-disable-next-line id-denylist
data.String = String;
data.RegExp = RegExp;
// Math
data.Math = Math;
// eslint-disable-next-line id-denylist
data.Number = Number;
data.BigInt = typeof BigInt !== 'undefined' ? BigInt : {};
data.Infinity = Infinity;
data.NaN = NaN;
data.isFinite = Number.isFinite;
data.isNaN = Number.isNaN;
data.parseFloat = parseFloat;
data.parseInt = parseInt;
// Structured data
data.JSON = JSON;
data.ArrayBuffer = typeof ArrayBuffer !== 'undefined' ? ArrayBuffer : {};
data.SharedArrayBuffer = typeof SharedArrayBuffer !== 'undefined' ? SharedArrayBuffer : {};
data.Atomics = typeof Atomics !== 'undefined' ? Atomics : {};
data.DataView = typeof DataView !== 'undefined' ? DataView : {};
data.encodeURI = encodeURI;
data.encodeURIComponent = encodeURIComponent;
data.decodeURI = decodeURI;
data.decodeURIComponent = decodeURIComponent;
// Other
// eslint-disable-next-line id-denylist
data.Boolean = Boolean;
data.Symbol = Symbol;
}
static resolveWithoutWorkflow(expression: string, data: IDataObject = {}) {
return evaluateExpression(expression, data);
}
/**
* Converts an object to a string in a way to make it clear that
* the value comes from an object
*
*/
convertObjectValueToString(value: object): string {
if (value instanceof DateTime && value.invalidReason !== null) {
throw new ApplicationError('invalid DateTime');
}
if (value === null) {
return 'null';
}
let typeName = value.constructor.name ?? 'Object';
if (DateTime.isDateTime(value)) {
typeName = 'DateTime';
}
let result = '';
if (value instanceof Date) {
// We don't want to use JSON.stringify for dates since it disregards workflow timezone
result = DateTime.fromJSDate(value, {
zone: this.timezone,
}).toISO();
} else if (DateTime.isDateTime(value)) {
result = value.toString();
} else {
result = JSON.stringify(value);
}
result = result
.replace(/,"/g, ', "') // spacing for
.replace(/":/g, '": '); // readability
return `[${typeName}: ${result}]`;
}
/**
* Resolves the parameter value. If it is an expression it will execute it and
* return the result. For everything simply the supplied value will be returned.
*
* @param {NodeParameterValue} parameterValue - The parameter value to resolve
* @param {IWorkflowDataProxyData} data - The workflow data proxy data
* @param {boolean} [returnObjectAsString=false] - Whether to convert objects to strings
*/
resolveSimpleParameterValue(
parameterValue: NodeParameterValue,
data: IWorkflowDataProxyData,
returnObjectAsString = false,
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] {
// Check if it is an expression
if (!isExpression(parameterValue)) {
// Is no expression so return value
return parameterValue;
}
// Is an expression
// Remove the equal sign
parameterValue = parameterValue.substr(1);
// Support only a subset of process properties
data.process =
typeof process !== 'undefined'
? {
arch: process.arch,
env: process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE !== 'false' ? {} : process.env,
platform: process.platform,
pid: process.pid,
ppid: process.ppid,
release: process.release,
version: process.pid,
versions: process.versions,
}
: {};
Expression.initializeGlobalContext(data);
// expression extensions
data.extend = extend;
data.extendOptional = extendOptional;
Object.defineProperty(data, sanitizerName, {
value: sanitizer,
writable: false,
configurable: false,
});
Object.assign(data, extendedFunctions);
const constructorValidation = new RegExp(/\.\s*constructor/gm);
if (parameterValue.match(constructorValidation)) {
throw new ExpressionError('Expression contains invalid constructor function call', {
causeDetailed: 'Constructor override attempt is not allowed due to security concerns',
runIndex: data.$thisRunIndex,
itemIndex: data.$thisItemIndex,
});
}
// Execute the expression
const extendedExpression = extendSyntax(parameterValue);
const returnValue = this.renderExpression(extendedExpression, data);
if (typeof returnValue === 'function') {
if (returnValue.name === 'DateTime')
throw new ApplicationError('this is a DateTime, please access its methods');
throw new ApplicationError('this is a function, please add ()');
} else if (typeof returnValue === 'string') {
return returnValue;
} else if (returnValue !== null && typeof returnValue === 'object') {
if (returnObjectAsString) {
return this.convertObjectValueToString(returnValue);
}
}
return returnValue;
}
private renderExpression(expression: string, data: IWorkflowDataProxyData) {
try {
return evaluateExpression(expression, data);
} catch (error) {
if (isExpressionError(error)) throw error;
if (isSyntaxError(error)) throw new ApplicationError('invalid syntax');
if (isTypeError(error) && IS_FRONTEND && error.message.endsWith('is not a function')) {
const match = error.message.match(/(?<msg>[^.]+is not a function)/);
if (!match?.groups?.msg) return null;
throw new ApplicationError(match.groups.msg);
}
}
return null;
}
/**
* Returns the resolved node parameter value. If it is an expression it will execute it and
* return the result. If the value to resolve is an array or object it will do the same
* for all of the items and values.
*
* @param {NodeParameterValueType | INodeParameterResourceLocator} parameterValue - The parameter value to resolve
* @param {IWorkflowDataProxyData} data - The workflow data proxy data
* @param {boolean} [returnObjectAsString=false] - Whether to convert objects to strings
*/
}
@@ -0,0 +1,9 @@
/**
* Checks if the given value is an expression. An expression is a string that
* starts with '='.
*/
export const isExpression = (expr: unknown): expr is string => {
if (typeof expr !== 'string') return false;
return expr.charAt(0) === '=';
};
@@ -0,0 +1,712 @@
// NOTE: This file is intentionally mirrored in @n8n/expression-runtime/src/extensions/
// for use inside the isolated VM. Changes here must be reflected there and vice versa.
// TODO: Eliminate the duplication. The blocker is that @n8n/expression-runtime is
// Vite-stubbed for browser builds (to exclude isolated-vm), which prevents n8n-workflow
// from importing these extension utilities directly from the runtime package. Fix by
// splitting @n8n/expression-runtime into a browser-safe extensions subpath (not stubbed)
// and a node-only VM entry (stubbed).
import isEqual from 'lodash/isEqual';
import uniqWith from 'lodash/uniqWith';
import type { Extension, ExtensionMap } from './extensions';
import { compact as oCompact } from './object-extensions';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
import { ExpressionError } from '../errors/expression.error';
import { randomInt } from '../utils';
function first(value: unknown[]): unknown {
return value[0];
}
function isEmpty(value: unknown[]): boolean {
return value.length === 0;
}
function isNotEmpty(value: unknown[]): boolean {
return value.length > 0;
}
function last(value: unknown[]): unknown {
return value[value.length - 1];
}
function pluck(value: unknown[], extraArgs: unknown[]): unknown[] {
if (!Array.isArray(extraArgs)) {
throw new ExpressionError('arguments must be passed to pluck');
}
if (!extraArgs || extraArgs.length === 0) {
return value;
}
const plucked = value.reduce<unknown[]>((pluckedFromObject, current) => {
if (current && typeof current === 'object') {
const p: unknown[] = [];
Object.keys(current).forEach((k) => {
(extraArgs as string[]).forEach((field) => {
if (current && field === k) {
p.push((current as { [key: string]: unknown })[k]);
}
});
});
if (p.length > 0) {
pluckedFromObject.push(p.length === 1 ? p[0] : p);
}
}
return pluckedFromObject;
}, new Array<unknown>());
return plucked;
}
function randomItem(value: unknown[]): unknown {
const len = value === undefined ? 0 : value.length;
return len ? value[randomInt(len)] : undefined;
}
function unique(value: unknown[], extraArgs: string[]): unknown[] {
const mapForEqualityCheck = (item: unknown): unknown => {
if (extraArgs.length > 0 && item && typeof item === 'object') {
return extraArgs.reduce<Record<string, unknown>>((acc, key) => {
acc[key] = (item as Record<string, unknown>)[key];
return acc;
}, {});
}
return item;
};
return uniqWith(value, (a, b) => isEqual(mapForEqualityCheck(a), mapForEqualityCheck(b)));
}
const ensureNumberArray = (arr: unknown[], { fnName }: { fnName: string }) => {
if (arr.some((i) => typeof i !== 'number')) {
throw new ExpressionExtensionError(`${fnName}(): all array elements must be numbers`);
}
};
function sum(value: unknown[]): number {
ensureNumberArray(value, { fnName: 'sum' });
return value.reduce((p: number, c: unknown) => {
if (typeof c === 'string') {
return p + parseFloat(c);
}
if (typeof c !== 'number') {
return NaN;
}
return p + c;
}, 0);
}
function min(value: unknown[]): number {
ensureNumberArray(value, { fnName: 'min' });
return Math.min(
...value.map((v) => {
if (typeof v === 'string') {
return parseFloat(v);
}
if (typeof v !== 'number') {
return NaN;
}
return v;
}),
);
}
function max(value: unknown[]): number {
ensureNumberArray(value, { fnName: 'max' });
return Math.max(
...value.map((v) => {
if (typeof v === 'string') {
return parseFloat(v);
}
if (typeof v !== 'number') {
return NaN;
}
return v;
}),
);
}
export function average(value: unknown[]) {
ensureNumberArray(value, { fnName: 'average' });
// This would usually be NaN but I don't think users
// will expect that
if (value.length === 0) {
return 0;
}
return sum(value) / value.length;
}
function compact(value: unknown[]): unknown[] {
return value
.filter((v) => {
if (v && typeof v === 'object' && Object.keys(v).length === 0) return false;
return v !== null && v !== undefined && v !== 'nil' && v !== '';
})
.map((v) => {
if (typeof v === 'object' && v !== null) {
return oCompact(v);
}
return v;
});
}
function smartJoin(value: unknown[], extraArgs: string[]): object {
const [keyField, valueField] = extraArgs;
if (!keyField || !valueField || typeof keyField !== 'string' || typeof valueField !== 'string') {
throw new ExpressionExtensionError(
'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")',
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
return value.reduce<any>((o, v) => {
if (typeof v === 'object' && v !== null && keyField in v && valueField in v) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
o[(v as any)[keyField]] = (v as any)[valueField];
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return o;
}, {});
}
function chunk(value: unknown[], extraArgs: number[]) {
const [chunkSize] = extraArgs;
if (typeof chunkSize !== 'number' || chunkSize === 0) {
throw new ExpressionExtensionError('chunk(): expected non-zero numeric arg, e.g. .chunk(5)');
}
const chunks: unknown[][] = [];
for (let i = 0; i < value.length; i += chunkSize) {
// I have no clue why eslint thinks 2 numbers could be anything but that but here we are
chunks.push(value.slice(i, i + chunkSize));
}
return chunks;
}
function renameKeys(value: unknown[], extraArgs: string[]): unknown[] {
if (extraArgs.length === 0 || extraArgs.length % 2 !== 0) {
throw new ExpressionExtensionError(
'renameKeys(): expected an even amount of args: from1, to1 [, from2, to2, ...]. e.g. .renameKeys("name", "title")',
);
}
return value.map((v) => {
if (typeof v !== 'object' || v === null) {
return v;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
const newObj = { ...(v as any) };
const chunkedArgs = chunk(extraArgs, [2]) as string[][];
chunkedArgs.forEach(([from, to]) => {
if (from in newObj) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObj[to] = newObj[from];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
delete newObj[from];
}
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj;
});
}
function mergeObjects(value: Record<string, unknown>, extraArgs: unknown[]): unknown {
const [other] = extraArgs;
if (!other) {
return value;
}
if (typeof other !== 'object') {
throw new ExpressionExtensionError('merge(): expected object arg');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(other)) {
if (!(key in newObject)) {
newObject[key] = val;
}
}
return newObject;
}
function merge(value: unknown[], extraArgs: unknown[][]): unknown {
const [others] = extraArgs;
if (others === undefined) {
// If there are no arguments passed, merge all objects within the array
const merged = value.reduce((combined, current) => {
if (current !== null && typeof current === 'object' && !Array.isArray(current)) {
combined = mergeObjects(combined as Record<string, unknown>, [current]);
}
return combined;
}, {});
return merged;
}
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'merge(): expected array arg, e.g. .merge([{ id: 1, otherValue: 3 }])',
);
}
const listLength = value.length > others.length ? value.length : others.length;
let merged = {};
for (let i = 0; i < listLength; i++) {
if (value[i] !== undefined) {
if (typeof value[i] === 'object' && typeof others[i] === 'object') {
merged = Object.assign(
merged,
mergeObjects(value[i] as Record<string, unknown>, [others[i]]),
);
}
}
}
return merged;
}
function union(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError('union(): expected array arg, e.g. .union([1, 2, 3, 4])');
}
const newArr: unknown[] = Array.from(value);
for (const v of others) {
if (newArr.findIndex((w) => isEqual(w, v)) === -1) {
newArr.push(v);
}
}
return unique(newArr, []);
}
function difference(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'difference(): expected array arg, e.g. .difference([1, 2, 3, 4])',
);
}
const newArr: unknown[] = [];
for (const v of value) {
if (others.findIndex((w) => isEqual(w, v)) === -1) {
newArr.push(v);
}
}
return unique(newArr, []);
}
function intersection(value: unknown[], extraArgs: unknown[][]): unknown[] {
const [others] = extraArgs;
if (!Array.isArray(others)) {
throw new ExpressionExtensionError(
'intersection(): expected array arg, e.g. .intersection([1, 2, 3, 4])',
);
}
const newArr: unknown[] = [];
for (const v of value) {
if (others.findIndex((w) => isEqual(w, v)) !== -1) {
newArr.push(v);
}
}
for (const v of others) {
if (value.findIndex((w) => isEqual(w, v)) !== -1) {
newArr.push(v);
}
}
return unique(newArr, []);
}
function append(value: unknown[], extraArgs: unknown[][]): unknown[] {
return value.concat(extraArgs);
}
export function toJsonString(value: unknown[]) {
return JSON.stringify(value);
}
export function toInt() {
return undefined;
}
export function toFloat() {
return undefined;
}
export function toBoolean() {
return undefined;
}
export function toDateTime() {
return undefined;
}
average.doc = {
name: 'average',
aliases: ['mean'],
description:
'Returns the average of the numbers in the array. Throws an error if there are any non-numbers.',
examples: [{ example: '[12, 1, 5].average()', evaluated: '6' }],
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-average',
};
compact.doc = {
name: 'compact',
aliases: ['removeEmpty'],
description:
'Removes any empty values from the array. <code>null</code>, <code>""</code> and <code>undefined</code> count as empty.',
examples: [{ example: '[2, null, 1, ""].compact()', evaluated: '[2, 1]' }],
returnType: 'Array',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-compact',
};
isEmpty.doc = {
name: 'isEmpty',
description: 'Returns <code>true</code> if the array has no elements or is <code>null</code>',
examples: [
{ example: '[].isEmpty()', evaluated: 'true' },
{ example: "['quick', 'brown', 'fox'].isEmpty()", evaluated: 'false' },
],
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-isEmpty',
};
isNotEmpty.doc = {
name: 'isNotEmpty',
description: 'Returns <code>true</code> if the array has at least one element',
examples: [
{ example: "['quick', 'brown', 'fox'].isNotEmpty()", evaluated: 'true' },
{ example: '[].isNotEmpty()', evaluated: 'false' },
],
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-isNotEmpty',
};
first.doc = {
name: 'first',
aliases: ['head'],
description: 'Returns the first element of the array',
examples: [{ example: "['quick', 'brown', 'fox'].first()", evaluated: "'quick'" }],
returnType: 'any',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-first',
};
last.doc = {
name: 'last',
aliases: ['tail'],
description: 'Returns the last element of the array',
examples: [{ example: "['quick', 'brown', 'fox'].last()", evaluated: "'fox'" }],
returnType: 'any',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-last',
};
max.doc = {
name: 'max',
description:
'Returns the largest number in the array. Throws an error if there are any non-numbers.',
examples: [{ example: '[1, 12, 5].max()', evaluated: '12' }],
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-max',
};
min.doc = {
name: 'min',
description:
'Returns the smallest number in the array. Throws an error if there are any non-numbers.',
examples: [{ example: '[12, 1, 5].min()', evaluated: '1' }],
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-min',
};
randomItem.doc = {
name: 'randomItem',
description: 'Returns a randomly-chosen element from the array',
examples: [
{ example: "['quick', 'brown', 'fox'].randomItem()", evaluated: "'brown'" },
{ example: "['quick', 'brown', 'fox'].randomItem()", evaluated: "'quick'" },
],
returnType: 'any',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-randomItem',
};
sum.doc = {
name: 'sum',
description:
'Returns the total of all the numbers in the array. Throws an error if there are any non-numbers.',
examples: [{ example: '[12, 1, 5].sum()', evaluated: '18' }],
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-sum',
};
chunk.doc = {
name: 'chunk',
description: 'Splits the array into an array of sub-arrays, each with the given length',
examples: [{ example: '[1, 2, 3, 4, 5, 6].chunk(2)', evaluated: '[[1,2],[3,4],[5,6]]' }],
returnType: 'Array',
args: [
{
name: 'length',
optional: false,
description: 'The number of elements in each chunk',
type: 'number',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-chunk',
};
difference.doc = {
name: 'difference',
description:
"Compares two arrays. Returns all elements in the base array that aren't present\nin <code>otherArray</code>.",
examples: [{ example: '[1, 2, 3].difference([2, 3])', evaluated: '[1]' }],
returnType: 'Array',
args: [
{
name: 'otherArray',
optional: false,
description: 'The array to compare to the base array',
type: 'Array',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-difference',
};
intersection.doc = {
name: 'intersection',
description:
'Compares two arrays. Returns all elements in the base array that are also present in the other array.',
examples: [{ example: '[1, 2].intersection([2, 3])', evaluated: '[2]' }],
returnType: 'Array',
args: [
{
name: 'otherArray',
optional: false,
description: 'The array to compare to the base array',
type: 'Array',
},
],
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-intersection',
};
merge.doc = {
name: 'merge',
description:
'Merges two Object-arrays into one object by merging the key-value pairs of each element.',
examples: [
{
example:
"[{ name: 'Nathan' }, { age: 42 }].merge([{ city: 'Berlin' }, { country: 'Germany' }])",
evaluated: "{ name: 'Nathan', age: 42, city: 'Berlin', country: 'Germany' }",
},
],
returnType: 'Object',
args: [
{
name: 'otherArray',
optional: false,
description: 'The array to merge into the base array',
type: 'Array',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-merge',
};
pluck.doc = {
name: 'pluck',
description:
'Returns an array containing the values of the given field(s) in each Object of the array. Ignores any array elements that arent Objects or dont have a key matching the field name(s) provided.',
examples: [
{
example: "[{ name: 'Nathan', age: 42 },{ name: 'Jan', city: 'Berlin' }].pluck('name')",
evaluated: '["Nathan", "Jan"]',
},
{
example: "[{ name: 'Nathan', age: 42 },{ name: 'Jan', city: 'Berlin' }].pluck('age')",
evaluated: '[42]',
},
],
returnType: 'Array',
args: [
{
name: 'fieldNames',
optional: false,
variadic: true,
description: 'The keys to retrieve the value of',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-pluck',
};
renameKeys.doc = {
name: 'renameKeys',
description:
'Changes all matching keys (field names) of any Objects in the array. Rename more than one key by\nadding extra arguments, i.e. <code>from1, to1, from2, to2, ...</code>.',
examples: [
{
example: "[{ name: 'bob' }, { name: 'meg' }].renameKeys('name', 'x')",
evaluated: "[{ x: 'bob' }, { x: 'meg' }]",
},
],
returnType: 'Array',
args: [
{
name: 'from',
optional: false,
description: 'The key to rename',
type: 'string',
},
{ name: 'to', optional: false, description: 'The new key name', type: 'string' },
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-renameKeys',
};
smartJoin.doc = {
name: 'smartJoin',
description:
'Creates a single Object from an array of Objects. Each Object in the array provides one field for the returned Object. Each Object in the array must contain a field with the key name and a field with the value.',
examples: [
{
example:
"[{ field: 'age', value: 2 }, { field: 'city', value: 'Berlin' }].smartJoin('field', 'value')",
evaluated: "{ age: 2, city: 'Berlin' }",
},
],
returnType: 'Object',
args: [
{
name: 'keyField',
optional: false,
description: 'The field in each Object containing the key name',
type: 'string',
},
{
name: 'nameField',
optional: false,
description: 'The field in each Object containing the value',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-smartJoin',
};
union.doc = {
name: 'union',
description: 'Concatenates two arrays and then removes any duplicates',
examples: [{ example: '[1, 2].union([2, 3])', evaluated: '[1, 2, 3]' }],
returnType: 'Array',
args: [
{
name: 'otherArray',
optional: false,
description: 'The array to union with the base array',
type: 'Array',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-union',
};
unique.doc = {
name: 'unique',
description: 'Removes any duplicate elements from the array',
examples: [
{ example: "['quick', 'brown', 'quick'].unique()", evaluated: "['quick', 'brown']" },
{
example: "[{ name: 'Nathan', age: 42 }, { name: 'Nathan', age: 22 }].unique()",
evaluated: "[{ name: 'Nathan', age: 42 }, { name: 'Nathan', age: 22 }]",
},
{
example: "[{ name: 'Nathan', age: 42 }, { name: 'Nathan', age: 22 }].unique('name')",
evaluated: "[{ name: 'Nathan', age: 42 }]",
},
],
returnType: 'any',
aliases: ['removeDuplicates'],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-unique',
args: [
{
name: 'fieldNames',
optional: false,
variadic: true,
description: 'The object keys to check for equality',
type: 'any',
},
],
};
toJsonString.doc = {
name: 'toJsonString',
description:
"Converts the array to a JSON string. The same as JavaScript's <code>JSON.stringify()</code>.",
examples: [
{
example: "['quick', 'brown', 'fox'].toJsonString()",
evaluated: '\'["quick","brown","fox"]\'',
},
],
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-toJsonString',
returnType: 'string',
};
append.doc = {
name: 'append',
aliases: ['push'],
description:
'Adds new elements to the end of the array. Similar to <code>push()</code>, but returns the modified array. Consider using spread syntax instead (see examples).',
examples: [
{ example: "['forget', 'me'].append('not')", evaluated: "['forget', 'me', 'not']" },
{ example: '[9, 0, 2].append(1, 0)', evaluated: '[9, 0, 2, 1, 0]' },
{
example: '[...[9, 0, 2], 1, 0]',
evaluated: '[9, 0, 2, 1, 0]',
description: 'Consider using spread syntax instead',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-append',
returnType: 'Array',
args: [
{
name: 'elements',
optional: false,
variadic: true,
description: 'The elements to append, in order',
type: 'any',
},
],
};
const removeDuplicates: Extension = unique.bind({});
removeDuplicates.doc = { ...unique.doc, hidden: true };
export const arrayExtensions: ExtensionMap = {
typeName: 'Array',
functions: {
removeDuplicates,
unique,
first,
last,
pluck,
randomItem,
sum,
min,
max,
average,
isNotEmpty,
isEmpty,
compact,
smartJoin,
chunk,
renameKeys,
merge,
union,
difference,
intersection,
append,
toJsonString,
toInt,
toFloat,
toBoolean,
toDateTime,
},
};
@@ -0,0 +1,48 @@
// NOTE: This file is intentionally mirrored in @n8n/expression-runtime/src/extensions/
// for use inside the isolated VM. Changes here must be reflected there and vice versa.
// TODO: Eliminate the duplication. The blocker is that @n8n/expression-runtime is
// Vite-stubbed for browser builds (to exclude isolated-vm), which prevents n8n-workflow
// from importing these extension utilities directly from the runtime package. Fix by
// splitting @n8n/expression-runtime into a browser-safe extensions subpath (not stubbed)
// and a node-only VM entry (stubbed).
import type { Extension, ExtensionMap } from './extensions';
export function toBoolean(value: boolean) {
return value;
}
export function toInt(value: boolean) {
return value ? 1 : 0;
}
export function toDateTime() {
return undefined;
}
const toFloat = toInt;
const toNumber: Extension = toInt.bind({});
toNumber.doc = {
name: 'toNumber',
description:
'Converts <code>true</code> to <code>1</code> and <code>false</code> to <code>0</code>.',
examples: [
{ example: 'true.toNumber()', evaluated: '1' },
{ example: 'false.toNumber()', evaluated: '0' },
],
section: 'cast',
returnType: 'number',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/booleans/#boolean-toNumber',
};
export const booleanExtensions: ExtensionMap = {
typeName: 'Boolean',
functions: {
toBoolean,
toInt,
toFloat,
toNumber,
toDateTime,
},
};
@@ -0,0 +1,630 @@
// NOTE: This file is intentionally mirrored in @n8n/expression-runtime/src/extensions/
// for use inside the isolated VM. Changes here must be reflected there and vice versa.
// TODO: Eliminate the duplication. The blocker is that @n8n/expression-runtime is
// Vite-stubbed for browser builds (to exclude isolated-vm), which prevents n8n-workflow
// from importing these extension utilities directly from the runtime package. Fix by
// splitting @n8n/expression-runtime into a browser-safe extensions subpath (not stubbed)
// and a node-only VM entry (stubbed).
import { DateTime } from 'luxon';
import type {
DateTimeUnit,
DurationLike,
DurationObjectUnits,
LocaleOptions,
WeekdayNumbers,
} from 'luxon';
import type { ExtensionMap } from './extensions';
import { toDateTime as stringToDateTime } from './string-extensions';
import { convertToDateTime } from './utils';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
const durationUnits = [
'milliseconds',
'seconds',
'minutes',
'hours',
'days',
'weeks',
'months',
'quarters',
'years',
] as const;
type DurationUnit = (typeof durationUnits)[number];
const dateParts = [
'day',
'week',
'month',
'year',
'hour',
'minute',
'second',
'millisecond',
'weekNumber',
'yearDayNumber',
'weekday',
] as const;
type DatePart = (typeof dateParts)[number];
const DURATION_MAP: Record<string, DurationUnit> = {
day: 'days',
month: 'months',
year: 'years',
week: 'weeks',
hour: 'hours',
minute: 'minutes',
second: 'seconds',
millisecond: 'milliseconds',
ms: 'milliseconds',
sec: 'seconds',
secs: 'seconds',
hr: 'hours',
hrs: 'hours',
min: 'minutes',
mins: 'minutes',
};
const DATETIMEUNIT_MAP: Record<string, DateTimeUnit> = {
days: 'day',
months: 'month',
years: 'year',
hours: 'hour',
minutes: 'minute',
seconds: 'second',
milliseconds: 'millisecond',
hrs: 'hour',
hr: 'hour',
mins: 'minute',
min: 'minute',
secs: 'second',
sec: 'second',
ms: 'millisecond',
};
function isDateTime(date: unknown): date is DateTime {
return date ? DateTime.isDateTime(date) : false;
}
function toDateTime(date: string | Date | DateTime): DateTime {
if (isDateTime(date)) return date;
if (typeof date === 'string') {
return stringToDateTime(date);
}
return DateTime.fromJSDate(date);
}
function generateDurationObject(durationValue: number, unit: DurationUnit): DurationObjectUnits {
const convertedUnit = DURATION_MAP[unit] || unit;
return { [`${convertedUnit}`]: durationValue };
}
function beginningOf(date: Date | DateTime, extraArgs: DurationUnit[]): Date | DateTime {
const [rawUnit = 'week'] = extraArgs;
const unit = DATETIMEUNIT_MAP[rawUnit] || rawUnit;
if (isDateTime(date)) return date.startOf(unit);
return DateTime.fromJSDate(date).startOf(unit).toJSDate();
}
function endOfMonth(date: Date | DateTime): Date | DateTime {
if (isDateTime(date)) return date.endOf('month');
return DateTime.fromJSDate(date).endOf('month').toJSDate();
}
function extract(date: Date | DateTime, args: DatePart[]): number {
let [part = 'week'] = args;
if (part === 'yearDayNumber') {
date = isDateTime(date) ? date.toJSDate() : date;
const firstDayOfTheYear = new Date(date.getFullYear(), 0, 0);
const diff =
date.getTime() -
firstDayOfTheYear.getTime() +
(firstDayOfTheYear.getTimezoneOffset() - date.getTimezoneOffset()) * 60 * 1000;
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
if (part === 'week') part = 'weekNumber';
const unit = (DATETIMEUNIT_MAP[part] as keyof DateTime) || part;
if (isDateTime(date)) return date.get(unit);
return DateTime.fromJSDate(date).get(unit);
}
function format(date: Date | DateTime, extraArgs: unknown[]): string {
const [dateFormat, localeOpts = {}] = extraArgs as [string, LocaleOptions];
if (isDateTime(date)) {
return date.toFormat(dateFormat, { ...localeOpts });
}
return DateTime.fromJSDate(date).toFormat(dateFormat, { ...localeOpts });
}
function isBetween(
date: Date | DateTime,
extraArgs: Array<string | Date | DateTime>,
): boolean | undefined {
if (extraArgs.length !== 2) {
throw new ExpressionExtensionError('isBetween(): expected exactly two args');
}
const [first, second] = extraArgs;
const firstDate = convertToDateTime(first);
const secondDate = convertToDateTime(second);
if (!firstDate || !secondDate) {
return;
}
if (firstDate > secondDate) {
return secondDate < date && date < firstDate;
}
return secondDate > date && date > firstDate;
}
function isDst(date: Date | DateTime): boolean {
if (isDateTime(date)) {
return date.isInDST;
}
return DateTime.fromJSDate(date).isInDST;
}
function isInLast(date: Date | DateTime, extraArgs: unknown[]): boolean {
const [durationValue = 0, unit = 'minutes'] = extraArgs as [number, DurationUnit];
const dateInThePast = DateTime.now().minus(generateDurationObject(durationValue, unit));
let thisDate = date;
if (!isDateTime(thisDate)) {
thisDate = DateTime.fromJSDate(thisDate);
}
return dateInThePast <= thisDate && thisDate <= DateTime.now();
}
const WEEKEND_DAYS: WeekdayNumbers[] = [6, 7];
function isWeekend(date: Date | DateTime): boolean {
const { weekday } = isDateTime(date) ? date : DateTime.fromJSDate(date);
return WEEKEND_DAYS.includes(weekday);
}
function minus(
date: Date | DateTime,
args: [DurationLike] | [number, DurationUnit],
): Date | DateTime {
if (args.length === 1) {
const [arg] = args;
if (isDateTime(date)) return date.minus(arg);
return DateTime.fromJSDate(date).minus(arg).toJSDate();
}
const [durationValue = 0, unit = 'minutes'] = args;
const duration = generateDurationObject(durationValue, unit);
if (isDateTime(date)) return date.minus(duration);
return DateTime.fromJSDate(date).minus(duration).toJSDate();
}
function plus(
date: Date | DateTime,
args: [DurationLike] | [number, DurationUnit],
): Date | DateTime {
if (args.length === 1) {
const [arg] = args;
if (isDateTime(date)) return date.plus(arg);
return DateTime.fromJSDate(date).plus(arg).toJSDate();
}
const [durationValue = 0, unit = 'minutes'] = args;
const duration = generateDurationObject(durationValue, unit);
if (isDateTime(date)) return date.plus(duration);
return DateTime.fromJSDate(date).plus(duration).toJSDate();
}
function diffTo(date: DateTime, args: [string | Date | DateTime, DurationUnit | DurationUnit[]]) {
const [otherDate, unit = 'days'] = args;
let units = Array.isArray(unit) ? unit : [unit];
if (units.length === 0) {
units = ['days'];
}
const allowedUnitSet = new Set([...dateParts, ...durationUnits]);
const errorUnit = units.find((u) => !allowedUnitSet.has(u));
if (errorUnit) {
throw new ExpressionExtensionError(
`Unsupported unit '${String(errorUnit)}'. Supported: ${durationUnits
.map((u) => `'${u}'`)
.join(', ')}.`,
);
}
const diffResult = date.diff(toDateTime(otherDate), units);
if (units.length > 1) {
return diffResult.toObject();
}
return diffResult.as(units[0]);
}
function diffToNow(date: DateTime, args: [DurationUnit | DurationUnit[]]) {
const [unit] = args;
return diffTo(date, [DateTime.now(), unit]);
}
function toInt(date: Date | DateTime): number {
if (isDateTime(date)) {
return date.toMillis();
}
return date.getTime();
}
const toFloat = toInt;
function toBoolean() {
return undefined;
}
// Only null/undefined return true, this is handled in ExpressionExtension.ts
function isEmpty(): boolean {
return false;
}
function isNotEmpty(): boolean {
return true;
}
endOfMonth.doc = {
name: 'endOfMonth',
returnType: 'DateTime',
hidden: true,
description: 'Transforms a date to the last possible moment that lies within the month.',
section: 'edit',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-endOfMonth',
};
isDst.doc = {
name: 'isDst',
returnType: 'boolean',
hidden: true,
description: 'Checks if a Date is within Daylight Savings Time.',
section: 'query',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isDst',
};
isWeekend.doc = {
name: 'isWeekend',
returnType: 'boolean',
hidden: true,
description: 'Checks if the Date falls on a Saturday or Sunday.',
section: 'query',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isWeekend',
};
beginningOf.doc = {
name: 'beginningOf',
description: 'Transform a Date to the start of the given time period. Default unit is `week`.',
section: 'edit',
hidden: true,
returnType: 'DateTime',
args: [{ name: 'unit?', type: 'DurationUnit' }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-beginningOf',
};
extract.doc = {
name: 'extract',
description:
'Extracts a part of the date or time, e.g. the month, as a number. To extract textual names instead, see <code>format()</code>.',
examples: [
{ example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.extract('month')", evaluated: '3' },
{ example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.extract('hour')", evaluated: '18' },
],
section: 'query',
returnType: 'number',
args: [
{
name: 'unit',
optional: true,
description:
'The part of the date or time to return. One of: <code>year</code>, <code>month</code>, <code>week</code>, <code>day</code>, <code>hour</code>, <code>minute</code>, <code>second</code>',
default: '"week"',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-extract',
};
format.doc = {
name: 'format',
description:
'Converts the DateTime to a string, using the format specified. <a target="_blank" href="https://moment.github.io/luxon/#/formatting?id=table-of-tokens">Formatting guide</a>. For common formats, <code>toLocaleString()</code> may be easier.',
examples: [
{
example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.format('dd/LL/yyyy')",
evaluated: "'30/04/2024'",
},
{
example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.format('dd LLL yy')",
evaluated: "'30 Apr 24'",
},
{
example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.setLocale('fr').format('dd LLL yyyy')",
evaluated: "'30 avr. 2024'",
},
{
example: "dt = '2024-04-30T18:49'.toDateTime()\ndt.format(\"HH 'hours and' mm 'minutes'\")",
evaluated: "'18 hours and 49 minutes'",
},
],
returnType: 'string',
section: 'format',
args: [
{
name: 'fmt',
description:
'The <a target="_blank" href="https://moment.github.io/luxon/#/formatting?id=table-of-tokens">format</a> of the string to return ',
default: "'yyyy-MM-dd'",
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-format',
};
isBetween.doc = {
name: 'isBetween',
description: 'Returns <code>true</code> if the DateTime lies between the two moments specified',
examples: [
{
example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.isBetween('2020-06-01', '2025-06-01')",
evaluated: 'true',
},
{
example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.isBetween('2020', '2025')",
evaluated: 'true',
},
],
section: 'compare',
returnType: 'boolean',
args: [
{
name: 'date1',
description:
'The moment that the base DateTime must be after. Can be an ISO date string or a Luxon DateTime.',
type: 'string | DateTime',
},
{
name: 'date2',
description:
'The moment that the base DateTime must be before. Can be an ISO date string or a Luxon DateTime.',
type: 'string | DateTime',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isBetween',
};
isInLast.doc = {
name: 'isInLast',
hidden: true,
description: 'Checks if a Date is within a given time period. Default unit is `minute`.',
section: 'query',
returnType: 'boolean',
args: [
{ name: 'n', type: 'number' },
{ name: 'unit?', type: 'DurationUnit' },
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-isInLast',
};
toDateTime.doc = {
name: 'toDateTime',
description:
'Converts a JavaScript Date to a Luxon DateTime. The DateTime contains the same information, but is easier to manipulate.',
examples: [
{
example: "jsDate = new Date('2024-03-30T18:49')\njsDate.toDateTime().plus(5, 'days')",
evaluated: '[DateTime: 2024-05-05T18:49:00.000Z]',
},
],
returnType: 'DateTime',
hidden: true,
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-toDateTime',
};
minus.doc = {
name: 'minus',
description: 'Subtracts a given period of time from the DateTime',
examples: [
{
example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.minus(7, 'days')",
evaluated: '[DateTime: 2024-04-23T18:49:00.000Z]',
},
{
example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.minus(4, 'years')",
evaluated: '[DateTime: 2020-04-30T18:49:00.000Z]',
},
],
section: 'edit',
returnType: 'DateTime',
args: [
{
name: 'n',
description:
'The number of units to subtract. Or use a Luxon <a target="_blank" href=”https://moment.github.io/luxon/api-docs/index.html#duration”>Duration</a> object to subtract multiple units at once.',
type: 'number | object',
},
{
name: 'unit',
optional: true,
description:
'The units of the number. One of: <code>years</code>, <code>months</code>, <code>weeks</code>, <code>days</code>, <code>hours</code>, <code>minutes</code>, <code>seconds</code>, <code>milliseconds</code>',
default: '"milliseconds"',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-minus',
};
plus.doc = {
name: 'plus',
description: 'Adds a given period of time to the DateTime',
examples: [
{
example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.plus(7, 'days')",
evaluated: '[DateTime: 2024-04-07T18:49:00.000Z]',
},
{
example: "dt = '2024-03-30T18:49'.toDateTime()\ndt.plus(4, 'years')",
evaluated: '[DateTime: 2028-03-30T18:49:00.000Z]',
},
],
section: 'edit',
returnType: 'DateTime',
args: [
{
name: 'n',
description:
'The number of units to add. Or use a Luxon <a target="_blank" href=”https://moment.github.io/luxon/api-docs/index.html#duration”>Duration</a> object to add multiple units at once.',
type: 'number | object',
},
{
name: 'unit',
optional: true,
description:
'The units of the number. One of: <code>years</code>, <code>months</code>, <code>weeks</code>, <code>days</code>, <code>hours</code>, <code>minutes</code>, <code>seconds</code>, <code>milliseconds</code>',
default: '"milliseconds"',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-plus',
};
diffTo.doc = {
name: 'diffTo',
description: 'Returns the difference between two DateTimes, in the given unit(s)',
examples: [
{
example: "dt = '2025-01-01'.toDateTime()\ndt.diffTo('2024-03-30T18:49:07.234', 'days')",
evaluated: '276.21',
},
{
example:
"dt1 = '2025-01-01T00:00:00.000'.toDateTime();\ndt2 = '2024-03-30T18:49:07.234'.toDateTime();\ndt1.diffTo(dt2, ['months', 'days'])",
evaluated: '{ months: 9, days: 1.21 }',
},
],
section: 'compare',
returnType: 'number | Record<DurationUnit, number>',
args: [
{
name: 'otherDateTime',
default: '$now',
description:
'The moment to subtract the base DateTime from. Can be an ISO date string or a Luxon DateTime.',
type: 'string | DateTime',
},
{
name: 'unit',
default: "'days'",
description:
'The unit, or array of units, to return the result in. Possible values: <code>years</code>, <code>months</code>, <code>weeks</code>, <code>days</code>, <code>hours</code>, <code>minutes</code>, <code>seconds</code>, <code>milliseconds</code>.',
type: 'string | string[]',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-diffTo',
};
diffToNow.doc = {
name: 'diffToNow',
description:
'Returns the difference between the current moment and the DateTime, in the given unit(s). For a textual representation, use <code>toRelative()</code> instead.',
examples: [
{
example: "dt = '2023-03-30T18:49:07.234'.toDateTime()\ndt.diffToNow('days')",
evaluated: '371.9',
},
{
example: "dt = '2023-03-30T18:49:07.234'.toDateTime()\ndt.diffToNow(['months', 'days'])",
evaluated: '{ months: 12, days: 5.9 }',
},
],
section: 'compare',
returnType: 'number | Record<DurationUnit, number>',
args: [
{
name: 'unit',
description:
'The unit, or array of units, to return the result in. Possible values: <code>years</code>, <code>months</code>, <code>weeks</code>, <code>days</code>, <code>hours</code>, <code>minutes</code>, <code>seconds</code>, <code>milliseconds</code>.',
default: "'days'",
type: 'string | string[]',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/dates/#date-diffToNow',
};
isEmpty.doc = {
name: 'isEmpty',
description:
'Returns <code>false</code> for all DateTimes. Returns <code>true</code> for <code>null</code>.',
examples: [
{ example: "dt = '2023-03-30T18:49:07.234'.toDateTime()\ndt.isEmpty()", evaluated: 'false' },
{ example: 'dt = null\ndt.isEmpty()', evaluated: 'true' },
],
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-isEmpty',
};
isNotEmpty.doc = {
name: 'isNotEmpty',
description:
'Returns <code>true</code> for all DateTimes. Returns <code>false</code> for <code>null</code>.',
examples: [
{ example: "dt = '2023-03-30T18:49:07.234'.toDateTime()\ndt.isNotEmpty()", evaluated: 'true' },
{ example: 'dt = null\ndt.isNotEmpty()', evaluated: 'false' },
],
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/arrays/#array-isNotEmpty',
};
export const dateExtensions: ExtensionMap = {
typeName: 'Date',
functions: {
beginningOf,
endOfMonth,
extract,
isBetween,
isDst,
isInLast,
isWeekend,
minus,
plus,
format,
toDateTime,
diffTo,
diffToNow,
toInt,
toFloat,
toBoolean,
isEmpty,
isNotEmpty,
},
};
@@ -0,0 +1,633 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import type { ExpressionKind } from 'ast-types/lib/gen/kinds';
import type { Config as EsprimaConfig } from 'esprima-next';
import { parse as esprimaParse } from 'esprima-next';
import { DateTime } from 'luxon';
import { parse, visit, types, print } from 'recast';
import { getOption } from 'recast/lib/util';
import { arrayExtensions } from './array-extensions';
import { booleanExtensions } from './boolean-extensions';
import { dateExtensions } from './date-extensions';
import { joinExpression, splitExpression } from './expression-parser';
import type { ExpressionChunk, ExpressionCode } from './expression-parser';
import type { ExtensionMap } from './extensions';
import { numberExtensions } from './number-extensions';
import { objectExtensions } from './object-extensions';
import { stringExtensions } from './string-extensions';
import { checkIfValueDefinedOrThrow } from './utils';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
import { isSafeObjectProperty } from '../utils';
const EXPRESSION_EXTENDER = 'extend';
const EXPRESSION_EXTENDER_OPTIONAL = 'extendOptional';
function isEmpty(value: unknown) {
return value === null || value === undefined || !value;
}
function isNotEmpty(value: unknown) {
return !isEmpty(value);
}
export const EXTENSION_OBJECTS: ExtensionMap[] = [
arrayExtensions,
dateExtensions,
numberExtensions,
objectExtensions,
stringExtensions,
booleanExtensions,
];
// eslint-disable-next-line @typescript-eslint/no-restricted-types
const genericExtensions: Record<string, Function> = {
isEmpty,
isNotEmpty,
};
const EXPRESSION_EXTENSION_METHODS = Array.from(
new Set([
...Object.keys(stringExtensions.functions),
...Object.keys(numberExtensions.functions),
...Object.keys(dateExtensions.functions),
...Object.keys(arrayExtensions.functions),
...Object.keys(objectExtensions.functions),
...Object.keys(booleanExtensions.functions),
...Object.keys(genericExtensions),
]),
);
const EXPRESSION_EXTENSION_REGEX = new RegExp(
`(\\$if|\\.(${EXPRESSION_EXTENSION_METHODS.join('|')})\\s*(\\?\\.)?)\\s*\\(`,
);
const isExpressionExtension = (str: string) => EXPRESSION_EXTENSION_METHODS.some((m) => m === str);
export const hasExpressionExtension = (str: string): boolean =>
EXPRESSION_EXTENSION_REGEX.test(str);
export const hasNativeMethod = (method: string): boolean => {
if (hasExpressionExtension(method)) {
return false;
}
const methods = method
.replace(/[^\w\s]/gi, ' ')
.split(' ')
.filter(Boolean); // DateTime.now().toLocaleString().format() => [DateTime,now,toLocaleString,format]
return methods.every((methodName) => {
return [String.prototype, Array.prototype, Number.prototype, Date.prototype].some(
(nativeType) => {
if (methodName in nativeType) {
return true;
}
return false;
},
);
});
};
// /**
// * recast's types aren't great and we need to use a lot of anys
// */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseWithEsprimaNext(source: string, options?: any): any {
const ast = esprimaParse(source, {
loc: true,
locations: true,
comment: true,
range: getOption(options, 'range', false) as boolean,
tolerant: getOption(options, 'tolerant', true) as boolean,
tokens: true,
jsx: getOption(options, 'jsx', false) as boolean,
sourceType: getOption(options, 'sourceType', 'module') as string,
} as EsprimaConfig);
return ast;
}
/**
* A function to inject an extender function call into the AST of an expression.
* This uses recast to do the transform.
*
* This function also polyfills optional chaining if using extended functions.
*
* ```ts
* 'a'.method('x') // becomes
* extend('a', 'method', ['x']);
*
* 'a'.first('x').second('y') // becomes
* extend(extend('a', 'first', ['x']), 'second', ['y']));
* ```
*/
export const extendTransform = (expression: string): { code: string } | undefined => {
try {
const ast = parse(expression, { parser: { parse: parseWithEsprimaNext } }) as types.ASTNode;
let currentChain = 1;
// Polyfill optional chaining
visit(ast, {
// eslint-disable-next-line complexity
visitChainExpression(path) {
this.traverse(path);
const chainNumber = currentChain;
currentChain += 1;
// This is to match behavior in our original expression evaluator (tmpl)
const globalIdentifier = types.builders.identifier(
typeof window !== 'object' ? 'global' : 'window',
);
// We want to define all of our commonly used identifiers and member
// expressions now so we don't have to create multiple instances
const undefinedIdentifier = types.builders.identifier('undefined');
const cancelIdentifier = types.builders.identifier(`chainCancelToken${chainNumber}`);
const valueIdentifier = types.builders.identifier(`chainValue${chainNumber}`);
const cancelMemberExpression = types.builders.memberExpression(
globalIdentifier,
cancelIdentifier,
);
const valueMemberExpression = types.builders.memberExpression(
globalIdentifier,
valueIdentifier,
);
const patchedStack: ExpressionKind[] = [];
// This builds the cancel check. This lets us slide to the end of the expression
// if it's undefined/null at any of the optional points of the chain.
const buildCancelCheckWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.conditionalExpression(
types.builders.binaryExpression(
'===',
cancelMemberExpression,
types.builders.booleanLiteral(true),
),
undefinedIdentifier,
node,
);
};
// This is just a quick small wrapper to create the assignment expression
// for the running value.
const buildValueAssignWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.assignmentExpression('=', valueMemberExpression, node);
};
// This builds what actually does the comparison. It wraps the current
// chunk of the expression with a nullish coalescing operator that returns
// undefined if it's null or undefined. We do this because optional chains
// always return undefined if they fail part way, even if the value they
// fail on is null.
const buildOptionalWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.binaryExpression(
'===',
types.builders.logicalExpression(
'??',
buildValueAssignWrapper(node),
undefinedIdentifier,
),
undefinedIdentifier,
);
};
// Another small wrapper, but for assigning to the cancel token this time.
const buildCancelAssignWrapper = (node: ExpressionKind): ExpressionKind => {
return types.builders.assignmentExpression('=', cancelMemberExpression, node);
};
let currentNode: ExpressionKind = path.node.expression;
let currentPatch: ExpressionKind | null = null;
let patchTop: ExpressionKind | null = null;
let wrapNextTopInOptionalExtend = false;
// This patches the previous node to use our current one as it's left hand value.
// It takes `window.chainValue1.test1` and `window.chainValue1.test2` and turns it
// into `window.chainValue1.test2.test1`.
const updatePatch = (toPatch: ExpressionKind, node: ExpressionKind) => {
if (toPatch.type === 'MemberExpression' || toPatch.type === 'OptionalMemberExpression') {
toPatch.object = node;
} else if (
toPatch.type === 'CallExpression' ||
toPatch.type === 'OptionalCallExpression'
) {
toPatch.callee = node;
}
};
// This loop walks down an optional chain from the top. This will walk
// from right to left through an optional chain. We keep track of our current
// top of the chain (furthest right) and create a chain below it. This chain
// contains all of the (member and call) expressions that we need. These are
// patched versions that reference our current chain value. We then push this
// chain onto a stack when we hit an optional point in our chain.
while (true) {
// This should only ever be these types but you can optional chain on
// JSX nodes, which we don't support.
if (
currentNode.type === 'MemberExpression' ||
currentNode.type === 'OptionalMemberExpression' ||
currentNode.type === 'CallExpression' ||
currentNode.type === 'OptionalCallExpression'
) {
let patchNode: ExpressionKind;
// Here we take the current node and extract the parts we actually care
// about.
// In the case of a member expression we take the property it's trying to
// access and make the object it's accessing be our chain value.
if (
currentNode.type === 'MemberExpression' ||
currentNode.type === 'OptionalMemberExpression'
) {
patchNode = types.builders.memberExpression(
valueMemberExpression,
currentNode.property,
);
// In the case of a call expression we take the arguments and make the
// callee our chain value.
} else {
patchNode = types.builders.callExpression(
valueMemberExpression,
currentNode.arguments,
);
}
// If we have a previous node we patch it here.
if (currentPatch) {
updatePatch(currentPatch, patchNode);
}
// If we have no top patch (first run, or just pushed onto the stack) we
// note it here.
if (!patchTop) {
patchTop = patchNode;
}
currentPatch = patchNode;
// This is an optional in our chain. In here we'll push the node onto the
// stack. We also do a polyfill if the top of the stack is function call
// that might be a extended function.
if (currentNode.optional) {
// Implement polyfill described below
if (wrapNextTopInOptionalExtend) {
wrapNextTopInOptionalExtend = false;
// This shouldn't ever happen
if (
patchTop.type === 'MemberExpression' &&
patchTop.property.type === 'Identifier'
) {
patchTop = types.builders.callExpression(
types.builders.identifier(EXPRESSION_EXTENDER_OPTIONAL),
[patchTop.object, types.builders.stringLiteral(patchTop.property.name)],
);
}
}
patchedStack.push(patchTop);
patchTop = null;
currentPatch = null;
// Attempting to optional chain on an extended function. If we don't
// polyfill this most calls will always be undefined. Marking that the
// next part of the chain should be wrapped in our polyfill.
if (
(currentNode.type === 'CallExpression' ||
currentNode.type === 'OptionalCallExpression') &&
(currentNode.callee.type === 'MemberExpression' ||
currentNode.callee.type === 'OptionalMemberExpression') &&
currentNode.callee.property.type === 'Identifier' &&
isExpressionExtension(currentNode.callee.property.name)
) {
wrapNextTopInOptionalExtend = true;
}
}
// Finally we get the next point AST to walk down.
if (
currentNode.type === 'MemberExpression' ||
currentNode.type === 'OptionalMemberExpression'
) {
currentNode = currentNode.object;
} else {
currentNode = currentNode.callee;
}
} else {
// We update the final patch to point to the first part of the optional chain
// which is probably an identifier for an object.
if (currentPatch) {
updatePatch(currentPatch, currentNode);
if (!patchTop) {
patchTop = currentPatch;
}
}
if (wrapNextTopInOptionalExtend) {
wrapNextTopInOptionalExtend = false;
// This shouldn't ever happen
if (
patchTop?.type === 'MemberExpression' &&
patchTop.property.type === 'Identifier'
) {
patchTop = types.builders.callExpression(
types.builders.identifier(EXPRESSION_EXTENDER_OPTIONAL),
[patchTop.object, types.builders.stringLiteral(patchTop.property.name)],
);
}
}
// Push the first part of our chain to stack.
if (patchTop) {
patchedStack.push(patchTop);
} else {
patchedStack.push(currentNode);
}
break;
}
}
// Since we're working from right to left we need to flip the stack
// for the correct order of operations
patchedStack.reverse();
// Walk the node stack and wrap all our expressions in cancel/assignment
// wrappers.
for (let i = 0; i < patchedStack.length; i++) {
let node = patchedStack[i];
// We don't wrap the last expression in an assignment wrapper because
// it's going to be returned anyway. We just wrap it in a cancel check
// wrapper.
if (i !== patchedStack.length - 1) {
node = buildCancelAssignWrapper(buildOptionalWrapper(node));
}
// Don't wrap the first part in a cancel wrapper because the cancel
// token will always be undefined.
if (i !== 0) {
node = buildCancelCheckWrapper(node);
}
// Replace the node in the stack with our wrapped one
patchedStack[i] = node;
}
// Put all our expressions in a sequence expression (also called a
// group operator). These will all be executed in order and the value
// of the final expression will be returned.
const sequenceNode = types.builders.sequenceExpression(patchedStack);
path.replace(sequenceNode);
},
});
// Extended functions
visit(ast, {
visitCallExpression(path) {
this.traverse(path);
if (
path.node.callee.type === 'MemberExpression' &&
path.node.callee.property.type === 'Identifier' &&
isExpressionExtension(path.node.callee.property.name)
) {
path.replace(
types.builders.callExpression(types.builders.identifier(EXPRESSION_EXTENDER), [
path.node.callee.object,
types.builders.stringLiteral(path.node.callee.property.name),
types.builders.arrayExpression(path.node.arguments),
]),
);
} else if (
path.node.callee.type === 'Identifier' &&
path.node.callee.name === '$if' &&
path.node.arguments.every((v) => v.type !== 'SpreadElement')
) {
if (path.node.arguments.length < 2) {
throw new ExpressionExtensionError(
'$if requires at least 2 parameters: test, value_if_true[, and value_if_false]',
);
}
const test = path.node.arguments[0];
const consequent = path.node.arguments[1];
const alternative =
path.node.arguments[2] === undefined
? types.builders.booleanLiteral(false)
: path.node.arguments[2];
path.replace(
types.builders.conditionalExpression(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
test as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
consequent as any,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
alternative as any,
),
);
}
},
});
return print(ast);
} catch (e) {
return;
}
};
function isDate(input: unknown): boolean {
if (typeof input !== 'string' || !input.length) {
return false;
}
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(input)) {
return false;
}
const d = new Date(input);
return d instanceof Date && !isNaN(d.valueOf()) && d.toISOString() === input;
}
interface FoundFunction {
type: 'native' | 'extended';
// eslint-disable-next-line @typescript-eslint/no-restricted-types
function: Function;
}
function findExtendedFunction(input: unknown, functionName: string): FoundFunction | undefined {
// Coerce to string early so the name is stable for the property check below
const name = typeof functionName === 'string' ? functionName : String(functionName);
// Ensure the property name is in the allowed set before looking it up
if (!isSafeObjectProperty(name)) {
throw new ExpressionExtensionError(
`Cannot access "${name}" via expression extension due to security concerns`,
);
}
// eslint-disable-next-line @typescript-eslint/no-restricted-types
let foundFunction: Function | undefined;
if (Array.isArray(input)) {
foundFunction = arrayExtensions.functions[name];
} else if (isDate(input) && name !== 'toDate' && name !== 'toDateTime') {
// If it's a string date (from $json), convert it to a Date object,
// unless that function is `toDate`, since `toDate` does something
// very different on date objects
input = new Date(input as string);
foundFunction = dateExtensions.functions[name];
} else if (typeof input === 'string') {
foundFunction = stringExtensions.functions[name];
} else if (typeof input === 'number') {
foundFunction = numberExtensions.functions[name];
} else if (input && (DateTime.isDateTime(input) || input instanceof Date)) {
foundFunction = dateExtensions.functions[name];
} else if (input !== null && typeof input === 'object') {
foundFunction = objectExtensions.functions[name];
} else if (typeof input === 'boolean') {
foundFunction = booleanExtensions.functions[name];
}
// Look for generic or builtin
if (!foundFunction) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputAny: any = input;
// This is likely a builtin we're implementing for another type
// (e.g. toLocaleString). We'll return that instead
if (inputAny && name && typeof inputAny[name] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { type: 'native', function: inputAny[name] };
}
// Use a generic version if available
foundFunction = genericExtensions[name];
}
if (!foundFunction) {
return undefined;
}
return { type: 'extended', function: foundFunction };
}
/**
* Extender function injected by expression extension plugin to allow calls to extensions.
*
* ```ts
* extend(input, "functionName", [...args]);
* ```
*/
export function extend(input: unknown, functionName: string, args: unknown[]) {
const foundFunction = findExtendedFunction(input, functionName);
// No type specific or generic function found. Check to see if
// any types have a function with that name. Then throw an error
// letting the user know the available types.
if (!foundFunction) {
checkIfValueDefinedOrThrow(input, functionName);
const haveFunction = EXTENSION_OBJECTS.filter((v) => functionName in v.functions);
if (!haveFunction.length) {
// This shouldn't really be possible but we should cover it anyway
throw new ExpressionExtensionError(`Unknown expression function: ${functionName}`);
}
if (haveFunction.length > 1) {
const lastType = `"${haveFunction.pop()!.typeName}"`;
const typeNames = `${haveFunction.map((v) => `"${v.typeName}"`).join(', ')}, and ${lastType}`;
throw new ExpressionExtensionError(
`${functionName}() is only callable on types ${typeNames}`,
);
} else {
throw new ExpressionExtensionError(
`${functionName}() is only callable on type "${haveFunction[0].typeName}"`,
);
}
}
if (foundFunction.type === 'native') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function.apply(input, args);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function(input, args);
}
export function extendOptional(
input: unknown,
functionName: string,
// eslint-disable-next-line @typescript-eslint/no-restricted-types
): Function | undefined {
const foundFunction = findExtendedFunction(input, functionName);
if (!foundFunction) {
return undefined;
}
if (foundFunction.type === 'native') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function.bind(input);
}
return (...args: unknown[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return foundFunction.function(input, args);
};
}
const EXTENDED_SYNTAX_CACHE: Record<string, string> = {};
export function extendSyntax(bracketedExpression: string, forceExtend = false): string {
const chunks = splitExpression(bracketedExpression);
const codeChunks = chunks
.filter((c) => c.type === 'code')
.map((c) => c.text.replace(/("|').*?("|')/, '').trim());
if (
(!codeChunks.some(hasExpressionExtension) || hasNativeMethod(bracketedExpression)) &&
!forceExtend
) {
return bracketedExpression;
}
// If we've seen this expression before grab it from the cache
if (bracketedExpression in EXTENDED_SYNTAX_CACHE) {
return EXTENDED_SYNTAX_CACHE[bracketedExpression];
}
const extendedChunks = chunks.map((chunk): ExpressionChunk => {
if (chunk.type === 'code') {
let output = extendTransform(chunk.text);
// esprima fails to parse bare objects (e.g. `{ data: something }`), we can
// work around this by wrapping it in an parentheses
if (!output?.code && chunk.text.trim()[0] === '{') {
output = extendTransform(`(${chunk.text})`);
}
if (!output?.code) {
throw new ExpressionExtensionError('invalid syntax');
}
let text = output.code;
// We need to cut off any trailing semicolons. These cause issues
// with certain types of expression and cause the whole expression
// to fail.
if (text.trim().endsWith(';')) {
text = text.trim().slice(0, -1);
}
return {
...chunk,
text,
} as ExpressionCode;
}
return chunk;
});
const expression = joinExpression(extendedChunks);
// Cache the expression so we don't have to do this transform again
EXTENDED_SYNTAX_CACHE[bracketedExpression] = expression;
return expression;
}
@@ -0,0 +1,100 @@
export interface ExpressionText {
type: 'text';
text: string;
}
export interface ExpressionCode {
type: 'code';
text: string;
// This is to match behavior in our original expression evaluator (tmpl),
// which has different behaviours if the last expression doesn't close itself.
hasClosingBrackets: boolean;
}
export type ExpressionChunk = ExpressionCode | ExpressionText;
const OPEN_BRACKET = /(?<escape>\\|)(?<brackets>\{\{)/;
const CLOSE_BRACKET = /(?<escape>\\|)(?<brackets>\}\})/;
export const escapeCode = (text: string): string => {
return text.replace('\\}}', '}}');
};
export const splitExpression = (expression: string): ExpressionChunk[] => {
const chunks: ExpressionChunk[] = [];
let searchingFor: 'open' | 'close' = 'open';
let activeRegex = OPEN_BRACKET;
let buffer = '';
let index = 0;
while (index < expression.length) {
const expr = expression.slice(index);
const res = activeRegex.exec(expr);
// No more brackets. If it's a closing bracket
// this is sort of valid so we accept it but mark
// that it has no closing bracket.
if (!res?.groups) {
buffer += expr;
if (searchingFor === 'open') {
chunks.push({
type: 'text',
text: buffer,
});
} else {
chunks.push({
type: 'code',
text: escapeCode(buffer),
hasClosingBrackets: false,
});
}
break;
}
if (res.groups.escape) {
buffer += expr.slice(0, res.index + 3);
index += res.index + 3;
} else {
buffer += expr.slice(0, res.index);
if (searchingFor === 'open') {
chunks.push({
type: 'text',
text: buffer,
});
searchingFor = 'close';
activeRegex = CLOSE_BRACKET;
} else {
chunks.push({
type: 'code',
text: escapeCode(buffer),
hasClosingBrackets: true,
});
searchingFor = 'open';
activeRegex = OPEN_BRACKET;
}
index += res.index + 2;
buffer = '';
}
}
return chunks;
};
// Expressions only have closing brackets escaped
const escapeTmplExpression = (part: string) => {
return part.replace('}}', '\\}}');
};
export const joinExpression = (parts: ExpressionChunk[]): string => {
return parts
.map((chunk) => {
if (chunk.type === 'code') {
return `{{${escapeTmplExpression(chunk.text)}${chunk.hasClosingBrackets ? '}}' : ''}`;
}
return chunk.text;
})
.join('');
};
@@ -0,0 +1,85 @@
import { average as aAverage } from './array-extensions';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
import { ExpressionError } from '../errors/expression.error';
const min = Math.min;
const max = Math.max;
const numberList = (start: number, end: number): number[] => {
const size = Math.abs(start - end) + 1;
const arr = new Array<number>(size);
let curr = start;
for (let i = 0; i < size; i++) {
if (start < end) {
arr[i] = curr++;
} else {
arr[i] = curr--;
}
}
return arr;
};
const zip = (keys: unknown[], values: unknown[]): unknown => {
if (keys.length !== values.length) {
throw new ExpressionExtensionError('keys and values not of equal length');
}
return keys.reduce((p, c, i) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
(p as any)[c as any] = values[i];
return p;
}, {});
};
const average = (...args: number[]) => {
return aAverage(args);
};
const not = (value: unknown): boolean => {
return !value;
};
function ifEmpty<T, V>(value: V, defaultValue: T) {
if (arguments.length !== 2) {
throw new ExpressionError('expected two arguments (value, defaultValue) for this function');
}
if (value === undefined || value === null || value === '') {
return defaultValue;
}
if (typeof value === 'object') {
if (Array.isArray(value) && !value.length) {
return defaultValue;
}
if (!Object.keys(value).length) {
return defaultValue;
}
}
return value;
}
ifEmpty.doc = {
name: 'ifEmpty',
description:
'Returns the default value if the value is empty. Empty values are undefined, null, empty strings, arrays without elements and objects without keys.',
returnType: 'any',
args: [
{ name: 'value', type: 'any' },
{ name: 'defaultValue', type: 'any' },
],
docURL: 'https://docs.n8n.io/code/builtin/convenience',
};
export const extendedFunctions = {
min,
max,
not,
average,
numberList,
zip,
$min: min,
$max: max,
$average: average,
$not: not,
$ifEmpty: ifEmpty,
};
@@ -0,0 +1,49 @@
import type { Completion } from '@codemirror/autocomplete';
export type Alias = { label: string; info?: string; mode?: 'prefix' | 'exact' };
export interface AliasCompletion extends Completion {
alias?: Alias[];
}
export interface ExtensionMap {
typeName: string;
functions: Record<string, Extension>;
}
// eslint-disable-next-line @typescript-eslint/no-restricted-types
export type Extension = Function & { doc?: DocMetadata };
export type NativeDoc = {
typeName: string;
properties?: Record<string, { doc?: DocMetadata }>;
functions: Record<string, { doc?: DocMetadata }>;
};
export type DocMetadataArgument = {
name: string;
type?: string;
optional?: boolean;
variadic?: boolean;
description?: string;
default?: string;
// Function arguments have nested arguments
args?: DocMetadataArgument[];
};
export type DocMetadataExample = {
example: string;
evaluated?: string;
description?: string;
};
export type DocMetadata = {
name: string;
returnType: string;
description?: string;
section?: string;
hidden?: boolean;
aliases?: string[];
aliasMode?: 'prefix' | 'exact';
args?: DocMetadataArgument[];
examples?: DocMetadataExample[];
docURL?: string;
};
+21
View File
@@ -0,0 +1,21 @@
export {
extend,
extendOptional,
hasExpressionExtension,
hasNativeMethod,
extendTransform,
EXTENSION_OBJECTS as ExpressionExtensions,
} from './expression-extension';
export type {
DocMetadata,
NativeDoc,
Extension,
DocMetadataArgument,
DocMetadataExample,
} from './extensions';
export type {
Alias,
AliasCompletion,
} from './extensions';
@@ -0,0 +1,275 @@
// NOTE: This file is intentionally mirrored in @n8n/expression-runtime/src/extensions/
// for use inside the isolated VM. Changes here must be reflected there and vice versa.
// TODO: Eliminate the duplication. The blocker is that @n8n/expression-runtime is
// Vite-stubbed for browser builds (to exclude isolated-vm), which prevents n8n-workflow
// from importing these extension utilities directly from the runtime package. Fix by
// splitting @n8n/expression-runtime into a browser-safe extensions subpath (not stubbed)
// and a node-only VM entry (stubbed).
// @vitest-environment jsdom
import { DateTime } from 'luxon';
import type { ExtensionMap } from './extensions';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
function format(value: number, extraArgs: unknown[]): string {
const [locales = 'en-US', config = {}] = extraArgs as [
string | string[],
Intl.NumberFormatOptions,
];
return new Intl.NumberFormat(locales, config).format(value);
}
function isEven(value: number) {
if (!Number.isInteger(value)) {
throw new ExpressionExtensionError('isEven() is only callable on integers');
}
return value % 2 === 0;
}
function isOdd(value: number) {
if (!Number.isInteger(value)) {
throw new ExpressionExtensionError('isOdd() is only callable on integers');
}
return Math.abs(value) % 2 === 1;
}
function floor(value: number) {
return Math.floor(value);
}
function ceil(value: number) {
return Math.ceil(value);
}
function abs(value: number) {
return Math.abs(value);
}
function isInteger(value: number) {
return Number.isInteger(value);
}
function round(value: number, extraArgs: number[]) {
const [decimalPlaces = 0] = extraArgs;
return +value.toFixed(decimalPlaces);
}
function toBoolean(value: number) {
return value !== 0;
}
function toInt(value: number) {
return round(value, []);
}
function toFloat(value: number) {
return value;
}
type DateTimeFormat = 'ms' | 's' | 'us' | 'excel';
export function toDateTime(value: number, extraArgs: [DateTimeFormat]) {
const [valueFormat = 'ms'] = extraArgs;
if (!['ms', 's', 'us', 'excel'].includes(valueFormat)) {
throw new ExpressionExtensionError(
`Unsupported format '${String(valueFormat)}'. toDateTime() supports 'ms', 's', 'us' and 'excel'.`,
);
}
switch (valueFormat) {
// Excel format is days since 1900
// There is a bug where 1900 is incorrectly treated as a leap year
case 'excel': {
const DAYS_BETWEEN_1900_1970 = 25567;
const DAYS_LEAP_YEAR_BUG_ADJUST = 2;
const SECONDS_IN_DAY = 86_400;
return DateTime.fromSeconds(
(value - (DAYS_BETWEEN_1900_1970 + DAYS_LEAP_YEAR_BUG_ADJUST)) * SECONDS_IN_DAY,
);
}
case 's':
return DateTime.fromSeconds(value);
case 'us':
return DateTime.fromMillis(value / 1000);
case 'ms':
default:
return DateTime.fromMillis(value);
}
}
ceil.doc = {
name: 'ceil',
description: 'Rounds the number up to the next whole number',
examples: [{ example: '(1.234).ceil()', evaluated: '2' }],
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-ceil',
};
floor.doc = {
name: 'floor',
description: 'Rounds the number down to the nearest whole number',
examples: [{ example: '(1.234).floor()', evaluated: '1' }],
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-floor',
};
isEven.doc = {
name: 'isEven',
description:
"Returns <code>true</code> if the number is even or <code>false</code> if not. Throws an error if the number isn't a whole number.",
examples: [
{ example: '(33).isEven()', evaluated: 'false' },
{ example: '(42).isEven()', evaluated: 'true' },
],
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-isEven',
};
isOdd.doc = {
name: 'isOdd',
description:
"Returns <code>true</code> if the number is odd or <code>false</code> if not. Throws an error if the number isn't a whole number.",
examples: [
{ example: '(33).isOdd()', evaluated: 'true' },
{ example: '(42).isOdd()', evaluated: 'false' },
],
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-isOdd',
};
format.doc = {
name: 'format',
description:
'Returns a formatted string representing the number. Useful for formatting for a specific language or currency. The same as <a target="_blank" href=”https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat”><code>Intl.NumberFormat()</code></a>.',
examples: [
{ example: "(123456.789).format('de-DE')", evaluated: '123.456,789' },
{
example: "(123456.789).format('de-DE', {'style': 'currency', 'currency': 'EUR'})",
evaluated: '123.456,79 €',
},
],
returnType: 'string',
args: [
{
name: 'locale',
optional: true,
description:
'A <a target="_blank" href=”https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument”>locale tag</a> for formatting the number, e.g. <code>fr-FR</code>, <code>en-GB</code>, <code>pr-BR</code>',
default: '"en-US"',
type: 'string',
},
{
name: 'options',
optional: true,
description:
'Configuration options for number formatting. <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat" target="_blank">More info</a>',
type: 'object',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-format',
};
round.doc = {
name: 'round',
description: 'Rounds the number to the nearest integer (or decimal place)',
examples: [
{ example: '(1.256).round()', evaluated: '1' },
{ example: '(1.256).round(1)', evaluated: '1.3' },
{ example: '(1.256).round(2)', evaluated: '1.26' },
],
returnType: 'number',
args: [
{
name: 'decimalPlaces',
optional: true,
description: 'The number of decimal places to round to',
default: '0',
type: 'number',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-round',
};
toBoolean.doc = {
name: 'toBoolean',
description:
'Returns <code>false</code> for <code>0</code> and <code>true</code> for any other number (including negative numbers).',
examples: [
{ example: '(12).toBoolean()', evaluated: 'true' },
{ example: '(0).toBoolean()', evaluated: 'false' },
{ example: '(-1.3).toBoolean()', evaluated: 'true' },
],
section: 'cast',
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-toBoolean',
};
toDateTime.doc = {
name: 'toDateTime',
description:
'Converts a numerical timestamp into a <a target="_blank" href="https://moment.github.io/luxon/api-docs/">Luxon</a> DateTime. The format of the timestamp must be specified if it\'s not in milliseconds. Uses the timezone specified in workflow settings if available; otherwise, it defaults to the timezone set for the instance.',
examples: [
{ example: "(1708695471).toDateTime('s')", evaluated: '2024-02-23T14:37:51.000+01:00' },
{ example: "(1708695471000).toDateTime('ms')", evaluated: '2024-02-23T14:37:51.000+01:00' },
{ example: "(1708695471000000).toDateTime('us')", evaluated: '2024-02-23T14:37:51.000+01:00' },
{ example: "(45345).toDateTime('excel')", evaluated: '2024-02-23T01:00:00.000+01:00' },
],
section: 'cast',
returnType: 'DateTime',
args: [
{
name: 'format',
optional: true,
description:
'The type of timestamp to convert. Options are <code>ms</code> (for Unix timestamp in milliseconds), <code>s</code> (for Unix timestamp in seconds), <code>us</code> (for Unix timestamp in microseconds) or <code>excel</code> (for days since 1900).',
default: '"ms"',
type: 'string',
},
],
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-toDateTime',
};
abs.doc = {
name: 'abs',
description: "Returns the number's absolute value, i.e. removes any minus sign",
examples: [
{ example: '(-1.7).abs()', evaluated: '1.7' },
{ example: '(1.7).abs()', evaluated: '1.7' },
],
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-abs',
};
isInteger.doc = {
name: 'isInteger',
description: 'Returns <code>true</code> if the number is a whole number',
examples: [
{ example: '(4).isInteger()', evaluated: 'true' },
{ example: '(4.12).isInteger()', evaluated: 'false' },
{ example: '(-4).isInteger()', evaluated: 'true' },
],
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/numbers/#number-isInteger',
};
export const numberExtensions: ExtensionMap = {
typeName: 'Number',
functions: {
ceil,
floor,
format,
round,
abs,
isInteger,
isEven,
isOdd,
toBoolean,
toInt,
toFloat,
toDateTime,
},
};
@@ -0,0 +1,326 @@
// NOTE: This file is intentionally mirrored in @n8n/expression-runtime/src/extensions/
// for use inside the isolated VM. Changes here must be reflected there and vice versa.
// TODO: Eliminate the duplication. The blocker is that @n8n/expression-runtime is
// Vite-stubbed for browser builds (to exclude isolated-vm), which prevents n8n-workflow
// from importing these extension utilities directly from the runtime package. Fix by
// splitting @n8n/expression-runtime into a browser-safe extensions subpath (not stubbed)
// and a node-only VM entry (stubbed).
import type { ExtensionMap } from './extensions';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
function isEmpty(value: object): boolean {
return Object.keys(value).length === 0;
}
function isNotEmpty(value: object): boolean {
return !isEmpty(value);
}
function keys(value: object): string[] {
return Object.keys(value);
}
function values(value: object): unknown[] {
return Object.values(value);
}
function hasField(value: object, extraArgs: string[]): boolean {
const [name] = extraArgs;
return name in value;
}
function removeField(value: object, extraArgs: string[]): object {
const [name] = extraArgs;
if (name in value) {
const newObject = { ...value };
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
delete (newObject as any)[name];
return newObject;
}
return value;
}
function removeFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string' || match === '') {
throw new ExpressionExtensionError('removeFieldsContaining(): expected non-empty string arg');
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
if (typeof val === 'string' && val.includes(match)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
delete (newObject as any)[key];
}
}
return newObject;
}
function keepFieldsContaining(value: object, extraArgs: string[]): object {
const [match] = extraArgs;
if (typeof match !== 'string' || match === '') {
throw new ExpressionExtensionError(
'argument of keepFieldsContaining must be a non-empty string',
);
}
const newObject = { ...value };
for (const [key, val] of Object.entries(value)) {
if (typeof val !== 'string' || (typeof val === 'string' && !val.includes(match))) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any
delete (newObject as any)[key];
}
}
return newObject;
}
export function compact(value: object): object {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newObj: any = {};
for (const [key, val] of Object.entries(value)) {
if (val !== null && val !== undefined && val !== 'nil' && val !== '') {
if (typeof val === 'object') {
if (Object.keys(val as object).length === 0) continue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
newObj[key] = compact(val);
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
newObj[key] = val;
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return newObj;
}
export function urlEncode(value: object) {
return new URLSearchParams(value as Record<string, string>).toString();
}
export function toJsonString(value: object) {
return JSON.stringify(value);
}
export function toInt() {
return undefined;
}
export function toFloat() {
return undefined;
}
export function toBoolean() {
return undefined;
}
export function toDateTime() {
return undefined;
}
isEmpty.doc = {
name: 'isEmpty',
description:
'Returns <code>true</code> if the Object has no keys (fields) set or is <code>null</code>',
examples: [
{ example: "({'name': 'Nathan'}).isEmpty()", evaluated: 'false' },
{ example: '({}).isEmpty()', evaluated: 'true' },
],
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-isEmpty',
};
isNotEmpty.doc = {
name: 'isNotEmpty',
description: 'Returns <code>true</code> if the Object has at least one key (field) set',
examples: [
{ example: "({'name': 'Nathan'}).isNotEmpty()", evaluated: 'true' },
{ example: '({}).isNotEmpty()', evaluated: 'false' },
],
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-isNotEmpty',
};
compact.doc = {
name: 'compact',
description:
'Removes all fields that have empty values, i.e. are <code>null</code>, <code>undefined</code>, <code>"nil"</code> or <code>""</code>',
examples: [{ example: "({ x: null, y: 2, z: '' }).compact()", evaluated: '{ y: 2 }' }],
returnType: 'Object',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-compact',
};
urlEncode.doc = {
name: 'urlEncode',
description:
"Generates a URL parameter string from the Object's keys and values. Only top-level keys are supported.",
examples: [
{
example: "({ name: 'Mr Nathan', city: 'hanoi' }).urlEncode()",
evaluated: "'name=Mr+Nathan&city=hanoi'",
},
],
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-urlEncode',
};
hasField.doc = {
name: 'hasField',
description:
'Returns <code>true</code> if there is a field called <code>name</code>. Only checks top-level keys. Comparison is case-sensitive.',
examples: [
{ example: "({ name: 'Nathan', age: 42 }).hasField('name')", evaluated: 'true' },
{ example: "({ name: 'Nathan', age: 42 }).hasField('Name')", evaluated: 'false' },
{ example: "({ name: 'Nathan', age: 42 }).hasField('inventedField')", evaluated: 'false' },
],
returnType: 'boolean',
args: [
{
name: 'name',
optional: false,
description: 'The name of the key to search for',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-hasField',
};
removeField.doc = {
name: 'removeField',
aliases: ['delete'],
description: "Removes a field from the Object. The same as JavaScript's <code>delete</code>.",
examples: [
{
example: "({ name: 'Nathan', city: 'hanoi' }).removeField('name')",
evaluated: "{ city: 'hanoi' }",
},
],
returnType: 'Object',
args: [
{
name: 'key',
optional: false,
description: 'The name of the field to remove',
type: 'string',
},
],
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-removeField',
};
removeFieldsContaining.doc = {
name: 'removeFieldsContaining',
description:
"Removes keys (fields) whose values at least partly match the given <code>value</code>. Comparison is case-sensitive. Fields that aren't strings are always kept.",
examples: [
{
example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).removeFieldsContaining('Nathan')",
evaluated: "{ city: 'hanoi', age: 42 }",
},
{
example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).removeFieldsContaining('Han')",
evaluated: '{ age: 42 }',
},
{
example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).removeFieldsContaining('nathan')",
evaluated: "{ name: 'Mr Nathan', city: 'hanoi', age: 42 }",
},
],
returnType: 'Object',
args: [
{
name: 'value',
optional: false,
description: 'The text that a value must contain in order to be removed',
type: 'string',
},
],
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-removeFieldsContaining',
};
keepFieldsContaining.doc = {
name: 'keepFieldsContaining',
description:
"Removes any fields whose values don't at least partly match the given <code>value</code>. Comparison is case-sensitive. Fields that aren't strings will always be removed.",
examples: [
{
example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).keepFieldsContaining('Nathan')",
evaluated: "{ name: 'Mr Nathan' }",
},
{
example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).keepFieldsContaining('nathan')",
evaluated: '{}',
},
{
example: "({ name: 'Mr Nathan', city: 'hanoi', age: 42 }).keepFieldsContaining('han')",
evaluated: "{ name: 'Mr Nathan', city: 'hanoi' }",
},
],
returnType: 'Object',
args: [
{
name: 'value',
optional: false,
description: 'The text that a value must contain in order to be kept',
type: 'string',
},
],
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-keepFieldsContaining',
};
keys.doc = {
name: 'keys',
description:
"Returns an array with all the field names (keys) the Object contains. The same as JavaScript's <code>Object.keys(obj)</code>.",
examples: [{ example: "({ name: 'Mr Nathan', age: 42 }).keys()", evaluated: "['name', 'age']" }],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-keys',
returnType: 'Array',
};
values.doc = {
name: 'values',
description:
"Returns an array with all the values of the fields the Object contains. The same as JavaScript's <code>Object.values(obj)</code>.",
examples: [
{ example: "({ name: 'Mr Nathan', age: 42 }).values()", evaluated: "['Mr Nathan', 42]" },
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-values',
returnType: 'Array',
};
toJsonString.doc = {
name: 'toJsonString',
description:
"Converts the Object to a JSON string. Similar to JavaScript's <code>JSON.stringify()</code>.",
examples: [
{
example: "({ name: 'Mr Nathan', age: 42 }).toJsonString()",
evaluated: '\'{"name":"Nathan","age":42}\'',
},
],
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/objects/#object-toJsonString',
returnType: 'string',
};
export const objectExtensions: ExtensionMap = {
typeName: 'Object',
functions: {
isEmpty,
isNotEmpty,
hasField,
removeField,
removeFieldsContaining,
keepFieldsContaining,
compact,
urlEncode,
keys,
values,
toJsonString,
toInt,
toFloat,
toBoolean,
toDateTime,
},
};
@@ -0,0 +1,920 @@
// NOTE: This file is intentionally mirrored in @n8n/expression-runtime/src/extensions/
// for use inside the isolated VM. Changes here must be reflected there and vice versa.
// TODO: Eliminate the duplication. The blocker is that @n8n/expression-runtime is
// Vite-stubbed for browser builds (to exclude isolated-vm), which prevents n8n-workflow
// from importing these extension utilities directly from the runtime package. Fix by
// splitting @n8n/expression-runtime into a browser-safe extensions subpath (not stubbed)
// and a node-only VM entry (stubbed).
import { toBase64, fromBase64 } from 'js-base64';
import SHA from 'jssha';
import { DateTime } from 'luxon';
import MD5 from 'md5';
import { titleCase } from 'title-case';
import { transliterate } from 'transliteration';
import type { Extension, ExtensionMap } from './extensions';
import { toDateTime as numberToDateTime } from './number-extensions';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
import { tryToParseDateTime } from '../type-validation';
export const SupportedHashAlgorithms = [
'md5',
'sha1',
'sha224',
'sha256',
'sha384',
'sha512',
'sha3',
] as const;
// All symbols from https://www.xe.com/symbols/ as for 2022/11/09
const CURRENCY_REGEXP =
/(\u004c\u0065\u006b|\u060b|\u0024|\u0192|\u20bc|\u0042\u0072|\u0042\u005a\u0024|\u0024\u0062|\u004b\u004d|\u0050|\u043b\u0432|\u0052\u0024|\u17db|\u00a5|\u20a1|\u006b\u006e|\u20b1|\u004b\u010d|\u006b\u0072|\u0052\u0044\u0024|\u00a3|\u20ac|\u00a2|\u0051|\u004c|\u0046\u0074|\u20b9|\u0052\u0070|\ufdfc|\u20aa|\u004a\u0024|\u20a9|\u20ad|\u0434\u0435\u043d|\u0052\u004d|\u20a8|\u20ae|\u004d\u0054|\u0043\u0024|\u20a6|\u0042\u002f\u002e|\u0047\u0073|\u0053\u002f\u002e|\u007a\u0142|\u006c\u0065\u0069|\u20bd|\u0414\u0438\u043d\u002e|\u0053|\u0052|\u0043\u0048\u0046|\u004e\u0054\u0024|\u0e3f|\u0054\u0054\u0024|\u20ba|\u20b4|\u0024\u0055|\u0042\u0073|\u20ab|\u005a\u0024)/gu;
/*
Extract the domain part from various inputs, including URLs, email addresses, and plain domains.
/^(?:(?:https?|ftp):\/\/)? // Match optional http, https, or ftp protocols
(?:mailto:)? // Match optional mailto:
(?:\/\/)? // Match optional double slashes
(?:www\.)? // Match optional www prefix
(?:[-\w]*\.)? // Match any optional subdomain
( // Capture the domain part
(?:(?:[-\w]+\.)+ // Match one or more subdomains
(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9]+) // Match top-level domain or Punycode encoded IDN(xn--80aswg.xn--p1ai)
|localhost // Match localhost
|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} // Match IPv4 addresses
)
)
(?::\d+)? // Match optional port number
(?:\/[^\s?]*)? // Match optional path
(?:\?[^\s#]*)? // Match optional query string
(?:#[^\s]*)?$/i; // Match optional hash fragment
*/
const DOMAIN_EXTRACT_REGEXP =
/^(?:(?:https?|ftp):\/\/)?(?:mailto:)?(?:\/\/)?((?:www\.)?(?:(?:[-\w]+\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9]+)|localhost|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(?::\d+)?(?:\/[^\s?]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?$/i;
/*
Matches domain names without the protocol or optional subdomains
/^(?:www\.)? // Match optional www prefix
( // Capture the domain part
(?:(?:[-\w]+\.)+ // Match one or more subdomains
(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9]+) // Match top-level domain or Punycode encoded IDN
|localhost // Match localhost
|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} // Match IPv4 addresses
)
)
(?::\d+)? // Match optional port number
(?:\/[^\s?]*)? // Match optional path
(?:\?[^\s#]*)? // Match optional query string
(?:#[^\s]*)?$/i; // Match optional fragment at the end of the string
*/
const DOMAIN_REGEXP =
/^(?:www\.)?((?:(?:[-\w]+\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9]+)|localhost|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(?::\d+)?(?:\/[^\s?]*)?(?:\?[^\s#]*)?(?:#[^\s]*)?$/i;
/*
Matches email addresses
/(
( // Capture local part of the email address
([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*) // One or more characters not in the set, followed by
a period, followed by one or more characters not in the set
|(".+") // Or one or more characters inside quotes
)
)
@ // Match @ symbol
(?<domain>( // Capture the domain part of the email address
\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\] // Match IPv4 address inside brackets
|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}) // Or match domain with at least two subdomains and TLD
))/;
*/
const EMAIL_REGEXP =
/(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@(?<domain>(\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
/*
Matches URLs with strict beginning and end of the string checks
/^(?:(?:https?|ftp):\/\/) // Match http, https, or ftp protocols at the start of the string
(?:www\.)? // Match optional www prefix
( // Capture the domain part
(?:(?:[-\w]+\.)+ // Match one or more subdomains
(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9]+) // Match top-level domain or Punycode encoded IDN
|localhost // Match localhost
|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3} // Match IPv4 addresses
)
)
(?::\d+)? // Match optional port number
(?:\/[^\s?#]*)? // Match optional path
(?:\?[^\s#]*)? // Match optional query string
(?=([^\s]+#.*)?) // Positive lookahead for the fragment identifier
#?[^\s]*$/i; // Match optional fragment at the end of the string
*/
const URL_REGEXP_EXACT =
/^(?:(?:https?|ftp):\/\/)(?:www\.)?((?:(?:[-\w]+\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9]+)|localhost|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(?::\d+)?(?:\/[^\s?#]*)?(?:\?[^\s#]*)?(?=([^\s]+#.*)?)#?[^\s]*$/i;
/*
Same as URL_REGEXP_EXACT but without the strict beginning and end of the string checks to allow for
matching URLs in the middle of a string
*/
const URL_REGEXP =
/(?:(?:https?|ftp):\/\/)(?:www\.)?((?:(?:[-\w]+\.)+(?:[a-zA-Z]{2,}|xn--[a-zA-Z0-9]+)|localhost|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(?::\d+)?(?:\/[^\s?#]*)?(?:\?[^\s#]*)?(?=([^\s]+#.*)?)#?[^\s]*/i;
const CHAR_TEST_REGEXP = /\p{L}/u;
const PUNC_TEST_REGEXP = /[!?.]/;
function hash(value: string, extraArgs: string[]): string {
const algorithm = extraArgs[0]?.toLowerCase() ?? 'md5';
switch (algorithm) {
case 'base64':
return toBase64(value);
case 'md5':
return MD5(value);
case 'sha1':
case 'sha224':
case 'sha256':
case 'sha384':
case 'sha512':
case 'sha3':
const variant = (
{
sha1: 'SHA-1',
sha224: 'SHA-224',
sha256: 'SHA-256',
sha384: 'SHA-384',
sha512: 'SHA-512',
sha3: 'SHA3-512',
} as const
)[algorithm];
return new SHA(variant, 'TEXT').update(value).getHash('HEX');
default:
throw new ExpressionExtensionError(
`Unknown algorithm ${algorithm}. Available algorithms are: ${SupportedHashAlgorithms.join()}, and Base64.`,
);
}
}
function isEmpty(value: string): boolean {
return value === '';
}
function isNotEmpty(value: string): boolean {
return !isEmpty(value);
}
function length(value: string): number {
return value.length;
}
export function toJsonString(value: string): string {
return JSON.stringify(value);
}
function removeMarkdown(value: string): string {
let output = value;
try {
output = output.replace(/^([\s\t]*)([*\-+]|\d\.)\s+/gm, '$1');
output = output
// Header
.replace(/\n={2,}/g, '\n')
// Strikethrough
.replace(/~~/g, '')
// Fenced codeblocks
.replace(/`{3}.*\n/g, '');
output = output
// Remove HTML tags
.replace(/<[\w|\s|=|'|"|:|(|)|,|;|/|0-9|.|-]+[>|\\>]/g, '')
// Remove setext-style headers
.replace(/^[=-]{2,}\s*$/g, '')
// Remove footnotes?
.replace(/\[\^.+?\](: .*?$)?/g, '')
.replace(/\s{0,2}\[.*?\]: .*?$/g, '')
// Remove images
.replace(/!\[.*?\][[(].*?[\])]/g, '')
// Remove inline links
.replace(/\[(.*?)\][[(].*?[\])]/g, '$1')
// Remove Blockquotes
.replace(/>/g, '')
// Remove reference-style links?
.replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, '')
// Remove atx-style headers
.replace(/^#{1,6}\s*([^#]*)\s*(#{1,6})?/gm, '$1')
.replace(/([*_]{1,3})(\S.*?\S)\1/g, '$2')
.replace(/(`{3,})(.*?)\1/gm, '$2')
.replace(/^-{3,}\s*$/g, '')
.replace(/`(.+?)`/g, '$1')
.replace(/\n{2,}/g, '\n\n');
} catch (e) {
return value;
}
return output;
}
function removeTags(value: string): string {
return value.replace(/<[^>]*>?/gm, '');
}
function toDate(value: string): Date {
const date = new Date(Date.parse(value));
if (date.toString() === 'Invalid Date') {
throw new ExpressionExtensionError('cannot convert to date');
}
// If time component is not specified, force 00:00h
if (!/:/.test(value)) {
date.setHours(0, 0, 0);
}
return date;
}
export function toDateTime(value: string, extraArgs: [string] = ['']): DateTime {
try {
const [valueFormat] = extraArgs;
if (valueFormat) {
if (
valueFormat === 'ms' ||
valueFormat === 's' ||
valueFormat === 'us' ||
valueFormat === 'excel'
) {
return numberToDateTime(Number(value), [valueFormat]);
}
return DateTime.fromFormat(value, valueFormat);
}
return tryToParseDateTime(value);
} catch (error) {
throw new ExpressionExtensionError('cannot convert to Luxon DateTime');
}
}
function urlDecode(value: string, extraArgs: boolean[]): string {
const [entireString = false] = extraArgs;
if (entireString) {
return decodeURI(value.toString());
}
return decodeURIComponent(value.toString());
}
function urlEncode(value: string, extraArgs: boolean[]): string {
const [entireString = false] = extraArgs;
if (entireString) {
return encodeURI(value.toString());
}
return encodeURIComponent(value.toString());
}
function toInt(value: string, extraArgs: Array<number | undefined>) {
const [radix] = extraArgs;
const int = parseInt(value.replace(CURRENCY_REGEXP, ''), radix);
if (isNaN(int)) {
throw new ExpressionExtensionError('cannot convert to integer');
}
return int;
}
function toFloat(value: string) {
if (value.includes(',')) {
throw new ExpressionExtensionError('cannot convert to float, expected . as decimal separator');
}
const float = parseFloat(value.replace(CURRENCY_REGEXP, ''));
if (isNaN(float)) {
throw new ExpressionExtensionError('cannot convert to float');
}
return float;
}
function toNumber(value: string) {
const num = Number(value.replace(CURRENCY_REGEXP, ''));
if (isNaN(num)) {
throw new ExpressionExtensionError('cannot convert to number');
}
return num;
}
function quote(value: string, extraArgs: string[]) {
const [quoteChar = '"'] = extraArgs;
return `${quoteChar}${value
.replace(/\\/g, '\\\\')
.replace(new RegExp(`\\${quoteChar}`, 'g'), `\\${quoteChar}`)}${quoteChar}`;
}
function isNumeric(value: string) {
if (value.includes(' ')) return false;
return !isNaN(value as unknown as number) && !isNaN(parseFloat(value));
}
function isUrl(value: string) {
return URL_REGEXP_EXACT.test(value);
}
function isDomain(value: string) {
return DOMAIN_REGEXP.test(value);
}
function isEmail(value: string) {
const result = EMAIL_REGEXP.test(value);
// email regex is loose so check manually for now
if (result && value.includes(' ')) {
return false;
}
return result;
}
function toTitleCase(value: string) {
return titleCase(value);
}
function replaceSpecialChars(value: string) {
return transliterate(value, { unknown: '?' });
}
function toSentenceCase(value: string) {
let current = value.slice();
let buffer = '';
while (CHAR_TEST_REGEXP.test(current)) {
const charIndex = current.search(CHAR_TEST_REGEXP);
current =
current.slice(0, charIndex) +
current[charIndex].toLocaleUpperCase() +
current.slice(charIndex + 1).toLocaleLowerCase();
const puncIndex = current.search(PUNC_TEST_REGEXP);
if (puncIndex === -1) {
buffer += current;
current = '';
break;
}
buffer += current.slice(0, puncIndex + 1);
current = current.slice(puncIndex + 1);
}
return buffer;
}
function toSnakeCase(value: string) {
return value
.toLocaleLowerCase()
.replace(/[ \-]/g, '_')
.replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,.\/:;<=>?@\[\]^`{|}~]/g, '');
}
function extractEmail(value: string) {
const matched = EMAIL_REGEXP.exec(value);
if (!matched) {
return undefined;
}
return matched[0];
}
function extractDomain(value: string) {
if (isEmail(value)) {
const matched = EMAIL_REGEXP.exec(value);
// This shouldn't happen
if (!matched) {
return undefined;
}
return matched.groups?.domain;
}
const domainMatch = value.match(DOMAIN_EXTRACT_REGEXP);
if (domainMatch) {
return domainMatch[1];
}
return undefined;
}
function extractUrl(value: string) {
const matched = URL_REGEXP.exec(value);
if (!matched) {
return undefined;
}
return matched[0];
}
function extractUrlPath(value: string) {
try {
const url = new URL(value);
return url.pathname;
} catch (error) {
return undefined;
}
}
function parseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch (error) {
if (value.includes("'")) {
throw new ExpressionExtensionError("Parsing failed. Check you're using double quotes");
}
throw new ExpressionExtensionError('Parsing failed');
}
}
function toBoolean(value: string): boolean {
const normalized = value.toLowerCase();
const FALSY = new Set(['false', 'no', '0']);
return normalized.length > 0 && !FALSY.has(normalized);
}
function base64Encode(value: string): string {
return toBase64(value);
}
function base64Decode(value: string): string {
return fromBase64(value);
}
removeMarkdown.doc = {
name: 'removeMarkdown',
description: 'Removes any Markdown formatting from the string. Also removes HTML tags.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeMarkdown',
examples: [{ example: '"*bold*, [link]()".removeMarkdown()', evaluated: '"bold, link"' }],
};
removeTags.doc = {
name: 'removeTags',
description: 'Removes tags, such as HTML or XML, from the string.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-removeTags',
examples: [{ example: '"<b>bold</b>, <a>link</a>".removeTags()', evaluated: '"bold, link"' }],
};
toDate.doc = {
name: 'toDate',
description: 'Converts a string to a date.',
section: 'cast',
returnType: 'Date',
hidden: true,
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDate',
};
toDateTime.doc = {
name: 'toDateTime',
description:
'Converts the string to a <a target="_blank" href="https://moment.github.io/luxon/api-docs/">Luxon</a> DateTime. Useful for further transformation. Supported formats for the string are ISO 8601, HTTP, RFC2822, SQL and Unix timestamp in milliseconds. To parse other formats, use <a target="_blank" href=”https://moment.github.io/luxon/api-docs/index.html#datetimefromformat”> <code>DateTime.fromFormat()</code></a>.',
section: 'cast',
returnType: 'DateTime',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDateTime',
examples: [
{ example: '"2024-03-29T18:06:31.798+01:00".toDateTime()' },
{ example: '"Fri, 29 Mar 2024 18:08:01 +0100".toDateTime()' },
{ example: '"20240329".toDateTime()' },
{ example: '"1711732132990".toDateTime("ms")' },
{ example: '"31-01-2024".toDateTime("dd-MM-yyyy")' },
],
args: [
{
name: 'format',
optional: true,
description:
'The format of the date string. Options are <code>ms</code> (for Unix timestamp in milliseconds), <code>s</code> (for Unix timestamp in seconds), <code>us</code> (for Unix timestamp in microseconds) or <code>excel</code> (for days since 1900). Custom formats can be specified using <a href="https://moment.github.io/luxon/#/formatting?id=table-of-tokens">Luxon tokens</a>.',
type: 'string',
},
],
};
toBoolean.doc = {
name: 'toBoolean',
description:
'Converts the string to a boolean value. <code>0</code>, <code>false</code> and <code>no</code> resolve to <code>false</code>, everything else to <code>true</code>. Case-insensitive.',
section: 'cast',
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toBoolean',
examples: [
{ example: '"true".toBoolean()', evaluated: 'true' },
{ example: '"false".toBoolean()', evaluated: 'false' },
{ example: '"0".toBoolean()', evaluated: 'false' },
{ example: '"hello".toBoolean()', evaluated: 'true' },
],
};
toFloat.doc = {
name: 'toFloat',
description: 'Converts a string to a decimal number.',
section: 'cast',
returnType: 'number',
aliases: ['toDecimalNumber'],
hidden: true,
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toDecimalNumber',
};
toInt.doc = {
name: 'toInt',
description: 'Converts a string to an integer.',
section: 'cast',
returnType: 'number',
args: [{ name: 'radix?', type: 'number' }],
aliases: ['toWholeNumber'],
hidden: true,
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toInt',
};
toSentenceCase.doc = {
name: 'toSentenceCase',
description:
'Changes the capitalization of the string to sentence case. The first letter of each sentence is capitalized and all others are lowercased.',
examples: [{ example: '"quick! brown FOX".toSentenceCase()', evaluated: '"Quick! Brown fox"' }],
section: 'case',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toSentenceCase',
};
toSnakeCase.doc = {
name: 'toSnakeCase',
description:
'Changes the format of the string to snake case. Spaces and dashes are replaced by <code>_</code>, symbols are removed and all letters are lowercased.',
examples: [{ example: '"quick brown $FOX".toSnakeCase()', evaluated: '"quick_brown_fox"' }],
section: 'case',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toSnakeCase',
};
toTitleCase.doc = {
name: 'toTitleCase',
description:
"Changes the capitalization of the string to title case. The first letter of each word is capitalized and the others left unchanged. Short prepositions and conjunctions aren't capitalized (e.g. 'a', 'the').",
examples: [{ example: '"quick a brown FOX".toTitleCase()', evaluated: '"Quick a Brown Fox"' }],
section: 'case',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toTitleCase',
};
urlEncode.doc = {
name: 'urlEncode',
description:
'Encodes the string so that it can be used in a URL. Spaces and special characters are replaced with codes of the form <code>%XX</code>.',
section: 'edit',
args: [
{
name: 'allChars',
optional: true,
description:
'Whether to encode characters that are part of the URI syntax (e.g. <code>=</code>, <code>?</code>)',
default: 'false',
type: 'boolean',
},
],
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlEncode',
examples: [
{ example: '"name=Nathan Automat".urlEncode()', evaluated: '"name%3DNathan%20Automat"' },
{ example: '"name=Nathan Automat".urlEncode(true)', evaluated: '"name=Nathan%20Automat"' },
],
};
urlDecode.doc = {
name: 'urlDecode',
description:
'Decodes a URL-encoded string. Replaces any character codes in the form of <code>%XX</code> with their corresponding characters.',
args: [
{
name: 'allChars',
optional: true,
description:
'Whether to decode characters that are part of the URI syntax (e.g. <code>=</code>, <code>?</code>)',
default: 'false',
type: 'boolean',
},
],
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-urlDecode',
examples: [
{ example: '"name%3DNathan%20Automat".urlDecode()', evaluated: '"name=Nathan Automat"' },
{ example: '"name%3DNathan%20Automat".urlDecode(true)', evaluated: '"name%3DNathan Automat"' },
],
};
replaceSpecialChars.doc = {
name: 'replaceSpecialChars',
description: 'Replaces special characters in the string with the closest ASCII character',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-replaceSpecialChars',
examples: [{ example: '"déjà".replaceSpecialChars()', evaluated: '"deja"' }],
};
length.doc = {
name: 'length',
section: 'query',
hidden: true,
description: 'Returns the character count of a string.',
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings',
};
isDomain.doc = {
name: 'isDomain',
description: 'Returns <code>true</code> if a string is a domain.',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isDomain',
examples: [
{ example: '"n8n.io".isDomain()', evaluated: 'true' },
{ example: '"http://n8n.io".isDomain()', evaluated: 'false' },
{ example: '"hello".isDomain()', evaluated: 'false' },
],
};
isEmail.doc = {
name: 'isEmail',
description: 'Returns <code>true</code> if the string is an email.',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmail',
examples: [
{ example: '"me@example.com".isEmail()', evaluated: 'true' },
{ example: '"It\'s me@example.com".isEmail()', evaluated: 'false' },
{ example: '"hello".isEmail()', evaluated: 'false' },
],
};
isNumeric.doc = {
name: 'isNumeric',
description: 'Returns <code>true</code> if the string represents a number.',
section: 'validation',
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNumeric',
examples: [
{ example: '"1.2234".isNumeric()', evaluated: 'true' },
{ example: '"hello".isNumeric()', evaluated: 'false' },
{ example: '"123E23".isNumeric()', evaluated: 'true' },
],
};
isUrl.doc = {
name: 'isUrl',
description: 'Returns <code>true</code> if a string is a valid URL',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isUrl',
examples: [
{ example: '"https://n8n.io".isUrl()', evaluated: 'true' },
{ example: '"n8n.io".isUrl()', evaluated: 'false' },
{ example: '"hello".isUrl()', evaluated: 'false' },
],
};
isEmpty.doc = {
name: 'isEmpty',
description: 'Returns <code>true</code> if the string has no characters or is <code>null</code>',
section: 'validation',
returnType: 'boolean',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isEmpty',
examples: [
{ example: '"".isEmpty()', evaluated: 'true' },
{ example: '"hello".isEmpty()', evaluated: 'false' },
],
};
isNotEmpty.doc = {
name: 'isNotEmpty',
description: 'Returns <code>true</code> if the string has at least one character.',
section: 'validation',
returnType: 'boolean',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-isNotEmpty',
examples: [
{ example: '"hello".isNotEmpty()', evaluated: 'true' },
{ example: '"".isNotEmpty()', evaluated: 'false' },
],
};
toJsonString.doc = {
name: 'toJsonString',
description:
'Prepares the string to be inserted into a JSON object. Escapes any quotes and special characters (e.g. new lines), and wraps the string in quotes.The same as JavaScripts JSON.stringify().',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toJsonString',
examples: [
{
example: 'The "best" colours: red\nbrown.toJsonString()',
evaluated: '"The \\"best\\" colours: red\\nbrown"',
},
{ example: 'foo.toJsonString()', evaluated: '"foo"' },
],
};
extractEmail.doc = {
name: 'extractEmail',
description:
'Extracts the first email found in the string. Returns <code>undefined</code> if none is found.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractEmail',
examples: [
{ example: '"My email is me@example.com".extractEmail()', evaluated: "'me@example.com'" },
],
};
extractDomain.doc = {
name: 'extractDomain',
description:
'If the string is an email address or URL, returns its domain (or <code>undefined</code> if nothing found). If the string also contains other content, try using <code>extractEmail()</code> or <code>extractUrl()</code> first.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractDomain',
examples: [
{ example: '"me@example.com".extractDomain()', evaluated: "'example.com'" },
{ example: '"http://n8n.io/workflows".extractDomain()', evaluated: "'n8n.io'" },
{
example: '"It\'s me@example.com".extractEmail().extractDomain()',
evaluated: "'example.com'",
},
],
};
extractUrl.doc = {
name: 'extractUrl',
description:
'Extracts the first URL found in the string. Returns <code>undefined</code> if none is found. Only recognizes full URLs, e.g. those starting with <code>http</code>.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrl',
examples: [{ example: '"Check out http://n8n.io".extractUrl()', evaluated: "'http://n8n.io'" }],
};
extractUrlPath.doc = {
name: 'extractUrlPath',
description:
'Returns the part of a URL after the domain, or <code>undefined</code> if no URL found. If the string also contains other content, try using <code>extractUrl()</code> first.',
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-extractUrlPath',
examples: [
{ example: '"http://n8n.io/workflows".extractUrlPath()', evaluated: "'/workflows'" },
{
example: '"Check out http://n8n.io/workflows".extractUrl().extractUrlPath()',
evaluated: "'/workflows'",
},
],
};
hash.doc = {
name: 'hash',
description:
'Returns the string hashed with the given algorithm. Defaults to md5 if not specified.',
section: 'edit',
returnType: 'string',
args: [
{
name: 'algo',
optional: true,
description:
'The hashing algorithm to use. One of <code>md5</code>, <code>base64</code>, <code>sha1</code>, <code>sha224</code>, <code>sha256</code>, <code>sha384</code>, <code>sha512</code>, <code>sha3</code>, <code>ripemd160</code>\n ',
default: '"md5"',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-hash',
examples: [{ example: '"hello".hash()', evaluated: "'5d41402abc4b2a76b9719d911017c592'" }],
};
quote.doc = {
name: 'quote',
description:
'Wraps a string in quotation marks, and escapes any quotation marks already in the string. Useful when constructing JSON, SQL, etc.',
section: 'edit',
returnType: 'string',
args: [
{
name: 'mark',
optional: true,
description: 'The type of quotation mark to use',
default: '"',
type: 'string',
},
],
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-quote',
examples: [{ example: '\'Nathan says "hi"\'.quote()', evaluated: '\'"Nathan says \\"hi\\""\'' }],
};
parseJson.doc = {
name: 'parseJson',
aliases: ['fromJson'],
description:
"Returns the JavaScript value or object represented by the string, or <code>undefined</code> if the string isn't valid JSON. Single-quoted JSON is not supported.",
section: 'cast',
returnType: 'any',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-parseJson',
examples: [
{ example: '\'{"name":"Nathan"}\'.parseJson()', evaluated: '\'{"name":"Nathan"}\'' },
{ example: "\"{'name':'Nathan'}\".parseJson()", evaluated: 'undefined' },
{ example: "'hello'.parseJson()", evaluated: 'undefined' },
],
};
base64Encode.doc = {
name: 'base64Encode',
aliases: ['toBase64'],
description: 'Converts plain text to a base64-encoded string',
examples: [{ example: '"hello".base64Encode()', evaluated: '"aGVsbG8="' }],
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-base64Encode',
};
base64Decode.doc = {
name: 'base64Decode',
aliases: ['fromBase64'],
description: 'Converts a base64-encoded string to plain text',
examples: [{ example: '"aGVsbG8=".base64Decode()', evaluated: '"hello"' }],
section: 'edit',
returnType: 'string',
docURL:
'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-base64Decode',
};
toNumber.doc = {
name: 'toNumber',
description:
"Converts a string representing a number to a number. Errors if the string doesn't start with a valid number.",
section: 'cast',
returnType: 'number',
docURL: 'https://docs.n8n.io/code/builtin/data-transformation-functions/strings/#string-toNumber',
examples: [
{ example: '"123".toNumber()', evaluated: '123' },
{ example: '"1.23E10".toNumber()', evaluated: '12300000000' },
],
};
const toDecimalNumber: Extension = toFloat.bind({});
const toWholeNumber: Extension = toInt.bind({});
export const stringExtensions: ExtensionMap = {
typeName: 'String',
functions: {
hash,
removeMarkdown,
removeTags,
toDate,
toDateTime,
toBoolean,
toDecimalNumber,
toNumber,
toFloat,
toInt,
toWholeNumber,
toSentenceCase,
toSnakeCase,
toTitleCase,
urlDecode,
urlEncode,
quote,
replaceSpecialChars,
length,
isDomain,
isEmail,
isNumeric,
isUrl,
isEmpty,
isNotEmpty,
toJsonString,
extractEmail,
extractDomain,
extractUrl,
extractUrlPath,
parseJson,
base64Encode,
base64Decode,
},
};
+36
View File
@@ -0,0 +1,36 @@
// NOTE: This file is intentionally mirrored in @n8n/expression-runtime/src/extensions/
// for use inside the isolated VM. Changes here must be reflected there and vice versa.
// TODO: Eliminate the duplication. The blocker is that @n8n/expression-runtime is
// Vite-stubbed for browser builds (to exclude isolated-vm), which prevents n8n-workflow
// from importing these extension utilities directly from the runtime package. Fix by
// splitting @n8n/expression-runtime into a browser-safe extensions subpath (not stubbed)
// and a node-only VM entry (stubbed).
import { DateTime } from 'luxon';
import { ExpressionExtensionError } from '../errors/expression-extension.error';
// Utility functions and type guards for expression extensions
export const convertToDateTime = (value: string | Date | DateTime): DateTime | undefined => {
let converted: DateTime | undefined;
if (typeof value === 'string') {
converted = DateTime.fromJSDate(new Date(value));
if (converted.invalidReason !== null) {
return;
}
} else if (value instanceof Date) {
converted = DateTime.fromJSDate(value);
} else if (DateTime.isDateTime(value)) {
converted = value;
}
return converted;
};
export function checkIfValueDefinedOrThrow<T>(value: T, functionName: string): void {
if (value === undefined || value === null) {
throw new ExpressionExtensionError(`${functionName} can't be used on ${String(value)} value`, {
description: `To ignore this error, add a ? to the variable before this function, e.g. my_var?.${functionName}`,
});
}
}
@@ -0,0 +1,488 @@
import { z } from 'zod';
import { FROM_AI_AUTO_GENERATED_MARKER } from './constants';
import { isExpression } from './expressions/expression-helpers';
import { jsonParse } from './utils';
/**
* This file contains the logic for parsing node parameters and extracting $fromAI calls
*/
export type FromAIArgumentType = 'string' | 'number' | 'boolean' | 'json';
export type FromAIArgument = {
key: string;
description?: string;
type?: FromAIArgumentType;
defaultValue?: string | number | boolean | Record<string, unknown>;
};
class ParseError extends Error {}
/**
* Generates a Zod schema based on the provided FromAIArgument placeholder.
* @param placeholder The FromAIArgument object containing key, type, description, and defaultValue.
* @returns A Zod schema corresponding to the placeholder's type and constraints.
*/
export function generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny {
let schema: z.ZodTypeAny;
switch (placeholder.type?.toLowerCase()) {
case 'string':
schema = z.string();
break;
case 'number':
schema = z.number();
break;
case 'boolean':
schema = z.boolean();
break;
case 'json': {
interface CustomSchemaDef extends z.ZodTypeDef {
jsonSchema?: {
anyOf: [
{
type: 'object';
minProperties: number;
additionalProperties: boolean;
},
{
type: 'array';
minItems: number;
},
];
};
}
// Create a custom schema to validate that the incoming data is either a non-empty object or a non-empty array.
const customSchema = z.custom<Record<string, unknown> | unknown[]>(
(data: unknown) => {
if (data === null || typeof data !== 'object') return false;
if (Array.isArray(data)) {
return data.length > 0;
}
return Object.keys(data).length > 0;
},
{
message: 'Value must be a non-empty object or a non-empty array',
},
);
// Cast the custom schema to a type that includes our JSON metadata.
const typedSchema = customSchema as z.ZodType<
Record<string, unknown> | unknown[],
CustomSchemaDef
>;
// Attach the updated `jsonSchema` metadata to the internal definition.
typedSchema._def.jsonSchema = {
anyOf: [
{
type: 'object',
minProperties: 1,
additionalProperties: true,
},
{
type: 'array',
minItems: 1,
},
],
};
schema = typedSchema;
break;
}
default:
schema = z.string();
}
if (placeholder.description) {
schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim());
}
if (placeholder.defaultValue !== undefined) {
schema = schema.default(placeholder.defaultValue);
}
return schema;
}
function isFromAIArgumentType(value: string): value is FromAIArgumentType {
return ['string', 'number', 'boolean', 'json'].includes(value.toLowerCase());
}
/**
* Parses the default value, preserving its original type.
* @param value The default value as a string.
* @param type The expected type of the default value.
* @returns The parsed default value in its appropriate type.
*/
function parseDefaultValue(
value: string | undefined,
type: FromAIArgumentType = 'string',
): string | number | boolean | Record<string, unknown> | undefined {
if (value === undefined) return value;
const lowerValue = value.toLowerCase();
if (type === 'string') {
return value.toString();
}
if (type === 'boolean' && (lowerValue === 'true' || lowerValue === 'false'))
return lowerValue === 'true';
if (type === 'number' && !isNaN(Number(value))) return Number(value);
// For type 'json' or any other case, attempt to parse as JSON
try {
return jsonParse(value);
} catch {
return value;
}
}
/**
* Parses the arguments of a single $fromAI function call.
* @param argsString The string containing the function arguments.
* @returns A FromAIArgument object.
*/
function parseArguments(argsString: string): FromAIArgument {
// Split arguments by commas not inside quotes
const args: string[] = [];
let currentArg = '';
let inQuotes = false;
let quoteChar = '';
let escapeNext = false;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if (escapeNext) {
currentArg += char;
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (['"', "'", '`'].includes(char)) {
if (!inQuotes) {
inQuotes = true;
quoteChar = char;
currentArg += char;
} else if (char === quoteChar) {
inQuotes = false;
quoteChar = '';
currentArg += char;
} else {
currentArg += char;
}
continue;
}
if (char === ',' && !inQuotes) {
args.push(currentArg.trim());
currentArg = '';
continue;
}
currentArg += char;
}
if (currentArg) {
args.push(currentArg.trim());
}
// Remove surrounding quotes if present
const cleanArgs = args.map((arg) => {
const trimmed = arg.trim();
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('`') && trimmed.endsWith('`')) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed
.slice(1, -1)
.replace(/\\'/g, "'")
.replace(/\\`/g, '`')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
return trimmed;
});
const type = cleanArgs?.[2] ?? 'string';
if (!isFromAIArgumentType(type)) {
throw new ParseError(`Invalid type: ${type}`);
}
return {
key: cleanArgs[0] || '',
description: cleanArgs[1],
type,
defaultValue: parseDefaultValue(cleanArgs[3], type),
};
}
/**
* Extracts all $fromAI calls from a given string
* @param str The string to search for $fromAI calls.
* @returns An array of FromAIArgument objects.
*
* This method uses a regular expression to find the start of each $fromAI function call
* in the input string. It then employs a character-by-character parsing approach to
* accurately extract the arguments of each call, handling nested parentheses and quoted strings.
*
* The parsing process:
* 1. Finds the starting position of a $fromAI call using regex.
* 2. Iterates through characters, keeping track of parentheses depth and quote status.
* 3. Handles escaped characters within quotes to avoid premature quote closing.
* 4. Builds the argument string until the matching closing parenthesis is found.
* 5. Parses the extracted argument string into a FromAIArgument object.
* 6. Repeats the process for all $fromAI calls in the input string.
*
*/
export function extractFromAICalls(str: string): FromAIArgument[] {
const args: FromAIArgument[] = [];
// Regular expression to match the start of a $fromAI function call
const pattern = /\$fromAI\s*\(\s*/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(str)) !== null) {
const startIndex = match.index + match[0].length;
let current = startIndex;
let inQuotes = false;
let quoteChar = '';
let parenthesesCount = 1;
let argsString = '';
// Parse the arguments string, handling nested parentheses and quotes
while (current < str.length && parenthesesCount > 0) {
const char = str[current];
if (inQuotes) {
// Handle characters inside quotes, including escaped characters
if (char === '\\' && current + 1 < str.length) {
argsString += char + str[current + 1];
current += 2;
continue;
}
if (char === quoteChar) {
inQuotes = false;
quoteChar = '';
}
argsString += char;
} else {
// Handle characters outside quotes
if (['"', "'", '`'].includes(char)) {
inQuotes = true;
quoteChar = char;
} else if (char === '(') {
parenthesesCount++;
} else if (char === ')') {
parenthesesCount--;
}
// Only add characters if we're still inside the main parentheses
if (parenthesesCount > 0 || char !== ')') {
argsString += char;
}
}
current++;
}
// If parentheses are balanced, parse the arguments
if (parenthesesCount === 0) {
try {
const parsedArgs = parseArguments(argsString);
args.push(parsedArgs);
} catch (error) {
// If parsing fails, throw an ParseError with details
throw new ParseError(`Failed to parse $fromAI arguments: ${argsString}: ${String(error)}`);
}
} else {
// Log an error if parentheses are unbalanced
throw new ParseError(
`Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`,
);
}
}
return args;
}
/**
* Recursively traverses the nodeParameters object to find all $fromAI calls.
* @param payload The current object or value being traversed.
* @param collectedArgs The array collecting FromAIArgument objects.
*/
export function traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) {
if (typeof payload === 'string') {
const fromAICalls = extractFromAICalls(payload);
fromAICalls.forEach((call) => collectedArgs.push(call));
} else if (Array.isArray(payload)) {
payload.forEach((item: unknown) => traverseNodeParameters(item, collectedArgs));
} else if (typeof payload === 'object' && payload !== null) {
Object.values(payload).forEach((value) => traverseNodeParameters(value, collectedArgs));
}
}
export function traverseNodeParametersWithParamNames(
payload: unknown,
collectedArgs: Map<string, FromAIArgument>,
name?: string,
) {
if (typeof payload === 'string') {
const fromAICalls = extractFromAICalls(payload);
fromAICalls.forEach((call) => collectedArgs.set(name as string, call));
} else if (Array.isArray(payload)) {
payload.forEach((item: unknown, index: number) =>
traverseNodeParametersWithParamNames(item, collectedArgs, name + `[${index}]`),
);
} else if (typeof payload === 'object' && payload !== null) {
for (const [key, value] of Object.entries(payload)) {
traverseNodeParametersWithParamNames(value, collectedArgs, name ? name + '.' + key : key);
}
}
}
/**
* Checks whether an expression string contains only a single `$fromAI()` call
* with literal arguments and nothing else.
*
* Only `$fromAI()` expressions are supported in chat hub tool parameters.
* Arguments must be literals (strings, numbers, booleans) — nested function
* calls like `$fromAI(evil())` are not supported.
*/
export function isFromAIOnlyExpression(expr: string): boolean {
let str = expr;
// Strip leading `=` prefix
if (str.startsWith('=')) {
str = str.slice(1);
}
str = str.trim();
// Strip `{{ }}` delimiters if present
if (str.startsWith('{{') && str.endsWith('}}')) {
str = str.slice(2, -2).trim();
}
// Strip optional auto-generated marker comment
if (str.startsWith(FROM_AI_AUTO_GENERATED_MARKER)) {
str = str.slice(FROM_AI_AUTO_GENERATED_MARKER.length).trim();
}
// Must start with $fromAI( (case-insensitive)
const fromAIPattern = /^\$fromAI\s*\(/i;
const match = fromAIPattern.exec(str);
if (!match) {
return false;
}
// Walk character by character from after the opening `(` to find matching `)`
// Reject any nested parentheses outside quotes (indicates function calls)
const startIndex = match[0].length;
let current = startIndex;
let inQuotes = false;
let quoteChar = '';
let depth = 1;
let lastOutsideChar = '';
while (current < str.length && depth > 0) {
const char = str[current];
if (inQuotes) {
if (char === '\\' && current + 1 < str.length) {
// Skip escaped character
current += 2;
continue;
}
// Reject template literal interpolation `${...}` inside backtick strings
if (
quoteChar === '`' &&
char === '$' &&
current + 1 < str.length &&
str[current + 1] === '{'
) {
return false;
}
if (char === quoteChar) {
inQuotes = false;
quoteChar = '';
}
} else {
if (['"', "'", '`'].includes(char)) {
// Reject tagged template literals: identifier immediately before backtick
if (char === '`' && /[a-zA-Z0-9_]/.test(lastOutsideChar)) {
return false;
}
inQuotes = true;
quoteChar = char;
} else if (char === ')') {
depth--;
} else if (!/[a-zA-Z0-9.,\s-]/.test(char)) {
// Outside quotes, only allow literal-value characters:
// alphanumeric (true/false, numbers), decimal point, minus (negative numbers),
// comma (argument separator), and whitespace.
// This rejects operators (+, *, etc.), $ (variable references like $env),
// brackets, and other expression syntax.
return false;
}
if (!/\s/.test(char)) {
lastOutsideChar = char;
}
}
current++;
}
// Unbalanced parentheses
if (depth !== 0) {
return false;
}
// Everything after the closing `)` must be whitespace only
const remainder = str.slice(current).trim();
return remainder.length === 0;
}
export type ExpressionViolation = {
path: string;
value: string;
};
/**
* Recursively traverses node parameters and finds all string values that are
* expressions other than supported `$fromAI()`-only expressions supported on Chat hub.
* Returns an array of violations with their dot-notation paths.
*/
export function findDisallowedChatToolExpressions(
payload: unknown,
path = '',
): ExpressionViolation[] {
const violations: ExpressionViolation[] = [];
if (typeof payload === 'string') {
if (isExpression(payload) && !isFromAIOnlyExpression(payload)) {
violations.push({ path, value: payload });
}
} else if (Array.isArray(payload)) {
payload.forEach((item: unknown, index: number) => {
violations.push(...findDisallowedChatToolExpressions(item, `${path}[${index}]`));
});
} else if (typeof payload === 'object' && payload !== null) {
for (const [key, value] of Object.entries(payload)) {
const newPath = path ? `${path}.${key}` : key;
violations.push(...findDisallowedChatToolExpressions(value, newPath));
}
}
return violations;
}
+15
View File
@@ -0,0 +1,15 @@
import { deepCopy } from './utils';
export interface GlobalState {
defaultTimezone: string;
}
let globalState: GlobalState = { defaultTimezone: 'America/New_York' };
export function setGlobalState(state: GlobalState) {
globalState = state;
}
export function getGlobalState() {
return deepCopy(globalState);
}
+273
View File
@@ -0,0 +1,273 @@
import type { IConnection, IConnections } from '../interfaces';
type MultipleInputNodesError = {
errorCode: 'Multiple Input Nodes';
nodes: Set<string>;
};
type MultipleOutputNodesError = {
errorCode: 'Multiple Output Nodes';
nodes: Set<string>;
};
type InputEdgeToNonRootNode = {
errorCode: 'Input Edge To Non-Root Node';
node: string;
};
type OutputEdgeFromNonLeafNode = {
errorCode: 'Output Edge From Non-Leaf Node';
node: string;
};
type NoContinuousPathFromRootToLeaf = {
errorCode: 'No Continuous Path From Root To Leaf In Selection';
start: string;
end: string;
};
export type ExtractableErrorResult =
| MultipleInputNodesError
| MultipleOutputNodesError
| InputEdgeToNonRootNode
| OutputEdgeFromNonLeafNode
| NoContinuousPathFromRootToLeaf;
export type IConnectionAdjacencyList = Map<string, Set<IConnection>>;
/**
* Find all edges leading into the graph described in `graphIds`.
*/
export function getInputEdges(
graphIds: Set<string>,
adjacencyList: IConnectionAdjacencyList,
): Array<[string, IConnection]> {
const result: Array<[string, IConnection]> = [];
for (const [from, tos] of adjacencyList.entries()) {
if (graphIds.has(from)) continue;
for (const to of tos) {
if (graphIds.has(to.node)) {
result.push([from, to]);
}
}
}
return result;
}
/**
* Find all edges leading out of the graph described in `graphIds`.
*/
export function getOutputEdges(
graphIds: Set<string>,
adjacencyList: IConnectionAdjacencyList,
): Array<[string, IConnection]> {
const result: Array<[string, IConnection]> = [];
for (const [from, tos] of adjacencyList.entries()) {
if (!graphIds.has(from)) continue;
for (const to of tos) {
if (!graphIds.has(to.node)) {
result.push([from, to]);
}
}
}
return result;
}
function intersection<T>(a: Set<T>, b: Set<T>): Set<T> {
const result = new Set<T>();
for (const x of a) {
if (b.has(x)) result.add(x);
}
return result;
}
function union<T>(a: Set<T>, b: Set<T>): Set<T> {
const result = new Set<T>();
for (const x of a) result.add(x);
for (const x of b) result.add(x);
return result;
}
function difference<T>(minuend: Set<T>, subtrahend: Set<T>): Set<T> {
const result = new Set<T>(minuend.values());
for (const x of subtrahend) {
result.delete(x);
}
return result;
}
export function getRootNodes(
graphIds: Set<string>,
adjacencyList: IConnectionAdjacencyList,
): Set<string> {
// Inner nodes are all nodes with an incoming edge from another node in the graph
let innerNodes = new Set<string>();
for (const nodeId of graphIds) {
innerNodes = union(
innerNodes,
new Set(
[...(adjacencyList.get(nodeId) ?? [])]
.filter((x) => x.type === 'main' && x.node !== nodeId)
.map((x) => x.node),
),
);
}
return difference(graphIds, innerNodes);
}
export function getLeafNodes(
graphIds: Set<string>,
adjacencyList: IConnectionAdjacencyList,
): Set<string> {
const result = new Set<string>();
for (const nodeId of graphIds) {
if (
intersection(
new Set(
[...(adjacencyList.get(nodeId) ?? [])]
.filter((x) => x.type === 'main' && x.node !== nodeId)
.map((x) => x.node),
),
graphIds,
).size === 0
) {
result.add(nodeId);
}
}
return result;
}
export function hasPath(start: string, end: string, adjacencyList: IConnectionAdjacencyList) {
const seen = new Set<string>();
const paths: string[] = [start];
while (true) {
const next = paths.pop();
if (next === end) return true;
if (next === undefined) return false;
seen.add(next);
paths.push(
...difference(
new Set(
[...(adjacencyList.get(next) ?? [])].filter((x) => x.type === 'main').map((x) => x.node),
),
seen,
),
);
}
}
export type ExtractableSubgraphData = {
start?: string;
end?: string;
};
export function buildAdjacencyList(
connectionsBySourceNode: IConnections,
): IConnectionAdjacencyList {
const result = new Map<string, Set<IConnection>>();
const addOrCreate = (k: string, v: IConnection) =>
result.set(k, union(result.get(k) ?? new Set(), new Set([v])));
for (const sourceNode of Object.keys(connectionsBySourceNode)) {
for (const type of Object.keys(connectionsBySourceNode[sourceNode])) {
for (const sourceIndex of Object.keys(connectionsBySourceNode[sourceNode][type])) {
for (const connectionIndex of Object.keys(
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)] ?? [],
)) {
const connection =
connectionsBySourceNode[sourceNode][type][parseInt(sourceIndex, 10)]?.[
parseInt(connectionIndex, 10)
];
if (connection) addOrCreate(sourceNode, connection);
}
}
}
}
return result;
}
/**
* A subgraph is considered extractable if the following properties hold:
* - 0-1 input nodes from outside the subgraph, to a root node
* - 0-1 output nodes to outside the subgraph, from a leaf node
* - continuous path between input and output nodes if they exist
*
* This also covers the requirement that all "inner" nodes between the root node
* and the output node are selected, since this would otherwise create extra
* input or output nodes.
*
* @returns An object containing optional start and end nodeIds
* indicating which nodes have outside connections, OR
* An array of errors if the selection is not valid.
*/
export function parseExtractableSubgraphSelection(
graphIds: Set<string>,
adjacencyList: IConnectionAdjacencyList,
): ExtractableSubgraphData | ExtractableErrorResult[] {
const errors: ExtractableErrorResult[] = [];
// 0-1 Input nodes
const inputEdges = getInputEdges(graphIds, adjacencyList);
// This filters out e.g. sub-nodes, which are technically parents
const inputNodes = new Set(inputEdges.filter((x) => x[1].type === 'main').map((x) => x[1].node));
let rootNodes = getRootNodes(graphIds, adjacencyList);
// this enables supporting cases where we have one input and a loop back to it from within the selection
if (rootNodes.size === 0 && inputNodes.size === 1) rootNodes = inputNodes;
for (const inputNode of difference(inputNodes, rootNodes).values()) {
errors.push({
errorCode: 'Input Edge To Non-Root Node',
node: inputNode,
});
}
const rootInputNodes = intersection(rootNodes, inputNodes);
if (rootInputNodes.size > 1) {
errors.push({
errorCode: 'Multiple Input Nodes',
nodes: rootInputNodes,
});
}
// 0-1 Output nodes
const outputEdges = getOutputEdges(graphIds, adjacencyList);
const outputNodes = new Set(outputEdges.filter((x) => x[1].type === 'main').map((x) => x[0]));
let leafNodes = getLeafNodes(graphIds, adjacencyList);
// If we have no leaf nodes, and only one output node, we can tolerate this output node
// and connect to it.
// Note that this is fairly theoretical, as return semantics in this case are not well-defined.
if (leafNodes.size === 0 && outputNodes.size === 1) leafNodes = outputNodes;
for (const outputNode of difference(outputNodes, leafNodes).values()) {
errors.push({
errorCode: 'Output Edge From Non-Leaf Node',
node: outputNode,
});
}
const leafOutputNodes = intersection(leafNodes, outputNodes);
if (leafOutputNodes.size > 1) {
errors.push({
errorCode: 'Multiple Output Nodes',
nodes: leafOutputNodes,
});
}
const start = rootInputNodes.values().next().value;
const end = leafOutputNodes.values().next().value;
if (start && end && !hasPath(start, end, adjacencyList)) {
errors.push({
errorCode: 'No Continuous Path From Root To Leaf In Selection',
start,
end,
});
}
return errors.length > 0 ? errors : { start, end };
}
+116
View File
@@ -0,0 +1,116 @@
import * as LoggerProxy from './logger-proxy';
import * as NodeHelpers from './node-helpers';
import * as ObservableObject from './observable-object';
import * as TelemetryHelpers from './telemetry-helpers';
export * from './errors';
export * from './constants';
export * from './common';
export * from './cron';
export * from './data-table.types';
export * from './deferred-promise';
export * from './execution-context';
export * from './execution-context-establishment-hooks';
export * from './global-state';
export * from './interfaces';
export * from './run-execution-data-factory';
export * from './message-event-bus';
export * from './execution-status';
export * from './expression';
export * from './expressions/expression-helpers';
export * from './from-ai-parse-utils';
export * from './node-helpers';
export * from './node-validation';
export * from './tool-helpers';
export * from './node-reference-parser-utils';
export * from './metadata-utils';
export * from './workflow';
export * from './workflow-checksum';
export * from './workflow-data-proxy';
export * from './workflow-data-proxy-env-provider';
export * from './workflow-validation';
export * from './versioned-node-type';
export * from './type-validation';
export * from './result';
export * from './schemas';
export * from './run-execution-data/run-execution-data';
export { WorkflowExpression } from './workflow-expression';
export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers };
export {
isObjectEmpty,
deepCopy,
jsonParse,
base64DecodeUTF8,
jsonStringify,
replaceCircularReferences,
sleep,
sleepWithAbort,
fileTypeFromMimeType,
assert,
removeCircularRefs,
updateDisplayOptions,
randomInt,
randomString,
isSafeObjectProperty,
setSafeObjectProperty,
isDomainAllowed,
isCommunityPackageName,
dedupe,
sanitizeFilename,
} from './utils';
export {
isINodeProperties,
isINodePropertyOptions,
isINodePropertyCollection,
isINodePropertiesList,
isINodePropertyCollectionList,
isINodePropertyOptionsList,
isResourceMapperValue,
isResourceLocatorValue,
isFilterValue,
isNodeConnectionType,
isBinaryValue,
} from './type-guards';
export {
parseExtractableSubgraphSelection,
buildAdjacencyList,
type ExtractableErrorResult,
type ExtractableSubgraphData,
type IConnectionAdjacencyList as AdjacencyList,
} from './graph/graph-utils';
export { ExpressionExtensions, type Alias, type AliasCompletion } from './extensions';
export * as ExpressionParser from './extensions/expression-parser';
export { NativeMethods } from './native-methods';
export * from './node-parameters/filter-parameter';
export * from './node-parameters/parameter-type-validation';
export * from './node-parameters/node-parameter-value-type-guard';
export * from './node-parameters/path-utils';
export * from './evaluation-helpers';
export * from './workflow-diff';
export * from './workflow-environments-helper';
export type {
DocMetadata,
NativeDoc,
DocMetadataArgument,
DocMetadataExample,
Extension,
} from './extensions';
declare module 'http' {
export interface IncomingMessage {
contentType?: string;
encoding: BufferEncoding;
contentDisposition?: { type: string; filename?: string };
rawBody: Buffer;
readRawBody(): Promise<void>;
_body: boolean;
// This gets added by the `follow-redirects` package
responseUrl?: string;
// This is added to response objects for all outgoing requests
req?: ClientRequest;
}
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
import type { Logger } from './interfaces';
const noOp = () => {};
export let error: Logger['error'] = noOp;
export let warn: Logger['warn'] = noOp;
export let info: Logger['info'] = noOp;
export let debug: Logger['debug'] = noOp;
export const init = (logger: Logger) => {
error = (message, meta) => logger.error(message, meta);
warn = (message, meta) => logger.warn(message, meta);
info = (message, meta) => logger.info(message, meta);
debug = (message, meta) => logger.debug(message, meta);
};
+306
View File
@@ -0,0 +1,306 @@
import type { DateTime } from 'luxon';
import { z } from 'zod';
import type { INodeCredentials } from './interfaces';
// ===============================
// General Enums And Interfaces
// ===============================
export const enum EventMessageTypeNames {
generic = '$$EventMessage',
audit = '$$EventMessageAudit',
confirm = '$$EventMessageConfirm',
workflow = '$$EventMessageWorkflow',
node = '$$EventMessageNode',
execution = '$$EventMessageExecution',
aiNode = '$$EventMessageAiNode',
runner = '$$EventMessageRunner',
queue = '$$EventMessageQueue',
}
export const enum MessageEventBusDestinationTypeNames {
abstract = '$$AbstractMessageEventBusDestination',
webhook = '$$MessageEventBusDestinationWebhook',
sentry = '$$MessageEventBusDestinationSentry',
syslog = '$$MessageEventBusDestinationSyslog',
}
export const messageEventBusDestinationTypeNames = [
MessageEventBusDestinationTypeNames.abstract,
MessageEventBusDestinationTypeNames.webhook,
MessageEventBusDestinationTypeNames.sentry,
MessageEventBusDestinationTypeNames.syslog,
];
// ===============================
// Event Message Interfaces
// ===============================
export interface IAbstractEventMessage {
__type: EventMessageTypeNames;
id: string;
ts: DateTime;
eventName: string;
message: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
payload: any;
}
// ===============================
// Event Destination Zod Schemas
// ===============================
// Circuit Breaker Options Schema
const circuitBreakerSchema = z
.object({
maxFailures: z.number().int().positive().optional(),
maxDuration: z.number().int().positive().optional(),
halfOpenRequests: z.number().int().positive().optional(),
failureWindow: z.number().int().positive().optional(),
maxConcurrentHalfOpenRequests: z.number().int().positive().optional(),
})
.optional();
// Webhook Parameter Item Schema
const webhookParameterItemSchema = z.object({
parameters: z.array(
z.object({
name: z.string(),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]).nullable(),
}),
),
});
// Webhook Parameter Options Schema
const webhookParameterOptionsSchema = z
.object({
batch: z
.object({
batchSize: z.number().int().positive().optional(),
batchInterval: z.number().int().positive().optional(),
})
.optional(),
allowUnauthorizedCerts: z.boolean().optional(),
queryParameterArrays: z.enum(['indices', 'brackets', 'repeat']).optional(),
redirect: z
.object({
redirect: z.object({
followRedirects: z.boolean().optional(),
maxRedirects: z.number().int().positive().optional(),
}),
})
.transform((val) => val.redirect)
.optional(),
response: z
.object({
response: z
.object({
fullResponse: z.boolean().optional(),
neverError: z.boolean().optional(),
responseFormat: z.string().optional(),
outputPropertyName: z.string().optional(),
})
.optional(),
})
.optional(),
proxy: z
.object({
proxy: z.object({
protocol: z.enum(['https', 'http']),
host: z.string(),
port: z.number().int().positive(),
}),
})
.transform((val) => val.proxy)
.optional(),
timeout: z.number().int().positive().optional(),
socket: z
.object({
keepAlive: z.boolean().optional(),
maxSockets: z.number().int().positive().optional(),
maxFreeSockets: z.number().int().positive().optional(),
})
.optional(),
})
.optional();
// Base Destination Options Schema
export const MessageEventBusDestinationOptionsSchema = z.object({
__type: z
.enum([
'$$AbstractMessageEventBusDestination',
'$$MessageEventBusDestinationWebhook',
'$$MessageEventBusDestinationSentry',
'$$MessageEventBusDestinationSyslog',
])
.optional(),
id: z.string().min(1).optional(),
label: z.string().min(1).optional(),
enabled: z.boolean().optional(),
subscribedEvents: z.array(z.string()).optional(),
credentials: z.record(z.unknown()).optional(),
anonymizeAuditMessages: z.boolean().optional(),
circuitBreaker: circuitBreakerSchema,
});
// Webhook Destination Schema
export const MessageEventBusDestinationWebhookOptionsSchema =
MessageEventBusDestinationOptionsSchema.extend({
__type: z.literal('$$MessageEventBusDestinationWebhook'),
url: z.string().url(),
responseCodeMustMatch: z.boolean().optional(),
expectedStatusCode: z.number().int().optional(),
method: z.string().optional(),
authentication: z
.enum(['predefinedCredentialType', 'genericCredentialType', 'none'])
.optional(),
sendQuery: z.boolean().optional(),
sendHeaders: z.boolean().optional(),
genericAuthType: z.string().optional(),
nodeCredentialType: z.string().optional(),
specifyHeaders: z.string().optional(),
specifyQuery: z.string().optional(),
jsonQuery: z.string().optional(),
jsonHeaders: z.string().optional(),
headerParameters: webhookParameterItemSchema.optional(),
queryParameters: webhookParameterItemSchema.optional(),
sendPayload: z.boolean().optional(),
options: webhookParameterOptionsSchema,
});
// Sentry Destination Schema
export const MessageEventBusDestinationSentryOptionsSchema =
MessageEventBusDestinationOptionsSchema.extend({
__type: z.literal('$$MessageEventBusDestinationSentry'),
dsn: z.string().url(),
tracesSampleRate: z.number().min(0).max(1).optional(),
sendPayload: z.boolean().optional(),
});
// Syslog Destination Schema
export const MessageEventBusDestinationSyslogOptionsSchema =
MessageEventBusDestinationOptionsSchema.extend({
__type: z.literal('$$MessageEventBusDestinationSyslog'),
expectedStatusCode: z.number().int().optional(),
host: z.string().min(1),
port: z.number().int().positive().optional(),
protocol: z.enum(['udp', 'tcp', 'tls']).optional(),
facility: z.number().int().min(0).max(23).optional(),
app_name: z.string().optional(),
eol: z.string().optional(),
tlsCa: z.string().optional(),
});
// ===============================
// Event Destination Types (Inferred from Zod Schemas)
// ===============================
// Base destination options type - __type is optional
export type MessageEventBusDestinationOptions = Omit<
z.infer<typeof MessageEventBusDestinationOptionsSchema>,
'__type' | 'credentials'
> & {
__type?: MessageEventBusDestinationTypeNames;
credentials?: INodeCredentials;
};
export type MessageEventBusDestinationWebhookParameterItem = z.infer<
typeof webhookParameterItemSchema
>;
export type MessageEventBusDestinationWebhookParameterOptions = z.infer<
typeof webhookParameterOptionsSchema
>;
// Specific destination types - use full enum type for compatibility with classes
export type MessageEventBusDestinationWebhookOptions = Omit<
z.infer<typeof MessageEventBusDestinationWebhookOptionsSchema>,
'__type' | 'credentials'
> & {
__type?: MessageEventBusDestinationTypeNames;
credentials?: INodeCredentials;
};
export type MessageEventBusDestinationSyslogOptions = Omit<
z.infer<typeof MessageEventBusDestinationSyslogOptionsSchema>,
'__type' | 'credentials'
> & {
__type?: MessageEventBusDestinationTypeNames;
credentials?: INodeCredentials;
};
export type MessageEventBusDestinationSentryOptions = Omit<
z.infer<typeof MessageEventBusDestinationSentryOptionsSchema>,
'__type' | 'credentials'
> & {
__type?: MessageEventBusDestinationTypeNames;
credentials?: INodeCredentials;
};
// ==================================
// Event Destination Default Settings
// ==================================
export const defaultMessageEventBusDestinationOptions: MessageEventBusDestinationOptions = {
__type: MessageEventBusDestinationTypeNames.abstract,
id: '',
label: 'New Event Destination',
enabled: true,
subscribedEvents: ['n8n.audit', 'n8n.workflow'],
credentials: {},
anonymizeAuditMessages: false,
};
export const defaultMessageEventBusDestinationSyslogOptions: MessageEventBusDestinationSyslogOptions =
{
...defaultMessageEventBusDestinationOptions,
__type: MessageEventBusDestinationTypeNames.syslog,
label: 'Syslog Server',
expectedStatusCode: 200,
host: '127.0.0.1',
port: 514,
protocol: 'tcp',
facility: 16,
app_name: 'n8n',
eol: '\n',
};
export const defaultMessageEventBusDestinationWebhookOptions: MessageEventBusDestinationWebhookOptions =
{
...defaultMessageEventBusDestinationOptions,
__type: MessageEventBusDestinationTypeNames.webhook,
credentials: {},
label: 'Webhook Endpoint',
expectedStatusCode: 200,
responseCodeMustMatch: false,
url: 'https://',
method: 'POST',
authentication: 'none',
sendQuery: false,
sendHeaders: false,
genericAuthType: '',
nodeCredentialType: '',
specifyHeaders: '',
specifyQuery: '',
jsonQuery: '',
jsonHeaders: '',
headerParameters: { parameters: [] },
queryParameters: { parameters: [] },
sendPayload: true,
options: {},
};
export const defaultMessageEventBusDestinationSentryOptions: MessageEventBusDestinationSentryOptions =
{
...defaultMessageEventBusDestinationOptions,
__type: MessageEventBusDestinationTypeNames.sentry,
label: 'Sentry DSN',
dsn: 'https://',
sendPayload: true,
};
+34
View File
@@ -0,0 +1,34 @@
import type { ITaskMetadata } from '.';
import { hasKey } from './utils';
function responseHasSubworkflowData(
response: unknown,
): response is { executionId: string; workflowId: string } {
return ['executionId', 'workflowId'].every(
(x) => hasKey(response, x) && typeof response[x] === 'string',
);
}
type ISubWorkflowMetadata = Required<Pick<ITaskMetadata, 'subExecution' | 'subExecutionsCount'>>;
function parseErrorResponseWorkflowMetadata(response: unknown): ISubWorkflowMetadata | undefined {
if (!responseHasSubworkflowData(response)) return undefined;
return {
subExecution: {
executionId: response.executionId,
workflowId: response.workflowId,
},
subExecutionsCount: 1,
};
}
export function parseErrorMetadata(error: unknown): ISubWorkflowMetadata | undefined {
if (hasKey(error, 'errorResponse')) {
return parseErrorResponseWorkflowMetadata(error.errorResponse);
}
// This accounts for cases where the backend attaches the properties on plain errors
// e.g. from custom nodes throwing literal `Error` or `ApplicationError` objects directly
return parseErrorResponseWorkflowMetadata(error);
}
@@ -0,0 +1,592 @@
import type { NativeDoc } from '../extensions/extensions';
export const arrayMethods: NativeDoc = {
typeName: 'Array',
properties: {
length: {
doc: {
name: 'length',
aliases: ['size', 'count'],
aliasMode: 'exact',
description: 'The number of elements in the array',
examples: [{ example: "['Bob', 'Bill', 'Nat'].length", evaluated: '3' }],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/length',
returnType: 'number',
},
},
},
functions: {
concat: {
doc: {
name: 'concat',
aliases: ['extend'],
description: 'Joins one or more arrays onto the end of the base array',
examples: [
{
example: "['Nathan', 'Jan'].concat(['Steve', 'Bill'])",
evaluated: "['Nathan', 'Jan', 'Steve', 'Bill']",
},
{
example: "[5, 4].concat([100, 101], ['a', 'b'])",
evaluated: "[5, 4, 100, 101, 'a', 'b']",
},
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/concat',
returnType: 'Array',
args: [
{
name: 'arrays',
variadic: true,
description: 'The arrays to be joined on the end of the base array, in order',
type: 'Array',
},
],
},
},
filter: {
doc: {
name: 'filter',
description:
'Returns an array with only the elements satisfying a condition. The condition is a function that returns <code>true</code> or <code>false</code>.',
examples: [
{
example: '[12, 33, 16, 40].filter(age => age > 18)',
evaluated: '[33, 40]',
description: 'Keep ages over 18 (using arrow function notation)',
},
{
example: "['Nathan', 'Bob', 'Sebastian'].filter(name => name.length < 5)",
evaluated: "['Bob']",
description: 'Keep names under 5 letters long (using arrow function notation)',
},
{
example:
"['Nathan', 'Bob', 'Sebastian'].filter(function(name) { return name.length < 5 })",
evaluated: "['Bob']",
description: 'Or using traditional function notation',
},
{
example: '[1, 7, 3, 10, 5].filter((num, index) => index % 2 !== 0)',
evaluated: '[7, 10]',
description: 'Keep numbers at odd indexes',
},
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter',
returnType: 'Array',
args: [
{
name: 'function',
description:
'A function to run for each array element. If it returns <code>true</code>, the element will be kept. Consider using <a target="_blank" href=”https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions”>arrow function notation</a> to save space.',
type: 'Function',
default: 'item => true',
args: [
{
name: 'element',
description: 'The value of the current element',
type: 'any',
},
{
name: 'index',
optional: true,
description: 'The position of the current element in the array (starting at 0)',
type: 'number',
},
{
name: 'array',
optional: true,
description: 'The array being processed. Rarely needed.',
type: 'Array',
},
{
name: 'thisValue',
optional: true,
description:
'A value passed to the function as its <code>this</code> value. Rarely needed.',
type: 'any',
},
],
},
],
},
},
find: {
doc: {
name: 'find',
description:
'Returns the first element from the array that satisfies the provided condition. The condition is a function that returns <code>true</code> or <code>false</code>. Returns <code>undefined</code> if no matches are found.\n\nIf you need all matching elements, use <code>filter()</code>.',
examples: [
{
example: '[12, 33, 16, 40].find(age => age > 18)',
evaluated: '33',
description: 'Find first age over 18 (using arrow function notation)',
},
{
example: "['Nathan', 'Bob', 'Sebastian'].find(name => name.length < 5)",
evaluated: "'Bob'",
description: 'Find first name under 5 letters long (using arrow function notation)',
},
{
example:
"['Nathan', 'Bob', 'Sebastian'].find(function(name) { return name.length < 5 })",
evaluated: "'Bob'",
description: 'Or using traditional function notation',
},
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find',
returnType: 'Array | undefined',
args: [
{
name: 'function',
description:
'A function to run for each array element. As soon as it returns <code>true</code>, that element will be returned. Consider using <a target="_blank" href=”https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions”>arrow function notation</a> to save space.',
type: 'Function',
default: 'item => true',
args: [
{
name: 'element',
description: 'The value of the current element',
type: 'any',
},
{
name: 'index',
optional: true,
description: 'The position of the current element in the array (starting at 0)',
type: 'number',
},
{
name: 'array',
optional: true,
description: 'The array being processed. Rarely needed.',
type: 'Array',
},
{
name: 'thisValue',
optional: true,
description:
'A value passed to the function as its <code>this</code> value. Rarely needed.',
type: 'any',
},
],
},
],
},
},
findIndex: {
doc: {
name: 'findIndex',
hidden: true,
description:
'Returns the index of the first element in an array that passes the test `fn`. If none are found, -1 is returned.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findIndex',
returnType: 'number',
args: [{ name: 'fn', type: 'Function' }],
},
},
findLast: {
doc: {
name: 'findLast',
hidden: true,
description: 'Returns the value of the last element that passes the test `fn`.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findLast',
returnType: 'any | undefined',
args: [{ name: 'fn', type: 'Function' }],
},
},
findLastIndex: {
doc: {
name: 'findLastIndex',
hidden: true,
description:
'Returns the index of the last element that satisfies the provided testing function. If none are found, -1 is returned.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/findLastIndex',
returnType: 'number',
args: [{ name: 'fn', type: 'Function' }],
},
},
indexOf: {
doc: {
name: 'indexOf',
description:
"Returns the position of the first matching element in the array, or -1 if the element isn't found. Positions start at 0.",
examples: [
{ example: "['Bob', 'Bill', 'Nat'].indexOf('Nat')", evaluated: '2' },
{ example: "['Bob', 'Bill', 'Nat'].indexOf('Nathan')", evaluated: '-1' },
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf',
returnType: 'number',
args: [
{
name: 'element',
description: 'The value to look for',
type: 'any',
},
{
name: 'start',
optional: true,
description: 'The index to start looking from',
default: '0',
type: 'number',
},
],
},
},
includes: {
doc: {
name: 'includes',
aliases: ['contains', 'has'],
description: 'Returns <code>true</code> if the array contains the specified element',
examples: [
{ example: "['Bob', 'Bill', 'Nat'].includes('Nat')", evaluated: 'true' },
{ example: "['Bob', 'Bill', 'Nat'].includes('Nathan')", evaluated: 'false' },
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/includes',
returnType: 'boolean',
args: [
{
name: 'element',
description: 'The value to search the array for',
type: 'any',
},
{
name: 'start',
optional: true,
description: 'The index to start looking from',
default: '0',
type: 'number',
},
],
},
},
join: {
doc: {
name: 'join',
description:
'Merges all elements of the array into a single string, with an optional separator between each element.\n\nThe opposite of <code>String.split()</code>.',
examples: [
{ example: "['Wind', 'Water', 'Fire'].join(' + ')", evaluated: "'Wind + Water + Fire'" },
{ example: "['Wind', 'Water', 'Fire'].join()", evaluated: "'Wind,Water,Fire'" },
{ example: "['Wind', 'Water', 'Fire'].join('')", evaluated: "'WindWaterFire'" },
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/join',
returnType: 'string',
args: [
{
name: 'separator',
optional: true,
description: 'The character(s) to insert between each element',
default: "','",
type: 'string',
},
],
},
},
map: {
doc: {
name: 'map',
description:
'Creates a new array by applying a function to each element of the original array',
examples: [
{
example: '[12, 33, 16].map(num => num * 2)',
evaluated: '[24, 66, 32]',
description: 'Double all numbers (using arrow function notation)',
},
{
example: "['hello', 'old', 'chap'].map(word => word.toUpperCase())",
evaluated: "['HELLO', 'OLD', 'CHAP']]",
description: 'Convert elements to uppercase (using arrow function notation)',
},
{
example: "['hello', 'old', 'chap'].map(function(word) { return word.toUpperCase() })",
evaluated: "['HELLO', 'OLD', 'CHAP']]",
description: 'Or using traditional function notation',
},
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map',
returnType: 'Array',
args: [
{
name: 'function',
description:
'A function to run for each array element. In the new array, the output of this function takes the place of the element. Consider using <a target="_blank" href=”https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions”>arrow function notation</a> to save space.',
type: 'Function',
default: 'item => item',
args: [
{
name: 'element',
description: 'The value of the current element',
type: 'any',
},
{
name: 'index',
optional: true,
description: 'The position of the current element in the array (starting at 0)',
type: 'number',
},
{
name: 'array',
optional: true,
description: 'The array being processed. Rarely needed.',
type: 'Array',
},
{
name: 'thisValue',
optional: true,
description:
'A value passed to the function as its <code>this</code> value. Rarely needed.',
type: 'any',
},
],
},
],
},
},
reverse: {
doc: {
name: 'reverse',
description: 'Reverses the order of the elements in the array',
examples: [
{ example: "['dog', 'bites', 'man'].reverse()", evaluated: "['man', 'bites', 'dog']" },
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reverse',
returnType: 'Array',
},
},
reduce: {
doc: {
name: 'reduce',
description:
'Executes a "reducer" function `fn` on each element of the array. Passing in the return value from the calculation on the preceding element. The final result of running the reducer across all elements of the array is a single value.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce',
returnType: 'any',
args: [
{
name: 'function',
description:
'A function to run for each array element. Takes the accumulated result and the current element, and returns a new accumulated result. Consider using <a target="_blank" href=”https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions”>arrow function notation</a> to save space.',
type: 'Function',
default: 'item => item',
args: [
{
name: 'prevResult',
description:
'The accumulated result from applying the function to previous elements. When processing the first element, its set to <code>initResult</code> (or the first array element if not specified).',
type: 'any',
},
{
name: 'currentElem',
description: 'The value in the array currently being processed',
type: 'any',
},
{
name: 'index',
optional: true,
description: 'The position of the current element in the array (starting at 0)',
type: 'number',
},
{
name: 'array',
optional: true,
description: 'The array being processed. Rarely needed.',
type: 'Array',
},
],
},
{
name: 'initResult',
optional: true,
description:
"The initial value of the prevResult, used when calling the function on the first array element. When not specified it's set to the first array element, and the first function call is on the second array element instead of the first.",
type: 'any',
},
],
},
},
slice: {
doc: {
name: 'slice',
description:
'Returns a portion of the array, from the <code>start</code> index up to (but not including) the <code>end</code> index. Indexes start at 0.',
examples: [
{ example: '[1, 2, 3, 4, 5].slice(0, -1)', evaluated: '[1, 2, 3, 4]' },
{ example: '[1, 2, 3, 4, 5].slice(2, 4)', evaluated: '[3, 4]' },
{ example: '[1, 2, 3, 4, 5].slice(2)', evaluated: '[3, 4, 5]' },
{ example: '[1, 2, 3, 4, 5].slice(-2)', evaluated: '[4, 5]' },
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice',
returnType: 'Array',
args: [
{
name: 'start',
optional: true,
description:
'The position to start from. Positions start at 0. Negative numbers count back from the end of the array.',
default: '0',
type: 'number',
},
{
name: 'end',
optional: true,
description:
'The position to select up to. The element at the end position is not included. Negative numbers select from the end of the array. If omitted, will extract to the end of the array.',
type: 'number',
},
],
},
},
sort: {
doc: {
name: 'sort',
description:
'Reorders the elements of the array. For sorting strings alphabetically, no parameter is required. For sorting numbers or Objects, see examples.',
examples: [
{
example: "['d', 'a', 'c', 'b'].sort()",
evaluated: "['a', 'b', 'c', 'd']",
description: 'No need for a param when sorting strings',
},
{
example: '[4, 2, 1, 3].sort((a, b) => (a - b))',
evaluated: '[1, 2, 3, 4]',
description: 'To sort numbers, you must use a function',
},
{
example: '[4, 2, 1, 3].sort(function(a, b) { return a - b })',
evaluated: '[1, 2, 3, 4]',
description: 'Or using traditional function notation',
},
{ example: 'Sort in reverse alphabetical order' },
{ example: "arr = ['d', 'a', 'c', 'b']" },
{
example: 'arr.sort((a, b) => b.localeCompare(a))',
evaluated: "['d', 'c', 'b', 'a']",
description: 'Sort in reverse alphabetical order',
},
{
example:
"[{name:'Zak'}, {name:'Abe'}, {name:'Bob'}].sort((a, b) => a.name.localeCompare(b.name))",
evaluated: "[{name:'Abe'}, {name:'Bob'}, {name:'Zak'}]",
description: 'Sort array of objects by a property',
},
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort',
returnType: 'Array',
args: [
{
name: 'compare',
optional: true,
description:
'A function to compare two array elements and return a number indicating which one comes first:\n<b>Return < 0</b>: <code>a</code> comes before <code>b</code>\n<b>Return 0</b>: <code>a</code> and <code>b</code> are equal (leave order unchanged)\n<b>Return > 0</b>: <code>b</code> comes before <code>a</code>\n\nIf no function is specified, converts all values to strings and compares their character codes.',
default: '""',
type: '(a, b) => number',
args: [
{
name: 'a',
description: 'The first element to compare in the function',
type: 'any',
},
{
name: 'b',
description: 'The second element to compare in the function',
type: 'any',
},
],
},
],
},
},
splice: {
doc: {
name: 'splice',
description: 'Changes the contents of an array by removing or replacing existing elements.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice',
returnType: 'Array',
hidden: true,
args: [
{ name: 'start', type: 'number' },
{ name: 'deleteCount?', type: 'number' },
{ name: 'item1?', type: 'Element' },
{ name: '...' },
{ name: 'itemN?', type: 'Element' },
],
},
},
toString: {
doc: {
name: 'toString',
hidden: true,
description: 'Returns a string representing the specified array and its elements.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toString',
returnType: 'string',
},
},
toSpliced: {
doc: {
name: 'toSpliced',
aliases: ['insertAt', 'removeAt'],
description:
'Adds and/or removes array elements at a given position. \n\nSee also <code>slice()</code> and <code>append()</code>.',
examples: [
{
example: "['Jan', 'Mar'.toSpliced(1, 0, 'Feb')",
evaluated: "['Jan', 'Feb', 'Mar']",
description: 'Insert element at index 1',
},
{
example: '["don\'t", "make", "me", "do", "this"].toSpliced(1, 2)',
evaluated: '["don\'t", "do", "this"]',
description: 'Delete 2 elements starting at index 1',
},
{
example: '["don\'t", "be", "evil"].toSpliced(1, 2, "eat", "slugs")',
evaluated: '["don\'t", "eat", "slugs"]',
description: 'Replace 2 elements starting at index 1',
},
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/toSpliced',
returnType: 'Array',
args: [
{
name: 'start',
description:
'The index (position) to add or remove elements at. New elements are inserted before the element at this index. A negative index counts back from the end of the array. ',
type: 'number',
},
{
name: 'deleteCount',
optional: true,
description:
'The number of elements to remove. If omitted, removes all elements from the <code>start</code> index onwards.',
type: 'number',
},
{
name: 'elements',
optional: true,
variadic: true,
description: 'The elements to be added, in order',
type: 'any',
},
],
},
},
},
};
@@ -0,0 +1,21 @@
import type { NativeDoc } from '../extensions/extensions';
export const booleanMethods: NativeDoc = {
typeName: 'Boolean',
functions: {
toString: {
doc: {
name: 'toString',
description:
"Converts <code>true</code> to the string <code>'true'</code> and <code>false</code> to the string <code>'false'</code>.",
examples: [
{ example: 'true.toString()', evaluated: "'true'" },
{ example: 'false.toString()', evaluated: "'false'" },
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean/toString',
returnType: 'string',
},
},
},
};
@@ -0,0 +1,16 @@
import { arrayMethods } from './array.methods';
import { booleanMethods } from './boolean.methods';
import { numberMethods } from './number.methods';
import { objectMethods } from './object.methods';
import { stringMethods } from './string.methods';
import type { NativeDoc } from '../extensions/extensions';
const NATIVE_METHODS: NativeDoc[] = [
stringMethods,
arrayMethods,
numberMethods,
objectMethods,
booleanMethods,
];
export { NATIVE_METHODS as NativeMethods };
@@ -0,0 +1,93 @@
import type { NativeDoc } from '../extensions/extensions';
export const numberMethods: NativeDoc = {
typeName: 'Number',
functions: {
toFixed: {
doc: {
name: 'toFixed',
hidden: true,
description:
'Formats a number using fixed-point notation. `digits` defaults to null if not given.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed',
returnType: 'string',
args: [{ name: 'digits?', type: 'number' }],
},
},
toPrecision: {
doc: {
name: 'toPrecision',
hidden: true,
description: 'Returns a string representing the number to the specified precision.',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toPrecision',
returnType: 'string',
args: [{ name: 'precision?', type: 'number' }],
},
},
toString: {
doc: {
name: 'toString',
description:
'Converts the number to a string. For more formatting options, see <code>toLocaleString()</code>.',
examples: [
{ example: '(2).toString()', evaluated: "'2'" },
{ example: '(50.125).toString()', evaluated: "'50.125'" },
{ example: '(5).toString(2)', evaluated: "'101'" },
{ example: '(412).toString(16)', evaluated: "'19c'" },
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toString',
args: [
{
name: 'base',
optional: true,
description:
'The base to use. Must be an integer between 2 and 36. E.g. base <code>2</code> is binary and base <code>16</code> is hexadecimal.',
default: '10',
type: 'number',
},
],
returnType: 'string',
},
},
toLocaleString: {
doc: {
name: 'toLocaleString',
description:
"Returns a localized string representing the number, i.e. in the language and format corresponding to its locale. Defaults to the system's locale if none specified.",
examples: [
{
example: '(500000.125).toLocaleString()',
evaluated: "'500,000.125' (if in US English locale)",
},
{ example: "(500000.125).toLocaleString('fr-FR')", evaluated: "'500 000,125'" },
{
example: "(500000.125).toLocaleString('fr-FR', {style:'currency', currency:'EUR'})",
evaluated: "'500 000,13 €'",
},
],
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toLocaleString',
args: [
{
name: 'locale(s)',
optional: true,
description:
'The locale to use, e.g. \'en-GB\' for British English or \'pt-BR\' for Brazilian Portuguese. See <a target="_blank" href="https://www.localeplanet.com/icu/">full list</a> (unofficial). Also accepts an <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument">array of locales</a>. Defaults to the system locale if not specified.',
type: 'string | string[]',
},
{
name: 'options',
optional: true,
description:
'An object with <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#parameters">formatting options</a>',
type: 'object',
},
],
returnType: 'string',
},
},
},
};
@@ -0,0 +1,6 @@
import type { NativeDoc } from '../extensions/extensions';
export const objectMethods: NativeDoc = {
typeName: 'Object',
functions: {},
};
@@ -0,0 +1,549 @@
import type { NativeDoc } from '../extensions/extensions';
export const stringMethods: NativeDoc = {
typeName: 'String',
properties: {
length: {
doc: {
name: 'length',
aliases: ['size', 'count'],
aliasMode: 'exact',
description: 'The number of characters in the string',
examples: [{ example: '"hello".length', evaluated: '5' }],
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length',
returnType: 'number',
},
},
},
functions: {
concat: {
doc: {
name: 'concat',
description:
'Joins one or more strings onto the end of the base string. Alternatively, use the <code>+</code> operator (see examples).',
examples: [
{ example: "'sea'.concat('food')", evaluated: "'seafood'" },
{ example: "'sea' + 'food'", evaluated: "'seafood'" },
{ example: "'work'.concat('a', 'holic')", evaluated: "'workaholic'" },
],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/concat',
args: [
{
name: 'strings',
optional: false,
variadic: true,
description: 'The strings to append, in order',
type: 'string[]',
},
],
returnType: 'string',
},
},
endsWith: {
doc: {
name: 'endsWith',
description:
'Returns <code>true</code> if the string ends with <code>searchString</code>. Case-sensitive.',
examples: [
{ example: "'team'.endsWith('eam')", evaluated: 'true' },
{ example: "'team'.endsWith('Eam')", evaluated: 'false' },
{
example: "'teaM'.toLowerCase().endsWith('eam')",
evaluated: 'true',
description:
"Returns false if the case doesn't match, so consider using .toLowerCase() first",
},
],
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith',
returnType: 'boolean',
args: [
{
name: 'searchString',
optional: false,
description: 'The text to check against the end of the base string',
type: 'string',
},
{
name: 'end',
optional: true,
description: 'The end position (index) to start searching from',
type: 'number',
},
],
},
},
indexOf: {
doc: {
name: 'indexOf',
description:
'Returns the index (position) of the first occurrence of <code>searchString</code> within the base string, or -1 if not found. Case-sensitive.',
examples: [
{ example: "'steam'.indexOf('tea')", evaluated: '1' },
{ example: "'steam'.indexOf('i')", evaluated: '-1' },
{
example: "'STEAM'.indexOf('tea')",
evaluated: '-1',
description:
"Returns -1 if the case doesn't match, so consider using .toLowerCase() first",
},
{ example: "'STEAM'.toLowerCase().indexOf('tea')", evaluated: '1' },
],
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/indexOf',
returnType: 'number',
args: [
{
name: 'searchString',
optional: false,
description: 'The text to search for',
type: 'string',
},
{
name: 'start',
optional: true,
description: 'The position (index) to start searching from',
default: '0',
type: 'number',
},
],
},
},
lastIndexOf: {
doc: {
name: 'lastIndexOf',
description:
'Returns the index (position) of the last occurrence of <code>searchString</code> within the base string, or -1 if not found. Case-sensitive.',
examples: [
{ example: "'canal'.lastIndexOf('a')", evaluated: '3' },
{ example: "'canal'.lastIndexOf('i')", evaluated: '-1' },
{
example: "'CANAL'.lastIndexOf('a')",
evaluated: '-1',
description:
"Returns -1 if the case doesn't match, so consider using .toLowerCase() first",
},
{ example: "'CANAL'.toLowerCase().lastIndexOf('a')", evaluated: '3' },
],
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/lastIndexOf',
returnType: 'number',
args: [
{
name: 'searchString',
optional: false,
description: 'The text to search for',
type: 'string',
},
{
name: 'end',
optional: true,
description: 'The position (index) to stop searching at',
default: '0',
type: 'number',
},
],
},
},
match: {
doc: {
name: 'match',
description:
'Matches the string against a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>. Returns an array containing the first match, or all matches if the <code>g</code> flag is set in the regular expression. Returns <code>null</code> if no matches are found. \n\nFor checking whether text is present, consider <code>includes()</code> instead.',
examples: [
{
example: '"rock and roll".match(/r[^ ]*/g)',
evaluated: "['rock', 'roll']",
description: "Match all words starting with 'r'",
},
{
example: '"rock and roll".match(/r[^ ]*/)',
evaluated: "['rock']",
description: "Match first word starting with 'r' (no 'g' flag)",
},
{
example: '"ROCK and roll".match(/r[^ ]*/ig)',
evaluated: "['ROCK', 'roll']",
description: "For case-insensitive, add 'i' flag",
},
],
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match',
returnType: 'string[]',
args: [
{
name: 'regexp',
optional: false,
description:
'A <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a> with the pattern to look for. Will look for multiple matches if the <code>g</code> flag is present (see examples).',
type: 'RegExp',
},
],
},
},
includes: {
doc: {
name: 'includes',
aliases: ['contains'],
description:
'Returns <code>true</code> if the string contains the <code>searchString</code>. Case-sensitive.',
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/includes',
returnType: 'boolean',
args: [
{
name: 'searchString',
optional: false,
description: 'The text to search for',
type: 'string',
},
{
name: 'start',
optional: true,
description: 'The position (index) to start searching from',
default: '0',
type: 'number',
},
],
examples: [
{ example: "'team'.includes('tea')", evaluated: 'true' },
{ example: "'team'.includes('i')", evaluated: 'false' },
{
example: "'team'.includes('Tea')",
evaluated: 'false',
description:
"Returns false if the case doesn't match, so consider using .toLowerCase() first",
},
{ example: "'Team'.toLowerCase().includes('tea')", evaluated: 'true' },
],
},
},
replace: {
doc: {
name: 'replace',
description:
'Returns a string with the first occurrence of <code>pattern</code> replaced by <code>replacement</code>. \n\nTo replace all occurrences, use <code>replaceAll()</code> instead.',
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace',
returnType: 'string',
args: [
{
name: 'pattern',
optional: false,
description:
'The pattern in the string to replace. Can be a string to match or a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>.',
type: 'string|RegExp',
},
{
name: 'replacement',
optional: false,
description: 'The new text to replace with',
type: 'string',
},
],
examples: [
{
example: "'Red or blue or green'.replace('or', 'and')",
evaluated: "'Red and blue or green'",
},
{
example:
'let text = "Mr Blue has a blue house and a blue car";\ntext.replace(/blue/gi, "red");',
evaluated: "'Mr red has a red house and a red car'",
description: 'A global, case-insensitive replacement:',
},
{
example:
'let text = "Mr Blue has a blue house and a blue car";\ntext.replace(/blue|house|car/gi, (t) => t.toUpperCase());',
evaluated: "'Mr BLUE has a BLUE HOUSE and a BLUE CAR'",
description: 'A function to return the replacement text:',
},
],
},
},
replaceAll: {
doc: {
name: 'replaceAll',
description:
'Returns a string with all occurrences of <code>pattern</code> replaced by <code>replacement</code>',
examples: [
{
example: "'Red or blue or green'.replaceAll('or', 'and')",
evaluated: "'Red and blue and green'",
},
{
example:
"text = 'Mr Blue has a blue car';\ntext.replaceAll(/blue|car/gi, t => t.toUpperCase())",
description:
"Uppercase any occurrences of 'blue' or 'car' (You must include the 'g' flag when using a regex)",
evaluated: "'Mr BLUE has a BLUE CAR'",
},
{
example: 'text.replaceAll(/blue|car/gi, function(x){return x.toUpperCase()})',
evaluated: "'Mr BLUE has a BLUE CAR'",
description: 'Or with traditional function notation:',
},
],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll',
returnType: 'string',
args: [
{
name: 'pattern',
optional: false,
description:
'The pattern in the string to replace. Can be a string to match or a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>.',
type: 'string|RegExp',
},
{
name: 'replacement',
optional: false,
description:
'The new text to replace with. Can be a string or a function that returns a string (see examples).',
type: 'string|Function',
},
],
},
},
search: {
doc: {
name: 'search',
description:
'Returns the index (position) of the first occurrence of a pattern within the string, or -1 if not found. The pattern is specified using a <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a>. To use text instead, see <code>indexOf()</code>.',
examples: [
{
example: '"Neat n8n node".search(/n[^ ]*/)',
evaluated: '5',
description: "Pos of first word starting with 'n'",
},
{
example: '"Neat n8n node".search(/n[^ ]*/i)',
evaluated: '0',
description:
"Case-insensitive match with 'i'\nPos of first word starting with 'n' or 'N'",
},
],
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/search',
returnType: 'string',
args: [
{
name: 'regexp',
optional: false,
description:
'A <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions">regular expression</a> with the pattern to look for',
type: 'RegExp',
},
],
},
},
slice: {
doc: {
name: 'slice',
description:
'Extracts a fragment of the string at the given position. For more advanced extraction, see <code>match()</code>.',
examples: [
{ example: "'Hello from n8n'.slice(0, 5)", evaluated: "'Hello'" },
{ example: "'Hello from n8n'.slice(6)", evaluated: "'from n8n'" },
{ example: "'Hello from n8n'.slice(-3)", evaluated: "'n8n'" },
],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/slice',
returnType: 'string',
args: [
{
name: 'start',
optional: false,
description:
'The position to start from. Positions start at 0. Negative numbers count back from the end of the string.',
type: 'number',
},
{
name: 'end',
optional: true,
description:
'The position to select up to. The character at the end position is not included. Negative numbers select from the end of the string. If omitted, will extract to the end of the string.',
type: 'string',
},
],
},
},
split: {
doc: {
name: 'split',
description:
"Splits the string into an array of substrings. Each split is made at the <code>separator</code>, and the separator isn't included in the output. \n\nThe opposite of using <code>join()</code> on an array.",
examples: [
{ example: '"wind,fire,water".split(",")', evaluated: "['wind', 'fire', 'water']" },
{ example: '"me and you and her".split("and")', evaluated: "['me ', ' you ', ' her']" },
{
example: '"me? you, and her".split(/[ ,?]+/)',
evaluated: "['me', 'you', 'and', 'her']",
description: "Split one or more of space, comma and '?' using a regular expression",
},
],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split',
returnType: 'string[]',
args: [
{
name: 'separator',
optional: true,
description:
'The string (or regular expression) to use for splitting. If omitted, an array with the original string is returned.',
type: 'string',
},
{
name: 'limit',
optional: true,
description:
'The max number of array elements to return. Returns all elements if omitted.',
type: 'number',
},
],
},
},
startsWith: {
doc: {
name: 'startsWith',
description:
'Returns <code>true</code> if the string starts with <code>searchString</code>. Case-sensitive.',
examples: [
{ example: "'team'.startsWith('tea')", evaluated: 'true' },
{ example: "'team'.startsWith('Tea')", evaluated: 'false' },
{
example: "'Team'.toLowerCase().startsWith('tea')",
evaluated: 'true',
description:
"Returns false if the case doesn't match, so consider using .toLowerCase() first",
},
],
section: 'query',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith',
returnType: 'boolean',
args: [
{
name: 'searchString',
optional: false,
description: 'The text to check against the start of the base string',
type: 'string',
},
{
name: 'start',
optional: true,
description: 'The position (index) to start searching from',
default: '0',
type: 'number',
},
],
},
},
substring: {
doc: {
name: 'substring',
description:
'Extracts a fragment of the string at the given position. For more advanced extraction, see <code>match()</code>.',
examples: [
{ example: "'Hello from n8n'.substring(0, 5)", evaluated: "'Hello'" },
{ example: "'Hello from n8n'.substring(6)", evaluated: "'from n8n'" },
],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/substring',
returnType: 'string',
args: [
{
name: 'start',
optional: false,
description: 'The position to start from. Positions start at 0.',
type: 'number',
},
{
name: 'end',
optional: true,
description:
'The position to select up to. The character at the end position is not included. If omitted, will extract to the end of the string.',
type: 'string',
},
],
},
},
toLowerCase: {
doc: {
name: 'toLowerCase',
aliases: ['lower'],
description: 'Converts all letters in the string to lower case',
section: 'case',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toLowerCase',
returnType: 'string',
examples: [{ example: '"I\'m SHOUTing".toLowerCase()', evaluated: '"i\'m shouting"' }],
},
},
toUpperCase: {
doc: {
name: 'toUpperCase',
aliases: ['upper'],
description: 'Converts all letters in the string to upper case (capitals)',
examples: [{ example: '"I\'m not angry".toUpperCase()', evaluated: '"I\'M NOT ANGRY"' }],
section: 'case',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase',
returnType: 'string',
},
},
trim: {
doc: {
name: 'trim',
description:
'Removes whitespace from both ends of the string. Whitespace includes new lines, tabs, spaces, etc.',
examples: [{ example: "' lonely '.trim()", evaluated: "'lonely'" }],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/Trim',
returnType: 'string',
},
},
trimEnd: {
doc: {
name: 'trimEnd',
aliases: ['trimRight'],
description:
'Removes whitespace from the end of a string and returns a new string. Whitespace includes new lines, tabs, spaces, etc.',
examples: [{ example: "' lonely '.trimEnd()", evaluated: "' lonely'" }],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimEnd',
returnType: 'string',
},
},
trimStart: {
doc: {
name: 'trimStart',
aliases: ['trimLeft'],
description:
'Removes whitespace from the beginning of a string and returns a new string. Whitespace includes new lines, tabs, spaces, etc.',
examples: [{ example: "' lonely '.trimStart()", evaluated: "'lonely '" }],
section: 'edit',
docURL:
'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trimStart',
returnType: 'string',
},
},
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
import { ApplicationError } from '@n8n/errors';
import type { DateTime } from 'luxon';
import type {
FilterConditionValue,
FilterOperatorType,
FilterOptionsValue,
FilterValue,
INodeProperties,
ValidationResult,
} from '../interfaces';
import * as LoggerProxy from '../logger-proxy';
import type { Result } from '../result';
import { validateFieldType } from '../type-validation';
type FilterConditionMetadata = {
index: number;
unresolvedExpressions: boolean;
itemIndex: number;
errorFormat: 'full' | 'inline';
};
export class FilterError extends ApplicationError {
constructor(
message: string,
readonly description: string,
) {
super(message, { level: 'warning' });
}
}
function parseSingleFilterValue(
value: unknown,
type: FilterOperatorType,
strict = false,
version: FilterOptionsValue['version'] = 1,
): ValidationResult {
if (type === 'any' || value === null || value === undefined) {
return { valid: true, newValue: value } as ValidationResult;
}
if (type === 'boolean' && !strict) {
if (version >= 2) {
const result = validateFieldType('filter', value, type);
if (result.valid) return result;
}
return { valid: true, newValue: Boolean(value) };
}
if (type === 'number') {
if (Number.isNaN(value)) {
return { valid: true, newValue: value };
}
const isEmptyString = typeof value === 'string' && value.trim() === '';
const isEmptyArray = Array.isArray(value) && value.length === 0;
// Number('') and Number([]) convert to 0 in validateFieldType, which is not intuitive, consider them empty values
if ((isEmptyString || isEmptyArray) && version >= 3) {
return { valid: true, newValue: null };
}
}
return validateFieldType('filter', value, type, { strict, parseStrings: true });
}
const withIndefiniteArticle = (noun: string): string => {
const article = 'aeiou'.includes(noun.charAt(0)) ? 'an' : 'a';
return `${article} ${noun}`;
};
function parseFilterConditionValues(
condition: FilterConditionValue,
options: FilterOptionsValue,
metadata: Partial<FilterConditionMetadata>,
): Result<{ left: unknown; right: unknown }, FilterError> {
const index = metadata.index ?? 0;
const itemIndex = metadata.itemIndex ?? 0;
const errorFormat = metadata.errorFormat ?? 'full';
const strict = options.typeValidation === 'strict';
const version = options.version ?? 1;
const { operator } = condition;
const rightType = operator.rightType ?? operator.type;
const parsedLeftValue = parseSingleFilterValue(
condition.leftValue,
operator.type,
strict,
version,
);
const parsedRightValue = parseSingleFilterValue(condition.rightValue, rightType, strict, version);
const leftValid =
parsedLeftValue.valid ||
(metadata.unresolvedExpressions &&
typeof condition.leftValue === 'string' &&
condition.leftValue.startsWith('='));
const rightValid =
parsedRightValue.valid ||
!!operator.singleValue ||
(metadata.unresolvedExpressions &&
typeof condition.rightValue === 'string' &&
condition.rightValue.startsWith('='));
const leftValueString = String(condition.leftValue);
const rightValueString = String(condition.rightValue);
const suffix =
errorFormat === 'full' ? `[condition ${index}, item ${itemIndex}]` : `[item ${itemIndex}]`;
const composeInvalidTypeMessage = (type: string, fromType: string, value: string) => {
fromType = fromType.toLocaleLowerCase();
if (strict) {
return `Wrong type: '${value}' is ${withIndefiniteArticle(
fromType,
)} but was expecting ${withIndefiniteArticle(type)} ${suffix}`;
}
return `Conversion error: the ${fromType} '${value}' can't be converted to ${withIndefiniteArticle(
type,
)} ${suffix}`;
};
const getTypeDescription = (isStrict: boolean) => {
if (isStrict)
return "Try changing the type of comparison. Alternatively you can enable 'Convert types where required'.";
return 'Try changing the type of the comparison.';
};
const composeInvalidTypeDescription = (
type: string,
fromType: string,
valuePosition: 'first' | 'second',
) => {
fromType = fromType.toLocaleLowerCase();
const expectedType = withIndefiniteArticle(type);
let convertionFunction = '';
if (type === 'string') {
convertionFunction = '.toString()';
} else if (type === 'number') {
convertionFunction = '.toNumber()';
} else if (type === 'boolean') {
convertionFunction = '.toBoolean()';
}
if (strict && convertionFunction) {
const suggestFunction = ` by adding <code>${convertionFunction}</code>`;
return `
<p>Try either:</p>
<ol>
<li>Enabling 'Convert types where required'</li>
<li>Converting the ${valuePosition} field to ${expectedType}${suggestFunction}</li>
</ol>
`;
}
return getTypeDescription(strict);
};
if (!leftValid && !rightValid && typeof condition.leftValue === typeof condition.rightValue) {
return {
ok: false,
error: new FilterError(
`Comparison type expects ${withIndefiniteArticle(operator.type)} but both fields are ${withIndefiniteArticle(
typeof condition.leftValue,
)}`,
getTypeDescription(strict),
),
};
}
if (!leftValid) {
return {
ok: false,
error: new FilterError(
composeInvalidTypeMessage(operator.type, typeof condition.leftValue, leftValueString),
composeInvalidTypeDescription(operator.type, typeof condition.leftValue, 'first'),
),
};
}
if (!rightValid) {
return {
ok: false,
error: new FilterError(
composeInvalidTypeMessage(rightType, typeof condition.rightValue, rightValueString),
composeInvalidTypeDescription(rightType, typeof condition.rightValue, 'second'),
),
};
}
return {
ok: true,
result: {
left: parsedLeftValue.valid ? parsedLeftValue.newValue : undefined,
right: parsedRightValue.valid ? parsedRightValue.newValue : undefined,
},
};
}
function parseRegexPattern(pattern: string): RegExp {
const regexMatch = (pattern || '').match(new RegExp('^/(.*?)/([gimusy]*)$'));
let regex: RegExp;
if (!regexMatch) {
regex = new RegExp((pattern || '').toString());
} else {
regex = new RegExp(regexMatch[1], regexMatch[2]);
}
return regex;
}
export function arrayContainsValue(array: unknown[], value: unknown, ignoreCase: boolean): boolean {
if (ignoreCase && typeof value === 'string') {
return array.some((item) => {
if (typeof item !== 'string') {
return false;
}
return item.toString().toLocaleLowerCase() === value.toLocaleLowerCase();
});
}
return array.includes(value);
}
// eslint-disable-next-line complexity
export function executeFilterCondition(
condition: FilterConditionValue,
filterOptions: FilterOptionsValue,
metadata: Partial<FilterConditionMetadata> = {},
): boolean {
const ignoreCase = !filterOptions.caseSensitive;
const { operator } = condition;
const parsedValues = parseFilterConditionValues(condition, filterOptions, metadata);
if (!parsedValues.ok) {
throw parsedValues.error;
}
let { left: leftValue, right: rightValue } = parsedValues.result;
const exists = leftValue !== undefined && leftValue !== null && !Number.isNaN(leftValue);
if (condition.operator.operation === 'exists') {
return exists;
} else if (condition.operator.operation === 'notExists') {
return !exists;
}
switch (operator.type) {
case 'string': {
if (ignoreCase) {
if (typeof leftValue === 'string') {
leftValue = leftValue.toLocaleLowerCase();
}
if (
typeof rightValue === 'string' &&
!(condition.operator.operation === 'regex' || condition.operator.operation === 'notRegex')
) {
rightValue = rightValue.toLocaleLowerCase();
}
}
const left = (leftValue ?? '') as string;
const right = (rightValue ?? '') as string;
switch (condition.operator.operation) {
case 'empty':
return left.length === 0;
case 'notEmpty':
return left.length !== 0;
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
case 'contains':
return left.includes(right);
case 'notContains':
return !left.includes(right);
case 'startsWith':
return left.startsWith(right);
case 'notStartsWith':
return !left.startsWith(right);
case 'endsWith':
return left.endsWith(right);
case 'notEndsWith':
return !left.endsWith(right);
case 'regex':
return parseRegexPattern(right).test(left);
case 'notRegex':
return !parseRegexPattern(right).test(left);
}
break;
}
case 'number': {
const left = leftValue as number;
const right = rightValue as number;
switch (condition.operator.operation) {
case 'empty':
return !exists;
case 'notEmpty':
return exists;
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
case 'gt':
return left > right;
case 'lt':
return left < right;
case 'gte':
return left >= right;
case 'lte':
return left <= right;
}
}
case 'dateTime': {
const left = leftValue as DateTime;
const right = rightValue as DateTime;
if (condition.operator.operation === 'empty') {
return !exists;
} else if (condition.operator.operation === 'notEmpty') {
return exists;
}
if (!left || !right) {
return false;
}
switch (condition.operator.operation) {
case 'equals':
return left.toMillis() === right.toMillis();
case 'notEquals':
return left.toMillis() !== right.toMillis();
case 'after':
return left.toMillis() > right.toMillis();
case 'before':
return left.toMillis() < right.toMillis();
case 'afterOrEquals':
return left.toMillis() >= right.toMillis();
case 'beforeOrEquals':
return left.toMillis() <= right.toMillis();
}
}
case 'boolean': {
const left = leftValue as boolean;
const right = rightValue as boolean;
switch (condition.operator.operation) {
case 'empty':
return !exists;
case 'notEmpty':
return exists;
case 'true':
return left;
case 'false':
return !left;
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
}
}
case 'array': {
const left = (leftValue ?? []) as unknown[];
const rightNumber = rightValue as number;
switch (condition.operator.operation) {
case 'contains':
return arrayContainsValue(left, rightValue, ignoreCase);
case 'notContains':
return !arrayContainsValue(left, rightValue, ignoreCase);
case 'lengthEquals':
return left.length === rightNumber;
case 'lengthNotEquals':
return left.length !== rightNumber;
case 'lengthGt':
return left.length > rightNumber;
case 'lengthLt':
return left.length < rightNumber;
case 'lengthGte':
return left.length >= rightNumber;
case 'lengthLte':
return left.length <= rightNumber;
case 'empty':
return left.length === 0;
case 'notEmpty':
return left.length !== 0;
}
}
case 'object': {
const left = leftValue;
switch (condition.operator.operation) {
case 'empty':
return !left || Object.keys(left).length === 0;
case 'notEmpty':
return !!left && Object.keys(left).length !== 0;
}
}
}
LoggerProxy.warn(`Unknown filter parameter operator "${operator.type}:${operator.operation}"`);
return false;
}
type ExecuteFilterOptions = {
itemIndex?: number;
};
export function executeFilter(
value: FilterValue,
{ itemIndex }: ExecuteFilterOptions = {},
): boolean {
const conditionPass = (condition: FilterConditionValue, index: number) =>
executeFilterCondition(condition, value.options, { index, itemIndex });
if (value.combinator === 'and') {
return value.conditions.every(conditionPass);
} else if (value.combinator === 'or') {
return value.conditions.some(conditionPass);
}
LoggerProxy.warn(`Unknown filter combinator "${value.combinator as string}"`);
return false;
}
export const validateFilterParameter = (
nodeProperties: INodeProperties,
value: FilterValue,
): Record<string, string[]> => {
return value.conditions.reduce(
(issues, condition, index) => {
const key = `${nodeProperties.name}.${index}`;
try {
parseFilterConditionValues(condition, value.options, {
index,
unresolvedExpressions: true,
errorFormat: 'inline',
});
} catch (error) {
if (error instanceof FilterError) {
issues[key].push(error.message);
}
}
return issues;
},
{} as Record<string, string[]>,
);
};
@@ -0,0 +1,127 @@
import type {
AssignmentCollectionValue,
INodeParameters,
NodeParameterValue,
NodeParameterValueType,
} from '../interfaces';
import { isResourceLocatorValue, isResourceMapperValue, isFilterValue } from '../type-guards';
/**
* Type guard for primitive NodeParameterValue types.
* Checks if a value is string, number, boolean, undefined, or null.
*/
export function isNodeParameterValue(value: unknown): value is NodeParameterValue {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === undefined ||
value === null
);
}
/**
* Type guard for AssignmentCollectionValue.
* Checks if a value has the structure of an assignment collection.
*/
export function isAssignmentCollectionValue(value: unknown): value is AssignmentCollectionValue {
if (typeof value !== 'object' || value === null || !('assignments' in value)) {
return false;
}
const assignments = (value as AssignmentCollectionValue).assignments;
if (!Array.isArray(assignments)) {
return false;
}
return assignments.every(
(assignment) =>
typeof assignment === 'object' &&
assignment !== null &&
'id' in assignment &&
'name' in assignment &&
'value' in assignment &&
typeof assignment.id === 'string' &&
typeof assignment.name === 'string' &&
isNodeParameterValue(assignment.value),
);
}
/**
* Type guard for INodeParameters.
* Recursively validates that all values in the object are valid NodeParameterValueType.
*/
export function isNodeParameters(value: unknown): value is INodeParameters {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
// Reject built-in class instances (Date, RegExp, etc.)
// Only accept plain objects created with {} or Object.create(null)
if (Object.prototype.toString.call(value) !== '[object Object]') {
return false;
}
// Recursively validate all values
return Object.values(value).every((val) => isValidNodeParameterValueType(val));
}
/**
* Comprehensive type guard for NodeParameterValueType.
* Validates that a value matches any of the valid node parameter value types.
*
* @param value - The value to check
* @returns true if the value is a valid NodeParameterValueType
*
* @example
* ```typescript
* const value: unknown = { foo: 'bar' };
* if (isValidNodeParameterValueType(value)) {
* // value is now typed as NodeParameterValueType
* }
* ```
*/
export function isValidNodeParameterValueType(value: unknown): value is NodeParameterValueType {
return (
// Primitives (most common case)
isNodeParameterValue(value) ||
// Special object types
isResourceLocatorValue(value) ||
isResourceMapperValue(value) ||
isFilterValue(value) ||
isAssignmentCollectionValue(value) ||
// Arrays - all items should be valid NodeParameterValueType
(Array.isArray(value) &&
(value.length === 0 ||
value.every(isNodeParameterValue) ||
value.every(isNodeParameters) ||
value.every(isResourceLocatorValue) ||
value.every(isResourceMapperValue))) ||
// INodeParameters (must be last to avoid infinite recursion on first check)
isNodeParameters(value)
);
}
/**
* Assertion function that throws if the value is not a valid NodeParameterValueType.
* Useful for runtime validation with TypeScript type narrowing.
*
* @param value - The value to validate
* @param errorMessage - Optional custom error message
* @throws Error if the value is not a valid NodeParameterValueType
*
* @example
* ```typescript
* const value: unknown = getData();
* assertIsValidNodeParameterValueType(value);
* // value is now typed as NodeParameterValueType
* ```
*/
export function assertIsValidNodeParameterValueType(
value: unknown,
errorMessage = 'Value is not a valid NodeParameterValueType',
): asserts value is NodeParameterValueType {
if (!isValidNodeParameterValueType(value)) {
throw new Error(errorMessage);
}
}
@@ -0,0 +1,272 @@
import { NodeOperationError } from '../errors';
import type { INode } from '../interfaces';
import { assert } from '../utils';
type ParameterType =
| 'string'
| 'boolean'
| 'number'
| 'resource-locator'
| 'string[]'
| 'number[]'
| 'boolean[]'
| 'object';
function assertUserInput<T>(condition: T, message: string, node: INode): asserts condition {
try {
assert(condition, message);
} catch (e: unknown) {
if (e instanceof Error) {
// Use level 'info' to prevent reporting to Sentry (only 'error' and 'fatal' levels are reported)
const nodeError = new NodeOperationError(node, e.message, { level: 'info' });
nodeError.stack = e.stack;
throw nodeError;
}
throw e;
}
}
function assertParamIsType<T>(
parameterName: string,
value: unknown,
type: 'string' | 'number' | 'boolean',
node: INode,
): asserts value is T {
assertUserInput(typeof value === type, `Parameter "${parameterName}" is not ${type}`, node);
}
export function assertParamIsNumber(
parameterName: string,
value: unknown,
node: INode,
): asserts value is number {
assertParamIsType<number>(parameterName, value, 'number', node);
}
export function assertParamIsString(
parameterName: string,
value: unknown,
node: INode,
): asserts value is string {
assertParamIsType<string>(parameterName, value, 'string', node);
}
export function assertParamIsBoolean(
parameterName: string,
value: unknown,
node: INode,
): asserts value is boolean {
assertParamIsType<boolean>(parameterName, value, 'boolean', node);
}
type TypeofMap = {
string: string;
number: number;
boolean: boolean;
};
export function assertParamIsOfAnyTypes<T extends ReadonlyArray<keyof TypeofMap>>(
parameterName: string,
value: unknown,
types: T,
node: INode,
): asserts value is TypeofMap[T[number]] {
const isValid = types.some((type) => typeof value === type);
if (!isValid) {
const typeList = types.join(' or ');
assertUserInput(false, `Parameter "${parameterName}" must be ${typeList}`, node);
}
}
export function assertParamIsArray<T>(
parameterName: string,
value: unknown,
validator: (val: unknown) => val is T,
node: INode,
): asserts value is T[] {
assertUserInput(Array.isArray(value), `Parameter "${parameterName}" is not an array`, node);
// Use for loop instead of .every() to properly handle sparse arrays
// .every() skips empty/sparse indices, which could allow invalid arrays to pass
for (let i = 0; i < value.length; i++) {
if (!validator(value[i])) {
assertUserInput(
false,
`Parameter "${parameterName}" has elements that don't match expected types`,
node,
);
}
}
}
function assertIsValidObject(
value: unknown,
node: INode,
): asserts value is Record<string, unknown> {
assertUserInput(typeof value === 'object' && value !== null, 'Value is not a valid object', node);
}
function assertIsRequiredParameter(
parameterName: string,
value: unknown,
isRequired: boolean,
node: INode,
): void {
if (isRequired && value === undefined) {
assertUserInput(false, `Required parameter "${parameterName}" is missing`, node);
}
}
function assertIsResourceLocator(parameterName: string, value: unknown, node: INode): void {
assertUserInput(
typeof value === 'object' &&
value !== null &&
'__rl' in value &&
'mode' in value &&
'value' in value,
`Parameter "${parameterName}" is not a valid resource locator object`,
node,
);
}
function assertParamIsObject(parameterName: string, value: unknown, node: INode): void {
assertUserInput(
typeof value === 'object' && value !== null,
`Parameter "${parameterName}" is not a valid object`,
node,
);
}
function createElementValidator<T extends 'string' | 'number' | 'boolean'>(elementType: T) {
return (
val: unknown,
): val is T extends 'string' ? string : T extends 'number' ? number : boolean =>
typeof val === elementType;
}
function assertParamIsArrayOfType(
parameterName: string,
value: unknown,
arrayType: string,
node: INode,
): void {
const baseType = arrayType.slice(0, -2);
const elementType =
baseType === 'string' || baseType === 'number' || baseType === 'boolean' ? baseType : 'string';
const validator = createElementValidator(elementType);
assertParamIsArray(parameterName, value, validator, node);
}
function assertParamIsPrimitive(
parameterName: string,
value: unknown,
type: string,
node: INode,
): void {
assertUserInput(
typeof value === type,
`Parameter "${parameterName}" is not a valid ${type}`,
node,
);
}
function validateParameterType(
parameterName: string,
value: unknown,
type: ParameterType,
node: INode,
): boolean {
try {
if (type === 'resource-locator') {
assertIsResourceLocator(parameterName, value, node);
} else if (type === 'object') {
assertParamIsObject(parameterName, value, node);
} else if (type.endsWith('[]')) {
assertParamIsArrayOfType(parameterName, value, type, node);
} else {
assertParamIsPrimitive(parameterName, value, type, node);
}
return true;
} catch {
return false;
}
}
function validateParameterAgainstTypes(
parameterName: string,
value: unknown,
types: ParameterType[],
node: INode,
): void {
let isValid = false;
for (const type of types) {
if (validateParameterType(parameterName, value, type, node)) {
isValid = true;
break;
}
}
if (!isValid) {
const typeList = types.join(' or ');
assertUserInput(
false,
`Parameter "${parameterName}" does not match any of the expected types: ${typeList}`,
node,
);
}
}
type InferParameterType<T extends ParameterType | ParameterType[]> = T extends ParameterType[]
? InferSingleParameterType<T[number]>
: T extends ParameterType
? InferSingleParameterType<T>
: never;
type InferSingleParameterType<T extends ParameterType> = T extends 'string'
? string
: T extends 'boolean'
? boolean
: T extends 'number'
? number
: T extends 'resource-locator'
? Record<string, unknown>
: T extends 'string[]'
? string[]
: T extends 'number[]'
? number[]
: T extends 'boolean[]'
? boolean[]
: T extends 'object'
? Record<string, unknown>
: unknown;
export function validateNodeParameters<
T extends Record<string, { type: ParameterType | ParameterType[]; required?: boolean }>,
>(
value: unknown,
parameters: T,
node: INode,
): asserts value is {
[K in keyof T]: T[K]['required'] extends true
? InferParameterType<T[K]['type']>
: InferParameterType<T[K]['type']> | undefined;
} {
assertIsValidObject(value, node);
Object.keys(parameters).forEach((key) => {
const param = parameters[key];
const paramValue = value[key];
assertIsRequiredParameter(key, paramValue, param.required ?? false, node);
// If required, value cannot be undefined and must be validated
// If not required, value can be undefined but should be validated when present
if (param.required || paramValue !== undefined) {
const types = Array.isArray(param.type) ? param.type : [param.type];
validateParameterAgainstTypes(key, paramValue, types, node);
}
});
}
@@ -0,0 +1,24 @@
/**
* Resolve relative paths starting in & in the context of a given full path including parameters,
* which will be dropped in the process.
* If `candidateRelativePath` is not relative, it is returned unchanged.
*
* `parameters.a.b.c`, `&d` -> `a.b.d`
* `parameters.a.b[0].c`, `&d` -> `a.b[0].d`
* `parameters.a.b.c`, `d` -> `d`
*/
export function resolveRelativePath(
fullPathWithParameters: string,
candidateRelativePath: string,
): string {
if (candidateRelativePath.startsWith('&')) {
const resolvedLeaf = candidateRelativePath.slice(1);
const pathToLeaf = fullPathWithParameters.split('.').slice(1, -1).join('.');
if (!pathToLeaf) return resolvedLeaf;
return `${pathToLeaf}.${resolvedLeaf}`;
}
return candidateRelativePath;
}
@@ -0,0 +1,29 @@
import type { INode, NodeParameterValueType } from '../interfaces';
export function renameFormFields(
node: INode,
renameField: (v: NodeParameterValueType) => NodeParameterValueType,
): void {
const formFields = node.parameters?.formFields;
const values =
formFields &&
typeof formFields === 'object' &&
'values' in formFields &&
typeof formFields.values === 'object' &&
// TypeScript thinks this is `Array.values` and gets very confused here
// eslint-disable-next-line @typescript-eslint/unbound-method
Array.isArray(formFields.values)
? // eslint-disable-next-line @typescript-eslint/unbound-method
(formFields.values ?? [])
: [];
for (const formFieldValue of values) {
if (!formFieldValue || typeof formFieldValue !== 'object') continue;
if ('fieldType' in formFieldValue && formFieldValue.fieldType === 'html') {
if ('html' in formFieldValue) {
formFieldValue.html = renameField(formFieldValue.html);
}
}
}
}
@@ -0,0 +1,643 @@
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import isEqual from 'lodash/isEqual';
import mapValues from 'lodash/mapValues';
import { OperationalError } from './errors';
import type { INode, INodeParameters, NodeParameterValueType } from './interfaces';
class LazyRegExp {
private regExp?: RegExp;
constructor(
private pattern: () => string,
private flags?: string,
) {}
get(): RegExp {
if (!this.regExp) this.regExp = new RegExp(this.pattern(), this.flags);
return this.regExp;
}
}
type ExpressionMapping = {
nodeNameInExpression: null | string; // 'abc';
originalExpression: string; // "$('abc').first().def.ghi";
replacementPrefix: string; // "$('Start').first()";
replacementName: string; // "def_ghi";
};
type ParameterMapping<T> = undefined | T[] | { [key: PropertyKey]: ParameterMapping<T> };
type ParameterExtractMapping = ParameterMapping<ExpressionMapping>;
const DOT_REFERENCEABLE_JS_VARIABLE = /\w[\w\d_\$]*/;
const INVALID_JS_DOT_PATH = /[^\.\w\d_\$]/;
const INVALID_JS_DOT_NAME = /[^\w\d_\$]/;
// These are the keys that are followed by one of DATA_ACCESSORS
const ITEM_TO_DATA_ACCESSORS = [
/^first\(\)/,
/^last\(\)/,
/^all\(\)/,
// The order here is relevant because `item` would match occurrences of `itemMatching`
/^itemMatching\(\d+\)/, // We only support trivial itemMatching arguments
/^item/,
];
const SPLIT_OUT_NODE_TYPE = 'n8n-nodes-base.splitOut';
// These we safely can convert to a normal argument
const ITEM_ACCESSORS = ['params', 'isExecuted'];
const DATA_ACCESSORS = ['json', 'binary'];
export function hasDotNotationBannedChar(nodeName: string) {
const DOT_NOTATION_BANNED_CHARS = /^(\d)|[\\ `!@#$%^&*()_+\-=[\]{};':"\\|,.<>?~]/g;
return DOT_NOTATION_BANNED_CHARS.test(nodeName);
}
export function backslashEscape(nodeName: string) {
const BACKSLASH_ESCAPABLE_CHARS = /[.*+?^${}()|[\]\\]/g;
return nodeName.replace(BACKSLASH_ESCAPABLE_CHARS, (char) => `\\${char}`);
}
export function dollarEscape(nodeName: string) {
return nodeName.replace(new RegExp('\\$', 'g'), '$$$$');
}
type AccessPattern = {
checkPattern: string;
replacePattern: (name: string) => string;
customCallback?: (expression: string, newName: string, escapedNewName: string) => string;
};
const ACCESS_PATTERNS: AccessPattern[] = [
{
checkPattern: '$(',
replacePattern: (s) => String.raw`(\$\(['"])${s}(['"]\))`,
},
{
checkPattern: '$node[',
replacePattern: (s) => String.raw`(\$node\[['"])${s}(['"]\])`,
},
{
checkPattern: '$node.',
replacePattern: (s) => String.raw`(\$node\.)${s}(\.?)`,
customCallback: (expression: string, newName: string, escapedNewName: string) => {
if (hasDotNotationBannedChar(newName)) {
const regex = new RegExp(`.${backslashEscape(newName)}( |\\.)`, 'g');
return expression.replace(regex, `["${escapedNewName}"]$1`);
}
return expression;
},
},
{
checkPattern: '$items(',
replacePattern: (s) => String.raw`(\$items\(['"])${s}(['"],|['"]\))`,
},
];
export function applyAccessPatterns(expression: string, previousName: string, newName: string) {
// To not run the "expensive" regex stuff when it is not needed
// make a simple check first if it really contains the node-name
if (!expression.includes(previousName)) return expression;
// Really contains node-name (even though we do not know yet if really as $node-expression)
const escapedOldName = backslashEscape(previousName); // for match
const escapedNewName = dollarEscape(newName); // for replacement
for (const pattern of ACCESS_PATTERNS) {
if (expression.includes(pattern.checkPattern)) {
expression = expression.replace(
new RegExp(pattern.replacePattern(escapedOldName), 'g'),
`$1${escapedNewName}$2`,
);
if (pattern.customCallback) {
expression = pattern.customCallback(expression, newName, escapedNewName);
}
}
}
return expression;
}
function convertToUniqueJsDotName(nodeName: string, allNodeNames: string[]) {
let jsLegal = nodeName
.replaceAll(' ', '_')
.split('')
.filter((x) => !INVALID_JS_DOT_NAME.test(x))
.join('');
if (nodeName === jsLegal) return jsLegal;
// This accounts for theoretical cases where we collide with other reduced names
// By adding our own index in the array we also avoid running into theoretical cases
// where a node with the name 'ourName_27' exists for our reduced name 'ourName'
// because we must have a different index, so therefore only one of us can be `ourName_27_27`
//
// The underscore prevents colliding e.g. index 1 with 11
while (allNodeNames.includes(jsLegal)) jsLegal += `_${allNodeNames.indexOf(nodeName)}`;
return jsLegal;
}
function convertDataAccessorName(name: string): string {
const [fnName, maybeDigits] = name.split('(');
switch (fnName.toLowerCase()) {
case 'item':
return fnName;
case 'first':
case 'last':
return `${fnName}Item`;
case 'all':
return `${fnName}Items`;
}
// use the digits without the )
return `${fnName}_${maybeDigits?.slice(0, -1) ?? 'unknown'}`;
}
function parseExpressionMapping(
isolatedExpression: string,
nodeNameInExpression: string | null,
nodeNamePlainJs: string | null,
startNodeName: string,
): ExpressionMapping | null {
const splitExpr = isolatedExpression.split('.');
// This supports literal . used in the node name
const dotsInName = nodeNameInExpression?.split('').filter((x) => x === '.').length ?? 0;
const dotInAccessorsOffset = isolatedExpression.startsWith('$node.') ? 1 : 0;
const exprStart = splitExpr.slice(0, dotInAccessorsOffset + dotsInName + 1).join('.');
const parts = splitExpr.slice(dotInAccessorsOffset + dotsInName + 1);
// The calling code is expected to only handle $json expressions for the root node
// As these are invalid conversions for inner nodes
if (exprStart === '$json') {
let partsIdx = 0;
for (; partsIdx < parts.length; ++partsIdx) {
if (!DOT_REFERENCEABLE_JS_VARIABLE.test(parts[partsIdx])) break;
}
return {
nodeNameInExpression: null,
originalExpression: `${exprStart}.${parts.slice(0, partsIdx + 1).join('.')}`, // $json.valid.until, but not ['x'] after
replacementPrefix: `${exprStart}`, // $json
replacementName: `${parts.slice(0, partsIdx).join('_')}`, // valid_until
};
}
if (parts.length === 0) {
// If a node is referenced by name without any accessor we return a proxy that stringifies as an empty object
// But it can still be validly passed to other functions
// However when passed to a sub-workflow it collapses into a true empty object
// So lets just abort porting this and don't touch it
return null;
}
// Handling `all()` is very awkward since we need to pass the value as a single parameter but
// can't do `$('Start').all() since it would be a different node's all
const accessorPrefix = parts[0] === 'all()' ? 'first()' : parts[0];
if (ITEM_TO_DATA_ACCESSORS.some((x) => parts[0].match(x))) {
if (parts.length === 1) {
// this case is a literal use of the return value of `$('nodeName').first()`
// Note that it's safe to rename to first, even if there is a variable of the same name
// since we resolve duplicate names later in the process
const originalName = parts[0];
return {
nodeNameInExpression,
originalExpression: `${exprStart}.${parts[0]}`, // $('abc').first()
replacementPrefix: `$('${startNodeName}').${accessorPrefix}.json`, // $('Start').first().json
replacementName: `${nodeNamePlainJs}_${convertDataAccessorName(originalName)}`, // nodeName_firstItem, nodeName_itemMatching_20
};
} else {
if (DATA_ACCESSORS.some((x) => parts[1] === x)) {
let partsIdx = 2;
for (; partsIdx < parts.length; ++partsIdx) {
if (!DOT_REFERENCEABLE_JS_VARIABLE.test(parts[partsIdx])) break;
}
// Use a separate name for anything except item to avoid users confusing their e.g. first() variables
const replacementPostfix =
parts[0] === 'item' ? '' : `_${convertDataAccessorName(parts[0])}`;
return {
nodeNameInExpression,
originalExpression: `${exprStart}.${parts.slice(0, partsIdx + 1).join('.')}`, // $('abc').item.json.valid.until, but not ['x'] after
replacementPrefix: `$('${startNodeName}').${accessorPrefix}.${parts[1]}`, // $('Start').item.json
replacementName: parts.slice(2, partsIdx).join('_') + replacementPostfix, // valid_until, or valid_until_firstItem
};
} else {
// this case covers any normal ObjectExtensions functions called on the ITEM_TO_DATA_ACCESSORS entry
// e.g. $('nodeName').first().toJsonObject().randomJSFunction() or $('nodeName').all().map(x => ({...x, a: 3 }))
return {
nodeNameInExpression,
originalExpression: `${exprStart}.${parts[0]}`, // $('abc').first()
replacementPrefix: `$('${startNodeName}').${accessorPrefix}.json`, // $('Start').first().json.
replacementName: `${nodeNamePlainJs}_${convertDataAccessorName(parts[0])}`, // nodeName_firstItem
};
}
}
}
// This covers specific metadata functions available on nodes
const itemAccessorMatch = ITEM_ACCESSORS.flatMap((x) => (x === parts[0] ? x : []))[0];
if (itemAccessorMatch !== undefined) {
return {
nodeNameInExpression,
originalExpression: `${exprStart}.${parts[0]}`, // $('abc').isExecuted
replacementPrefix: `$('${startNodeName}').first().json`, // $('Start').first()
replacementName: `${nodeNamePlainJs}_${parts[0]}`, // nodeName_isExecuted
};
}
// If we end up here it means that:
// - we have a complex `itemMatching(<expr>)` case, or
// - the expression should be invalid, or
// - a new function was added that we're not aware of.
//
// In these cases let's just not touch it and keep it as is
return null;
}
// find `$('NodeName').item.json.path.to.x` in `{{ $('NodeName').item.json.path.to.x[someFunction()] }}`
function extractExpressionCandidate(expression: string, startIndex: number, endIndex: number) {
const firstPartException = ITEM_TO_DATA_ACCESSORS.map((x) =>
x.exec(expression.slice(endIndex)),
).filter((x) => x !== null);
// Note that by choosing match 0 we use `itemMatching` matches over `item`
// matches by relying on the order in ITEM_TO_DATA_ACCESSORS
let after_accessor_idx = endIndex + (firstPartException[0]?.[0].length ?? -1);
// skip `.` to continue, but halt before other symbols like `[` in `all()[0]`
if (expression[after_accessor_idx + 1] === '.') after_accessor_idx += 1;
const after_accessor = expression.slice(after_accessor_idx);
const firstInvalidCharMatch = INVALID_JS_DOT_PATH.exec(after_accessor);
// we should at least find the }} closing the JS expressions in valid cases
if (!firstInvalidCharMatch) return null;
return expression.slice(startIndex, after_accessor_idx + firstInvalidCharMatch.index);
}
// Parse a given regex accessor match (e.g. `$('nodeName')`, `$node['nodeName']`)
// and extract a potential ExpressionMapping
function parseCandidateMatch(
match: RegExpExecArray,
expression: string,
nodeNames: string[],
startNodeName: string,
): ExpressionMapping | null {
const startIndex = match.index;
const endIndex = startIndex + match[0].length + 1;
// this works because all access patterns define match groups
// [fullMatch, "$('", "nodeName", "')"]
const nodeNameInExpression = match[2];
// This should be invalid in theory, since the regex matches should only act
// on known node names
if (!nodeNames.includes(nodeNameInExpression)) return null;
const candidate = extractExpressionCandidate(expression, startIndex, endIndex);
if (candidate === null) return null;
return parseExpressionMapping(
candidate,
nodeNameInExpression,
convertToUniqueJsDotName(nodeNameInExpression, nodeNames),
startNodeName,
);
}
// Handle matches of form `$json.path.to.value`, which is necessary for the selection input node
function parse$jsonMatch(match: RegExpExecArray, expression: string, startNodeName: string) {
const candidate = extractExpressionCandidate(
expression,
match.index,
match.index + match[0].length + 1,
);
if (candidate === null) return null;
return parseExpressionMapping(candidate, null, null, startNodeName);
}
// Parse all references to other nodes in `expression` and return them as `ExpressionMappings`
function parseReferencingExpressions(
expression: string,
nodeRegexps: Array<readonly [string, LazyRegExp]>,
nodeNames: string[],
startNodeName: string,
parse$json: boolean,
): ExpressionMapping[] {
const result: ExpressionMapping[] = [];
for (const [pattern, regexp] of nodeRegexps) {
if (!expression.includes(pattern)) continue;
const matches = [...expression.matchAll(regexp.get())];
result.push(
...matches
.map((x) => parseCandidateMatch(x, expression, nodeNames, startNodeName))
.filter((x) => x !== null),
);
}
if (parse$json && expression.includes('$json')) {
for (const match of expression.matchAll(/\$json/gi)) {
const res = parse$jsonMatch(match, expression, startNodeName);
if (res) result.push(res);
}
}
return result;
}
// Recursively apply `mapper` to all expressions in `parameterValue`
function applyParameterMapping(
parameterValue: NodeParameterValueType,
mapper: (s: string) => ExpressionMapping[],
keyOfValue?: string,
): [ParameterExtractMapping, ExpressionMapping[]] {
const result: ParameterExtractMapping = {};
if (typeof parameterValue !== 'object' || parameterValue === null) {
if (
typeof parameterValue === 'string' &&
(parameterValue.charAt(0) === '=' || keyOfValue === 'jsCode')
) {
const mapping = mapper(parameterValue);
return [mapping, mapping];
}
return [undefined, []];
}
const allMappings = [];
for (const [key, value] of Object.entries(parameterValue)) {
const [mapping, all] = applyParameterMapping(value as NodeParameterValueType, mapper, key);
result[key] = mapping;
allMappings.push(...all);
}
return [result, allMappings];
}
// Ensure all expressions have a unique variable name
function resolveDuplicates(data: ExpressionMapping[], allNodeNames: string[]) {
// Map from candidate variableName to its expressionData
const triggerArgumentMap = new Map<string, ExpressionMapping>();
const originalExpressionMap = new Map<string, string>();
for (const mapping of data) {
const { nodeNameInExpression, originalExpression, replacementPrefix } = mapping;
let { replacementName } = mapping;
const hasKeyAndCollides = (key: string) => {
const value = triggerArgumentMap.get(key);
if (!value) return false;
return !isEqual(value, mapping);
};
// We need both parts in the key as we may need to pass e.g. `.first()` and `.item` separately
// Since we cannot pass the node itself as its proxy reduces it to an empty object
const key = () => `${replacementPrefix}.${replacementName}`;
// This covers a realistic case where two nodes have the same path, e.g.
// $('original input').item.json.path.to.url
// $('some time later in the workflow').item.json.path.to.url
if (hasKeyAndCollides(key()) && nodeNameInExpression) {
replacementName = `${convertToUniqueJsDotName(nodeNameInExpression, allNodeNames)}_${replacementName}`;
}
// This covers all other theoretical cases, like where `${nodeName}_${variable}` might clash with another variable name
while (hasKeyAndCollides(key())) replacementName += '_1';
triggerArgumentMap.set(key(), {
originalExpression,
nodeNameInExpression,
replacementName,
replacementPrefix,
});
originalExpressionMap.set(originalExpression, key());
}
return {
triggerArgumentMap,
originalExpressionMap,
};
}
// Recursively loop through the nodeProperties and apply `parameterExtractMapping` where defined
function applyExtractMappingToNode(node: INode, parameterExtractMapping: ParameterExtractMapping) {
const usedMappings: ExpressionMapping[] = [];
const applyMapping = (
parameters: NodeParameterValueType,
mapping: ParameterExtractMapping,
): NodeParameterValueType => {
if (!mapping) return parameters;
if (typeof parameters !== 'object' || parameters === null) {
if (Array.isArray(mapping) && typeof parameters === 'string') {
for (const mapper of mapping) {
if (!parameters.includes(mapper.originalExpression)) continue;
parameters = parameters.replaceAll(
mapper.originalExpression,
`${mapper.replacementPrefix}.${mapper.replacementName}`,
);
usedMappings.push(mapper);
}
}
return parameters;
}
// This should be an invalid state, though an explicit check makes typings easier
if (Array.isArray(mapping)) {
return parameters;
}
if (Array.isArray(parameters) && typeof mapping === 'object' && !Array.isArray(mapping)) {
return parameters.map((x, i) => applyMapping(x, mapping[i]) as INodeParameters);
}
return mapValues(parameters, (v, k) => applyMapping(v, mapping[k])) as NodeParameterValueType;
};
const parameters = applyMapping(node.parameters, parameterExtractMapping);
return { result: { ...node, parameters } as INode, usedMappings };
}
// Recursively find the finalized mapping for provisional mappings
function applyCanonicalMapping(
mapping: ParameterExtractMapping,
getCanonicalData: (m: ExpressionMapping) => ExpressionMapping | undefined,
): ParameterExtractMapping {
if (!mapping) return;
if (Array.isArray(mapping)) {
// Sort by longest so that we don't accidentally replace part of a longer expression
return mapping
.map(getCanonicalData)
.filter((x) => x !== undefined)
.sort((a, b) => b.originalExpression.length - a.originalExpression.length);
}
return mapValues(mapping, (v) => applyCanonicalMapping(v, getCanonicalData));
}
/**
* Extracts references to nodes in `nodeNames` from the nodes in `subGraph`.
*
* @returns an object with two keys:
* - nodes: Transformed copies of nodes in `subGraph`, ready for use in a sub-workflow
* - variables: A map from variable name in the sub-workflow to the replaced expression
*
* @throws if the startNodeName already exists in `nodeNames`
* @throws if `nodeNames` does not include all node names in `subGraph`
*/
export function extractReferencesInNodeExpressions(
subGraph: INode[],
nodeNames: string[],
insertedStartName: string,
graphInputNodeNames?: string[],
) {
const [start] = graphInputNodeNames ?? [];
////
// STEP 1 - Validate input invariants
////
const subGraphNames = subGraph.map((x) => x.name);
if (subGraphNames.includes(insertedStartName))
throw new OperationalError(
`StartNodeName ${insertedStartName} already exists in nodeNames: ${JSON.stringify(subGraphNames)}`,
);
if (subGraphNames.some((x) => !nodeNames.includes(x))) {
throw new OperationalError(
`extractReferencesInNodeExpressions called with node in subGraph ${JSON.stringify(subGraphNames)} whose name is not in provided 'nodeNames' list ${JSON.stringify(nodeNames)}.`,
);
}
////
// STEP 2 - Compile all candidate regexp patterns
////
// This looks scary for large workflows, but RegExp should support >1 million characters and
// it's a very linear pattern.
const namesRegexp = '(' + nodeNames.map(escapeRegExp).join('|') + ')';
const nodeRegexps = ACCESS_PATTERNS.map(
(pattern) =>
[
pattern.checkPattern,
// avoid compiling the expensive regex for rare legacy ways of accessing nodes
new LazyRegExp(() => pattern.replacePattern(namesRegexp), 'g'),
] as const,
);
////
// STEP 3 - Parse expressions used in parameters and build mappings
////
// This map is used to change the actual expressions once resolved
// The value represents fields in the actual parameters object which require change
const parameterTreeMappingByNode = new Map<string, ParameterExtractMapping>();
// This is used to track all candidates for change, necessary for deduplication
const allData = [];
// Additional mappings that should contribute to sub-workflow inputs (e.g. Split Out 'fieldToSplitOut')
const extraVariableCandidates: ExpressionMapping[] = [];
for (const node of subGraph) {
const [parameterMapping, allMappings] = applyParameterMapping(node.parameters, (s) =>
parseReferencingExpressions(
s,
nodeRegexps,
nodeNames,
insertedStartName,
graphInputNodeNames?.includes(node.name) ?? false,
),
);
parameterTreeMappingByNode.set(node.name, parameterMapping);
allData.push(...allMappings);
if (node.name === start && node.type === SPLIT_OUT_NODE_TYPE) {
const raw = node.parameters?.fieldToSplitOut;
if (typeof raw === 'string' && raw.trim() !== '') {
const trimmed = raw.trim();
const isExpression = trimmed.startsWith('=');
// Expressions in Split Out 'fieldToSplitOut' parameters are not supported,
// as they define the fields to split out only at execution time.
if (isExpression) {
throw new OperationalError(
`Extracting sub-workflow from Split Out node with 'fieldToSplitOut' parameter having expression "${trimmed}" is not supported.`,
);
}
// Parameter value is a CSV of fields to split out.
// Create synthetic $json expressions for each field
const fields = isExpression
? [trimmed]
: trimmed.split(',').map((field) => `={{$json.${field.trim()}}}`);
for (const expression of fields) {
const mappingsFromField = parseReferencingExpressions(
expression,
nodeRegexps,
nodeNames,
insertedStartName,
graphInputNodeNames?.includes(node.name) ?? false,
);
extraVariableCandidates.push(...mappingsFromField);
}
}
}
}
////
// STEP 4 - Filter out nodes in subGraph and handle name clashes
////
const subGraphNodeNames = new Set(subGraphNames);
const dataFromOutsideSubgraph = [...allData, ...extraVariableCandidates].filter(
// `nodeNameInExpression` being absent implies direct access via `$json` or `$binary`
(x) => !x.nodeNameInExpression || !subGraphNodeNames.has(x.nodeNameInExpression),
);
const { originalExpressionMap, triggerArgumentMap } = resolveDuplicates(
dataFromOutsideSubgraph,
nodeNames,
);
////
// STEP 5 - Apply canonical mappings to nodes and track created variables
////
// triggerArgumentMap[originalExpressionMap[originalExpression]] returns its canonical object
// These should never be undefined at this stage
const getCanonicalData = (e: ExpressionMapping) => {
const key = originalExpressionMap.get(e.originalExpression);
if (!key) return undefined;
return triggerArgumentMap.get(key);
};
for (const [key, value] of parameterTreeMappingByNode.entries()) {
parameterTreeMappingByNode.set(key, applyCanonicalMapping(value, getCanonicalData));
}
const allUsedMappings = [];
const output = [];
for (const node of subGraph) {
const { result, usedMappings } = applyExtractMappingToNode(
cloneDeep(node),
parameterTreeMappingByNode.get(node.name),
);
allUsedMappings.push(...usedMappings);
output.push(result);
}
for (const candidate of extraVariableCandidates) {
const key = originalExpressionMap.get(candidate.originalExpression);
if (!key) continue;
const canonical = triggerArgumentMap.get(key);
if (!canonical) continue;
if (!allUsedMappings.some((u) => u.replacementName === canonical.replacementName)) {
allUsedMappings.push(canonical);
}
}
const variables = new Map(allUsedMappings.map((m) => [m.replacementName, m.originalExpression]));
return { nodes: output, variables };
}
+88
View File
@@ -0,0 +1,88 @@
import type { INode, INodeType, IConnections } from './interfaces';
import { displayParameter } from './node-helpers';
export interface NodeValidationIssue {
credential?: string;
parameter?: string;
}
export interface NodeCredentialIssue {
type: 'missing' | 'not-configured';
displayName: string;
credentialName: string;
}
/**
* Validates that all required credentials are set for a node.
* Respects displayOptions to only validate credentials that should be shown.
*/
export function validateNodeCredentials(node: INode, nodeType: INodeType): NodeCredentialIssue[] {
const issues: NodeCredentialIssue[] = [];
const credentialDescriptions = nodeType.description?.credentials || [];
for (const credDesc of credentialDescriptions) {
if (!credDesc.required) continue;
// Check if this credential should be displayed based on displayOptions
const shouldDisplay = displayParameter(node.parameters, credDesc, node, nodeType.description);
if (!shouldDisplay) continue;
const credentialName = credDesc.name;
const nodeCredential = node.credentials?.[credentialName];
const displayName = credDesc.displayName ?? credentialName;
if (!nodeCredential) {
issues.push({
type: 'missing',
displayName,
credentialName,
});
continue;
}
if (!nodeCredential.id) {
issues.push({
type: 'not-configured',
displayName,
credentialName,
});
}
}
return issues;
}
/**
* Checks if a node has any incoming or outgoing connections.
*/
export function isNodeConnected(
nodeName: string,
connections: IConnections,
connectionsByDestination: IConnections,
): boolean {
// Check outgoing connections
if (connections[nodeName] && Object.keys(connections[nodeName]).length > 0) {
return true;
}
// Check incoming connections
if (
connectionsByDestination[nodeName] &&
Object.keys(connectionsByDestination[nodeName]).length > 0
) {
return true;
}
return false;
}
/**
* Checks if a node type is a trigger-like node (trigger, webhook, or poll).
* These nodes are workflow entry points and should always be validated.
*/
export function isTriggerLikeNode(nodeType: INodeType): boolean {
return (
nodeType.trigger !== undefined || nodeType.webhook !== undefined || nodeType.poll !== undefined
);
}
@@ -0,0 +1,75 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import type { IDataObject, IObservableObject } from './interfaces';
interface IObservableOptions {
ignoreEmptyOnFirstChild?: boolean;
}
export function create(
target: IDataObject,
parent?: IObservableObject,
option?: IObservableOptions,
depth?: number,
): IDataObject {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
depth = depth || 0;
// Make all the children of target also observable
for (const key in target) {
if (typeof target[key] === 'object' && target[key] !== null) {
target[key] = create(
target[key] as IDataObject,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
(parent || target) as IObservableObject,
option,
depth + 1,
);
}
}
Object.defineProperty(target, '__dataChanged', {
value: false,
writable: true,
});
return new Proxy(target, {
deleteProperty(target, name) {
if (parent === undefined) {
// If no parent is given mark current data as changed
(target as IObservableObject).__dataChanged = true;
} else {
// If parent is given mark the parent data as changed
parent.__dataChanged = true;
}
return Reflect.deleteProperty(target, name);
},
get(target, name, receiver) {
return Reflect.get(target, name, receiver);
},
has(target, key) {
return Reflect.has(target, key);
},
set(target, name, value) {
if (parent === undefined) {
// If no parent is given mark current data as changed
if (
option !== undefined &&
option.ignoreEmptyOnFirstChild === true &&
depth === 0 &&
target[name.toString()] === undefined &&
typeof value === 'object' &&
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
Object.keys(value).length === 0
) {
} else {
(target as IObservableObject).__dataChanged = true;
}
} else {
// If parent is given mark the parent data as changed
parent.__dataChanged = true;
}
return Reflect.set(target, name, value);
},
});
}
+30
View File
@@ -0,0 +1,30 @@
import { ensureError } from './errors';
export type ResultOk<T> = { ok: true; result: T };
export type ResultError<E> = { ok: false; error: E };
export type Result<T, E> = ResultOk<T> | ResultError<E>;
export const createResultOk = <T>(data: T): ResultOk<T> => ({
ok: true,
result: data,
});
export const createResultError = <E = unknown>(error: E): ResultError<E> => ({
ok: false,
error,
});
/**
* Executes the given function and converts it to a Result object.
*
* @example
* const result = toResult(() => fs.writeFileSync('file.txt', 'Hello, World!'));
*/
export const toResult = <T, E extends Error = Error>(fn: () => T): Result<T, E> => {
try {
return createResultOk<T>(fn());
} catch (e) {
const error = ensureError(e);
return createResultError<E>(error as E);
}
};
@@ -0,0 +1,160 @@
import type { IExecutionContext } from './execution-context';
import type {
IRunData,
IPinData,
IExecuteContextData,
IExecuteData,
ITaskMetadata,
IWaitingForExecution,
IWaitingForExecutionSource,
StartNodeData,
ExecutionError,
RelatedExecution,
INode,
} from './interfaces';
import type { IRunExecutionData } from './run-execution-data/run-execution-data';
import type { IRunExecutionDataV1 } from './run-execution-data/run-execution-data.v1';
export interface CreateFullRunExecutionDataOptions {
startData?: {
startNodes?: StartNodeData[];
destinationNode?: NonNullable<IRunExecutionData['startData']>['destinationNode'];
originalDestinationNode?: NonNullable<
IRunExecutionData['startData']
>['originalDestinationNode'];
runNodeFilter?: string[];
};
resultData?: {
error?: ExecutionError;
runData?: IRunData | null;
pinData?: IPinData;
lastNodeExecuted?: string;
metadata?: Record<string, string>;
};
executionData?: {
contextData?: IExecuteContextData;
nodeExecutionStack?: IExecuteData[];
metadata?: Record<string, ITaskMetadata[]>;
waitingExecution?: IWaitingForExecution;
waitingExecutionSource?: IWaitingForExecutionSource | null;
runtimeData?: IExecutionContext;
} | null;
parentExecution?: RelatedExecution;
validateSignature?: boolean;
waitTill?: Date;
manualData?: IRunExecutionData['manualData'];
pushRef?: IRunExecutionData['pushRef'];
}
/**
* Creates a complete IRunExecutionData object with all properties initialized.
* You can pass `executionData: null` and `resultData.runData: null` if you
* don't want them initialized.
*/
export function createRunExecutionData(
options: CreateFullRunExecutionDataOptions = {},
): IRunExecutionData {
return {
version: 1,
startData: options.startData ?? {},
resultData: {
error: options.resultData?.error,
// @ts-expect-error CAT-752
runData:
options.resultData?.runData === null ? undefined : (options.resultData?.runData ?? {}),
pinData: options.resultData?.pinData,
lastNodeExecuted: options.resultData?.lastNodeExecuted,
metadata: options.resultData?.metadata,
},
executionData:
options.executionData === null
? undefined
: {
contextData: options.executionData?.contextData ?? {},
nodeExecutionStack: options.executionData?.nodeExecutionStack ?? [],
metadata: options.executionData?.metadata ?? {},
waitingExecution: options.executionData?.waitingExecution ?? {},
waitingExecutionSource: options.executionData?.waitingExecutionSource ?? {},
runtimeData: options.executionData?.runtimeData,
},
parentExecution: options.parentExecution,
validateSignature: options.validateSignature,
waitTill: options.waitTill,
manualData: options.manualData,
pushRef: options.pushRef,
} satisfies IRunExecutionDataV1 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type.
}
/**
* Creates a minimal IRunExecutionData object. It only contains an empty
* `runData` field. Used when we are not actually executing a workflow, but
* need the run data. E.g. in expression evaluations.
*/
export function createEmptyRunExecutionData(): IRunExecutionData {
return {
version: 1,
resultData: {
runData: {},
},
} satisfies IRunExecutionDataV1 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type.
}
/**
* Creates an IRunExecutionData object for error execution scenarios.
* Used when creating execution records for failed nodes with specific
* error data and execution context.
*
* @param node - The node that failed.
* @param error - The error that occurred.
*/
export function createErrorExecutionData(node: INode, error: ExecutionError): IRunExecutionData {
return {
version: 1,
startData: {
destinationNode: {
nodeName: node.name,
mode: 'inclusive',
},
runNodeFilter: [node.name],
},
executionData: {
contextData: {},
metadata: {},
nodeExecutionStack: [
{
node,
data: {
main: [
[
{
json: {},
pairedItem: {
item: 0,
},
},
],
],
},
source: null,
},
],
waitingExecution: {},
waitingExecutionSource: {},
},
resultData: {
runData: {
[node.name]: [
{
startTime: 0,
executionIndex: 0,
executionTime: 0,
error,
source: [],
},
],
},
error,
lastNodeExecuted: node.name,
},
} satisfies IRunExecutionDataV1 as unknown as IRunExecutionData; // NOTE: we cast to unknown to avoid manual construction of branded type.
}
@@ -0,0 +1,44 @@
/**
* Contains all the data which is needed to execute a workflow and so also to
* restart it again if it fails.
* RunData, ExecuteData and WaitForExecution contain often the same data.
*
*/
import type { IRunExecutionDataV0 } from './run-execution-data.v0';
import { runExecutionDataV0ToV1, type IRunExecutionDataV1 } from './run-execution-data.v1';
export type { RedactionInfo } from './run-execution-data.v1';
/**
* All the versions of the interface.
* !!! Only used at the data access layer to handle records saved under older versions. !!!
* !!! All other code should use the current version, below. !!!
*/
export type IRunExecutionDataAll = IRunExecutionDataV0 | IRunExecutionDataV1;
const __brand = Symbol('brand');
/**
* Current version of IRunExecutionData.
*/
export type IRunExecutionData = IRunExecutionDataV1 & {
[__brand]: 'Use createRunExecutionData factory instead of constructing manually';
};
export function migrateRunExecutionData(data: IRunExecutionDataAll): IRunExecutionData {
switch (data.version) {
case 0:
case undefined: // Missing version means version 0
data = runExecutionDataV0ToV1(data);
// Fall through to subsequent versions as they're added.
}
if (data.version !== 1) {
throw new Error(
`Unsupported IRunExecutionData version: ${(data as { version?: number }).version}`,
);
}
return data as IRunExecutionData;
}
@@ -0,0 +1,56 @@
import type {
ExecutionError,
IExecuteContextData,
IExecuteData,
IExecutionContext,
IPinData,
IRunData,
ITaskMetadata,
IWaitingForExecution,
IWaitingForExecutionSource,
IWorkflowExecutionDataProcess,
RelatedExecution,
StartNodeData,
} from '..';
export interface IRunExecutionDataV0 {
version?: 0; // Missing version means version 0
startData?: {
startNodes?: StartNodeData[];
destinationNode?: string;
originalDestinationNode?: string;
runNodeFilter?: string[];
};
resultData: {
error?: ExecutionError;
runData: IRunData;
pinData?: IPinData;
lastNodeExecuted?: string;
metadata?: Record<string, string>;
};
executionData?: {
contextData: IExecuteContextData;
runtimeData?: IExecutionContext;
nodeExecutionStack: IExecuteData[];
metadata: {
// node-name: metadata by runIndex
[key: string]: ITaskMetadata[];
};
waitingExecution: IWaitingForExecution;
waitingExecutionSource: IWaitingForExecutionSource | null;
};
parentExecution?: RelatedExecution;
/**
* This is used to prevent breaking change
* for waiting executions started before signature validation was added
*/
validateSignature?: boolean;
waitTill?: Date;
pushRef?: string;
/** Data needed for a worker to run a manual execution. */
manualData?: Pick<
IWorkflowExecutionDataProcess,
'dirtyNodeNames' | 'triggerToStartFrom' | 'userId'
>;
}
@@ -0,0 +1,93 @@
import type {
ExecutionError,
IDestinationNode,
IExecuteContextData,
IExecuteData,
IExecutionContext,
IPinData,
IRunData,
ITaskMetadata,
IWaitingForExecution,
IWaitingForExecutionSource,
IWorkflowExecutionDataProcess,
RelatedExecution,
StartNodeData,
} from '..';
import type { IRunExecutionDataV0 } from './run-execution-data.v0';
export interface RedactionInfo {
isRedacted: boolean;
reason: string;
canReveal: boolean;
}
// DIFF: switches startData.destinationNode to a structured object, rather than just the name of the string.
export interface IRunExecutionDataV1 {
version: 1;
startData?: {
startNodes?: StartNodeData[];
destinationNode?: IDestinationNode;
originalDestinationNode?: IDestinationNode;
runNodeFilter?: string[];
};
resultData: {
error?: ExecutionError;
runData: IRunData;
pinData?: IPinData;
lastNodeExecuted?: string;
metadata?: Record<string, string>;
};
executionData?: {
contextData: IExecuteContextData;
runtimeData?: IExecutionContext;
nodeExecutionStack: IExecuteData[];
metadata: {
// node-name: metadata by runIndex
[key: string]: ITaskMetadata[];
};
waitingExecution: IWaitingForExecution;
waitingExecutionSource: IWaitingForExecutionSource | null;
};
parentExecution?: RelatedExecution;
/**
* This is used to prevent breaking change
* for waiting executions started before signature validation was added
*/
validateSignature?: boolean;
waitTill?: Date;
pushRef?: string;
/** Data needed for a worker to run a manual execution. */
manualData?: Pick<
IWorkflowExecutionDataProcess,
'dirtyNodeNames' | 'triggerToStartFrom' | 'userId'
>;
/** Metadata about whether and how this execution's data was redacted. */
redactionInfo?: RedactionInfo;
}
export function runExecutionDataV0ToV1(data: IRunExecutionDataV0): IRunExecutionDataV1 {
const destinationNodeV0 = data.startData?.destinationNode;
const originalDestinationNodeV0 = data.startData?.originalDestinationNode;
return {
...data,
version: 1,
startData: {
...data.startData,
destinationNode: destinationNodeV0
? {
nodeName: destinationNodeV0,
mode: 'inclusive',
}
: undefined,
originalDestinationNode: originalDestinationNodeV0
? {
nodeName: originalDestinationNodeV0,
mode: 'inclusive',
}
: undefined,
},
};
}
+499
View File
@@ -0,0 +1,499 @@
import type {
AssignmentCollectionValue,
FilterValue,
IconOrEmoji,
INodeCredentials,
INodeCredentialsDetails,
INodeParameterResourceLocator,
INodeParameters,
NodeConnectionType,
NodeParameterValueType,
OnError,
ResourceMapperValue,
ResourceMapperField,
FieldType,
INodePropertyOptions,
IDisplayOptions,
INodePropertyRouting,
FilterOptionsValue,
FilterOperatorValue,
FilterConditionValue,
FilterOperatorType,
AssignmentValue,
NodeParameterValue,
DisplayCondition,
INode,
IN8nRequestOperations,
IN8nRequestOperationPaginationGeneric,
IDataObject,
IN8nRequestOperationPaginationOffset,
IExecutePaginationFunctions,
DeclarativeRestApiSettings,
INodeExecutionData,
INodeRequestOutput,
PostReceiveAction,
IPostReceiveBinaryData,
IExecuteSingleFunctions,
IN8nHttpFullResponse,
IPostReceiveFilter,
IPostReceiveLimit,
IPostReceiveRootProperty,
IPostReceiveSet,
IPostReceiveSetKeyValue,
IPostReceiveSort,
IHttpRequestOptions,
INodeRequestSend,
GenericValue,
} from './interfaces';
import { z } from 'zod';
export const INodeParameterResourceLocatorSchema: z.ZodType<INodeParameterResourceLocator> =
z.object({
__rl: z.literal(true),
mode: z.string(),
value: z.union([z.string(), z.number(), z.null()]),
cachedResultName: z.string().optional(),
cachedResultUrl: z.string().optional(),
__regex: z.string().optional(),
});
const NodeParameterValueSchema: z.ZodType<NodeParameterValue> = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.undefined(),
]);
const RequiredNodeParameterValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
export const FieldTypeSchema: z.ZodType<FieldType> = z.enum([
'boolean',
'number',
'string',
'string-alphanumeric',
'dateTime',
'time',
'array',
'object',
'options',
'url',
'jwt',
'form-fields',
]);
// For `object` in GenericValue's type definition.
// We should probably look into not using `object` there,
// it's unclear whether functions are really expected.
const ObjectLikeSchema = z.custom<object>(
(v) => (typeof v === 'object' && v !== null) || typeof v === 'function',
{ message: 'Expected a non-primitive object' },
);
export const GenericValueSchema: z.ZodType<GenericValue> = z.union([
z.string(),
z.number(),
z.boolean(),
z.undefined(),
z.null(),
ObjectLikeSchema,
]);
export const IDataObjectSchema: z.ZodType<IDataObject> = z.lazy(() =>
z.record(
z.string(),
z.union([
GenericValueSchema,
z.array(GenericValueSchema),
IDataObjectSchema,
z.array(IDataObjectSchema),
]),
),
);
export const IRequestOptionsSimplifiedAuthSchema = z.object({
auth: z
.object({
username: z.string(),
password: z.string(),
sendImmediately: z.boolean().optional(),
})
.optional(),
body: z.object({}).optional(),
headers: IDataObjectSchema.optional(),
qs: IDataObjectSchema.optional(),
url: z.string().optional(),
skipSslCertificateValidation: z.union([z.boolean(), z.string()]).optional(),
});
export const IN8nRequestOperationPaginationBaseSchema = z.object({
type: z.string(),
properties: z.record(z.string(), z.unknown()),
});
export const IN8nRequestOperationPaginationGenericSchema: z.ZodType<IN8nRequestOperationPaginationGeneric> =
IN8nRequestOperationPaginationBaseSchema.extend({
type: z.literal('generic'),
properties: z.object({
continue: z.union([z.boolean(), z.string()]),
request: IRequestOptionsSimplifiedAuthSchema,
}),
});
export const IN8nRequestOperationPaginationOffsetSchema: z.ZodType<IN8nRequestOperationPaginationOffset> =
IN8nRequestOperationPaginationBaseSchema.extend({
type: z.literal('offset'),
properties: z.object({
limitParameter: z.string(),
offsetParameter: z.string(),
pageSize: z.number(),
rootProperty: z.string().optional(),
type: z.enum(['body', 'query']),
}),
});
export const IN8nRequestOperationsSchema: z.ZodType<IN8nRequestOperations> = z.object({
pagination: z
.union([
IN8nRequestOperationPaginationGenericSchema,
IN8nRequestOperationPaginationOffsetSchema,
// TODO: Validating the function shape is skipped at runtime, any function is accepted
z.custom<
(
this: IExecutePaginationFunctions,
requestOptions: DeclarativeRestApiSettings.ResultOptions,
) => Promise<INodeExecutionData[]>
>((v) => typeof v === 'function'),
])
.optional(),
});
export const IPostReceiveBaseSchema = z.object({
type: z.string(),
enabled: z.union([z.boolean(), z.string()]).optional(),
properties: z.record(
z.string(),
z.union([z.string(), z.number(), z.boolean(), IDataObjectSchema]),
),
errorMessage: z.string().optional(),
});
export const IPostReceiveBinaryDataSchema: z.ZodType<IPostReceiveBinaryData> =
IPostReceiveBaseSchema.extend({
type: z.literal('binaryData'),
properties: z.object({
destinationProperty: z.string(),
}),
});
export const IPostReceiveFilterSchema: z.ZodType<IPostReceiveFilter> =
IPostReceiveBaseSchema.extend({
type: z.literal('filter'),
properties: z.object({
pass: z.union([z.boolean(), z.string()]),
}),
});
export const IPostReceiveLimitSchema: z.ZodType<IPostReceiveLimit> = IPostReceiveBaseSchema.extend({
type: z.literal('limit'),
properties: z.object({
maxResults: z.union([z.number(), z.string()]),
}),
});
export const IPostReceiveRootPropertySchema: z.ZodType<IPostReceiveRootProperty> =
IPostReceiveBaseSchema.extend({
type: z.literal('rootProperty'),
properties: z.object({
property: z.string(),
}),
});
export const IPostReceiveSetSchema: z.ZodType<IPostReceiveSet> = IPostReceiveBaseSchema.extend({
type: z.literal('set'),
properties: z.object({
value: z.string(),
}),
});
export const IPostReceiveSetKeyValueSchema: z.ZodType<IPostReceiveSetKeyValue> =
IPostReceiveBaseSchema.extend({
type: z.literal('setKeyValue'),
properties: z.record(z.union([z.string(), z.number()])),
});
export const IPostReceiveSortSchema: z.ZodType<IPostReceiveSort> = IPostReceiveBaseSchema.extend({
type: z.literal('sort'),
properties: z.object({
key: z.string(),
}),
});
export const PostReceiveActionSchema: z.ZodType<PostReceiveAction> = z.union([
// TODO: Validating the function shape is skipped at runtime, any function is accepted
z.custom<
(
this: IExecuteSingleFunctions,
items: INodeExecutionData[],
response: IN8nHttpFullResponse,
) => Promise<INodeExecutionData[]>
>((v) => typeof v === 'function'),
IPostReceiveBinaryDataSchema,
IPostReceiveFilterSchema,
IPostReceiveLimitSchema,
IPostReceiveRootPropertySchema,
IPostReceiveSetSchema,
IPostReceiveSetKeyValueSchema,
IPostReceiveSortSchema,
]);
export const INodeRequestOutputSchema: z.ZodType<INodeRequestOutput> = z.object({
maxResults: z.union([z.number(), z.string()]),
postReceive: z.array(PostReceiveActionSchema),
});
export const HttpRequestOptionsSchema: z.ZodType<DeclarativeRestApiSettings.HttpRequestOptions> =
z.object({}); // TODO
export const INodeRequestSendSchema: z.ZodType<INodeRequestSend> = z.object({
preSend: z.array(
z.custom<
(
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
) => Promise<IHttpRequestOptions>
>((v) => typeof v === 'function'),
),
paginate: z.union([z.boolean(), z.string()]).optional(),
property: z.string().optional(),
propertyInDotNotation: z.boolean().optional(),
type: z.enum(['body', 'query']),
value: z.string().optional(),
});
export const INodePropertyRoutingSchema: z.ZodType<INodePropertyRouting> = z.object({
operations: IN8nRequestOperationsSchema.optional(),
output: INodeRequestOutputSchema.optional(),
request: HttpRequestOptionsSchema.optional(),
send: INodeRequestSendSchema.optional(),
});
export const NumberOrStringSchema = z.union([z.number(), z.string()]);
export const DisplayConditionSchema: z.ZodType<DisplayCondition> = z.union([
z.object({ _cnd: z.object({ eq: RequiredNodeParameterValueSchema }) }),
z.object({ _cnd: z.object({ not: RequiredNodeParameterValueSchema }) }),
z.object({ _cnd: z.object({ gte: NumberOrStringSchema }) }),
z.object({ _cnd: z.object({ lte: NumberOrStringSchema }) }),
z.object({ _cnd: z.object({ gt: NumberOrStringSchema }) }),
z.object({ _cnd: z.object({ lt: NumberOrStringSchema }) }),
z.object({
_cnd: z.object({
between: z.object({ from: NumberOrStringSchema, to: NumberOrStringSchema }),
}),
}),
z.object({ _cnd: z.object({ startsWith: z.string() }) }),
z.object({ _cnd: z.object({ endsWith: z.string() }) }),
z.object({ _cnd: z.object({ includes: z.string() }) }),
z.object({ _cnd: z.object({ regex: z.string() }) }),
z.object({ _cnd: z.object({ exists: z.literal(true) }) }),
]);
export const IDisplayOptionsSchema: z.ZodType<IDisplayOptions> = z.object({
show: z
.object({
'@version': z.array(z.union([z.number(), DisplayConditionSchema])).optional(),
'@feature': z.array(z.union([z.string(), DisplayConditionSchema])).optional(),
'@tool': z.array(z.boolean()).optional(),
})
.catchall(
z.union([
z.array(z.union([NodeParameterValueSchema, DisplayConditionSchema])),
z.undefined(),
]),
),
hide: z.record(
z.string(),
z.union([z.array(z.union([NodeParameterValueSchema, DisplayConditionSchema])), z.undefined()]),
),
hideOnCloud: z.boolean().optional(),
});
export const NodeConnectionTypeSchema: z.ZodType<NodeConnectionType> = z.enum([
'ai_agent',
'ai_chain',
'ai_document',
'ai_embedding',
'ai_languageModel',
'ai_memory',
'ai_outputParser',
'ai_retriever',
'ai_reranker',
'ai_textSplitter',
'ai_tool',
'ai_vectorStore',
'main',
]);
export const INodePropertyOptionsSchema: z.ZodType<INodePropertyOptions> = z.object({
name: z.string(),
value: z.union([z.string(), z.number(), z.boolean()]),
action: z.string().optional(),
description: z.string().optional(),
routing: INodePropertyRoutingSchema.optional(),
outputConnectionType: NodeConnectionTypeSchema.optional(),
inputSchema: z.any().optional(),
displayOptions: IDisplayOptionsSchema.optional(),
disabledOptions: z.literal(undefined).optional(),
});
export const ResourceMapperFieldSchema: z.ZodType<ResourceMapperField> = z.object({
id: z.string(),
displayName: z.string(),
defaultMatch: z.boolean(),
canBeUsedToMatch: z.boolean().optional(),
required: z.boolean(),
display: z.boolean(),
type: FieldTypeSchema.optional(),
removed: z.boolean().optional(),
options: z.array(INodePropertyOptionsSchema),
readOnly: z.boolean().optional(),
});
export const ResourceMapperValueSchema: z.ZodType<ResourceMapperValue> = z.object({
mappingMode: z.string(),
value: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])),
matchingColumns: z.array(z.string()),
schema: z.array(ResourceMapperFieldSchema),
attemptToConvertTypes: z.boolean(),
convertFieldsToString: z.boolean(),
});
export const FilterOptionsValueSchema: z.ZodType<FilterOptionsValue> = z.object({
caseSensitive: z.boolean(),
leftValue: z.string(),
typeValidation: z.enum(['strict', 'loose']),
version: z.union([z.literal(1), z.literal(2), z.literal(3)]),
});
export const FilterOperatorTypeSchema: z.ZodType<FilterOperatorType> = z.enum([
'string',
'number',
'boolean',
'array',
'object',
'dateTime',
'any',
]);
export const FilterOperatorValueSchema: z.ZodType<FilterOperatorValue> = z.object({
type: FilterOperatorTypeSchema,
operation: z.string(),
rightType: FilterOperatorTypeSchema.optional(),
singleValue: z.boolean().optional(),
});
export const FilterConditionValueSchema: z.ZodType<FilterConditionValue> = z.object({
id: z.string(),
leftValue: z.union([RequiredNodeParameterValueSchema, z.array(RequiredNodeParameterValueSchema)]),
operator: FilterOperatorValueSchema,
rightValue: z.union([
RequiredNodeParameterValueSchema,
z.array(RequiredNodeParameterValueSchema),
]),
});
export const FilterTypeCombinatorSchema = z.enum(['and', 'or']);
export const FilterValueSchema: z.ZodType<FilterValue> = z.object({
options: FilterOptionsValueSchema,
conditions: z.array(FilterConditionValueSchema),
combinator: FilterTypeCombinatorSchema,
});
export const AssignmentValueSchema: z.ZodType<AssignmentValue> = z.object({
id: z.string(),
name: z.string(),
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
type: z.string().optional(),
});
export const AssignmentCollectionValueSchema: z.ZodType<AssignmentCollectionValue> = z.object({
assignments: z.array(AssignmentValueSchema),
});
export const IconOrEmojiSchema: z.ZodType<IconOrEmoji> = z.discriminatedUnion('type', [
z.object({ type: z.literal('icon'), value: z.string() }),
z.object({ type: z.literal('emoji'), value: z.string() }),
]);
export const NodeParameterValueTypeSchema: z.ZodType<NodeParameterValueType> = z.lazy(() =>
z.union([
NodeParameterValueSchema,
INodeParameterResourceLocatorSchema,
ResourceMapperValueSchema,
FilterValueSchema,
AssignmentCollectionValueSchema,
IconOrEmojiSchema,
INodeParametersSchema,
// only the shapes allowed by the TS union
z.array(NodeParameterValueSchema),
z.array(INodeParametersSchema),
z.array(INodeParameterResourceLocatorSchema),
z.array(ResourceMapperValueSchema),
]),
);
export const INodeParametersSchema: z.ZodType<INodeParameters> = z.record(
z.string(),
NodeParameterValueTypeSchema,
);
export const OnErrorSchema: z.ZodType<OnError> = z.enum([
'continueErrorOutput',
'continueRegularOutput',
'stopWorkflow',
]);
export const INodeCredentialsDetailsSchema: z.ZodType<INodeCredentialsDetails> = z.object({
id: z.string().nullable(),
name: z.string(),
});
export const INodeCredentialsSchema: z.ZodType<INodeCredentials> = z.record(
z.string(),
INodeCredentialsDetailsSchema,
);
export const INodeSchema: z.ZodType<INode> = z.object({
id: z.string(),
name: z.string(),
typeVersion: z.number(),
type: z.string(),
position: z.tuple([z.number(), z.number()]),
disabled: z.boolean().optional(),
notes: z.string().optional(),
notesInFlow: z.boolean().optional(),
retryOnFail: z.boolean().optional(),
maxTries: z.number().optional(),
waitBetweenTries: z.number().optional(),
alwaysOutputData: z.boolean().optional(),
executeOnce: z.boolean().optional(),
onError: OnErrorSchema.optional(),
continueOnFail: z.boolean().optional(),
webhookId: z.string().optional(),
extendsCredential: z.string().optional(),
rewireOutputLogTo: NodeConnectionTypeSchema.optional(),
parameters: INodeParametersSchema,
credentials: INodeCredentialsSchema.optional(),
forceCustomOperation: z
.object({
resource: z.string(),
operation: z.string(),
})
.optional(),
});
export const INodesSchema: z.ZodType<INode[]> = z.array(INodeSchema);
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import type { INode } from './interfaces';
const MAX_TOOL_NAME_LENGTH = 64;
/**
* Converts a node name to a valid tool name by replacing special characters with underscores,
* collapsing consecutive underscores into a single one, and truncating to 64 characters
* (which is the maximum length allowed by OpenAI's API).
*/
export function nodeNameToToolName(nodeOrName: INode | string): string {
const name = typeof nodeOrName === 'string' ? nodeOrName : nodeOrName.name;
let toolName = name.replace(/[^a-zA-Z0-9_-]+/g, '_');
if (toolName.length > MAX_TOOL_NAME_LENGTH) {
toolName = toolName.slice(0, MAX_TOOL_NAME_LENGTH);
// Remove trailing underscore or hyphen left from truncation
toolName = toolName.replace(/[_-]+$/, '');
}
return toolName;
}
+110
View File
@@ -0,0 +1,110 @@
import {
type AssignmentCollectionValue,
type AssignmentValue,
type FilterValue,
type INodeParameterResourceLocator,
type INodeProperties,
type INodePropertyCollection,
type INodePropertyOptions,
type NodeConnectionType,
type ResourceMapperValue,
nodeConnectionTypes,
type IBinaryData,
} from './interfaces';
export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator {
return Boolean(
typeof value === 'object' && value && 'mode' in value && 'value' in value && '__rl' in value,
);
}
export const isINodeProperties = (
item: INodePropertyOptions | INodeProperties | INodePropertyCollection,
): item is INodeProperties => 'name' in item && 'type' in item && !('value' in item);
export const isINodePropertyOptions = (
item: INodePropertyOptions | INodeProperties | INodePropertyCollection,
): item is INodePropertyOptions => 'value' in item && 'name' in item && !('displayName' in item);
export const isINodePropertyCollection = (
item: INodePropertyOptions | INodeProperties | INodePropertyCollection,
): item is INodePropertyCollection => 'values' in item && 'name' in item && 'displayName' in item;
export const isINodePropertiesList = (
items: INodeProperties['options'],
): items is INodeProperties[] => Array.isArray(items) && items.every(isINodeProperties);
export const isINodePropertyOptionsList = (
items: INodeProperties['options'],
): items is INodePropertyOptions[] => Array.isArray(items) && items.every(isINodePropertyOptions);
export const isINodePropertyCollectionList = (
items: INodeProperties['options'],
): items is INodePropertyCollection[] => {
return Array.isArray(items) && items.every(isINodePropertyCollection);
};
export const isValidResourceLocatorParameterValue = (
value: INodeParameterResourceLocator,
): boolean => {
if (typeof value === 'object') {
if (typeof value.value === 'number') {
return true; // Accept all numbers
}
return !!value.value;
} else {
return !!value;
}
};
export const isResourceMapperValue = (value: unknown): value is ResourceMapperValue => {
return (
typeof value === 'object' &&
value !== null &&
'mappingMode' in value &&
'schema' in value &&
'value' in value
);
};
export const isAssignmentValue = (value: unknown): value is AssignmentValue => {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'name' in value &&
typeof value.name === 'string' &&
'value' in value &&
(!('type' in value) || typeof value.type === 'string')
);
};
export const isAssignmentCollectionValue = (value: unknown): value is AssignmentCollectionValue => {
return (
typeof value === 'object' &&
value !== null &&
'assignments' in value &&
Array.isArray(value.assignments) &&
value.assignments.every(isAssignmentValue)
);
};
export const isFilterValue = (value: unknown): value is FilterValue => {
return (
typeof value === 'object' && value !== null && 'conditions' in value && 'combinator' in value
);
};
export const isNodeConnectionType = (value: unknown): value is NodeConnectionType => {
return nodeConnectionTypes.includes(value as NodeConnectionType);
};
export const isBinaryValue = (value: unknown): value is IBinaryData => {
return (
typeof value === 'object' &&
value !== null &&
'mimeType' in value &&
('data' in value || 'id' in value)
);
};
+481
View File
@@ -0,0 +1,481 @@
import isObject from 'lodash/isObject';
import { DateTime } from 'luxon';
import { ApplicationError } from './errors';
import type {
FieldType,
FormFieldsParameter,
IBinaryData,
INodePropertyOptions,
ValidationResult,
} from './interfaces';
import { jsonParse } from './utils';
import { isBinaryValue } from './type-guards';
export const tryToParseNumber = (value: unknown): number => {
const isValidNumber = !isNaN(Number(value));
if (!isValidNumber) {
throw new ApplicationError('Failed to parse value to number', { extra: { value } });
}
return Number(value);
};
export const tryToParseString = (value: unknown): string => {
if (typeof value === 'object') return JSON.stringify(value);
if (typeof value === 'undefined') return '';
if (
typeof value === 'string' ||
typeof value === 'bigint' ||
typeof value === 'boolean' ||
typeof value === 'number'
) {
return value.toString();
}
return String(value);
};
export const tryToParseAlphanumericString = (value: unknown): string => {
const parsed = tryToParseString(value);
// We do not allow special characters, only letters, numbers and underscore
// Numbers not allowed as the first character
const regex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!regex.test(parsed)) {
throw new ApplicationError('Value is not a valid alphanumeric string', { extra: { value } });
}
return parsed;
};
export const tryToParseBoolean = (value: unknown): value is boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
return value.toLowerCase() === 'true';
}
// If value is not a empty string, try to parse it to a number
if (!(typeof value === 'string' && value.trim() === '')) {
const num = Number(value);
if (num === 0) {
return false;
} else if (num === 1) {
return true;
}
}
throw new ApplicationError('Failed to parse value as boolean', {
extra: { value },
});
};
export const tryToParseDateTime = (value: unknown, defaultZone?: string): DateTime => {
if (DateTime.isDateTime(value) && value.isValid) {
// Ignore the defaultZone if the value is already a DateTime
// because DateTime objects already contain the zone information
return value;
}
if (value instanceof Date) {
const fromJSDate = DateTime.fromJSDate(value, { zone: defaultZone });
if (fromJSDate.isValid) {
return fromJSDate;
}
}
const dateString = String(value).trim();
// Rely on luxon to parse different date formats
const isoDate = DateTime.fromISO(dateString, { zone: defaultZone, setZone: true });
if (isoDate.isValid) {
return isoDate;
}
const httpDate = DateTime.fromHTTP(dateString, { zone: defaultZone, setZone: true });
if (httpDate.isValid) {
return httpDate;
}
const rfc2822Date = DateTime.fromRFC2822(dateString, { zone: defaultZone, setZone: true });
if (rfc2822Date.isValid) {
return rfc2822Date;
}
const sqlDate = DateTime.fromSQL(dateString, { zone: defaultZone, setZone: true });
if (sqlDate.isValid) {
return sqlDate;
}
const parsedDateTime = DateTime.fromMillis(Date.parse(dateString), { zone: defaultZone });
if (parsedDateTime.isValid) {
return parsedDateTime;
}
throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
};
export const tryToParseTime = (value: unknown): string => {
const isTimeInput = /^\d{2}:\d{2}(:\d{2})?((\-|\+)\d{4})?((\-|\+)\d{1,2}(:\d{2})?)?$/s.test(
String(value),
);
if (!isTimeInput) {
throw new ApplicationError('Value is not a valid time', { extra: { value } });
}
return String(value);
};
export const tryToParseArray = (value: unknown): unknown[] => {
try {
if (typeof value === 'object' && Array.isArray(value)) {
return value;
}
let parsed: unknown[];
try {
parsed = JSON.parse(String(value)) as unknown[];
} catch (e) {
parsed = JSON.parse(String(value).replace(/'/g, '"')) as unknown[];
}
if (!Array.isArray(parsed)) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
return parsed;
} catch (e) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
};
export const tryToParseObject = (value: unknown): object => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
try {
const o = jsonParse<object>(String(value), { acceptJSObject: true });
if (typeof o !== 'object' || Array.isArray(o)) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
return o;
} catch (e) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
};
export const tryToParseBinary = (value: unknown): IBinaryData => {
if (!value || typeof value !== 'object' || Array.isArray(value) || !isBinaryValue(value)) {
throw new ApplicationError('Value is not a valid binary data object', { extra: { value } });
}
return value;
};
const ALLOWED_FORM_FIELDS_KEYS = [
'fieldLabel',
'fieldType',
'placeholder',
'defaultValue',
'fieldOptions',
'multiselect',
'multipleFiles',
'acceptFileTypes',
'formatDate',
'requiredField',
'fieldValue',
'elementName',
'html',
'fieldName',
'limitSelection',
'numberOfSelections',
'minSelections',
'maxSelections',
];
const ALLOWED_FIELD_TYPES = [
'date',
'dropdown',
'email',
'file',
'number',
'password',
'text',
'textarea',
'checkbox',
'radio',
'html',
'hiddenField',
];
export const tryToParseJsonToFormFields = (value: unknown): FormFieldsParameter => {
const fields: FormFieldsParameter = [];
try {
const rawFields = jsonParse<Array<{ [key: string]: unknown }>>(value as string, {
acceptJSObject: true,
});
for (const [index, field] of rawFields.entries()) {
for (const key of Object.keys(field)) {
if (!ALLOWED_FORM_FIELDS_KEYS.includes(key)) {
throw new ApplicationError(`Key '${key}' in field ${index} is not valid for form fields`);
}
if (
key !== 'fieldOptions' &&
!['string', 'number', 'boolean'].includes(typeof field[key])
) {
field[key] = String(field[key]);
} else if (typeof field[key] === 'string' && key !== 'html') {
field[key] = field[key].replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
if (key === 'fieldType' && !ALLOWED_FIELD_TYPES.includes(field[key] as string)) {
throw new ApplicationError(
`Field type '${field[key] as string}' in field ${index} is not valid for form fields`,
);
}
if (key === 'fieldOptions') {
if (Array.isArray(field[key])) {
field[key] = { values: field[key] };
}
if (
typeof field[key] !== 'object' ||
!(field[key] as { [key: string]: unknown }).values
) {
throw new ApplicationError(
`Field dropdown in field ${index} does has no 'values' property that contain an array of options`,
);
}
for (const [optionIndex, option] of (
(field[key] as { [key: string]: unknown }).values as Array<{
[key: string]: { option: string };
}>
).entries()) {
if (Object.keys(option).length !== 1 || typeof option.option !== 'string') {
throw new ApplicationError(
`Field dropdown in field ${index} has an invalid option ${optionIndex}`,
);
}
}
}
}
fields.push(field as FormFieldsParameter[number]);
}
} catch (error) {
if (error instanceof ApplicationError) throw error;
throw new ApplicationError('Value is not valid JSON');
}
return fields;
};
export const getValueDescription = <T>(value: T): string => {
if (typeof value === 'object') {
if (value === null) return "'null'";
if (Array.isArray(value)) return 'array';
return 'object';
}
return `'${String(value)}'`;
};
const ALLOWED_URL_PROTOCOLS = ['http:', 'https:', 'ftp:', 'file:'];
export const tryToParseUrl = (value: unknown): string => {
if (typeof value === 'string' && !value.includes('://')) {
value = `https://${value}`;
}
try {
const parsed = new URL(String(value));
if (!ALLOWED_URL_PROTOCOLS.includes(parsed.protocol)) {
throw new ApplicationError(`The value "${String(value)}" is not a valid url.`, {
extra: { value },
});
}
return String(value);
} catch (e) {
if (e instanceof ApplicationError) throw e;
throw new ApplicationError(`The value "${String(value)}" is not a valid url.`, {
extra: { value },
});
}
};
export const tryToParseJwt = (value: unknown): string => {
const error = new ApplicationError(`The value "${String(value)}" is not a valid JWT token.`, {
extra: { value },
});
if (!value) throw error;
const jwtPattern = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*$/;
if (!jwtPattern.test(String(value))) throw error;
return String(value);
};
type ValidateFieldTypeOptions = Partial<{
valueOptions: INodePropertyOptions[];
strict: boolean;
parseStrings: boolean;
}>;
// Validates field against the schema and tries to parse it to the correct type
export function validateFieldType<K extends FieldType>(
fieldName: string,
value: unknown,
type: K,
options?: ValidateFieldTypeOptions,
): ValidationResult<K>;
// eslint-disable-next-line complexity
export function validateFieldType(
fieldName: string,
value: unknown,
type: FieldType,
options: ValidateFieldTypeOptions = {},
): ValidationResult {
if (value === null || value === undefined) return { valid: true };
const strict = options.strict ?? false;
const valueOptions = options.valueOptions ?? [];
const parseStrings = options.parseStrings ?? false;
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got ${getValueDescription(value)}`;
switch (type.toLowerCase()) {
case 'string': {
if (!parseStrings) return { valid: true, newValue: value };
try {
if (strict && typeof value !== 'string') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseString(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'string-alphanumeric': {
try {
return { valid: true, newValue: tryToParseAlphanumericString(value) };
} catch (e) {
return {
valid: false,
errorMessage:
'Value is not a valid alphanumeric string, only letters, numbers and underscore allowed',
};
}
}
case 'number': {
try {
if (strict && typeof value !== 'number') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseNumber(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'boolean': {
try {
if (strict && typeof value !== 'boolean') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseBoolean(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'datetime': {
try {
return { valid: true, newValue: tryToParseDateTime(value) };
} catch (e) {
const luxonDocsURL =
'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat';
const errorMessage = `${defaultErrorMessage} <br/><br/> Consider using <a href="${luxonDocsURL}" target="_blank"><code>DateTime.fromFormat</code></a> to work with custom date formats.`;
return { valid: false, errorMessage };
}
}
case 'time': {
try {
return { valid: true, newValue: tryToParseTime(value) };
} catch (e) {
return {
valid: false,
errorMessage: `'${fieldName}' expects time (hh:mm:(:ss)) but we got ${getValueDescription(value)}.`,
};
}
}
case 'binary': {
try {
return { valid: true, newValue: tryToParseBinary(value) };
} catch (e) {
const errorMessage = `${defaultErrorMessage}. Make sure the value is a valid binary data object with 'mimeType' and 'data' or 'id' property.`;
return { valid: false, errorMessage };
}
}
case 'object': {
try {
if (strict && !isObject(value)) {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseObject(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'array': {
if (strict && !Array.isArray(value)) {
return { valid: false, errorMessage: defaultErrorMessage };
}
try {
return { valid: true, newValue: tryToParseArray(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'options': {
const validOptions = valueOptions.map((option) => option.value).join(', ');
const isValidOption = valueOptions.some((option) => option.value === value);
if (!isValidOption) {
return {
valid: false,
errorMessage: `'${fieldName}' expects one of the following values: [${validOptions}] but we got ${getValueDescription(
value,
)}`,
};
}
return { valid: true, newValue: value };
}
case 'url': {
try {
return { valid: true, newValue: tryToParseUrl(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'jwt': {
try {
return { valid: true, newValue: tryToParseJwt(value) };
} catch (e) {
return {
valid: false,
errorMessage: 'Value is not a valid JWT token',
};
}
}
case 'form-fields': {
try {
return { valid: true, newValue: tryToParseJsonToFormFields(value) };
} catch (e) {
return {
valid: false,
errorMessage: (e as Error).message,
};
}
}
default: {
return { valid: true, newValue: value };
}
}
}
+20
View File
@@ -0,0 +1,20 @@
/// <reference lib="es2022.error" />
declare module '@n8n_io/riot-tmpl' {
interface Brackets {
set(token: string): void;
}
type ReturnValue = string | null | (() => unknown);
type TmplFn = (value: string, data: unknown) => ReturnValue;
interface Tmpl extends TmplFn {
errorHandler?(error: Error): void;
}
let brackets: Brackets;
let tmpl: Tmpl;
}
interface BigInt {
toJSON(): string;
}
+512
View File
@@ -0,0 +1,512 @@
import { ApplicationError } from '@n8n/errors';
import { parse as esprimaParse, Syntax } from 'esprima-next';
import type { Node as SyntaxNode, ExpressionStatement } from 'esprima-next';
import FormData from 'form-data';
import { jsonrepair } from 'jsonrepair';
import merge from 'lodash/merge';
import path from 'path';
import { ALPHABET } from './constants';
import { ManualExecutionCancelledError } from './errors/execution-cancelled.error';
import type { BinaryFileType, IDisplayOptions, INodeProperties, JsonObject } from './interfaces';
import * as LoggerProxy from './logger-proxy';
const readStreamClasses = new Set(['ReadStream', 'Readable', 'ReadableStream']);
// NOTE: BigInt.prototype.toJSON is not available, which causes JSON.stringify to throw an error
// as well as the flatted stringify method. This is a workaround for that.
BigInt.prototype.toJSON = function () {
return this.toString();
};
/**
* Type guard for plain objects suitable for key-based traversal/serialization.
*
* Returns `true` for objects whose prototype is `Object.prototype` (object literals)
* or `null` (`Object.create(null)`), and `false` for arrays and non-plain objects
* such as `Date`, `Map`, `Set`, and class instances.
*/
export function isObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') return false;
if (Array.isArray(value)) return false;
if (Object.prototype.toString.call(value) !== '[object Object]') return false;
return Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
}
export const isObjectEmpty = (obj: object | null | undefined): boolean => {
if (obj === undefined || obj === null) return true;
if (typeof obj === 'object') {
if (obj instanceof FormData) return obj.getLengthSync() === 0;
if (Array.isArray(obj)) return obj.length === 0;
if (obj instanceof Set || obj instanceof Map) return obj.size === 0;
if (ArrayBuffer.isView(obj) || obj instanceof ArrayBuffer) return obj.byteLength === 0;
if (Symbol.iterator in obj || readStreamClasses.has(obj.constructor.name)) return false;
return Object.keys(obj).length === 0;
}
return true;
};
export type Primitives = string | number | boolean | bigint | symbol | null | undefined;
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument */
export const deepCopy = <T extends ((object | Date) & { toJSON?: () => string }) | Primitives>(
source: T,
hash = new WeakMap(),
path = '',
): T => {
const hasOwnProp = Object.prototype.hasOwnProperty.bind(source);
// Primitives & Null & Function
if (typeof source !== 'object' || source === null || typeof source === 'function') {
return source;
}
// Date and other objects with toJSON method
// TODO: remove this when other code parts not expecting objects with `.toJSON` method called and add back checking for Date and cloning it properly
if (typeof source.toJSON === 'function') {
return source.toJSON() as T;
}
if (hash.has(source)) {
return hash.get(source);
}
// Array
if (Array.isArray(source)) {
const clone = [];
const len = source.length;
for (let i = 0; i < len; i++) {
clone[i] = deepCopy(source[i], hash, path + `[${i}]`);
}
return clone as T;
}
// Object
const clone = Object.create(Object.getPrototypeOf({}));
hash.set(source, clone);
for (const i in source) {
if (hasOwnProp(i)) {
clone[i] = deepCopy((source as any)[i], hash, path + `.${i}`);
}
}
return clone;
};
// eslint-enable
function syntaxNodeToValue(expression?: SyntaxNode | null): unknown {
switch (expression?.type) {
case Syntax.ObjectExpression:
return Object.fromEntries(
expression.properties
.filter((prop) => prop.type === Syntax.Property)
.map(({ key, value }) => [syntaxNodeToValue(key), syntaxNodeToValue(value)]),
);
case Syntax.Identifier:
return expression.name;
case Syntax.Literal:
return expression.value;
case Syntax.ArrayExpression:
return expression.elements.map((exp) => syntaxNodeToValue(exp));
case Syntax.UnaryExpression: {
const value = syntaxNodeToValue(expression.argument);
if (typeof value === 'number' && expression.operator === '-') {
return -value;
}
return value;
}
default:
return undefined;
}
}
/**
* Parse any JavaScript ObjectExpression, including:
* - single quoted keys
* - unquoted keys
*/
function parseJSObject(objectAsString: string): object {
const jsExpression = esprimaParse(`(${objectAsString})`).body.find(
(node): node is ExpressionStatement =>
node.type === Syntax.ExpressionStatement && node.expression.type === Syntax.ObjectExpression,
);
return syntaxNodeToValue(jsExpression?.expression) as object;
}
type MutuallyExclusive<T, U> =
| (T & { [k in Exclude<keyof U, keyof T>]?: never })
| (U & { [k in Exclude<keyof T, keyof U>]?: never });
type JSONParseOptions<T> = { acceptJSObject?: boolean; repairJSON?: boolean } & MutuallyExclusive<
{ errorMessage?: string },
{ fallbackValue?: T }
>;
/**
* Parses a JSON string into an object with optional error handling and recovery mechanisms.
*
* @param {string} jsonString - The JSON string to parse.
* @param {Object} [options] - Optional settings for parsing the JSON string. Either `fallbackValue` or `errorMessage` can be set, but not both.
* @param {boolean} [options.acceptJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object.
* @param {boolean} [options.repairJSON=false] - If true, attempts to repair common JSON format errors by repairing the JSON string.
* @param {string} [options.errorMessage] - A custom error message to throw if the JSON string cannot be parsed.
* @param {*} [options.fallbackValue] - A fallback value to return if the JSON string cannot be parsed.
* @returns {Object} - The parsed object, or the fallback value if parsing fails and `fallbackValue` is set.
*/
export const jsonParse = <T>(jsonString: string, options?: JSONParseOptions<T>): T => {
try {
return JSON.parse(jsonString) as T;
} catch (error) {
if (options?.acceptJSObject) {
try {
const jsonStringCleaned = parseJSObject(jsonString);
return jsonStringCleaned as T;
} catch (e) {
// Ignore this error and return the original error or the fallback value
}
}
if (options?.repairJSON) {
try {
const jsonStringCleaned = jsonrepair(jsonString);
return JSON.parse(jsonStringCleaned) as T;
} catch (e) {
// Ignore this error and return the original error or the fallback value
}
}
if (options?.fallbackValue !== undefined) {
if (options.fallbackValue instanceof Function) {
return options.fallbackValue();
}
return options.fallbackValue;
} else if (options?.errorMessage) {
throw new ApplicationError(options.errorMessage);
}
throw error;
}
};
type JSONStringifyOptions = {
replaceCircularRefs?: boolean;
};
/**
* Decodes a Base64 string with proper UTF-8 character handling.
*
* @param str - The Base64 string to decode
* @returns The decoded UTF-8 string
*/
export const base64DecodeUTF8 = (str: string): string => {
try {
// Use modern TextDecoder for proper UTF-8 handling
const bytes = new Uint8Array(
atob(str)
.split('')
.map((char) => char.charCodeAt(0)),
);
return new TextDecoder('utf-8').decode(bytes);
} catch (error) {
// Fallback method for older browsers
console.warn('TextDecoder not available, using fallback method');
return atob(str);
}
};
export const replaceCircularReferences = <T>(value: T, knownObjects = new WeakSet()): T => {
if (typeof value !== 'object' || value === null || value instanceof RegExp) return value;
if ('toJSON' in value && typeof value.toJSON === 'function') return value.toJSON() as T;
if (knownObjects.has(value)) return '[Circular Reference]' as T;
knownObjects.add(value);
const copy = (Array.isArray(value) ? [] : {}) as T;
for (const key in value) {
try {
copy[key] = replaceCircularReferences(value[key], knownObjects);
} catch (error: unknown) {
if (
error instanceof TypeError &&
error.message.includes('Cannot assign to read only property')
) {
LoggerProxy.error('Error while replacing circular references: ' + error.message, { error });
continue; // Skip properties that cannot be assigned to (readonly, non-configurable, etc.)
}
throw error;
}
}
knownObjects.delete(value);
return copy;
};
export const jsonStringify = (obj: unknown, options: JSONStringifyOptions = {}): string => {
return JSON.stringify(options?.replaceCircularRefs ? replaceCircularReferences(obj) : obj);
};
export const sleep = async (ms: number): Promise<void> =>
await new Promise((resolve) => {
setTimeout(resolve, ms);
});
export const sleepWithAbort = async (ms: number, abortSignal?: AbortSignal): Promise<void> =>
await new Promise((resolve, reject) => {
if (abortSignal?.aborted) {
reject(new ManualExecutionCancelledError(''));
return;
}
const timeout = setTimeout(resolve, ms);
const abortHandler = () => {
clearTimeout(timeout);
reject(new ManualExecutionCancelledError(''));
};
abortSignal?.addEventListener('abort', abortHandler, { once: true });
});
export function fileTypeFromMimeType(mimeType: string): BinaryFileType | undefined {
if (mimeType.startsWith('application/json')) return 'json';
if (mimeType.startsWith('text/html')) return 'html';
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('text/') || mimeType.startsWith('application/javascript')) return 'text';
if (mimeType.startsWith('application/pdf')) return 'pdf';
return;
}
export function assert<T>(condition: T, msg?: string): asserts condition {
if (!condition) {
const error = new Error(msg ?? 'Invalid assertion');
// hide assert stack frame if supported
if (Error.hasOwnProperty('captureStackTrace')) {
// V8 only - https://nodejs.org/api/errors.html#errors_error_capturestacktrace_targetobject_constructoropt
Error.captureStackTrace(error, assert);
} else if (error.stack) {
// fallback for IE and Firefox
error.stack = error.stack
.split('\n')
.slice(1) // skip assert function from stack frames
.join('\n');
}
throw error;
}
}
export const isTraversableObject = (value: any): value is JsonObject => {
return value && typeof value === 'object' && !Array.isArray(value) && !!Object.keys(value).length;
};
export const removeCircularRefs = (obj: JsonObject, seen = new Set()) => {
seen.add(obj);
Object.entries(obj).forEach(([key, value]) => {
if (isTraversableObject(value)) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
seen.has(value) ? (obj[key] = { circularReference: true }) : removeCircularRefs(value, seen);
return;
}
if (Array.isArray(value)) {
value.forEach((val, index) => {
if (seen.has(val)) {
value[index] = { circularReference: true };
return;
}
if (isTraversableObject(val)) {
removeCircularRefs(val, seen);
}
});
}
});
};
export function updateDisplayOptions(
displayOptions: IDisplayOptions,
properties: INodeProperties[],
) {
return properties.map((nodeProperty) => {
return {
...nodeProperty,
displayOptions: merge({}, nodeProperty.displayOptions, displayOptions),
};
});
}
export function randomInt(max: number): number;
export function randomInt(min: number, max: number): number;
/**
* Generates a random integer within a specified range.
*
* @param {number} min - The lower bound of the range. If `max` is not provided, this value is used as the upper bound and the lower bound is set to 0.
* @param {number} [max] - The upper bound of the range, not inclusive.
* @returns {number} A random integer within the specified range.
*/
export function randomInt(min: number, max?: number): number {
if (max === undefined) {
max = min;
min = 0;
}
return min + (crypto.getRandomValues(new Uint32Array(1))[0] % (max - min));
}
export function randomString(length: number): string;
export function randomString(minLength: number, maxLength: number): string;
/**
* Generates a random alphanumeric string of a specified length, or within a range of lengths.
*
* @param {number} minLength - If `maxLength` is not provided, this is the length of the string to generate. Otherwise, this is the lower bound of the range of possible lengths.
* @param {number} [maxLength] - The upper bound of the range of possible lengths. If provided, the actual length of the string will be a random number between `minLength` and `maxLength`, inclusive.
* @returns {string} A random alphanumeric string of the specified length or within the specified range of lengths.
*/
export function randomString(minLength: number, maxLength?: number): string {
const length = maxLength === undefined ? minLength : randomInt(minLength, maxLength + 1);
return [...crypto.getRandomValues(new Uint32Array(length))]
.map((byte) => ALPHABET[byte % ALPHABET.length])
.join('');
}
/**
* Checks if a value is an object with a specific key and provides a type guard for the key.
*/
export function hasKey<T extends PropertyKey>(value: unknown, key: T): value is Record<T, unknown> {
return value !== null && typeof value === 'object' && value.hasOwnProperty(key);
}
const unsafeObjectProperties = new Set([
'__proto__',
'prototype',
'constructor',
'getPrototypeOf',
'mainModule',
'binding',
'_linkedBinding',
'_load',
'prepareStackTrace',
'__lookupGetter__',
'__lookupSetter__',
'__defineGetter__',
'__defineSetter__',
'caller',
'arguments',
'getBuiltinModule',
'dlopen',
'execve',
'loadEnvFile',
]);
/**
* Checks if a property key is safe to use on an object, preventing prototype pollution.
* setting untrusted properties can alter the object's prototype chain and introduce vulnerabilities.
*
* @see setSafeObjectProperty
*/
export function isSafeObjectProperty(property: string) {
return !unsafeObjectProperties.has(property);
}
/**
* Safely sets a property on an object, preventing prototype pollution.
*
* @see isSafeObjectProperty
*/
export function setSafeObjectProperty(
target: Record<string, unknown>,
property: string,
value: unknown,
) {
if (isSafeObjectProperty(property)) {
target[property] = value;
}
}
export function isDomainAllowed(
urlString: string,
options: {
allowedDomains: string;
},
): boolean {
if (!options.allowedDomains || options.allowedDomains.trim() === '') {
return true; // If no restrictions are set, allow all domains
}
try {
const url = new URL(urlString);
// Normalize hostname: lowercase and remove trailing dot
const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
// Reject empty hostnames
if (!hostname) {
return false;
}
const allowedDomainsList = options.allowedDomains
.split(',')
.map((domain) => domain.trim().toLowerCase().replace(/\.$/, ''))
.filter(Boolean);
for (const allowedDomain of allowedDomainsList) {
// Handle wildcard domains (*.example.com)
if (allowedDomain.startsWith('*.')) {
const domainSuffix = allowedDomain.substring(2);
// Ensure the suffix itself is valid
if (!domainSuffix) continue;
// Wildcard matches only subdomains, not the base domain itself
// *.example.com matches sub.example.com but NOT example.com
if (hostname.endsWith('.' + domainSuffix)) {
return true;
}
}
// Exact match
else if (hostname === allowedDomain) {
return true;
}
}
return false;
} catch (error) {
// If URL parsing fails, deny access to be safe
return false;
}
}
const COMMUNITY_PACKAGE_NAME_REGEX = /^(?!@n8n\/)(@[\w.-]+\/)?n8n-nodes-(?!base\b)\b\w+/g;
export function isCommunityPackageName(packageName: string): boolean {
COMMUNITY_PACKAGE_NAME_REGEX.lastIndex = 0;
// Community packages names start with <@username/>n8n-nodes- not followed by word 'base'
const nameMatch = COMMUNITY_PACKAGE_NAME_REGEX.exec(packageName);
return !!nameMatch;
}
export function dedupe<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
/**
* Extracts a safe filename from a path or filename string.
*
* Handles both Unix and Windows path separators, removing directory
* components and null bytes to return just the filename.
*
* @param fileName - The filename or path to sanitize
* @returns The extracted filename without path components
*
* @example
* sanitizeFilename('path/to/file.txt') // returns 'file.txt'
* sanitizeFilename('/tmp/upload/doc.pdf') // returns 'doc.pdf'
* sanitizeFilename('C:\\Users\\file.txt') // returns 'file.txt'
* sanitizeFilename('../../../etc/passwd') // returns 'passwd'
*/
export function sanitizeFilename(fileName: string): string {
// Normalize to forward slashes first to handle Windows paths on Unix
const normalized = fileName.replace(/\\/g, '/');
// Extract just the filename, stripping all directory components
let sanitized = path.basename(normalized);
// Remove null bytes which could be used for null byte injection attacks
sanitized = sanitized.replace(/\0/g, '');
// If the result is empty or just dots, use a default name
if (!sanitized || /^\.+$/.test(sanitized)) {
sanitized = 'untitled';
}
return sanitized;
}
@@ -0,0 +1,30 @@
import type { INodeTypeBaseDescription, IVersionedNodeType, INodeType } from './interfaces';
export class VersionedNodeType implements IVersionedNodeType {
currentVersion: number;
nodeVersions: IVersionedNodeType['nodeVersions'];
description: INodeTypeBaseDescription;
constructor(
nodeVersions: IVersionedNodeType['nodeVersions'],
description: INodeTypeBaseDescription,
) {
this.nodeVersions = nodeVersions;
this.currentVersion = description.defaultVersion ?? this.getLatestVersion();
this.description = description;
}
getLatestVersion() {
return Math.max(...Object.keys(this.nodeVersions).map(Number));
}
getNodeType(version?: number): INodeType {
if (version) {
return this.nodeVersions[version];
} else {
return this.nodeVersions[this.currentVersion];
}
}
}

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