diff --git a/packages/frontend/editor-ui/vite.config.mts b/packages/frontend/editor-ui/vite.config.mts index deea91bdc9c..f3bcb5c6508 100644 --- a/packages/frontend/editor-ui/vite.config.mts +++ b/packages/frontend/editor-ui/vite.config.mts @@ -26,6 +26,11 @@ const packagesDir = resolve(__dirname, '..', '..'); const alias = [ { find: '@', replacement: resolve(__dirname, 'src') }, { find: 'stream', replacement: 'stream-browserify' }, + // Stub out @n8n/expression-runtime for browser build (it pulls in isolated-vm, a Node.js-only native module) + { + find: '@n8n/expression-runtime', + replacement: resolve(__dirname, 'vite/expression-runtime-stub.ts'), + }, // Ensure bare imports resolve to sources (not dist) { find: '@n8n/i18n', replacement: resolve(packagesDir, 'frontend', '@n8n', 'i18n', 'src') }, { diff --git a/packages/frontend/editor-ui/vite/expression-runtime-stub.ts b/packages/frontend/editor-ui/vite/expression-runtime-stub.ts new file mode 100644 index 00000000000..e274d314867 --- /dev/null +++ b/packages/frontend/editor-ui/vite/expression-runtime-stub.ts @@ -0,0 +1,54 @@ +/** + * Browser stub for @n8n/expression-runtime. + * The real implementation uses isolated-vm (a Node.js-only native module). + * IS_FRONTEND guards in expression.ts prevent these from ever being instantiated. + */ + +export class ExpressionEvaluator { + constructor(_config?: unknown) { + throw new Error('ExpressionEvaluator is not available in browser environments'); + } +} + +export class IsolatedVmBridge { + constructor(_config?: unknown) { + throw new Error('IsolatedVmBridge is not available in browser environments'); + } +} + +export class ExpressionError extends Error { + constructor( + message: string, + public context: Record = {}, + ) { + super(message); + } +} +export class MemoryLimitError extends Error {} +export class TimeoutError extends Error {} +export class SecurityViolationError extends Error {} +// Note: SyntaxError not re-exported to avoid shadowing built-in + +export class RuntimeError extends Error {} + +export function extend() {} +export function extendOptional() {} +export const EXTENSION_OBJECTS: unknown[] = []; +export class ExpressionExtensionError extends Error {} +export class IsolateError extends Error {} + +export const DEFAULT_BRIDGE_CONFIG = {}; + +// Type-only exports (resolved by TypeScript, erased at runtime) +export type IExpressionEvaluator = never; +export type EvaluatorConfig = never; +export type WorkflowData = Record; +export type EvaluateOptions = never; +export type RuntimeBridge = never; +export type BridgeConfig = never; +export type ObservabilityProvider = never; +export type MetricsAPI = never; +export type TracesAPI = never; +export type Span = never; +export type LogsAPI = never; +export type ExecuteOptions = never; diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 3bd310b5602..1574856e517 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@n8n/errors": "workspace:*", + "@n8n/expression-runtime": "workspace:*", "@n8n/tournament": "1.0.6", "ast-types": "0.16.1", "callsites": "catalog:", diff --git a/packages/workflow/src/expression.ts b/packages/workflow/src/expression.ts index d7e834d405b..832ab92a1af 100644 --- a/packages/workflow/src/expression.ts +++ b/packages/workflow/src/expression.ts @@ -1,11 +1,19 @@ import { DateTime, Duration, Interval } from 'luxon'; import { ApplicationError } from '@n8n/errors'; +import type { IExpressionEvaluator, ObservabilityProvider } from '@n8n/expression-runtime'; import { ExpressionExtensionError } from './errors/expression-extension.error'; import { ExpressionError } from './errors/expression.error'; import { evaluateExpression, setErrorHandler } from './expression-evaluator-proxy'; -import { sanitizer, sanitizerName } from './expression-sandboxing'; +import { + DollarSignValidator, + PrototypeSanitizer, + ThisSanitizer, + sanitizer, + sanitizerName, +} from './expression-sandboxing'; import { isExpression } from './expressions/expression-helpers'; +import * as LoggerProxy from './logger-proxy'; import { extend, extendOptional } from './extensions'; import { extendSyntax } from './extensions/expression-extension'; import { extendedFunctions } from './extensions/extended-functions'; @@ -180,6 +188,78 @@ const createSafeErrorSubclass = (ErrorClass: T): T = export class Expression { constructor(private readonly workflow: Workflow) {} + private static expressionEngine: 'legacy' | 'vm' = 'legacy'; + + private static vmEvaluator?: IExpressionEvaluator; + + /** + * Check if VM evaluator should be used for evaluation. + * @private + */ + private static shouldUseVm(): boolean { + return this.expressionEngine === 'vm' && !IS_FRONTEND && !!this.vmEvaluator; + } + + /** + * Initialize the VM evaluator (if feature flag is enabled). + * Should be called once during application startup. + * Only available in Node.js environments (not in browser). + */ + static async initExpressionEngine(options: { + engine: 'legacy' | 'vm'; + bridgeTimeout: number; + bridgeMemoryLimit: number; + poolSize: number; + maxCodeCacheSize: number; + observability?: ObservabilityProvider; + idleTimeoutMs?: number; + }): Promise { + this.expressionEngine = options.engine; + if (options.engine !== 'vm' || IS_FRONTEND) return; + + if (!this.vmEvaluator) { + // Dynamic import to avoid loading expression-runtime in browser environments + const { ExpressionEvaluator, IsolatedVmBridge } = await import('@n8n/expression-runtime'); + this.vmEvaluator = new ExpressionEvaluator({ + createBridge: () => + new IsolatedVmBridge({ + timeout: options.bridgeTimeout, + memoryLimit: options.bridgeMemoryLimit, + logger: LoggerProxy, + }), + maxCodeCacheSize: options.maxCodeCacheSize, + poolSize: options.poolSize, + idleTimeoutMs: options.idleTimeoutMs, + hooks: { + before: [ThisSanitizer], + after: [PrototypeSanitizer, DollarSignValidator], + }, + logger: LoggerProxy, + observability: options.observability, + }); + await this.vmEvaluator.initialize(); + } + } + + async acquireIsolate(): Promise { + if (Expression.vmEvaluator) await Expression.vmEvaluator.acquire(this); + } + + async releaseIsolate(): Promise { + if (Expression.vmEvaluator) await Expression.vmEvaluator.release(this); + } + + /** + * Dispose the VM evaluator and release resources. + * Should be called during application shutdown or test teardown. + */ + static async disposeExpressionEngine(): Promise { + if (this.vmEvaluator) { + await this.vmEvaluator.dispose(); + this.vmEvaluator = undefined; + } + } + static initializeGlobalContext(data: IDataObject) { /** * Denylist diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76a598bb78a..90041d2807d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3386,6 +3386,9 @@ importers: '@n8n/errors': specifier: workspace:* version: link:../@n8n/errors + '@n8n/expression-runtime': + specifier: workspace:* + version: link:../@n8n/expression-runtime '@n8n/tournament': specifier: 1.0.6 version: 1.0.6