mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
dcda65f7d9
commit
a19bf32e51
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -689,6 +689,7 @@ describe('GlobalConfig', () => {
|
|||
allowWebhookIsolateSkip: true,
|
||||
lazyAcquire: false,
|
||||
compileCache: false,
|
||||
nativeEvaluation: false,
|
||||
},
|
||||
instanceSettingsLoader: {
|
||||
ownerManagedByEnv: false,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"dependencies": {
|
||||
"@n8n/errors": "workspace:*",
|
||||
"@n8n/tournament": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"isolated-vm": "catalog:",
|
||||
"jmespath": "0.16.0",
|
||||
"js-base64": "catalog:",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
336
packages/workflow/src/expressions/native-evaluation/evaluator.ts
Normal file
336
packages/workflow/src/expressions/native-evaluation/evaluator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
143
packages/workflow/src/expressions/native-evaluation/grammar.ts
Normal file
143
packages/workflow/src/expressions/native-evaluation/grammar.ts
Normal 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);
|
||||
195
packages/workflow/src/expressions/native-evaluation/index.ts
Normal file
195
packages/workflow/src/expressions/native-evaluation/index.ts
Normal 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(''));
|
||||
}
|
||||
207
packages/workflow/src/expressions/native-evaluation/parser.ts
Normal file
207
packages/workflow/src/expressions/native-evaluation/parser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
282
packages/workflow/test/native-evaluation-corpus-coverage.test.ts
Normal file
282
packages/workflow/test/native-evaluation-corpus-coverage.test.ts
Normal 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'),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
262
packages/workflow/test/native-evaluation-corpus.ts
Normal file
262
packages/workflow/test/native-evaluation-corpus.ts
Normal 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,
|
||||
];
|
||||
205
packages/workflow/test/native-evaluation-fuzz.test.ts
Normal file
205
packages/workflow/test/native-evaluation-fuzz.test.ts
Normal 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
355
packages/workflow/test/native-evaluation-parity.test.ts
Normal file
355
packages/workflow/test/native-evaluation-parity.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user