feat(core): Add fast native evaluation for a subset of expressions (no-changelog) (#37613)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mike Repeć 2026-09-30 10:16:42 +00:00 committed by GitHub
parent dcda65f7d9
commit a19bf32e51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 2149 additions and 29 deletions

View File

@ -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;
}

View File

@ -689,6 +689,7 @@ describe('GlobalConfig', () => {
allowWebhookIsolateSkip: true,
lazyAcquire: false,
compileCache: false,
nativeEvaluation: false,
},
instanceSettingsLoader: {
ownerManagedByEnv: false,

View File

@ -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

View File

@ -37,6 +37,7 @@
"dependencies": {
"@n8n/errors": "workspace:*",
"@n8n/tournament": "workspace:*",
"@n8n/utils": "workspace:*",
"isolated-vm": "catalog:",
"jmespath": "0.16.0",
"js-base64": "catalog:",

View File

@ -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,

View File

@ -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,

View File

@ -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.

View File

@ -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);

View File

@ -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', () => {

View File

@ -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));

View File

@ -269,6 +269,7 @@ export abstract class BaseCommand<F = never> {
idleTimeout,
lazyAcquire,
compileCache,
nativeEvaluation,
} = this.globalConfig.expressionEngine;
const observability = Container.get(ExpressionObservabilityProvider);
try {
@ -281,6 +282,7 @@ export abstract class BaseCommand<F = never> {
idleTimeoutMs: idleTimeout === undefined ? undefined : idleTimeout * 1000,
lazyAcquire,
compileCache,
nativeEvaluation,
observability,
});
} catch (error) {

View File

@ -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;

View File

@ -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(

View File

@ -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;

View File

@ -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

View File

@ -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']),

View File

@ -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);
});
});
});
});

View File

@ -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<void> {
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) {

View File

@ -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<string | number, unknown> =>
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<T>(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<BinaryOp, (l: any, r: any) => 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<SimpleNode, { kind: 'member' }>,
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<SimpleNode, { kind: 'call' }>,
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<SimpleNode, { kind: 'chain' }>,
data: IWorkflowDataProxyData,
): unknown {
try {
return evalNode(node.expression, data);
} catch (error) {
if (error === chainShortCircuit) return undefined;
throw error;
}
}
function evalUnary(
node: Extract<SimpleNode, { kind: 'unary' }>,
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<SimpleNode, { kind: 'binary' }>,
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<SimpleNode, { kind: 'logical' }>,
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;
}
}

View File

@ -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<string, NativeMethod> => {
const methods = new Map<string, NativeMethod>();
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<string, unknown> =>
typeof value === 'object' && value !== null;
export const isOneOf = <T extends string>(ops: readonly T[], op: unknown): op is T =>
typeof op === 'string' && (ops as readonly string[]).includes(op);

View File

@ -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<string, CompiledExpression | null>(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(''));
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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');

View File

@ -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<P extends INodeParameters>(
fn: (parameters: P) => NodeParameterValueType | undefined,
@ -73,6 +88,28 @@ export function fromFunction<P extends INodeParameters>(
};
}
/**
* 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<P extends INodeParameters>(
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.

View File

@ -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<string, unknown>;
function literalFeatures(o: AstRecord, feats: Set<string>): 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<string>): 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<string> | null {
let chunks;
try {
chunks = getParsedExpression(stripped);
} catch {
return null;
}
const feats = new Set<string>();
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<string>();
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<string, string>();
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'),
);
}
});
});

View File

@ -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,
];

View File

@ -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 },
);
});
});

View File

@ -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<string>(20_000).fill(''),
manyBig: new Array<string>(2_000).fill('z'.repeat(1_000)),
huge: 'h'.repeat(MAX_RESULT_LENGTH + 1),
hugeArr: new Array<number>(MAX_RESULT_LENGTH + 1).fill(0),
re: /o/g,
},
},
});
const item = makeItem();
const runDataFor = (items: Array<ReturnType<typeof makeItem>>) =>
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<ReturnType<typeof makeItem>> = [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<string, unknown>) => {
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<number>(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);
}
});
});

View File

@ -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