packages/@n8n/expression-runtime/ARCHITECTURE.md
This package provides a secure, isolated expression evaluation runtime that works across multiple execution environments (isolated-vm, Web Workers, and task runners).
The architecture is split into three distinct layers:
┌─────────────────────────────────────────────────────────┐
│ Host Process │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ ExpressionEvaluator (Layer 3) │ │
│ │ - Public API │ │
│ │ - Tournament integration │ │
│ │ - Code caching │ │
│ │ - Observability │ │
│ └────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌────────────────▼───────────────────────────────┐ │
│ │ Bridge (Layer 2) │ │
│ │ - IsolatedVmBridge (Phase 1.1) │ │
│ │ - WebWorkerBridge (Phase 2+) │ │
│ │ - Task Runner Integration (TBD) │ │
│ └────────────────┬───────────────────────────────┘ │
│ │ IPC/Message Passing │
└───────────────────┼─────────────────────────────────────┘
│
┌───────────────────▼─────────────────────────────────────┐
│ Isolated Context │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ Runtime (Layer 1) │ │
│ │ - Runs inside isolation │ │
│ │ - No Node.js dependencies │ │
│ │ - Lazy loading proxies │ │
│ │ - Helper functions ($json, $item, etc.) │ │
│ │ - lodash, Luxon │ │
│ └────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
Location: Runs inside the isolated context (isolate, worker, subprocess)
Purpose: Provides the JavaScript execution environment for expressions
Key Components:
$json, $item, $input, $, etc.Bundle: IIFE format for isolated-vm, ESM for Web Workers
Location: Runs in the host process
Purpose: Manages communication between host and isolated context
Key Components:
Responsibilities:
Location: Runs in the host process
Purpose: Public API for expression evaluation
Key Components:
Responsibilities:
sequenceDiagram
participant WF as Workflow
participant Eval as ExpressionEvaluator
participant Bridge as IsolatedVmBridge
participant Runtime as Runtime (Isolated)
WF->>Eval: evaluate(expr, data)
Eval->>Eval: Transform with Tournament (cached)
Eval->>Bridge: execute(transformedCode, data)
Bridge->>Bridge: registerCallbacks(data) — creates ivm.Reference callbacks
Bridge->>Runtime: resetDataProxies() — initialise $json, $input, etc. as lazy proxies
Bridge->>Runtime: run wrapped code (this === __data)
Runtime->>Runtime: Access $json.field
Runtime->>Bridge: __getValueAtPath(['$json','field']) via ivm.Reference
Bridge->>Bridge: Navigate data object
Bridge-->>Runtime: Metadata or primitive
Runtime-->>Bridge: Expression result
Bridge-->>Eval: Result (copied from isolate)
Eval-->>WF: Result
Data access from inside the isolate goes through ivm.Reference callbacks
registered by the bridge — not through a method on RuntimeBridge itself.
sequenceDiagram
participant Runtime as Runtime (Isolated)
participant Proxy as Lazy Proxy
participant Bridge as IsolatedVmBridge (host)
Runtime->>Proxy: $json.user.email
Proxy->>Bridge: __getValueAtPath(['$json','user','email']) via ivm.Reference
Bridge->>Bridge: Navigate data object registered via registerCallbacks()
Bridge-->>Proxy: "[email protected]" (primitive copied into isolate)
Proxy-->>Runtime: "[email protected]"
Uses isolated-vm for V8 isolate-based isolation:
class IsolatedVmBridge implements RuntimeBridge {
private isolate: ivm.Isolate;
private context: ivm.Context;
async initialize(): Promise<void> {
this.isolate = new ivm.Isolate({
memoryLimit: 128
});
this.context = await this.isolate.createContext();
// Load runtime code
await this.context.eval(runtimeCode);
}
async execute(code: string, dataId: string): Promise<unknown> {
// Implementation...
}
}
Uses Web Workers for browser-based isolation:
class WebWorkerBridge implements RuntimeBridge {
private worker: Worker;
async initialize(): Promise<void> {
this.worker = new Worker('/runtime.worker.js');
// Setup message handlers
}
async execute(code: string, dataId: string): Promise<unknown> {
// Implementation...
}
}
Task runners already provide process-level isolation. When code nodes call evaluateExpression(), evaluation happens inside the task runner (not via IPC to worker).
Architecture decision pending - two options:
Option A: Task runner uses IsolatedVmBridge locally
// Inside task runner process
const evaluator = new ExpressionEvaluator({
bridge: new IsolatedVmBridge(config), // Evaluates locally
});
// Code node calls evaluateExpression()
const result = await evaluator.evaluate(expression, workflowData);
// ^ All happens inside task runner, no IPC, no lazy loading needed
Option B: Task runner evaluates directly (no extra sandbox)
// Task runner already isolated at process level
// No need for isolated-vm sandbox on top
const result = evaluateExpressionDirectly(expression, workflowData);
Key point: Task runner already has all workflow data, so no lazy loading or IPC communication is needed for data access.
packages/@n8n/expression-runtime/
├── ARCHITECTURE.md # This file
├── README.md
├── package.json
├── tsconfig.json
├── tsconfig.build.json
├── vitest.config.ts
├── esbuild.config.js # Bundles src/runtime/index.ts → dist/bundle/runtime.iife.js
│
├── src/
│ ├── index.ts # Public API exports
│ │
│ ├── types/ # TypeScript interfaces (no implementations)
│ │ ├── index.ts
│ │ ├── bridge.ts # RuntimeBridge, BridgeConfig
│ │ ├── evaluator.ts # IExpressionEvaluator, EvaluatorConfig, error classes
│ │ └── runtime.ts # RuntimeHostInterface, RuntimeGlobals, RuntimeError
│ │
│ ├── runtime/ # Layer 1: runs inside the V8 isolate
│ │ └── index.ts # Proxy system, resetDataProxies, __sanitize,
│ │ # SafeObject, SafeError, Lodash/Luxon wiring,
│ │ # all extension functions
│ │
│ ├── bridge/ # Layer 2: host-process isolate management
│ │ └── isolated-vm-bridge.ts # IsolatedVmBridge (ivm.Isolate, callbacks, script cache)
│ │
│ ├── evaluator/ # Layer 3: public-facing API
│ │ └── expression-evaluator.ts # Tournament integration, expression code cache
│ │
│ ├── extensions/ # Expression extension functions (bundled into runtime)
│ │ ├── array-extensions.ts
│ │ ├── boolean-extensions.ts
│ │ ├── date-extensions.ts
│ │ ├── number-extensions.ts
│ │ ├── object-extensions.ts
│ │ ├── string-extensions.ts
│ │ ├── extend.ts
│ │ ├── extensions.ts
│ │ ├── expression-extension-error.ts
│ │ └── utils.ts
│ │
│ └── __tests__/
│ └── integration.test.ts
│
└── dist/
├── *.js / *.d.ts # Compiled TypeScript (tsc output)
└── bundle/
└── runtime.iife.js # Self-contained IIFE loaded into isolated-vm
Separation of Concerns: Each layer has a single responsibility:
Environment Agnostic: The Runtime and Evaluator layers are identical across all environments. Only the Bridge changes.
Memory Efficiency: Large workflow data (100MB+) cannot fit in isolate memory limits (128MB). Lazy loading fetches only the fields that expressions actually access.
Performance: Transferring only accessed fields is faster than transferring entire objects.
Limitation: Lazy loading requires synchronous callbacks from runtime to host. This works for:
ivm.Reference for true synchronous callbacksNo Node.js Dependencies: Runtime must work in environments without Node.js (browser, isolated-vm). Bundling produces a self-contained IIFE/ESM module.
Immutability: Bundled runtime is immutable and can be cached.
Future-Proofing: Frontend will use Web Workers. Backend uses isolated-vm. Abstract bridge allows adding new environments without changing other layers.
Testing: Integration tests use IsolatedVmBridge directly (see src/__tests__/integration.test.ts).
JavaScript Proxy trap handlers are synchronous, which creates a fundamental limitation:
const proxy = new Proxy({}, {
get(target, prop) {
// This handler MUST be synchronous
// Cannot use await or return Promise
return someValue;
}
});
Impact by Environment:
isolated-vm ✅
ivm.Reference for true synchronous callbacks from isolate to hostNode.js vm ✅
Web Workers ❌
postMessage is always asyncSharedArrayBuffer + Atomics for synchronous data accessPhase 1 (Initial implementation):
Phase 2+ (Future enhancement):
SharedArrayBuffer + Atomics for sync accessThe runtime has no access to:
The runtime can only:
ivm.Reference callbacks to fetch workflow dataIntegration Tests (vitest):
IsolatedVmBridge with real isolated-vmBridge Tests (vitest):
Evaluator Tests (vitest):
Integration Tests (jest in workflow package):
All layers emit metrics, traces, and logs:
Metrics:
expression.evaluation.countexpression.evaluation.duration_msexpression.code_cache.hit (transformed code cache)expression.code_cache.missexpression.isolate.memory_mbTraces:
expression.evaluate span wraps entire evaluationexpression.tournament span for AST transformationexpression.isolate.execute span for isolated executionLogs:
See observability package documentation for details.