diff --git a/packages/@n8n/config/src/configs/expression-engine.config.ts b/packages/@n8n/config/src/configs/expression-engine.config.ts index eae71575d11..1fc924c2155 100644 --- a/packages/@n8n/config/src/configs/expression-engine.config.ts +++ b/packages/@n8n/config/src/configs/expression-engine.config.ts @@ -88,6 +88,8 @@ export class ExpressionEngineConfig { * Whether a production webhook request may skip acquiring an isolate when its * trigger provably evaluates no expression during the webhook phase. Off * acquires one for every request. + * + * TODO(native-evaluation rollout, CAT-4699): remove through a deprecation entry. */ @Env('N8N_EXPRESSION_ENGINE_ALLOW_WEBHOOK_ISOLATE_SKIP') allowWebhookIsolateSkip: boolean = true; @@ -111,4 +113,13 @@ export class ExpressionEngineConfig { */ @Env('N8N_EXPRESSION_ENGINE_COMPILE_CACHE') compileCache: boolean = false; + + /** + * Experimental: fast native evaluation. Simple expressions are evaluated + * directly by n8n instead of the expression engine, so they never need an + * isolate. Every other expression is evaluated by the engine as before. + * Applies to every engine. + */ + @Env('N8N_EXPRESSION_ENGINE_NATIVE_EVALUATION') + nativeEvaluation: boolean = false; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index c68bc82b8ed..dc6864d9e50 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -689,6 +689,7 @@ describe('GlobalConfig', () => { allowWebhookIsolateSkip: true, lazyAcquire: false, compileCache: false, + nativeEvaluation: false, }, instanceSettingsLoader: { ownerManagedByEnv: false, diff --git a/packages/@n8n/expression-runtime/README.md b/packages/@n8n/expression-runtime/README.md index c1a91760f3a..4033e338a2e 100644 --- a/packages/@n8n/expression-runtime/README.md +++ b/packages/@n8n/expression-runtime/README.md @@ -206,6 +206,7 @@ N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT= # seconds; unset = pool never scales t # Experimental N8N_EXPRESSION_ENGINE_LAZY_ACQUIRE=false # create the isolate on the first evaluation that needs it N8N_EXPRESSION_ENGINE_COMPILE_CACHE=false # reuse the V8 compile cache for the runtime bundle ('vm' only) +N8N_EXPRESSION_ENGINE_NATIVE_EVALUATION=false # interpret expressions that fit the native subset in-process, no isolate # Bridge limits N8N_EXPRESSION_ENGINE_TIMEOUT=5000 # ms; positive integer diff --git a/packages/@n8n/expression-runtime/package.json b/packages/@n8n/expression-runtime/package.json index bb643aa2cd2..5de2b2e4a86 100644 --- a/packages/@n8n/expression-runtime/package.json +++ b/packages/@n8n/expression-runtime/package.json @@ -37,6 +37,7 @@ "dependencies": { "@n8n/errors": "workspace:*", "@n8n/tournament": "workspace:*", + "@n8n/utils": "workspace:*", "isolated-vm": "catalog:", "jmespath": "0.16.0", "js-base64": "catalog:", diff --git a/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts b/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts index 842628cf4b7..9f14f2fde87 100644 --- a/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts +++ b/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts @@ -1,3 +1,4 @@ +import { LruCache } from '@n8n/utils/lru-cache'; import { readFileSync } from 'node:fs'; import * as path from 'node:path'; @@ -12,7 +13,6 @@ import { TRANSFER_OPAQUE_KEY, TRANSFER_TYPE_KEY, } from '../runtime/transfer'; -import { LruCache } from '../evaluator/lru-cache'; import { dispatchHostCall, getArrayElement, diff --git a/packages/@n8n/expression-runtime/src/evaluator/expression-evaluator.ts b/packages/@n8n/expression-runtime/src/evaluator/expression-evaluator.ts index 9598fea1b45..de3e24da3f7 100644 --- a/packages/@n8n/expression-runtime/src/evaluator/expression-evaluator.ts +++ b/packages/@n8n/expression-runtime/src/evaluator/expression-evaluator.ts @@ -9,12 +9,12 @@ import type { } from '../types'; import { DEFAULT_BRIDGE_CONFIG } from '../types/bridge'; import { IsolateError } from '@n8n/errors'; +import { LruCache } from '@n8n/utils/lru-cache'; import { IdleScalingPool } from '../pool/idle-scaling-pool'; import type { IPool } from '../pool/isolate-pool'; import { IsolatePool, PoolDisposedError, PoolExhaustedError } from '../pool/isolate-pool'; import { EXPRESSION_METRICS } from '../observability/metrics'; import { classifyExpressionError } from './error-classification'; -import { LruCache } from './lru-cache'; function recordOutcome( observability: ObservabilityProvider | undefined, diff --git a/packages/@n8n/tournament/src/ExpressionBuilder.ts b/packages/@n8n/tournament/src/ExpressionBuilder.ts index beb581fb4e3..4744ecc6e0c 100644 --- a/packages/@n8n/tournament/src/ExpressionBuilder.ts +++ b/packages/@n8n/tournament/src/ExpressionBuilder.ts @@ -129,7 +129,7 @@ const buildFunctionBody = (expr: ExpressionKind) => { ]); }; -type ParsedCode = ExpressionCode & { parsed: types.namedTypes.File }; +export type ParsedCode = ExpressionCode & { parsed: types.namedTypes.File }; // This replaces any actual new lines with \n's. This only happens in // template strings. diff --git a/packages/@n8n/tournament/src/index.ts b/packages/@n8n/tournament/src/index.ts index 891f21224ef..5027fa110ae 100644 --- a/packages/@n8n/tournament/src/index.ts +++ b/packages/@n8n/tournament/src/index.ts @@ -6,6 +6,10 @@ import { FunctionEvaluator } from './FunctionEvaluator'; export type { ExpressionEvaluator, ExpressionEvaluatorClass } from './Evaluator'; export * from './ast'; +export { getParsedExpression } from './ExpressionBuilder'; +export type { ParsedCode } from './ExpressionBuilder'; +export { splitExpression } from './ExpressionSplitter'; +export type { ExpressionChunk, ExpressionCode, ExpressionText } from './ExpressionSplitter'; const DATA_NODE_NAME = '___n8n_data'; export type ReturnValue = string | null | (() => unknown); diff --git a/packages/@n8n/expression-runtime/src/evaluator/__tests__/lru-cache.test.ts b/packages/@n8n/utils/src/lru-cache.test.ts similarity index 98% rename from packages/@n8n/expression-runtime/src/evaluator/__tests__/lru-cache.test.ts rename to packages/@n8n/utils/src/lru-cache.test.ts index 30771c33f88..05fa49931c7 100644 --- a/packages/@n8n/expression-runtime/src/evaluator/__tests__/lru-cache.test.ts +++ b/packages/@n8n/utils/src/lru-cache.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { LruCache } from '../lru-cache'; +import { LruCache } from './lru-cache'; describe('LruCache', () => { it('should store and retrieve values', () => { diff --git a/packages/@n8n/expression-runtime/src/evaluator/lru-cache.ts b/packages/@n8n/utils/src/lru-cache.ts similarity index 100% rename from packages/@n8n/expression-runtime/src/evaluator/lru-cache.ts rename to packages/@n8n/utils/src/lru-cache.ts diff --git a/packages/cli/src/commands/__tests__/execute.test.ts b/packages/cli/src/commands/__tests__/execute.test.ts index 43c723717c0..72d61484526 100644 --- a/packages/cli/src/commands/__tests__/execute.test.ts +++ b/packages/cli/src/commands/__tests__/execute.test.ts @@ -208,6 +208,7 @@ test('should exit with a crash when expression engine init fails', async () => { idleTimeout: 30, lazyAcquire: false, compileCache: false, + nativeEvaluation: false, }, generic: { gracefulShutdownTimeout: 30 }, }), @@ -234,6 +235,7 @@ test('should exit with a crash when expression engine init fails', async () => { idleTimeoutMs: 30_000, // the config value is in seconds lazyAcquire: false, compileCache: false, + nativeEvaluation: false, observability: expressionObservability, }); expect(exitSpy).toHaveBeenCalledWith(expect.stringContaining('isolated-vm'), expect.any(Error)); diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 34a6db84861..83a4b0cc97e 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -269,6 +269,7 @@ export abstract class BaseCommand { idleTimeout, lazyAcquire, compileCache, + nativeEvaluation, } = this.globalConfig.expressionEngine; const observability = Container.get(ExpressionObservabilityProvider); try { @@ -281,6 +282,7 @@ export abstract class BaseCommand { idleTimeoutMs: idleTimeout === undefined ? undefined : idleTimeout * 1000, lazyAcquire, compileCache, + nativeEvaluation, observability, }); } catch (error) { diff --git a/packages/cli/src/webhooks/live-webhooks.ts b/packages/cli/src/webhooks/live-webhooks.ts index 0ab5303e2e3..934101927ae 100644 --- a/packages/cli/src/webhooks/live-webhooks.ts +++ b/packages/cli/src/webhooks/live-webhooks.ts @@ -216,6 +216,9 @@ export class LiveWebhooks implements IWebhookManager { * description field of the trigger resolves natively (see * `webhookDescriptionFields` in n8n-workflow) and the node's own parameters * contain no expressions. Anything not proven below acquires eagerly. + * + * TODO(native-evaluation rollout, CAT-4699): delete this gate and its flag; under + * lazy acquisition with native evaluation the prediction is unnecessary. */ private webhookPhaseNeedsIsolate(startNode: INode | null): boolean { if (!this.expressionEngineConfig.allowWebhookIsolateSkip) return true; diff --git a/packages/cli/src/webhooks/webhook-execution-context.ts b/packages/cli/src/webhooks/webhook-execution-context.ts index 25cf320359c..b1920b196a1 100644 --- a/packages/cli/src/webhooks/webhook-execution-context.ts +++ b/packages/cli/src/webhooks/webhook-execution-context.ts @@ -74,6 +74,8 @@ export class WebhookExecutionContext { * `webhookDescriptionFields` in n8n-workflow). Like the engine path, the * resolved value is returned as-is: `defaultValue` only stands in for a * field the description does not define at all. + * + * TODO(native-evaluation rollout, CAT-4699): remove; callers evaluate the template. */ private resolveNatively(propertyName: WebhookDescriptionKey) { return resolveWebhookDescriptionField( diff --git a/packages/cli/src/webhooks/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts index ee941e71524..c2ee432db21 100644 --- a/packages/cli/src/webhooks/webhook.service.ts +++ b/packages/cli/src/webhooks/webhook.service.ts @@ -501,6 +501,7 @@ export class WebhookService { property: string, defaultValue?: string | boolean, ) { + // TODO(native-evaluation rollout, CAT-4699): remove the native resolution, keep the engine call. const native = resolveWebhookDescriptionField(node, webhookDescription, property); if (native.resolved) return native.value; diff --git a/packages/core/src/execution-engine/node-execution-context/utils/webhook-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/webhook-helper-functions.ts index 46c0083a28e..742d87a2994 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/webhook-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/webhook-helper-functions.ts @@ -53,6 +53,7 @@ export function getNodeWebhookUrl( // Prefer the field's native resolver (see `webhookDescriptionFields` in // n8n-workflow) so static-parameter nodes never engage the expression engine. + // TODO(native-evaluation rollout, CAT-4699): remove both native resolutions below. const nativePath = resolveWebhookDescriptionField(node, webhookDescription, 'path'); const path = nativePath.resolved ? nativePath.value diff --git a/packages/nodes-base/nodes/Webhook/description.ts b/packages/nodes-base/nodes/Webhook/description.ts index 75ba5cce164..2f3b9ea0c92 100644 --- a/packages/nodes-base/nodes/Webhook/description.ts +++ b/packages/nodes-base/nodes/Webhook/description.ts @@ -4,7 +4,7 @@ import type { INodeTypeDescription, IWebhookDescription, } from 'n8n-workflow'; -import { fromFunction, fromParameter, webhookDescriptionFields } from 'n8n-workflow'; +import { fromExpression, fromParameter, webhookDescriptionFields } from 'n8n-workflow'; import { getResponseCode, getResponseData } from './utils'; @@ -22,14 +22,31 @@ const n8nOAuth2AuthOption: INodePropertyOptions = { // Each field declares its expression template and native resolver in one place: // the editor evaluates the generated template strings, while the backend reads // parameters directly (no expression engine) whenever they are static. +// +// TODO(native-evaluation rollout, CAT-4699): once lazy isolate acquisition and +// native evaluation are the defaults, replace every fromParameter/ +// fromExpression declaration below with its plain inline template string and +// drop the webhookDescriptionFields wrapper - the resolvers only exist to +// predict engine use, which native evaluation makes unnecessary. export const defaultWebhookDescription: IWebhookDescription = { name: 'default', isFullPath: true, ...webhookDescriptionFields({ httpMethod: fromParameter('httpMethod', 'GET'), - responseCode: fromFunction(getResponseCode), + // The templates for responseCode/responseData are hand-written in the + // native subset grammar so they evaluate in-process without an engine. + // They mirror getResponseCode/getResponseData branch for branch; + // description.test.ts executes both across every branch to pin the + // parity. + responseCode: fromExpression( + '={{ $parameter.responseCode || ($parameter.options?.responseCode?.values ? ($parameter.options.responseCode.values.customCode || $parameter.options.responseCode.values.responseCode) : 200) }}', + getResponseCode, + ), responseMode: fromParameter('responseMode'), - responseData: fromFunction(getResponseData), + responseData: fromExpression( + "={{ $parameter.responseData || ($parameter.responseMode === 'onReceived' && $parameter.options?.responseData ? $parameter.options.responseData : ($parameter.options?.noResponseBody ? 'noData' : undefined)) }}", + getResponseData, + ), responseBinaryPropertyName: fromParameter('responseBinaryPropertyName'), responseContentType: fromParameter(['options', 'responseContentType']), responsePropertyName: fromParameter(['options', 'responsePropertyName']), diff --git a/packages/nodes-base/nodes/Webhook/test/description.test.ts b/packages/nodes-base/nodes/Webhook/test/description.test.ts index 648fe53c1cb..f83b0912f15 100644 --- a/packages/nodes-base/nodes/Webhook/test/description.test.ts +++ b/packages/nodes-base/nodes/Webhook/test/description.test.ts @@ -1,5 +1,11 @@ import type { IDataObject, INodeParameters, INodeType, INodeTypes } from 'n8n-workflow'; -import { Workflow, WEBHOOK_RESOLVERS, webhookDescriptionIsNativelyResolvable } from 'n8n-workflow'; +import { + Expression, + Workflow, + WEBHOOK_RESOLVERS, + isNativelyEvaluable, + webhookDescriptionIsNativelyResolvable, +} from 'n8n-workflow'; import { defaultWebhookDescription } from '../description'; import { Webhook } from '../Webhook.node'; @@ -54,9 +60,11 @@ describe('defaultWebhookDescription', () => { responseHeaders: '={{$parameter["options"]["responseHeaders"]}}', path: '={{$parameter["path"]}}', }); - // The function-body templates inline the functions' source, as before. - expect(defaultWebhookDescription.responseCode).toMatch(/^=\{\{\(.+\)\(\$parameter\)\}\}$/s); - expect(defaultWebhookDescription.responseData).toMatch(/^=\{\{\(.+\)\(\$parameter\)\}\}$/s); + // responseCode/responseData are hand-written in the native subset + // grammar (fromExpression) so they evaluate in-process; the parity + // suite below pins them to their resolvers. + expect(isNativelyEvaluable(String(defaultWebhookDescription.responseCode).slice(1))).toBe(true); + expect(isNativelyEvaluable(String(defaultWebhookDescription.responseData).slice(1))).toBe(true); }); describe('resolvers match their templates', () => { @@ -99,20 +107,32 @@ describe('defaultWebhookDescription', () => { const resolvers = defaultWebhookDescription[WEBHOOK_RESOLVERS]!; - describe.each(parameterSets)('$case', ({ parameters }) => { - const { workflow, node } = nodeWithParameters(parameters); + afterEach(() => { + Expression.setNativeEvaluation(false); + }); - test.each(Object.keys(resolvers))('%s', (field) => { - const native = resolvers[field].resolve(node.parameters); + // The template is evaluated both through the engine and natively, so + // resolver, template and native evaluation are pinned to each other. + describe.each([ + { path: 'engine', nativeEvaluation: false }, + { path: 'native evaluation', nativeEvaluation: true }, + ])('via $path', ({ nativeEvaluation }) => { + describe.each(parameterSets)('$case', ({ parameters }) => { + const { workflow, node } = nodeWithParameters(parameters); - const viaEngine = workflow.expression.getSimpleParameterValue( - node, - defaultWebhookDescription[field] as string, - 'internal', - {}, - ); + test.each(Object.keys(resolvers))('%s', (field) => { + const native = resolvers[field].resolve(node.parameters); - expect(native).toEqual(viaEngine); + Expression.setNativeEvaluation(nativeEvaluation); + const viaTemplate = workflow.expression.getSimpleParameterValue( + node, + defaultWebhookDescription[field] as string, + 'internal', + {}, + ); + + expect(native).toEqual(viaTemplate); + }); }); }); }); diff --git a/packages/workflow/src/expression.ts b/packages/workflow/src/expression.ts index ae41298450a..c8c248a637d 100644 --- a/packages/workflow/src/expression.ts +++ b/packages/workflow/src/expression.ts @@ -8,6 +8,7 @@ import { ExpressionError } from './errors/expression.error'; import { evaluateExpression, setErrorHandler } from './expression-evaluator-proxy'; import { expressionSandboxHooks, sanitizer, sanitizerName } from './expression-sandboxing'; import { isExpression } from './expressions/expression-helpers'; +import { evaluateNatively } from './expressions/native-evaluation'; import * as LoggerProxy from './logger-proxy'; import { extend, extendOptional } from './extensions'; import { extendSyntax } from './extensions/expression-extension'; @@ -226,6 +227,8 @@ export class Expression { private static useSharedCaller = false; + private static nativeEvaluation = false; + constructor(private readonly timezone: string) {} /** @@ -271,7 +274,10 @@ export class Expression { sharedCaller?: boolean; lazyAcquire?: boolean; compileCache?: boolean; + /** Experimental: interpret expressions that fit the native subset in-process. Applies to every engine. */ + nativeEvaluation?: boolean; }): Promise { + this.nativeEvaluation = options.nativeEvaluation ?? false; if (options.engine === 'legacy') return; if (options.engine === 'vm' && IS_FRONTEND) return; this.expressionEngine = options.engine; @@ -400,6 +406,11 @@ export class Expression { this.expressionEngine = engine; } + /** Toggle native evaluation without restarting the engine. For tests and benchmarks; production sets `N8N_EXPRESSION_ENGINE_NATIVE_EVALUATION`. */ + static setNativeEvaluation(enabled: boolean): void { + this.nativeEvaluation = enabled; + } + static initializeGlobalContext(data: IDataObject) { /** * Denylist @@ -623,6 +634,14 @@ export class Expression { // Remove the equal sign parameterValue = parameterValue.substr(1); + // An expression that fits the native subset grammar is interpreted + // in-process, skipping the global-context setup, extendSyntax, and the + // engine (isolate). Everything else takes the regular pipeline below. + if (Expression.nativeEvaluation) { + const native = evaluateNatively(parameterValue, data); + if (native.handled) return this.finalizeResolvedValue(native.value, returnObjectAsString); + } + // Support only a subset of process properties data.process = typeof process !== 'undefined' @@ -688,20 +707,25 @@ export class Expression { // Execute the expression const extendedExpression = extendSyntax(parameterValue); const returnValue = this.renderExpression(extendedExpression, data); + return this.finalizeResolvedValue(returnValue, returnObjectAsString); + } + + private finalizeResolvedValue( + returnValue: unknown, + returnObjectAsString: boolean, + ): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] { if (typeof returnValue === 'function') { if (returnValue.name === 'DateTime') throw new UserError('this is a DateTime, please access its methods'); throw new UserError('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); - } + } else if (returnValue !== null && typeof returnValue === 'object' && returnObjectAsString) { + return this.convertObjectValueToString(returnValue); } - return returnValue; + // The engines return arbitrary JSON-ish values; mirror the loose typing + // the previous inline code relied on. + return returnValue as NodeParameterValue; } private renderExpression(expression: string, data: IWorkflowDataProxyData) { diff --git a/packages/workflow/src/expressions/native-evaluation/evaluator.ts b/packages/workflow/src/expressions/native-evaluation/evaluator.ts new file mode 100644 index 00000000000..f0913b4a096 --- /dev/null +++ b/packages/workflow/src/expressions/native-evaluation/evaluator.ts @@ -0,0 +1,336 @@ +import { ExpressionExtensionError } from '../../errors/expression-extension.error'; +import { ExpressionError } from '../../errors/expression.error'; +import type { IWorkflowDataProxyData } from '../../interfaces'; +import { + ARRAY_METHODS, + MAX_RESULT_LENGTH, + NUMBER_METHODS, + STRING_METHODS, + hasOwn, + isArray, + toNum, + toStr, + type BinaryOp, + type NativeMethod, + type SimpleNode, +} from './grammar'; + +// ── Evaluation: total over the grammar. ──────────────────────────────────── +// +// evalNode reads only fields parseSimple constructed, because those are the +// only fields that exist. The switch has no default branch: adding a kind to +// the grammar stops compilation until it is handled here. + +// Thrown when a runtime value falls outside what parsing proved statically +// (a whitelisted string method on a non-string receiver, an operator on an +// object operand, a result above MAX_RESULT_LENGTH). The whole evaluation is +// abandoned and the caller re-runs the expression through the regular engine +// pipeline. Anything evaluated before the bail runs again in the engine: a +// nested `$parameter` expression, or a getter on a data object. Expressions +// are pure and workflow data is JSON, so the only cost is the repeated work. +export class EngineFallbackError extends Error {} + +// Thrown by an optional member/call on a nullish receiver and caught by the +// enclosing chain node, which yields undefined for the whole chain. One +// shared instance: a missing optional hop is ordinary data, not an error. +class ChainShortCircuit extends Error {} +const chainShortCircuit = new ChainShortCircuit(); + +// Property lookup on a non-nullish primitive is well-defined and side-effect +// free, so primitives are indexable here even though the predicate's type +// only names objects. +const isIndexable = (value: unknown): value is Record => + value !== null && value !== undefined; + +// Operators only ever see primitives. An object operand would coerce through +// its valueOf/toString on the host, where the engine sees a structured-clone +// copy; a reference comparison would differ from the engine's copy semantics. +const isPrimitive = (value: unknown): boolean => + value === null || (typeof value !== 'object' && typeof value !== 'function'); + +export function bounded(value: T): T { + const isSizeable = typeof value === 'string' || isArray(value); + + if (isSizeable && value.length > MAX_RESULT_LENGTH) { + throw new EngineFallbackError(); + } + + return value; +} + +// Operand casts are intentional: the interpreter must reproduce JS coercion +// semantics exactly (parity with the engines), not re-implement them. +/* eslint-disable @typescript-eslint/no-explicit-any */ +const binaryOps: Record unknown> = Object.freeze({ + '===': (l, r) => l === r, + '!==': (l, r) => l !== r, + // eslint-disable-next-line eqeqeq + '==': (l, r) => l == r, + // eslint-disable-next-line eqeqeq + '!=': (l, r) => l != r, + '<': (l, r) => l < r, + '<=': (l, r) => l <= r, + '>': (l, r) => l > r, + '>=': (l, r) => l >= r, + '+': (l, r) => bounded(l + r), + '-': (l, r) => l - r, + '*': (l, r) => l * r, + '/': (l, r) => l / r, + '%': (l, r) => l % r, +}); +/* eslint-enable @typescript-eslint/no-explicit-any */ + +function evalMember( + node: Extract, + data: IWorkflowDataProxyData, +): unknown { + const object = evalNode(node.object, data); + + if (node.optional && (object === null || object === undefined)) { + throw chainShortCircuit; + } + + if (!isIndexable(object)) { + throw new TypeError(`Cannot read properties of ${toStr(object)} (reading '${node.key}')`); + } + + // Below the roots (get-trap proxies, where Object.hasOwn misreports every + // key) the data is plain JSON, so an inherited property is a host prototype + // the isolates never see. The engine decides what it yields. + const inherited = + node.object.kind !== 'root' && + typeof object === 'object' && + node.key in object && + !hasOwn(object, node.key); + if (inherited) { + throw new EngineFallbackError(); + } + + const value = object[node.key]; + + // Neither crosses an engine boundary the same way twice (the vm bridge + // drops a nested function but fails on an inherited one; legacy throws), + // so the configured engine decides rather than this module guessing. + if (typeof value === 'function' || typeof value === 'symbol') { + throw new EngineFallbackError(); + } + + return value; +} + +// Parsing only proves the method name; the receiver's type is data. A +// receiver whose type has no allowlist entry for the method could be +// intercepted by extensions, so it hands the whole expression to the engine. +function methodFor(receiver: unknown, name: string): NativeMethod { + let method: NativeMethod | undefined; + + if (typeof receiver === 'string') { + method = STRING_METHODS.get(name); + } else if (typeof receiver === 'number') { + method = NUMBER_METHODS.get(name); + } else if (isArray(receiver)) { + method = ARRAY_METHODS.get(name); + } + + if (method === undefined) throw new EngineFallbackError(); + + return method; +} + +// Arguments are primitives, plus arrays for concat. An object argument would +// compare by live reference where the isolate compares copies (includes/ +// indexOf) or coerce on the host where the isolate sees a copy. +function isAllowedArgument(method: string, arg: unknown): boolean { + if (isPrimitive(arg)) return true; + + return method === 'concat' && isArray(arg); +} + +// The engine runs under a timeout; a synchronous native call cannot be +// interrupted, so the input size is the budget. A receiver above the result +// cap goes to the engine before any work is done, and the amplifying methods +// (which can allocate far beyond MAX_RESULT_LENGTH before bounded() sees the +// result) bail on an upper bound of their output. +function preflightSize(receiver: unknown, method: string, args: unknown[]): void { + let upperBound = typeof receiver === 'number' ? 0 : (receiver as { length: number }).length; + + if (method === 'concat') { + for (const arg of args) { + upperBound += isArray(arg) ? arg.length : 1; + } + } else if (method === 'flat' && isArray(receiver)) { + const depth = args.length === 0 ? 1 : toNum(args[0]); + upperBound = flatSize(receiver, depth); + } else if (method === 'replaceAll' && typeof receiver === 'string') { + // A missing replacement inserts the string "undefined". + const replacement = args.length < 2 ? 'undefined' : toStr(args[1]); + + // `$&`, `$\``, `$'` splice match context into every replacement, so + // the result is not bounded by the replacement's length. + if (replacement.includes('$')) throw new EngineFallbackError(); + + upperBound = (receiver.length + 1) * (replacement.length + 1); + } else if (method === 'join' && isArray(receiver)) { + // Only an undefined separator means ","; null joins with "null". + const separator = args[0] === undefined ? ',' : toStr(args[0]); + upperBound = separator.length * Math.max(receiver.length - 1, 0); + + for (const element of receiver) { + upperBound += toStr(element ?? '').length; + } + } + + if (upperBound > MAX_RESULT_LENGTH) { + throw new EngineFallbackError(); + } +} + +// Element count of `array.flat(depth)`, stopping early once past the cap +// (a nested structure can flatten to far more elements than the outer +// array holds). +function flatSize(array: unknown[], depth: number): number { + let size = 0; + for (const element of array) { + size += depth >= 1 && isArray(element) ? flatSize(element, depth - 1) : 1; + if (size > MAX_RESULT_LENGTH) break; + } + return size; +} + +function evalCall( + node: Extract, + data: IWorkflowDataProxyData, +): unknown { + const receiver = evalNode(node.receiver, data); + const receiverMissing = receiver === null || receiver === undefined; + + if (node.optional && receiverMissing) { + throw chainShortCircuit; + } + + if (receiverMissing) { + throw new TypeError(`Cannot read properties of ${toStr(receiver)} (reading '${node.method}')`); + } + + // An own property shadowing the method (an engine-resolved $parameter value + // can carry one) would take precedence in the engines. + if (hasOwn(receiver, node.method)) { + throw new EngineFallbackError(); + } + + const method = methodFor(receiver, node.method); + + const args = node.args.map((argument) => evalNode(argument, data)); + if (!args.every((arg) => isAllowedArgument(node.method, arg))) { + throw new EngineFallbackError(); + } + + preflightSize(receiver, node.method, args); + + return bounded(method.apply(receiver, args)); +} + +function evalChain( + node: Extract, + data: IWorkflowDataProxyData, +): unknown { + try { + return evalNode(node.expression, data); + } catch (error) { + if (error === chainShortCircuit) return undefined; + + throw error; + } +} + +function evalUnary( + node: Extract, + data: IWorkflowDataProxyData, +): unknown { + const argument = evalNode(node.argument, data); + + if (node.op === '!') { + return !argument; + } + + if (!isPrimitive(argument)) { + throw new EngineFallbackError(); + } + + return node.op === '-' ? -toNum(argument) : toNum(argument); +} + +function evalBinary( + node: Extract, + data: IWorkflowDataProxyData, +): unknown { + const left = evalNode(node.left, data); + const right = evalNode(node.right, data); + + if (!isPrimitive(left) || !isPrimitive(right)) { + throw new EngineFallbackError(); + } + + return binaryOps[node.op](left, right); +} + +function evalLogical( + node: Extract, + data: IWorkflowDataProxyData, +): unknown { + const left = evalNode(node.left, data); + + switch (node.op) { + case '&&': + return left ? evalNode(node.right, data) : left; + case '||': + return left ? left : evalNode(node.right, data); + case '??': + return left ?? evalNode(node.right, data); + } +} + +function evalNode(node: SimpleNode, data: IWorkflowDataProxyData): unknown { + switch (node.kind) { + case 'literal': + return node.value; + case 'root': + return node.name === '$json' ? data.$json : data.$parameter; + case 'undefined': + return undefined; + case 'member': + return evalMember(node, data); + case 'chain': + return evalChain(node, data); + case 'unary': + return evalUnary(node, data); + case 'binary': + return evalBinary(node, data); + case 'logical': + return evalLogical(node, data); + case 'conditional': + return evalNode(node.test, data) + ? evalNode(node.consequent, data) + : evalNode(node.alternate, data); + case 'call': + return evalCall(node, data); + } +} + +// Tournament wraps each code chunk in try/catch and routes errors to the E() +// handler, which rethrows ExpressionErrors and swallows everything else (the +// chunk then yields undefined). Mirror that exactly. +export function evalChunk(node: SimpleNode, data: IWorkflowDataProxyData): unknown { + try { + return evalNode(node, data); + } catch (error) { + const rethrow = + error instanceof EngineFallbackError || + error instanceof ExpressionError || + error instanceof ExpressionExtensionError; + + if (rethrow) throw error; + + return undefined; + } +} diff --git a/packages/workflow/src/expressions/native-evaluation/grammar.ts b/packages/workflow/src/expressions/native-evaluation/grammar.ts new file mode 100644 index 00000000000..55021d4699f --- /dev/null +++ b/packages/workflow/src/expressions/native-evaluation/grammar.ts @@ -0,0 +1,143 @@ +import type { namedTypes } from 'ast-types'; + +// The closed subset grammar, its operator and method allowlists, and the +// limits the parser and the evaluator share. See index.ts for the overview. + +export const BINARY_OPS = [ + '===', + '!==', + '==', + '!=', + '<', + '<=', + '>', + '>=', + '+', + '-', + '*', + '/', + '%', +] as const; +export const UNARY_OPS = ['!', '-', '+'] as const; + +export type BinaryOp = (typeof BINARY_OPS)[number]; +export type LogicalOp = namedTypes.LogicalExpression['operator']; +export type UnaryOp = (typeof UNARY_OPS)[number]; + +// The subset grammar. Closed: nothing outside it is representable, so nothing +// outside it can reach the interpreter. `kind` (not esprima's `type`) is the +// discriminant, so a raw AST node can never be mistaken for a SimpleNode. +export type SimpleNode = + | { kind: 'literal'; value: string | number | boolean | null } + | { kind: 'root'; name: '$json' | '$parameter' } + | { kind: 'undefined' } + | { kind: 'member'; object: SimpleNode; key: string | number; optional: boolean } + | { kind: 'chain'; expression: SimpleNode } + | { kind: 'unary'; op: UnaryOp; argument: SimpleNode } + | { kind: 'binary'; op: BinaryOp; left: SimpleNode; right: SimpleNode } + | { kind: 'logical'; op: LogicalOp; left: SimpleNode; right: SimpleNode } + | { kind: 'conditional'; test: SimpleNode; consequent: SimpleNode; alternate: SimpleNode } + | { kind: 'call'; receiver: SimpleNode; method: string; args: SimpleNode[]; optional: boolean }; + +// Roots are `$json` and `$parameter` only (the literal comparisons in +// parseIdentifier). More roots are CAT-4697. `$now`/`$today` are excluded on +// purpose: they are Luxon DateTimes whose methods would make every useful +// expression on them a CallExpression anyway. +// +// Note on $parameter: the proxy resolves nested `=` parameter values by +// re-entering resolveSimpleParameterValue - a nested non-simple value simply +// takes the engine path there (under lazy acquisition, creating the bridge on +// demand). + +// Intrinsics are captured at import so a later patch to a prototype or a +// global (a polyfill, a community package) does not change what the native +// path runs; the isolates never see such a patch either. +export const { isArray } = Array; +export const { hasOwn } = Object; +export const toStr = String; +export const toNum = Number; +export const clone = structuredClone; + +export type NativeMethod = (this: unknown, ...args: unknown[]) => unknown; + +const captureMethods = (proto: object, names: string[]): ReadonlyMap => { + const methods = new Map(); + for (const name of names) { + const method: unknown = Reflect.get(proto, name); + if (typeof method === 'function') methods.set(name, method as NativeMethod); + } + return methods; +}; + +// Native prototype methods callable on a receiver of the matching type. +// Every name here must stay disjoint from the expression-extension method +// names (extendSyntax rewrites extension calls into `extend()` dispatch; +// natives pass through untouched) - pinned by a test in the parity corpus. +// The receiver's type is only known at runtime: a receiver whose type has no +// allowlist entry for the method makes the evaluation bail to the engine +// (EngineFallbackError below). Callback-taking forms (regex/function args, +// array callbacks) are unrepresentable: those argument nodes decline parsing. +// Accepting callbacks is CAT-4698. +export const STRING_METHODS = captureMethods(String.prototype, [ + 'toUpperCase', + 'toLowerCase', + 'trim', + 'trimStart', + 'trimEnd', + 'includes', + 'startsWith', + 'endsWith', + 'slice', + 'indexOf', + 'charAt', + 'replace', + 'replaceAll', +]); + +export const NUMBER_METHODS = captureMethods(Number.prototype, [ + 'toFixed', + 'toPrecision', + 'toString', +]); + +// Non-mutating methods only: the receiver is the live workflow data (the vm +// engine evaluates a copy inside the isolate), so an in-place mutator like +// sort()/reverse()/fill() would corrupt execution data as a side effect. Use +// the toSorted()/toReversed() immutable variants instead. Iterator-returning +// methods (entries/values/keys) are also excluded: a live iterator has no +// equivalent representation across the engine boundary. +export const ARRAY_METHODS = captureMethods(Array.prototype, [ + 'includes', + 'indexOf', + 'lastIndexOf', + 'join', + 'slice', + 'at', + 'concat', + 'flat', + 'toSorted', + 'toReversed', +]); + +export const CALLABLE_METHODS = new Set([ + ...STRING_METHODS.keys(), + ...NUMBER_METHODS.keys(), + ...ARRAY_METHODS.keys(), +]); + +// The engine evaluates under a timeout and a memory limit; this interpreter +// has neither. A string or array result above this length is handed to the +// engine so those limits apply (nested replaceAll('', ...) or concat chains +// can otherwise expand without bound on the main thread). +export const MAX_RESULT_LENGTH = 1_000_000; + +// Nesting depth of the subset grammar. Parsing and evaluation both recurse +// once per level, so this keeps a pathological expression off the host stack; +// real expressions are a handful of levels deep. +export const MAX_DEPTH = 64; + +export const isObj = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +export const isOneOf = (ops: readonly T[], op: unknown): op is T => + typeof op === 'string' && (ops as readonly string[]).includes(op); diff --git a/packages/workflow/src/expressions/native-evaluation/index.ts b/packages/workflow/src/expressions/native-evaluation/index.ts new file mode 100644 index 00000000000..d75ec1e6529 --- /dev/null +++ b/packages/workflow/src/expressions/native-evaluation/index.ts @@ -0,0 +1,195 @@ +import { getParsedExpression } from '@n8n/tournament'; +import type { ParsedCode } from '@n8n/tournament'; +import { LruCache } from '@n8n/utils/lru-cache'; + +import { EngineFallbackError, bounded, evalChunk } from './evaluator'; +import { MAX_RESULT_LENGTH, clone, isObj, type SimpleNode } from './grammar'; +import { parseSimple } from './parser'; +import type { IWorkflowDataProxyData } from '../../interfaces'; + +export { CALLABLE_METHODS, MAX_RESULT_LENGTH } from './grammar'; + +// Fast native evaluation: an in-process interpreter for a closed subset of +// the expression grammar. +// +// The subset is data path access on `$json` and `$parameter`, literals, a +// fixed set of operators, and calls to a closed allowlist of native +// string/number/array methods. Such expressions cannot loop, reach +// prototypes, or touch anything outside the data proxy, so they are +// interpreted here without the sandbox AST hooks, the global-context setup, +// or an engine (isolate) evaluation. Anything that does not fit the subset +// is declined and takes the regular pipeline. +// +// Structure follows "parse, don't validate": the esprima AST is not checked +// in place, it is re-parsed into the closed {@link SimpleNode} grammar below. +// Construction either yields a node of that grammar or null; the interpreter +// only ever sees objects this module built, so it cannot read a field the +// parser did not put there, and unsupported constructs are unrepresentable +// rather than rejected. Expression text is never executed as code. +// +// Data is the other input. Node output is normalised to JSON at the node +// boundary (verified for Set and Code output on a running instance), so +// `$json` holds plain objects, arrays and primitives. The runtime guards +// below therefore target what JSON content can do (sizes) and where the host +// would diverge from the isolates (live references, coercion, aliasing), not +// hooks that cannot arrive. Non-JSON values in workflow data are engine bugs. +// +// Layout: grammar.ts holds the closed grammar and the allowlists, parser.ts +// turns esprima output into it, evaluator.ts interprets it, and this file +// splits, compiles, caches and runs whole expressions. + +type CompiledChunk = { type: 'text'; text: string } | { type: 'code'; node: SimpleNode }; + +interface CompiledExpression { + chunks: CompiledChunk[]; + // Exactly [empty text, code]: return the code chunk's raw value instead of + // string-concatenating (tmpl compatibility, see ExpressionBuilder). + isWholeValue: boolean; +} + +// null = outside the subset. Declined expressions are cached too, so an +// expression the engine owns costs one parse here, not one per evaluation. +// Entries are keyed by expression text, so the key length is capped to keep +// the cache's memory bounded; longer expressions are compiled per evaluation. +const cache = new LruCache(1024); +const MAX_CACHED_EXPRESSION_LENGTH = 10_000; + +// One code chunk of the split expression, or null when it is outside the +// subset. A throw during the re-parse must always mean "declined", never +// escape. +function compileChunk(chunk: ParsedCode): SimpleNode | null { + const body = chunk.parsed.program.body; + if (body.length !== 1) return null; + + const statement = body[0]; + if (statement.type !== 'ExpressionStatement') return null; + + try { + return parseSimple(statement.expression); + } catch { + return null; + } +} + +function compile(expression: string): CompiledExpression | null { + let parsed; + try { + parsed = getParsedExpression(expression); + } catch { + // Syntax errors fall through to the engine for its error reporting. + return null; + } + + const chunks: CompiledChunk[] = []; + for (const chunk of parsed) { + if (chunk.type === 'text') { + chunks.push(chunk); + continue; + } + + const node = compileChunk(chunk); + if (node === null) return null; + + chunks.push({ type: 'code', node }); + } + + // Mirrors the branch condition in ExpressionBuilder.getExpressionCode. + const isWholeValue = chunks.length === 2 && chunks[0].type === 'text' && chunks[0].text === ''; + + return { chunks, isWholeValue }; +} + +function getCompiled(expression: string): CompiledExpression | null { + if (expression.length > MAX_CACHED_EXPRESSION_LENGTH) return compile(expression); + + let compiled = cache.get(expression); + + if (compiled === undefined) { + compiled = compile(expression); + cache.set(expression, compiled); + } + + return compiled; +} + +/** Does the expression (leading `=` stripped) fit the native subset? */ +export function isNativelyEvaluable(expression: string): boolean { + return getCompiled(expression) !== null; +} + +/** + * Evaluate an expression (with the leading `=` already stripped) in-process. + * Returns `{ handled: false }` when the expression is outside the subset or a + * runtime value fell outside what parsing proved; the caller must then run + * the regular pipeline. + */ +export function evaluateNatively( + expression: string, + data: IWorkflowDataProxyData, +): { handled: true; value: unknown } | { handled: false } { + const compiled = getCompiled(expression); + if (compiled === null) return { handled: false }; + + try { + const value = copyResult(evalCompiled(compiled, data)); + return { handled: true, value }; + } catch (error) { + if (error instanceof EngineFallbackError) return { handled: false }; + + throw error; + } +} + +// The engines hand back a copy of any object they return; a whole-value read +// like `{{ $json.body }}` here would hand back the live execution data, which +// callers then mutate in place (cleanupParameterData). Match the engines. +// The `$parameter` root is a Proxy and cannot be cloned: the engine owns it. +function copyResult(value: unknown): unknown { + if (!isObj(value)) return value; + + try { + return clone(value); + } catch { + throw new EngineFallbackError(); + } +} + +function evalCompiled(compiled: CompiledExpression, data: IWorkflowDataProxyData): unknown { + if (compiled.isWholeValue) { + const code = compiled.chunks[1]; + // isWholeValue guarantees chunks = [text '', code] + return code.type === 'code' ? evalChunk(code.node, data) : ''; + } + + // String concatenation, mirroring tmpl semantics: falsy chunk values other + // than 0/false render as '', parts are joined with String() coercion. + const parts: unknown[] = []; + let length = 0; + for (const chunk of compiled.chunks) { + if (chunk.type === 'text') { + if (chunk.text !== '') parts.push(chunk.text); + length += chunk.text.length; + continue; + } + + const value = evalChunk(chunk.node, data); + + // An object here would coerce through its toString on the host. + if (isObj(value)) { + throw new EngineFallbackError(); + } + + const part = value || value === 0 || value === false ? value : ''; + parts.push(part); + length += typeof part === 'string' ? part.length : 1; + + // Bound before the join allocates the combined string. + if (length > MAX_RESULT_LENGTH) throw new EngineFallbackError(); + } + + // Single-chunk expressions (plain text, or a lone blank `{{}}`) return the + // part as-is; everything else joins to a string. + if (compiled.chunks.length < 2) return parts[0] ?? ''; + + return bounded(parts.join('')); +} diff --git a/packages/workflow/src/expressions/native-evaluation/parser.ts b/packages/workflow/src/expressions/native-evaluation/parser.ts new file mode 100644 index 00000000000..4a43d292dfc --- /dev/null +++ b/packages/workflow/src/expressions/native-evaluation/parser.ts @@ -0,0 +1,207 @@ +import type { namedTypes } from 'ast-types'; +import type { ExpressionKind, SpreadElementKind } from 'ast-types/lib/gen/kinds'; + +import { + BINARY_OPS, + CALLABLE_METHODS, + MAX_DEPTH, + UNARY_OPS, + isOneOf, + type SimpleNode, +} from './grammar'; +import { isSafeObjectProperty } from '../../utils'; + +// ── Parsing: esprima output → the subset grammar, or null. ──────────────── +// +// Total (never throws), constructive (returns new objects; the esprima node +// is read and dropped, so no unvetted field survives), and recursive (one +// unsupported leaf makes the whole expression decline). The ast-types +// interfaces describe the ESTree shapes esprima emits; they are the same +// types tournament builds its AST with. + +// Child parses go through the depth-tracking closure parseSimple builds. +type ParseChild = (node: AstNode) => SimpleNode | null; + +type AstNode = ExpressionKind | SpreadElementKind; + +function parseLiteral(node: namedTypes.Literal): SimpleNode | null { + // Regex literals stay on the engine (backtracking blowup has no isolate + // timeout here). + if ('regex' in node && node.regex) return null; + // BigInt literals carry `bigint` metadata; `value` is null where BigInt + // is unsupported, so decline on the metadata rather than the value. + if ('bigint' in node && node.bigint) return null; + + const value = node.value; + const isPrimitiveLiteral = + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean'; + + if (!isPrimitiveLiteral) return null; + + return { kind: 'literal', value }; +} + +function parseIdentifier(node: namedTypes.Identifier): SimpleNode | null { + if (node.name === '$json' || node.name === '$parameter') { + return { kind: 'root', name: node.name }; + } + + if (node.name === 'undefined') { + return { kind: 'undefined' }; + } + + return null; +} + +// Only static keys are representable. Dynamic keys ($json[$json.key]) need +// the sandbox's PrototypeSanitizer, so they stay on the engine. String keys +// are vetted at parse time (isSafeObjectProperty), which keeps prototype +// names unrepresentable. An own-property-only lookup would make that vetting +// unnecessary, but $json/$parameter are get-trap proxies for which +// Object.hasOwn misreports every key, so the parse-time vet is the boundary. +function parseMember(node: namedTypes.MemberExpression, parse: ParseChild): SimpleNode | null { + const object = parse(node.object); + if (object === null) return null; + + const key = + node.computed === true ? parseComputedKey(node.property) : parseStaticKey(node.property); + if (key === null) return null; + + return { kind: 'member', object, key, optional: node.optional === true }; +} + +// `a[0]` or `a['b']`: the key is a literal. +function parseComputedKey( + property: namedTypes.MemberExpression['property'], +): string | number | null { + if (property.type !== 'Literal') return null; + + const value = property.value; + + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'string' && isSafeObjectProperty(value)) { + return value; + } + + return null; +} + +// `a.b`: the key is an identifier. +function parseStaticKey(property: namedTypes.MemberExpression['property']): string | null { + if (property.type !== 'Identifier' || !isSafeObjectProperty(property.name)) return null; + + return property.name; +} + +function parseCall(node: namedTypes.CallExpression, parse: ParseChild): SimpleNode | null { + const callee = node.callee; + if (callee.type !== 'MemberExpression' || callee.computed === true) return null; + + const property = callee.property; + if (property.type !== 'Identifier' || !CALLABLE_METHODS.has(property.name)) return null; + + const receiver = parse(callee.object); + if (receiver === null) return null; + + const args: SimpleNode[] = []; + for (const argument of node.arguments) { + const parsed = parse(argument); + if (parsed === null) return null; + + args.push(parsed); + } + + // `a?.m()` marks the member optional, `a.m?.()` marks the call optional; + // both short-circuit on a missing receiver, so one flag carries both. + const optional = node.optional === true || callee.optional === true; + + return { kind: 'call', receiver, method: property.name, args, optional }; +} + +// The wrapper esprima puts around every optional chain. It is the boundary an +// optional hop short-circuits to: `a?.b.c` yields undefined for a missing +// `a`, while `(a?.b).c` throws. Parentheses end the chain in the AST too. +function parseChain(node: namedTypes.ChainExpression, parse: ParseChild): SimpleNode | null { + const expression = parse(node.expression); + if (expression === null) return null; + + return { kind: 'chain', expression }; +} + +function parseBranches( + node: namedTypes.ConditionalExpression, + parse: ParseChild, +): SimpleNode | null { + const test = parse(node.test); + const consequent = parse(node.consequent); + const alternate = parse(node.alternate); + + if (test === null || consequent === null || alternate === null) return null; + + return { kind: 'conditional', test, consequent, alternate }; +} + +function parseUnary(node: namedTypes.UnaryExpression, parse: ParseChild): SimpleNode | null { + if (node.prefix !== true || !isOneOf(UNARY_OPS, node.operator)) return null; + + const argument = parse(node.argument); + if (argument === null) return null; + + return { kind: 'unary', op: node.operator, argument }; +} + +function parseBinary(node: namedTypes.BinaryExpression, parse: ParseChild): SimpleNode | null { + if (!isOneOf(BINARY_OPS, node.operator)) return null; + + const left = parse(node.left); + const right = parse(node.right); + + if (left === null || right === null) return null; + + return { kind: 'binary', op: node.operator, left, right }; +} + +function parseLogical(node: namedTypes.LogicalExpression, parse: ParseChild): SimpleNode | null { + const left = parse(node.left); + const right = parse(node.right); + + if (left === null || right === null) return null; + + return { kind: 'logical', op: node.operator, left, right }; +} + +export function parseSimple(node: AstNode, depth = 0): SimpleNode | null { + if (depth > MAX_DEPTH) return null; + const parse: ParseChild = (child) => parseSimple(child, depth + 1); + + switch (node.type) { + case 'Literal': + return parseLiteral(node); + case 'Identifier': + return parseIdentifier(node); + case 'MemberExpression': + return parseMember(node, parse); + case 'ChainExpression': + return parseChain(node, parse); + case 'UnaryExpression': + return parseUnary(node, parse); + case 'BinaryExpression': + return parseBinary(node, parse); + case 'LogicalExpression': + return parseLogical(node, parse); + case 'ConditionalExpression': + return parseBranches(node, parse); + case 'CallExpression': + return parseCall(node, parse); + default: + // Everything else (functions, templates, object/array literals, + // regex, dynamic keys, spread, ...) is outside the subset. + return null; + } +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 23435991711..de7b47f597f 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -17,6 +17,7 @@ export * from './safe-regex'; export * from './global-state'; export * from './interfaces'; export { + fromExpression, fromFunction, fromParameter, nodeParametersAreStatic, @@ -26,6 +27,7 @@ export { type NativeResolution, type WebhookDescriptionField, } from './webhook-description-fields'; +export { isNativelyEvaluable } from './expressions/native-evaluation'; export * from './sub-workflow-output'; export * from './run-execution-data-factory'; export * from './message-event-bus'; diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 92ff7b2612d..08ce386d4f3 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -3219,6 +3219,9 @@ export type WebhookType = 'default' | 'setup'; * resolvers for its expression-template fields, keyed by field name. Populated * by `webhookDescriptionFields()` and read via `resolveWebhookDescriptionField()`. * Backend-only: not serialized with the description. + * + * TODO(native-evaluation rollout, CAT-4699): remove with `NativeParameterResolvers` and the + * `[WEBHOOK_RESOLVERS]` index below. */ export const WEBHOOK_RESOLVERS: unique symbol = Symbol.for('n8n.webhookDescriptionResolvers'); diff --git a/packages/workflow/src/webhook-description-fields.ts b/packages/workflow/src/webhook-description-fields.ts index 602b61df96f..8b9cca25d1b 100644 --- a/packages/workflow/src/webhook-description-fields.ts +++ b/packages/workflow/src/webhook-description-fields.ts @@ -13,6 +13,14 @@ * declaration, so the two cannot drift. Backend resolution sites call * {@link resolveWebhookDescriptionField} first and only fall back to the engine * when the user's parameters actually contain expressions. + * + * TODO(native-evaluation rollout, CAT-4699): this whole module is + * transitional. Once lazy isolate acquisition and native evaluation are the + * defaults, a plain inline template in the native subset costs a cached parse + * plus an in-process interpretation - no isolate, no prediction. Delete this + * module (with the `webhookPhaseNeedsIsolate` gate and its flag) and revert + * the descriptions to plain inline template strings. CAT-4699 lists every + * site. */ import type { @@ -33,6 +41,9 @@ export type WebhookDescriptionField = NativeParameterResolvers[string]; * the same truthiness fallback. Nested reads take a path array. Walking into a * missing parent yields `undefined`, matching what the engine returns for the * same reference. + * + * TODO(native-evaluation rollout, CAT-4699): remove - the template alone + * suffices (see module doc). */ export function fromParameter(path: string | string[], fallback?: string): WebhookDescriptionField { const segments = Array.isArray(path) ? path : [path]; @@ -63,6 +74,10 @@ export function fromParameter(path: string | string[], fallback?: string): Webho * import, a module constant) produces a template the expression sandbox cannot * evaluate. The parity test in the Webhook node's description.test.ts catches * this; add one for any node that declares `fromFunction` fields. + * + * TODO(native-evaluation rollout, CAT-4699): remove - its inlined-function + * templates never fit the native subset, so rewrite any remaining use as a + * plain subset template (see module doc). */ export function fromFunction

( fn: (parameters: P) => NodeParameterValueType | undefined, @@ -73,6 +88,28 @@ export function fromFunction

( }; } +/** + * A field whose template is hand-written in the native subset grammar (path + * access, `?.`, ternary, `||`) instead of inlining a function like + * {@link fromFunction}. Such a template stays natively evaluable, so it never + * forces the engine (an isolate) even when the node's parameters hold + * expressions and the native resolver declines. + * + * Template and resolver are two representations of the same logic. Drift is + * caught by executing both: the node's description parity tests must cover + * every branch (see the Webhook node's description.test.ts), and a test must + * assert the template fits the subset (`isNativelyEvaluable`). + * + * TODO(native-evaluation rollout, CAT-4699): remove - drop the resolver and + * inline the template string directly in the description (see module doc). + */ +export function fromExpression

( + template: string, + resolve: (parameters: P) => NodeParameterValueType | undefined, +): WebhookDescriptionField { + return { template, resolve: resolve as WebhookDescriptionField['resolve'] }; +} + /** * Spreads into an {@link IWebhookDescription}: the template string per field, * plus the resolver map under the backend-only {@link WEBHOOK_RESOLVERS} key. diff --git a/packages/workflow/test/native-evaluation-corpus-coverage.test.ts b/packages/workflow/test/native-evaluation-corpus-coverage.test.ts new file mode 100644 index 00000000000..198cad7cba9 --- /dev/null +++ b/packages/workflow/test/native-evaluation-corpus-coverage.test.ts @@ -0,0 +1,282 @@ +// @vitest-environment jsdom + +// Coverage guard for the native-evaluation parity corpus. +// +// The parity corpus (native-evaluation-parity.test.ts) must exercise every +// shape feature the native grammar accepts, so grammar changes stay +// parity-visible. This test keeps a catalog of grammar-eligible shape +// features (CANDIDATES below), tokenizes both the catalog and every corpus +// expression through the real parse pipeline, and fails naming any catalog +// feature no corpus entry reaches. +// +// A feature is a shape token: an operator, a method with its argument +// count, a member form, a literal form, a grammar-level identifier, a chunk +// structure. Data names are not features. Only missing features fail; there +// is deliberately no snapshot of the complete feature set, so the catalog +// can grow with the grammar (more roots, methods, callback forms) without +// rewriting this test. + +import { getParsedExpression } from '@n8n/tournament'; +import { isNativelyEvaluable } from '../src/expressions/native-evaluation'; +import { ALL_CORPORA } from './native-evaluation-corpus'; + +// ── Shape tokenization ──────────────────────────────────────────────────── +// A generic AST walk over the same parse the native evaluator uses. + +const SKIP_KEYS = new Set(['loc', 'range', 'tokens', 'comments', 'start', 'end']); + +type AstRecord = Record; + +function literalFeatures(o: AstRecord, feats: Set): void { + const value = o.value; + feats.add(value === null ? 'lit:null' : `lit:${typeof value}`); + const raw = typeof o.raw === 'string' ? o.raw : ''; + if (typeof value === 'number') { + if (/^0[xX]/.test(raw)) feats.add('num:hex'); + if (/^0[oO]/.test(raw)) feats.add('num:octal'); + if (/^0[bB]/.test(raw)) feats.add('num:binary'); + if (/[eE]/.test(raw)) feats.add('num:exp'); + if (raw.includes('_')) feats.add('num:sep'); + if (raw.includes('.')) feats.add('num:float'); + } + if (typeof value === 'string' && raw.includes('\\')) feats.add('lit:str:escape'); +} + +function memberFeature(o: AstRecord): string { + const property = o.property as AstRecord | undefined; + let kind = 'dot'; + if (o.computed === true) { + if (property?.type !== 'Literal') kind = 'cdyn'; + else kind = typeof property.value === 'number' ? 'cnum' : 'cstr'; + } + return `member:${kind}${o.optional === true ? '?' : ''}`; +} + +function callFeature(o: AstRecord): string { + const callee = o.callee as { property?: { type?: string; name?: unknown } } | undefined; + const method = callee?.property?.type === 'Identifier' ? String(callee.property.name) : '?'; + const arity = Array.isArray(o.arguments) ? o.arguments.length : 0; + return `call:${method}:${arity}${o.optional === true ? '?' : ''}`; +} + +function walk(node: unknown, feats: Set): void { + if (Array.isArray(node)) { + for (const child of node) walk(child, feats); + return; + } + if (node === null || typeof node !== 'object') return; + + const o = node as AstRecord; + + switch (o.type) { + case 'Identifier': + // Grammar-level identifiers only; member names are data, not shape. + if (o.name === '$json' || o.name === '$parameter' || o.name === 'undefined') { + feats.add(`id:${String(o.name)}`); + } + break; + case 'Literal': + literalFeatures(o, feats); + break; + case 'MemberExpression': + feats.add(memberFeature(o)); + break; + case 'UnaryExpression': + feats.add(`unary:${String(o.operator)}`); + break; + case 'BinaryExpression': + feats.add(`bin:${String(o.operator)}`); + break; + case 'LogicalExpression': + feats.add(`log:${String(o.operator)}`); + break; + case 'ConditionalExpression': + feats.add('ternary'); + break; + case 'CallExpression': + feats.add(callFeature(o)); + break; + default: + break; + } + + for (const key of Object.keys(o)) { + if (SKIP_KEYS.has(key)) continue; + walk(o[key], feats); + } +} + +/** Shape features of an expression (leading `=` stripped), or null if it does not parse. */ +function featuresOf(stripped: string): Set | null { + let chunks; + try { + chunks = getParsedExpression(stripped); + } catch { + return null; + } + + const feats = new Set(); + for (const chunk of chunks) { + if (chunk.type === 'text') { + feats.add(chunk.text === '' ? 'chunk:text-empty' : 'chunk:text'); + continue; + } + walk(chunk.parsed, feats); + } + return feats; +} + +// ── The shape catalog ────────────────────────────────────────────────────── +// One example per grammar-eligible feature class. When the grammar grows +// (new roots, methods, callback forms), add the new shapes here; the test +// then fails until the corpus exercises them. +const CANDIDATES = [ + // operators + '={{ $json.item.count == "2" }}', + '={{ $json.item.count != "2" }}', + '={{ $json.item.count === 2 }}', + '={{ $json.item.count !== 2 }}', + '={{ $json.item.count < 3 }}', + '={{ $json.item.count <= 2 }}', + '={{ $json.item.count > 1 }}', + '={{ $json.item.count >= 2 }}', + '={{ $json.item.count + 1 }}', + '={{ $json.item.count - 1 }}', + '={{ $json.item.count * 2 }}', + '={{ $json.item.count / 2 }}', + '={{ $json.item.count % 2 }}', + '={{ !$json.item.active }}', + '={{ -$json.item.count }}', + '={{ +$json.item.count }}', + '={{ $json.item.active && $json.item.name }}', + '={{ $json.item.disabled || $json.item.count }}', + '={{ $json.item.missing ?? $json.item.name }}', + '={{ $json.item.count > 1 ? "many" : "one" }}', + // literals + '={{ "text" }}', + '={{ 5 }}', + '={{ 0.5 }}', + '={{ 1e3 }}', + '={{ 0x10 }}', + '={{ 0o17 }}', + '={{ 0b101 }}', + '={{ 1_000 }}', + '={{ true }}', + '={{ false }}', + '={{ null }}', + '={{ undefined }}', + '={{ $json.item["\\u006eame"] }}', + // roots and member forms + '={{ $json.item.name }}', + '={{ $parameter.value1 }}', + '={{ $json.item?.name }}', + '={{ $json.item.my_object?.addresses }}', + "={{ $json.item['name'] }}", + '={{ $json.item.names[0] }}', + "={{ $json.item?.['name'] }}", + '={{ $json.item.names?.[0] }}', + // string methods, by argument count + '={{ $json.item.name.toUpperCase() }}', + '={{ $json.item.name.toUpperCase($json.item.count) }}', + '={{ $json.item.name.toUpperCase?.() }}', + '={{ $json.item.name.toLowerCase() }}', + '={{ $json.item.name.trim() }}', + '={{ $json.item.name.trimStart() }}', + '={{ $json.item.name.trimEnd() }}', + '={{ $json.item.name.charAt() }}', + '={{ $json.item.name.charAt(1) }}', + "={{ $json.item.name.includes('o') }}", + "={{ $json.item.name.includes('o', 1) }}", + "={{ $json.item.name.startsWith('f') }}", + "={{ $json.item.name.startsWith('o', 1) }}", + "={{ $json.item.name.endsWith('o') }}", + "={{ $json.item.name.endsWith('o', 2) }}", + '={{ $json.item.name.slice(1) }}', + '={{ $json.item.name.slice(1, 2) }}', + "={{ $json.item.name.indexOf('o') }}", + "={{ $json.item.name.indexOf('o', 1) }}", + "={{ $json.item.name.replace('o') }}", + "={{ $json.item.name.replace('o', '0') }}", + "={{ $json.item.name.replaceAll('o', '0') }}", + // number methods, by argument count + '={{ $json.item.count.toFixed() }}', + '={{ $json.item.count.toFixed(2) }}', + '={{ $json.item.count.toPrecision() }}', + '={{ $json.item.count.toPrecision(3) }}', + '={{ $json.item.count.toString() }}', + '={{ $json.item.count.toString(2) }}', + // array methods, by argument count + "={{ $json.item.names.includes('bar') }}", + "={{ $json.item.names.indexOf('baz') }}", + "={{ $json.item.names.lastIndexOf('baz') }}", + '={{ $json.item.names.join() }}', + "={{ $json.item.names.join('-') }}", + '={{ $json.item.names.slice(0) }}', + '={{ $json.item.names.slice(0, 1) }}', + '={{ $json.item.names.at(0) }}', + '={{ $json.item.names.at(-1) }}', + '={{ $json.item.names.concat() }}', + '={{ $json.item.names.concat($json.item.names) }}', + '={{ $json.item.names.flat() }}', + '={{ $json.item.names.flat(2) }}', + '={{ $json.item.names.toSorted() }}', + '={{ $json.item.names.toReversed() }}', + // compound shapes + '={{ $json.item.name.trim().toUpperCase() }}', + '={{ $json.item.name.toUpperCase().length }}', + '={{ ($json.item.count + 1) * 2 }}', + '={{ $json.item.name.slice($json.item.count + 1) }}', + '={{ $json.item.name.slice($json.item.count > 1 ? 1 : 2) }}', + '={{ $json.item.name.slice($json.item.name.trim().length) }}', + // chunk structures + '={{ $json.item.name }}', + '={{ $json.item.name }}{{ $json.item.count }}', + '=text {{ $json.item.name }} tail', +]; + +describe('native evaluation corpus coverage', () => { + test('every grammar-eligible shape feature is exercised by the parity corpus', () => { + const corpus = ALL_CORPORA; + + const covered = new Set(); + for (const entry of corpus) { + if (!entry.startsWith('=')) continue; + const stripped = entry.slice(1); + try { + if (!isNativelyEvaluable(stripped)) continue; + } catch { + continue; + } + const feats = featuresOf(stripped); + if (feats) for (const feat of feats) covered.add(feat); + } + + // Catalog drift: a candidate that left the grammar means the catalog + // needs updating, not the corpus. + const ineligible = CANDIDATES.filter((c) => { + try { + return !isNativelyEvaluable(c.slice(1)); + } catch { + return true; + } + }); + expect(ineligible).toEqual([]); + + const gaps = new Map(); + for (const candidate of CANDIDATES) { + const feats = featuresOf(candidate.slice(1)); + if (!feats) continue; + for (const feat of feats) { + if (!covered.has(feat) && !gaps.has(feat)) gaps.set(feat, candidate); + } + } + + if (gaps.size > 0) { + throw new Error( + `The parity corpus does not exercise ${gaps.size} grammar-eligible shape feature(s). ` + + 'Add a corpus entry for each (HANDLED, or RUNTIME_BAILOUT / DECLINED as appropriate):\n' + + [...gaps.entries()].map(([feat, example]) => ` ${feat} e.g. ${example}`).join('\n'), + ); + } + }); +}); diff --git a/packages/workflow/test/native-evaluation-corpus.ts b/packages/workflow/test/native-evaluation-corpus.ts new file mode 100644 index 00000000000..6ff2dacfd75 --- /dev/null +++ b/packages/workflow/test/native-evaluation-corpus.ts @@ -0,0 +1,262 @@ +// Parity corpus for fast native expression evaluation. +// +// Every expression in HANDLED_CORPUS must (a) fit the native subset and (b) +// produce exactly the same value natively as through the regular pipeline. +// The file runs once per engine project (legacy, vm, quickjs - see +// vitest.config.ts), so native evaluation is pinned against all three +// engines. +// +// Every expression in DECLINED_CORPUS must be outside the subset so it takes +// the engine untouched. Every expression in RUNTIME_BAILOUT_CORPUS fits the +// subset but meets a runtime value the parse could not see, and must bail to +// the engine with the engine's result coming back unchanged. +// +// The arrays live in this module so native-evaluation-corpus-coverage.test.ts +// can check that every grammar-eligible shape feature appears in one of them. + +export const HANDLED_CORPUS: string[] = [ + '={{ $json.item.name }}', + "={{ $json.item.name !== 'foo' }}", + "={{ $json.item.my_object.addresses.primary ?? 'no address' }}", + "={{ $json.item.missing ?? 'fallback' }}", + '={{ $json.item.missing.deep }}', + '={{ $json.item.names[0] }}', + '={{ $json.item.count + 1 }}', + "={{ $json.item.count > 1 ? 'many' : 'one' }}", + '={{ $json.item.active && $json.item.name }}', + '={{ $json.item.disabled || $json.item.count }}', + '={{ !$json.item.active }}', + '={{ !$json.item.my_object }}', + '={{ -$json.item.count }}', + "={{ 'a' + 'b' }}", + '={{ 5 }}', + '={{ null }}', + '={{ undefined }}', + '={{ $json.item.nothing }}', + '={{ $json.item.my_object }}', + '={{ $json.item.names }}', + '={{ $json.item.my_object?.addresses?.primary }}', + '={{ $json.item.missing?.deep }}', + // A missing optional hop short-circuits the whole chain, so the enclosing + // fallback still applies. Parentheses end the chain: the outer read throws. + '={{ $json.item.missing?.deep.deeper ?? true }}', + "={{ $json.item.missing?.deep.deeper.toUpperCase() || 'fallback' }}", + '={{ ($json.item.missing?.deep).deeper ?? true }}', + '={{ $json.item.my_object?.addresses.primary ?? true }}', + '={{ $json.item.name.length }}', + '={{ $parameter["value1"] }}', + "={{ $parameter['missing'] || 'GET' }}", + // value2 is itself an expression: the $parameter proxy resolves it by + // re-entering resolveSimpleParameterValue (natively again when enabled). + '={{ $parameter.value2 }}', + // value3 is an expression outside the subset: the nested resolution takes + // the engine while the outer expression stays native. + '={{ $parameter.value3 }}', + '=Name: {{ $json.item.name }}!', + '=({{ $json.item.nothing }})', + '=count: {{ $json.item.count }} / {{ $json.item.count + 1 }}', + "=zero: {{ 0 }} false: {{ false }} empty: {{ '' }}", + '=plain text', + '=', + // Allowlisted String.prototype methods on a runtime-verified string + // receiver. + "={{ $json.item.name.toUpperCase() === 'FOO' }}", + '={{ $json.item.name.toLowerCase() }}', + "={{ $json.item.name.includes('oo') }}", + "={{ $json.item.name.startsWith('f') && $json.item.name.endsWith('o') }}", + '={{ $json.item.name.slice(1, 2) }}', + "={{ $json.item.name.indexOf('o') }}", + '={{ $json.item.name.trim().toUpperCase() }}', + '={{ $json.item.missing?.toUpperCase() }}', + '={{ $json.item.nothing.toUpperCase() }}', + "={{ $json.item.name.replace('f', 'b') }}", + "={{ $json.item.name.replaceAll('o', '0') }}", + '={{ $json.item.count.toFixed(2) }}', + '={{ $json.item.count.toString() }}', + '={{ $json.item.count.toPrecision(3) }}', + "={{ $json.item.names.includes('bar') }}", + "={{ $json.item.names.indexOf('baz') }}", + "={{ $json.item.names.join(', ') }}", + '={{ $json.item.names.slice(0, 1) }}', + '={{ $json.item.names.at(-1) }}', + '={{ $json.item.names.toSorted() }}', + "={{ $json.item.names.toReversed().join('-') }}", + "={{ $json.item.names.concat('qux') }}", + '={{ $json.item.names.concat($json.item.names) }}', + '={{ $json.item.names.flat() }}', + '={{ $json.item.names.flat(2) }}', + // Binary operators beyond the exercised ===, !==, > and +, including + // the coercion-sensitive loose equality. + '={{ $json.item.count == "2" }}', + '={{ $json.item.count != "2" }}', + '={{ $json.item.count < 3 }}', + '={{ $json.item.count <= 2 }}', + '={{ $json.item.count >= 2 }}', + '={{ $json.item.count - 1 }}', + '={{ $json.item.count * 2 }}', + '={{ $json.item.count / 2 }}', + '={{ $json.item.count % 2 }}', + // Unary + (the coercion counterpart of the exercised unary -). + '={{ +$json.item.count }}', + '={{ +true }}', + // Allowlisted methods no other entry exercises. + '={{ $json.item.name.trimStart() }}', + '={{ $json.item.name.trimEnd() }}', + '={{ $json.item.name.charAt(1) }}', + '={{ $json.item.name.charAt() }}', + '={{ $json.item.names.lastIndexOf("baz") }}', + // Argument-count variants: default separators and replacements, radixes, + // fromIndex positions, and an extra argument the method ignores. + '={{ $json.item.names.join() }}', + '={{ $json.item.names.concat() }}', + "={{ $json.item.name.replace('o') }}", + '={{ $json.item.count.toFixed() }}', + '={{ $json.item.count.toPrecision() }}', + '={{ $json.item.count.toString(2) }}', + "={{ $json.item.name.includes('o', 1) }}", + "={{ $json.item.name.indexOf('o', 1) }}", + "={{ $json.item.name.startsWith('o', 1) }}", + "={{ $json.item.name.endsWith('o', 2) }}", + '={{ $json.item.name.toUpperCase($json.item.count) }}', + // Optional-call form (`?.()` marks the call, not the member). + '={{ $json.item.name.toUpperCase?.() }}', + // Optional computed members (`?.[...]`). + "={{ $json.item?.['name'] }}", + '={{ $json.item.names?.[0] }}', + // Numeric literal forms beyond plain integers. + '={{ 0x10 }}', + '={{ 0o17 }}', + '={{ 0b101 }}', + '={{ 1e3 }}', + '={{ 0.5 }}', + '={{ 1_000 }}', + // A string literal with an escape sequence. One case by design: it + // exercises esprima's decoding more than this module. + '={{ $json.item["\\u006eame"] }}', + // A parenthesized subexpression in a compound position. + '={{ ($json.item.count + 1) * 2 }}', + // Adjacent code chunks with no text between them. + '={{ $json.item.name }}{{ $json.item.count }}', + // Compound shapes: logical and ternary over call results, call arguments + // that are themselves compound, cross-type chains, member on a call. + "={{ $json.item.name.includes('o') && $json.item.count }}", + "={{ $json.item.name.startsWith('f') ? 'yes' : 'no' }}", + '={{ $json.item.name.slice($json.item.count + 1) }}', + '={{ $json.item.name.slice($json.item.count > 1 ? 1 : 2) }}', + '={{ $json.item.name.slice($json.item.name.trim().length) }}', + '={{ $json.item.name.slice(0, 2).toUpperCase() }}', + "={{ $json.item.names.slice(0, 1).join(',') }}", + '={{ $json.item.name.toUpperCase().length }}', + '={{ $json.item.nothing ?? $json.item.name }}', + // Argument defaults that change the value but stay under the size limit: + // a missing replacement inserts "undefined", a null separator joins with + // "null". + "={{ $json.item.big.replaceAll('y') }}", + '={{ $json.item.manyEmpty.join(null) }}', +]; + +// Fits the subset, but a runtime value falls outside what the parse proved: +// evaluation bails and the engine result must come back unchanged. +export const RUNTIME_BAILOUT_CORPUS: string[] = [ + // Allowlisted method on a receiver of another type. + '={{ $json.item.count.toUpperCase() }}', + '={{ $json.item.name.toFixed(1) }}', + "={{ $json.item.my_object.includes('x') }}", + // Operators on object operands: the engine compares and coerces copies. + "={{ $json.item.my_object + '' }}", + '={{ $json.item.names === $json.item.names }}', + '={{ -$json.item.names }}', + '=Name: {{ $json.item.my_object }}', + // A result above MAX_RESULT_LENGTH goes to the engine's limits, whether + // it shows in the result or in the pre-flight bound. + "={{ $json.item.name.replaceAll('', $json.item.filler).replaceAll('', $json.item.filler).replaceAll('', $json.item.filler) }}", + "={{ $json.item.big.replaceAll('', $json.item.filler) }}", + // `$\`` splices the text before each match into the result. + "={{ $json.item.name.replaceAll('o', '$`') }}", + // A receiver above MAX_RESULT_LENGTH is the engine's work, whatever the + // method. (concat is bounded the same way, by receiver plus arguments; a + // corpus entry would make quickjs marshal a million-element result.) + '={{ $json.item.huge.toUpperCase() }}', + '={{ $json.item.huge.slice(0, 1) }}', + '={{ $json.item.hugeArr.at(0) }}', + // join('') is bounded by the elements, not the separator. + "={{ $json.item.manyBig.join('') }}", + // Several chunks obey the same limit as one. + '={{ $json.item.bigger }}{{ $json.item.bigger }}{{ $json.item.bigger }}{{ $json.item.bigger }}{{ $json.item.bigger }}{{ $json.item.bigger }}{{ $json.item.bigger }}', + "={{ $json.item.bigger.replaceAll('y') }}", + '={{ $json.item.manyEmpty.join($json.item.filler) }}', + // Object arguments to string methods coerce on the host where the isolates + // see a copy; the engine owns them. + "={{ $json.item.name.replace($json.item.re, 'X') }}", + '={{ $json.item.name.includes($json.item.re) }}', + '={{ $json.item.name.slice($json.item.my_object) }}', + // Object arguments to array methods: coercion hooks, or a live-reference + // comparison where the isolate compares copies. + '={{ $json.item.names.includes($json.item.my_object) }}', + '={{ $json.item.names.concat($json.item.my_object) }}', + '={{ $json.item.names.slice($json.item.my_object) }}', +]; + +export const DECLINED_CORPUS: string[] = [ + // BigInt literals are not part of the subset. + '={{ 1n }}', + "={{ $json.item.names.filter((n) => n.includes('bar')) }}", + '={{ $json.item.names.first() }}', + '={{ Object.keys($json.item) }}', + '={{ $json.item[$json.item.name] }}', + '={{ $now }}', + "={{ $json.item['__proto__'] }}", + '={{ $json.item.name.constructor }}', + // eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string + '={{ `hi ${$json.item.name}` }}', + '={{ /foo/.test($json.item.name) }}', + '={{ (function () { return 1 })() }}', + '={{ [1, 2, 3] }}', + '={{ { a: 1 } }}', + // Extension methods and non-allowlisted natives stay on the engine. + '={{ $json.item.name.isEmpty() }}', + '={{ $json.item.name.hash() }}', + '={{ $json.item.name.padStart(8) }}', + // sort()/fill() mutate the receiver in place; only immutable variants are + // allowlisted. A callback makes any call non-simple. + '={{ $json.item.names.sort() }}', + "={{ $json.item.names.fill('x') }}", + '={{ $json.item.names.toSorted((a, b) => a.length - b.length) }}', + // Iterator-returning methods have no engine-equivalent value. + '={{ $json.item.names.entries() }}', + '={{ $json.item.names.values() }}', + '={{ $json.item.names.keys() }}', + "={{ $json.item.name['toUpperCase']() }}", + '={{ $json.item.name.toUpperCase($json.item[$json.item.name]) }}', + // Syntax errors go to the engine for its error reporting. + '={{ $json.item. }}', + // Deep enough to overflow the recursive re-parse: declined, never thrown. + `={{ $json${'.a'.repeat(5000)} }}`, +]; + +// Both paths must throw the same error. +export const ERROR_CORPUS: string[] = [ + '={{ $json.item.name }}', + '=Name: {{ $json.item.name }}', + '={{ $parameter.value2 }}', +]; + +// Function and symbol values hand the expression to the engine, so the +// outcome is whatever the configured engine does: legacy throws for a +// function, the isolates drop a nested one and fail on an inherited one. +// Compared as values or as errors, whichever the engine produces. +export const ENGINE_DECIDED_CORPUS: string[] = [ + '={{ $json.item.fn }}', + '={{ $json.item.sym }}', + '={{ $json.hasOwnProperty }}', + '={{ $json.toString }}', + '={{ $json.item.valueOf }}', +]; + +export const ALL_CORPORA: string[] = [ + ...HANDLED_CORPUS, + ...RUNTIME_BAILOUT_CORPUS, + ...DECLINED_CORPUS, + ...ERROR_CORPUS, + ...ENGINE_DECIDED_CORPUS, +]; diff --git a/packages/workflow/test/native-evaluation-fuzz.test.ts b/packages/workflow/test/native-evaluation-fuzz.test.ts new file mode 100644 index 00000000000..b3c26fe0da7 --- /dev/null +++ b/packages/workflow/test/native-evaluation-fuzz.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment jsdom + +// Property-based parity for fast native evaluation. +// +// The corpus (native-evaluation-corpus.ts) pins hand-picked shapes; this +// file generates random expressions over the subset grammar together with +// random JSON data and asserts that the native path and the engine agree on +// the value, or fail with the same error. It covers the value space the +// corpus cannot enumerate: -0, empty strings, nulls in arithmetic, unicode, +// odd method arguments. Runs once per engine project (vitest.config.ts). + +import fc from 'fast-check'; + +import * as Helpers from './helpers'; +import type { IDataObject } from '../src'; +import { createRunExecutionData } from '../src'; +import { Expression } from '../src/expression'; +import { CALLABLE_METHODS } from '../src/expressions/native-evaluation'; +import { Workflow } from '../src/workflow'; + +// Three pre-existing, flag-independent quickjs bridge behaviours are kept out +// of the comparison: a string is truncated at its first NUL character on the +// way across, a lone surrogate (which indexing an astral character yields) +// comes back as U+FFFD replacement characters, and undefined inside an array +// result comes back as null (vm keeps undefined; native sides with vm). Data +// strings stay NUL-free and inside the Basic Multilingual Plane; the array +// case is skipped below. +const noNul = (s: string) => !s.includes('\0'); +const bmpString = (maxLength: number) => + fc + .string({ unit: 'binary', maxLength }) + .filter((s) => noNul(s) && [...s].every((c) => (c.codePointAt(0) ?? 0) < 0x10000)); + +const KEYS = ['a', 'b', 'c', 'd'] as const; +const INNER_KEYS = ['x', 'y'] as const; + +const primitive = fc.oneof( + fc.string({ maxLength: 6 }).filter(noNul), + bmpString(4), + fc.integer({ min: -100, max: 100 }), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.boolean(), + fc.constant(null), +); + +const jsonValue = fc.oneof( + { weight: 3, arbitrary: primitive }, + fc.array(primitive, { maxLength: 4 }), + fc.record({ x: primitive, y: fc.array(primitive, { maxLength: 3 }) }), +); + +const data = fc.record({ a: jsonValue, b: jsonValue, c: jsonValue, d: jsonValue }); + +const literal = fc.oneof( + fc + .string({ maxLength: 6 }) + .filter(noNul) + .map((s) => JSON.stringify(s)), + bmpString(3).map((s) => JSON.stringify(s)), + fc.integer({ min: 0, max: 200 }).map(String), + fc.double({ min: 0, noNaN: true, noDefaultInfinity: true }).map(String), + fc.constantFrom('true', 'false', 'null', 'undefined'), +); + +const hop = fc.constantFrom('.', '?.'); +const path = fc + .tuple( + fc.constantFrom(...KEYS), + fc.oneof( + fc.constant(''), + fc.tuple(hop, fc.constantFrom(...INNER_KEYS, 'length')).map(([h, k]) => `${h}${k}`), + fc + .tuple(hop, fc.integer({ min: 0, max: 3 })) + .map(([h, i]) => (h === '.' ? `[${i}]` : `?.[${i}]`)), + ), + ) + .map(([key, rest]) => `$json.${key}${rest}`); + +const { expr } = fc.letrec<{ expr: string }>((tie) => ({ + expr: fc.oneof( + { depthSize: 'small', withCrossShrink: true }, + { weight: 4, arbitrary: path }, + { weight: 2, arbitrary: literal }, + fc.tuple(fc.constantFrom('!', '-', '+'), tie('expr')).map(([op, e]) => `${op}(${e})`), + fc + .tuple( + tie('expr'), + fc.constantFrom('===', '!==', '==', '!=', '<', '<=', '>', '>=', '+', '-', '*', '/', '%'), + tie('expr'), + ) + .map(([l, op, r]) => `(${l} ${op} ${r})`), + fc + .tuple(tie('expr'), fc.constantFrom('&&', '||', '??'), tie('expr')) + .map(([l, op, r]) => `(${l} ${op} ${r})`), + fc.tuple(tie('expr'), tie('expr'), tie('expr')).map(([t, c, a]) => `(${t} ? ${c} : ${a})`), + fc + .tuple( + fc.oneof({ weight: 3, arbitrary: path }, tie('expr')), + hop, + fc.constantFrom(...CALLABLE_METHODS), + fc.array(fc.oneof(literal, path), { maxLength: 2 }), + ) + .map(([recv, h, method, args]) => `(${recv})${h}${method}(${args.join(', ')})`), + ), +})); + +const expression = fc.oneof( + { weight: 3, arbitrary: expr.map((e) => `={{ ${e} }}`) }, + fc.tuple(expr, expr).map(([e1, e2]) => `=v: {{ ${e1} }} / {{ ${e2} }}`), +); + +describe('Expression - fast native evaluation fuzz parity', () => { + const workflow = new Workflow({ + id: '1', + nodes: [ + { + name: 'Source', + typeVersion: 1, + type: 'test.set', + id: 's', + position: [0, 0], + parameters: {}, + }, + { + name: 'Current', + typeVersion: 1, + type: 'test.set', + id: 'c', + position: [1, 0], + parameters: {}, + }, + ], + connections: { Source: { main: [[{ node: 'Current', type: 'main', index: 0 }]] } }, + active: false, + nodeTypes: Helpers.NodeTypes(), + }); + + beforeAll(async () => { + await workflow.expression.acquireIsolate(); + }); + afterAll(async () => { + await workflow.expression.releaseIsolate(); + }); + + const outcome = (expr: string, json: IDataObject, native: boolean) => { + const item = { json }; + const runData = createRunExecutionData({ + resultData: { + runData: { + Source: [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + data: { main: [[item]] }, + }, + ], + }, + }, + }); + Expression.setNativeEvaluation(native); + try { + return { + value: workflow.expression.getParameterValue( + expr, + runData, + 0, + 0, + 'Current', + [item], + 'manual', + {}, + ), + }; + } catch (error) { + return { error: error as Error }; + } finally { + Expression.setNativeEvaluation(false); + } + }; + + test('native and engine agree on value or error', () => { + fc.assert( + fc.property(expression, data, (expr, json) => { + const viaEngine = outcome(expr, json, false); + const viaNative = outcome(expr, json, true); + + const holesAsNull = + Expression.getActiveImplementation() === 'quickjs' && + Array.isArray(viaNative.value) && + viaNative.value.some((element) => element === undefined); + if (holesAsNull) return; + + if (viaEngine.error) { + expect(viaNative.error).toBeInstanceOf(viaEngine.error.constructor); + expect(viaNative.error?.message).toBe(viaEngine.error.message); + } else { + expect(viaNative).toStrictEqual(viaEngine); + } + }), + { numRuns: 300 }, + ); + }); +}); diff --git a/packages/workflow/test/native-evaluation-parity.test.ts b/packages/workflow/test/native-evaluation-parity.test.ts new file mode 100644 index 00000000000..7998dd76cf6 --- /dev/null +++ b/packages/workflow/test/native-evaluation-parity.test.ts @@ -0,0 +1,355 @@ +// @vitest-environment jsdom + +import * as Helpers from './helpers'; +import { createRunExecutionData } from '../src'; +import { ExpressionExtensions } from '../src/extensions'; +import { + evaluateNatively, + isNativelyEvaluable, + CALLABLE_METHODS, + MAX_RESULT_LENGTH, +} from '../src/expressions/native-evaluation'; +import { WorkflowDataProxy } from '../src/workflow-data-proxy'; +import { Expression } from '../src/expression'; +import { Workflow } from '../src/workflow'; +import { DateTime } from 'luxon'; +import { + DECLINED_CORPUS, + ENGINE_DECIDED_CORPUS, + ERROR_CORPUS, + HANDLED_CORPUS, + RUNTIME_BAILOUT_CORPUS, +} from './native-evaluation-corpus'; + +// The expression corpora live in native-evaluation-corpus.ts. + +describe('Expression - fast native evaluation parity', () => { + const workflow = new Workflow({ + id: '1', + nodes: [ + { + name: 'Source', + typeVersion: 1, + type: 'test.set', + id: 'source-1', + position: [0, 0], + parameters: {}, + }, + { + name: 'Current', + typeVersion: 1, + type: 'test.set', + id: 'current-1', + position: [100, 0], + parameters: { + value1: 'hello', + value2: '={{ $json.item.name }}', + value3: '={{ $json.item.names.first() }}', + }, + }, + ], + connections: { + Source: { + main: [[{ node: 'Current', type: 'main', index: 0 }]], + }, + }, + active: false, + nodeTypes: Helpers.NodeTypes(), + }); + const expression = workflow.expression; + + const makeItem = () => ({ + json: { + item: { + name: 'foo', + count: 2, + active: true, + disabled: false, + nothing: null, + my_object: { addresses: { primary: '123 Main St' } }, + names: ['bar', 'baz'], + filler: 'x'.repeat(Math.ceil(Math.cbrt(MAX_RESULT_LENGTH))), + big: 'y'.repeat(20_000), + bigger: 'y'.repeat(150_000), + manyEmpty: new Array(20_000).fill(''), + manyBig: new Array(2_000).fill('z'.repeat(1_000)), + huge: 'h'.repeat(MAX_RESULT_LENGTH + 1), + hugeArr: new Array(MAX_RESULT_LENGTH + 1).fill(0), + re: /o/g, + }, + }, + }); + const item = makeItem(); + + const runDataFor = (items: Array>) => + createRunExecutionData({ + resultData: { + runData: { + Source: [ + { + startTime: 0, + executionTime: 0, + executionIndex: 0, + source: [], + data: { main: [items] }, + }, + ], + }, + }, + }); + const runExecutionData = runDataFor([item]); + + beforeAll(async () => { + await expression.acquireIsolate(); + }); + afterAll(async () => { + await expression.releaseIsolate(); + }); + afterEach(() => { + Expression.setNativeEvaluation(false); + }); + + const evaluate = ( + expr: string, + native: boolean, + data = runExecutionData, + items: Array> = [item], + ) => { + Expression.setNativeEvaluation(native); + try { + return expression.getParameterValue(expr, data, 0, 0, 'Current', items, 'manual', {}); + } finally { + Expression.setNativeEvaluation(false); + } + }; + + // The native outcome on its own, through the same data proxy the workflow + // builds: HANDLED entries must be evaluated natively, RUNTIME_BAILOUT + // entries must hand off to the engine. Parity alone cannot tell the two + // apart, since a bailout returns the engine's value either way. + const nativeOutcome = (expr: string) => { + const node = workflow.getNode('Current'); + const proxy = new WorkflowDataProxy( + workflow, + runExecutionData, + 0, + 0, + 'Current', + [item], + node?.parameters ?? {}, + 'manual', + {}, + ).getDataProxy(); + + Expression.setNativeEvaluation(true); + try { + return evaluateNatively(expr.slice(1), proxy); + } finally { + Expression.setNativeEvaluation(false); + } + }; + + describe('handled expressions match the engine result', () => { + test.each(HANDLED_CORPUS)('%s', (expr) => { + // Guard against the parity check passing vacuously: the expression + // must actually fit the subset. + expect(isNativelyEvaluable(expr.slice(1))).toBe(true); + expect(nativeOutcome(expr).handled).toBe(true); + + const engineResult = evaluate(expr, false); + const nativeResult = evaluate(expr, true); + expect(nativeResult).toStrictEqual(engineResult); + }); + + // concat is bounded by receiver plus arguments. Asserted natively only: + // the engine would marshal a million-element result. + test('concat bails when receiver plus arguments exceed the limit', () => { + expect(nativeOutcome('={{ $json.item.names.concat($json.item.hugeArr) }}').handled).toBe( + false, + ); + expect(nativeOutcome('={{ $json.item.names.concat($json.item.names) }}').handled).toBe(true); + }); + }); + + describe('non-simple expressions are declined', () => { + test.each(DECLINED_CORPUS)('%s', (expr) => { + expect(isNativelyEvaluable(expr.slice(1))).toBe(false); + }); + }); + + describe('expressions that bail at runtime match the engine result', () => { + test.each(RUNTIME_BAILOUT_CORPUS)('%s', (expr) => { + expect(isNativelyEvaluable(expr.slice(1))).toBe(true); + expect(nativeOutcome(expr).handled).toBe(false); + + const engineResult = evaluate(expr, false); + const nativeResult = evaluate(expr, true); + expect(nativeResult).toStrictEqual(engineResult); + }); + + // concat is bounded by receiver plus arguments. Asserted natively only: + // the engine would marshal a million-element result. + test('concat bails when receiver plus arguments exceed the limit', () => { + expect(nativeOutcome('={{ $json.item.names.concat($json.item.hugeArr) }}').handled).toBe( + false, + ); + expect(nativeOutcome('={{ $json.item.names.concat($json.item.names) }}').handled).toBe(true); + }); + }); + + describe('errors match the engine', () => { + // No execution data for the source node: every `$json` read throws. + const emptyRunData = runDataFor([]); + + test.each(ERROR_CORPUS)('%s', (expr) => { + expect(isNativelyEvaluable(expr.slice(1))).toBe(true); + + const capture = (native: boolean): Error => { + try { + evaluate(expr, native, emptyRunData, []); + } catch (error) { + return error as Error; + } + throw new Error('expected an error'); + }; + const engineError = capture(false); + const nativeError = capture(true); + expect(nativeError).toBeInstanceOf(engineError.constructor); + expect(nativeError.message).toBe(engineError.message); + }); + }); + + test('object results are copies, never the live execution data', () => { + const fresh = makeItem(); + const result = evaluate('={{ $json.item.my_object }}', true, runDataFor([fresh]), [fresh]); + expect(result).toStrictEqual(fresh.json.item.my_object); + expect(result).not.toBe(fresh.json.item.my_object); + + (result as { addresses: { primary: string } }).addresses.primary = 'changed'; + expect(fresh.json.item.my_object.addresses.primary).toBe('123 Main St'); + }); + + // Values no node should produce (the engine does not enforce that yet) and + // inherited members on the root. Native either matches the configured + // engine or hands the expression to it; the one remaining divergence is + // the DateTime copy under legacy, pinned so a change is visible. + describe('non-JSON values and inherited members', () => { + const exotic = { + json: { + item: { fn: () => 1, sym: Symbol('s'), dt: DateTime.fromISO('2026-01-02T03:04:05Z') }, + }, + }; + const exoticRunData = runDataFor([exotic as never]); + const evaluateExotic = (expr: string, native: boolean) => + evaluate(expr, native, exoticRunData, [exotic as never]); + // Read per test: the engine is only initialised once the suite runs. + const isLegacy = () => Expression.getActiveImplementation() === 'legacy'; + + test.each(ENGINE_DECIDED_CORPUS)('%s matches the engine, value or error', (expr) => { + const capture = (native: boolean) => { + try { + return { value: evaluateExotic(expr, native) }; + } catch (error) { + return { error: error as Error }; + } + }; + const viaEngine = capture(false); + const viaNative = capture(true); + + if (viaEngine.error) { + expect(viaNative.error).toBeInstanceOf(viaEngine.error.constructor); + expect(viaNative.error?.message).toBe(viaEngine.error.message); + } else { + expect(viaNative).toStrictEqual(viaEngine); + } + }); + + test('a whole-value DateTime read is a copy natively; legacy returns the instance', () => { + const legacy = isLegacy(); + const native = evaluateExotic('={{ $json.item.dt }}', true); + const viaEngine = evaluateExotic('={{ $json.item.dt }}', false); + expect(native).not.toBeInstanceOf(DateTime); + expect(viaEngine instanceof DateTime).toBe(legacy); + // Both copies carry the instant; their cloned Luxon internals (locale + // and zone caches) depend on the host environment, so compare only + // what the expression author can observe. + expect((native as { ts: number }).ts).toBe((viaEngine as { ts: number }).ts); + // Member reads keep working on both paths. + expect(evaluateExotic('={{ $json.item.dt.year }}', true)).toBe(2026); + }); + }); + + describe('structural guards', () => { + // Direct calls: the data shapes here cannot come out of a node, so the + // engines have nothing to agree with. + const nativeOn = (expr: string, data: Record) => { + Expression.setNativeEvaluation(true); + try { + return evaluateNatively(expr, data as never); + } finally { + Expression.setNativeEvaluation(false); + } + }; + + test('nesting deeper than the cap is declined', () => { + expect(isNativelyEvaluable(`{{ ${'!'.repeat(20)}$json.item.active }}`)).toBe(true); + expect(isNativelyEvaluable(`{{ ${'!'.repeat(100)}$json.item.active }}`)).toBe(false); + }); + + test('an expression too long to cache still evaluates natively', () => { + const literal = 'x'.repeat(20_000); + expect(nativeOutcome(`={{ '${literal}' }}`)).toEqual({ handled: true, value: literal }); + }); + + test('flat is bounded by the flattened size, not the outer length', () => { + const big = new Array(600_000).fill(0); + expect(nativeOutcome('={{ $json.item.names.flat() }}').handled).toBe(true); + expect(nativeOn('{{ $json.rows.flat() }}', { $json: { rows: [big, big] } })).toEqual({ + handled: false, + }); + expect(nativeOn('{{ $json.rows.flat(0) }}', { $json: { rows: [big, big] } }).handled).toBe( + true, + ); + }); + + test('an inherited member below the root hands off to the engine', () => { + const $json = { item: Object.create({ inherited: 1 }) as object }; + expect(nativeOn('{{ $json.item.inherited }}', { $json })).toEqual({ handled: false }); + expect(nativeOn('{{ $json.item.missing }}', { $json })).toEqual({ + handled: true, + value: undefined, + }); + }); + + test('a prototype patched after import does not reach the native path', () => { + const original = String.prototype.toUpperCase; + String.prototype.toUpperCase = () => 'patched'; + try { + expect(nativeOn('{{ $json.name.toUpperCase() }}', { $json: { name: 'foo' } })).toEqual({ + handled: true, + value: 'FOO', + }); + } finally { + String.prototype.toUpperCase = original; + } + }); + + test('an own property shadowing a method hands off to the engine', () => { + const $json = { list: Object.assign(['a'], { join: null }) }; + expect(nativeOn('{{ $json.list.join() }}', { $json })).toEqual({ handled: false }); + expect(nativeOn('{{ $json.list.at(0) }}', { $json })).toEqual({ handled: true, value: 'a' }); + }); + }); + + // extendSyntax rewrites calls to extension-named methods into extend() + // dispatch; native evaluation calls natives directly, so its allowlist + // must never contain an extension name. + test('method allowlist is disjoint from expression extensions', () => { + const extensionNames = new Set( + ExpressionExtensions.flatMap((extension) => Object.keys(extension.functions)), + ); + for (const method of CALLABLE_METHODS) { + expect(extensionNames.has(method)).toBe(false); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eda6af9372f..215062decb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2878,6 +2878,9 @@ importers: '@n8n/tournament': specifier: workspace:* version: link:../tournament + '@n8n/utils': + specifier: workspace:* + version: link:../utils isolated-vm: specifier: 'catalog:' version: 7.0.1