feat(editor): Run expressions through the QuickJS engine in the browser (no-changelog) (#37448)

Co-authored-by: Claude Opus 4.7 <[email protected]>
Co-authored-by: Luigi Gubello <[email protected]>
Co-authored-by: Luigi Gubello <[email protected]>
Co-authored-by: Emilia <[email protected]>
This commit is contained in:
Danny Martini
2026-09-15 13:27:20 +00:00
committed by GitHub
co-authored by Claude Opus 4.7 Luigi Gubello Luigi Gubello Emilia
parent e3c3cb8f5a
commit c5534b54a7
30 changed files with 878 additions and 114 deletions
@@ -305,6 +305,14 @@ export interface FrontendSettings {
activeModules: string[];
canvasOnly: boolean;
envFeatureFlags: N8nEnvFeatFlags;
/**
* Which expression engine the editor evaluates expressions with
* (`N8N_EXPRESSION_ENGINE_FRONTEND`). Read at runtime rather than baked in at
* build time, so one image serves either engine. Independent of the engine the
* backend evaluates with, and never `vm`: isolated-vm is a native module.
*/
expressionEngine: 'legacy' | 'quickjs';
}
export type FrontendModuleSettings = {
@@ -14,6 +14,10 @@ describe('ExpressionEngineConfig', () => {
expect(Container.get(ExpressionEngineConfig).engine).toBe('vm');
});
test('frontendEngine defaults to legacy', () => {
expect(Container.get(ExpressionEngineConfig).frontendEngine).toBe('legacy');
});
test('bridgeTimeout defaults to 5000', () => {
expect(Container.get(ExpressionEngineConfig).bridgeTimeout).toBe(5000);
});
@@ -38,6 +42,38 @@ describe('ExpressionEngineConfig', () => {
});
});
describe('N8N_EXPRESSION_ENGINE_FRONTEND', () => {
test('overrides frontendEngine to quickjs', () => {
vi.stubEnv('N8N_EXPRESSION_ENGINE_FRONTEND', 'quickjs');
expect(Container.get(ExpressionEngineConfig).frontendEngine).toBe('quickjs');
});
// isolated-vm is a native module, so the editor can never run it.
test('rejects vm and falls back to legacy', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('N8N_EXPRESSION_ENGINE_FRONTEND', 'vm');
expect(Container.get(ExpressionEngineConfig).frontendEngine).toBe('legacy');
expect(consoleWarnSpy).toHaveBeenCalled();
});
test('falls back to default on invalid value', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('N8N_EXPRESSION_ENGINE_FRONTEND', 'not-an-engine');
expect(Container.get(ExpressionEngineConfig).frontendEngine).toBe('legacy');
expect(consoleWarnSpy).toHaveBeenCalled();
});
test('is independent of the backend engine', () => {
vi.stubEnv('N8N_EXPRESSION_ENGINE', 'vm');
vi.stubEnv('N8N_EXPRESSION_ENGINE_FRONTEND', 'quickjs');
const config = Container.get(ExpressionEngineConfig);
expect(config.engine).toBe('vm');
expect(config.frontendEngine).toBe('quickjs');
});
});
describe('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT', () => {
test('overrides idleTimeout', () => {
vi.stubEnv('N8N_EXPRESSION_ENGINE_IDLE_TIMEOUT', '60');
@@ -4,6 +4,13 @@ import { Config, Env } from '../decorators';
const expressionEngineSchema = z.enum(['legacy', 'vm', 'quickjs']);
/**
* The engines the editor can run in a browser. `vm` is absent on purpose: isolated-vm
* is a native module, so the enum rejects it here instead of letting the editor accept
* the value and silently keep the legacy evaluator.
*/
const frontendExpressionEngineSchema = z.enum(['legacy', 'quickjs']);
@Config
export class ExpressionEngineConfig {
/**
@@ -15,6 +22,15 @@ export class ExpressionEngineConfig {
@Env('N8N_EXPRESSION_ENGINE', expressionEngineSchema)
engine: 'legacy' | 'vm' | 'quickjs' = 'vm';
/**
* Which expression engine the editor uses in the browser. Independent of `engine`:
* the backend can evaluate with `vm` while the editor evaluates with `quickjs`.
* - `legacy` (default) runs expressions without isolation. Soon to be deprecated.
* - `quickjs` runs expressions in a QuickJS WASM sandbox.
*/
@Env('N8N_EXPRESSION_ENGINE_FRONTEND', frontendExpressionEngineSchema)
frontendEngine: 'legacy' | 'quickjs' = 'legacy';
/** Number of V8 isolates ready in the pool. */
@Env('N8N_EXPRESSION_ENGINE_POOL_SIZE')
poolSize: number = 1;
+1
View File
@@ -675,6 +675,7 @@ describe('GlobalConfig', () => {
},
expressionEngine: {
engine: 'vm',
frontendEngine: 'legacy',
poolSize: 1,
maxCodeCacheSize: 1024,
bridgeTimeout: 5000,
@@ -1,10 +1,10 @@
# Expression Runtime Architecture
This package provides a secure, isolated expression evaluation runtime that works across multiple execution environments (isolated-vm, Web Workers, and task runners).
This package provides a secure, isolated expression evaluation runtime that works across multiple execution environments (isolated-vm, QuickJS in WASM, and task runners).
## Design Goals
1. **Environment Agnostic**: Single codebase that works in Node.js (isolated-vm), browsers (Web Workers), and task runner processes
1. **Environment Agnostic**: Single codebase that works in Node.js (isolated-vm), browsers (QuickJS in WASM), and task runner processes
2. **Security**: Expressions run in isolated contexts with memory limits and timeouts
3. **Performance**: Lazy data loading, code caching, and efficient data transfer
4. **Observability**: Built-in metrics, traces, and logs
@@ -29,7 +29,7 @@ The architecture is split into three distinct layers:
│ ┌────────────────▼───────────────────────────────┐ │
│ │ Bridge (Layer 2) │ │
│ │ - IsolatedVmBridge (Phase 1.1) │ │
│ │ - WebWorkerBridge (Phase 2+) │ │
│ │ - QuickJsBridge (WASM) │ │
│ │ - Task Runner Integration (TBD) │ │
│ └────────────────┬───────────────────────────────┘ │
│ │ IPC/Message Passing │
@@ -62,7 +62,7 @@ The architecture is split into three distinct layers:
- **Libraries**: lodash, Luxon (bundled)
- **No Node.js APIs**: Pure JavaScript only
**Bundle**: IIFE format for isolated-vm, ESM for Web Workers
**Bundle**: IIFE format for both isolated-vm and QuickJS
### Layer 2: Bridge (Host Process)
@@ -73,7 +73,7 @@ The architecture is split into three distinct layers:
**Key Components**:
- **RuntimeBridge Interface**: Abstract interface for all bridge implementations
- **IsolatedVmBridge**: Uses isolated-vm API for Node.js backend (Phase 1.1)
- **WebWorkerBridge**: Uses postMessage API for browser (Phase 2+)
- **QuickJsBridge**: Uses quickjs-emscripten (WASM) for Node.js and the browser
- **Task Runner Integration**: TBD - May use IsolatedVmBridge locally or direct evaluation (Phase 2+)
**Responsibilities**:
@@ -175,24 +175,12 @@ class IsolatedVmBridge implements RuntimeBridge {
}
```
### WebWorkerBridge (Browser Frontend)
### QuickJsBridge (Browser Frontend)
Uses Web Workers for browser-based isolation:
```typescript
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...
}
}
```
The editor runs the same `QuickJsBridge` as Node.
The browser has no filesystem, so the host passes the runtime bundle in with the
`runtimeBundle` option. The vite stub in `packages/frontend/editor-ui/vite/` maps
`@n8n/expression-runtime` to the real bridge for the browser build.
### Task Runner Integration (TBD - Phase 2+)
@@ -294,7 +282,7 @@ packages/@n8n/expression-runtime/
**Limitation**: Lazy loading requires **synchronous** callbacks from runtime to host. This works for:
- ✅ **isolated-vm**: Uses `ivm.Reference` for true synchronous callbacks
- ✅ **Node.js vm**: Direct synchronous function calls
- ❌ **Web Workers**: postMessage is always async (see Known Limitations below)
- ✅ **QuickJS**: host functions are plain synchronous calls in the WASM context
### 3. Why Bundle the Runtime?
@@ -304,7 +292,7 @@ packages/@n8n/expression-runtime/
### 4. Why Abstract Bridge?
**Future-Proofing**: Frontend will use Web Workers. Backend uses isolated-vm. Abstract bridge allows adding new environments without changing other layers.
**Portability**: The backend uses isolated-vm. The browser uses QuickJS in WASM. The abstract bridge allows adding new environments without changing other layers.
**Testing**: Integration tests use `IsolatedVmBridge` directly (see `src/__tests__/integration.test.ts`).
@@ -334,23 +322,9 @@ const proxy = new Proxy({}, {
- Direct synchronous function calls
- Full lazy loading support (used for testing)
3. **Web Workers** ❌
- `postMessage` is always async
- **Phase 1 Limitation**: No lazy loading, must pre-fetch all data before evaluation
- **Future Enhancement (Phase 2+)**: Explore `SharedArrayBuffer` + `Atomics` for synchronous data access
### Web Worker Support Roadmap
**Phase 1** (Initial implementation):
- WebWorkerBridge will pre-fetch all workflow data
- Transfer complete data object to worker before evaluation
- Works for small/medium datasets (< 50MB)
- No lazy loading benefit
**Phase 2+** (Future enhancement):
- Investigate `SharedArrayBuffer` + `Atomics` for sync access
- Or accept pre-fetching as the Web Worker approach
- Decision based on real-world usage patterns
3. **QuickJS (WASM)** ✅
- Host functions are plain synchronous calls in the QuickJS context
- Full lazy loading support, in Node.js and in the browser
### Security Boundaries
@@ -422,5 +396,5 @@ See observability package documentation for details.
## References
- [isolated-vm GitHub](https://github.com/laverdet/isolated-vm)
- [Web Workers MDN](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API)
- [quickjs-emscripten GitHub](https://github.com/justjake/quickjs-emscripten)
- [n8n workflow package](../workflow/)
+6 -3
View File
@@ -11,10 +11,10 @@ Secure, isolated expression evaluation runtime for n8n workflows.
- ✅ `IsolatedVmBridge`: V8 isolate management via `isolated-vm`
- ✅ `ExpressionEvaluator`: tournament integration, expression code caching, isolate pooling
- ✅ Workflow integration — default engine; `N8N_EXPRESSION_ENGINE=legacy` opts out
- ✅ Editor support — the `QuickJsBridge` runs in the browser; `N8N_EXPRESSION_ENGINE_FRONTEND=quickjs` opts in
- ✅ Observability (metrics, traces, logs) wired up in `packages/cli`
Coming later:
- 🚧 Web Worker support (Phase 2+)
- 🚧 Performance optimizations (Phase 3)
## Overview
@@ -23,9 +23,9 @@ This package provides a secure runtime for evaluating expressions in isolated co
Currently supports:
- **Node.js Backend**: Uses `isolated-vm` for V8 isolate-based isolation with lazy data loading
- **Browser Frontend**: Uses `QuickJsBridge` (QuickJS compiled to WASM); `N8N_EXPRESSION_ENGINE_FRONTEND=quickjs` opts in
Future support (Phase 2+):
- **Browser Frontend**: Will use Web Workers for browser-based isolation
- **Task Runners**: Will use IPC for separate process isolation
## Features
@@ -170,7 +170,7 @@ interface RuntimeBridge {
- Synchronous callbacks via ivm.Reference
- Security wrappers (SafeObject, SafeError)
- `E()` error handler for tournament-generated try-catch code
- **WebWorkerBridge**: 🚧 For browser frontend (Web Workers) - Phase 2+
- **QuickJsBridge**: ✅ QuickJS in WASM, for the Node.js backend and the browser frontend
- **Task Runner Integration**: 🚧 TBD - May use IsolatedVmBridge locally or direct evaluation - Phase 2+
## Configuration
@@ -195,6 +195,9 @@ In n8n, the evaluator is configured via `ExpressionEngineConfig` (`@n8n/config`)
# Engine selection ('vm' is the default; 'legacy' opts out of isolation)
N8N_EXPRESSION_ENGINE=vm
# Editor engine selection ('legacy' is the default; 'quickjs' runs the WASM engine in the browser)
N8N_EXPRESSION_ENGINE_FRONTEND=legacy
# Isolate pool and code cache
N8N_EXPRESSION_ENGINE_POOL_SIZE=1
N8N_EXPRESSION_ENGINE_MAX_CODE_CACHE_SIZE=1024
@@ -12,6 +12,7 @@
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./runtime-bundle.iife.js": "./dist/bundle/runtime.iife.js",
"./*": "./*"
},
"scripts": {
@@ -1,7 +1,11 @@
import { readFile } from 'node:fs/promises';
import * as path from 'node:path';
import { DateTime, Duration, Interval } from 'luxon';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ExpressionEvaluator } from '../evaluator/expression-evaluator';
import { IsolatedVmBridge } from '../bridge/isolated-vm-bridge';
import { QuickJsBridge } from '../bridge/quickjs-bridge';
import type { WorkflowData } from '../types';
import { TimeoutError, MemoryLimitError } from '../types';
import { createBridge, engineName, isQuickJS, newBridge } from './test-bridge';
@@ -1240,3 +1244,59 @@ describe(`Integration: nested evaluation time budget (${engineName})`, () => {
expect(elapsed).toBeLessThan(TIMEOUT_MS * 2);
});
});
describe('QuickJsBridge runtimeBundle injection', () => {
it('should not cache a runtimeBundle that fails to load', async () => {
const withBadBundle = new QuickJsBridge({
timeout: 5000,
runtimeBundle: 'this is not javascript (((',
});
try {
await expect(withBadBundle.initialize()).rejects.toThrow('Failed to load runtime bundle');
} finally {
await withBadBundle.dispose();
}
const withDiskRead = new QuickJsBridge({ timeout: 5000 });
try {
await expect(withDiskRead.initialize()).resolves.toBeUndefined();
} finally {
await withDiskRead.dispose();
}
});
it('should initialize and evaluate when runtimeBundle is provided as a string', async () => {
let dir = __dirname;
let bundle: string | undefined;
while (dir !== path.dirname(dir)) {
try {
bundle = await readFile(path.join(dir, 'dist', 'bundle', 'runtime.iife.js'), 'utf-8');
break;
} catch {}
dir = path.dirname(dir);
}
if (!bundle) throw new Error('runtime bundle not found for test setup');
const capturedBundle = bundle;
const evaluator = new ExpressionEvaluator({
createBridge: () => new QuickJsBridge({ timeout: 5000, runtimeBundle: capturedBundle }),
maxCodeCacheSize: 1024,
});
await evaluator.initialize();
const caller = {};
await evaluator.acquire(caller);
try {
const result = evaluator.evaluate(
'{{ $json.name }}',
{ $json: { name: 'injected' } },
caller,
);
expect(result).toBe('injected');
} finally {
await evaluator.release(caller);
await evaluator.dispose();
}
});
});
@@ -3,7 +3,6 @@ import * as path from 'node:path';
import type { RuntimeBridge, BridgeConfig, ExecuteOptions, WorkflowData } from '../types';
import { DEFAULT_BRIDGE_CONFIG, TimeoutError, MemoryLimitError } from '../types';
import type { ErrorSentinel } from '../runtime/lazy-proxy';
import { isLuxonSentinel, rebuildLuxonValue } from '../runtime/luxon-transfer';
import type { EscapedTransferValue } from '../runtime/transfer';
import {
@@ -46,7 +45,9 @@ async function getQuickJSModule(): Promise<QuickJSModule> {
return _quickjs;
}
const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
// Joined by hand, not with `path.join`. This runs at module load, and a browser
// build resolves `node:path` to an empty module, so `path.join` is undefined there.
const BUNDLE_RELATIVE_PATH = ['dist', 'bundle', 'runtime.iife.js'].join('/');
// Captured at module load so values rendered into generated code stay stable
// even if the global is later replaced.
@@ -269,6 +270,9 @@ function wrapSpecialValuesForGuest(value: unknown): unknown {
* `dist/bundle/runtime.iife.js` is found. Walking up (rather than a fixed
* relative path) works from either compiled output dir — `dist/cjs/bridge/`
* and `dist/esm/bridge/` sit at different depths from the bundle.
*
* Node-only. A browser never reaches it: `initialize()` seeds the cache from
* `config.runtimeBundle` first.
*/
function loadRuntimeBundle(): string {
if (_runtimeBundle !== null) return _runtimeBundle;
@@ -493,7 +497,15 @@ export class QuickJsBridge implements RuntimeBridge {
const QuickJS = await getQuickJS();
_quickjsWasm = QuickJS;
this.setupContext(QuickJS, loadRuntimeBundle());
// A host without a filesystem (the browser) passes the bundle in, so
// loadRuntimeBundle() — the only Node-only code here — is never reached.
const runtimeBundle = this.config.runtimeBundle || _runtimeBundle || loadRuntimeBundle();
this.setupContext(QuickJS, runtimeBundle);
// Cached after the load, so initializeSync() can only ever build a later
// bridge from a bundle this one proved good.
_runtimeBundle = runtimeBundle;
}
/**
@@ -412,4 +412,23 @@ describe('IdleScalingPool', () => {
await pool.dispose();
});
it('should schedule the idle timer when setTimeout returns a number', async () => {
// The stub drops the callback: this covers the `unref` guard on the handle,
// not the scale-down the timer would later trigger. Scheduling for real
// would fire that scale-down after the pool is disposed.
vi.spyOn(globalThis, 'setTimeout').mockImplementation(
(() => 1) as unknown as typeof globalThis.setTimeout,
);
const pool = new IdleScalingPool(createFactory(), 1, IDLE_TIMEOUT_MS);
try {
await expect(pool.initialize()).resolves.toBeUndefined();
expect(() => pool.acquire()).not.toThrow();
} finally {
vi.mocked(globalThis.setTimeout).mockRestore();
await pool.dispose();
}
});
});
@@ -112,4 +112,45 @@ describe('IsolatePool', () => {
expect(replenished).toBeDefined();
await pool.dispose();
});
it('should retry replenishment without leaking a rejection when setTimeout returns a number', async () => {
const nodeSetTimeout = globalThis.setTimeout;
const wait = async (ms: number) => await new Promise((resolve) => nodeSetTimeout(resolve, ms));
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((
handler: () => void,
ms?: number,
): number => {
nodeSetTimeout(handler, ms);
return 1;
}) as unknown as typeof globalThis.setTimeout);
const leaked: unknown[] = [];
const onUnhandled = (reason: unknown) => leaked.push(reason);
process.on('unhandledRejection', onUnhandled);
let attempts = 0;
const factory = vi.fn().mockImplementation(async () => {
attempts++;
if (attempts === 1) return createMockBridge();
throw new Error('fail');
});
const pool = new IsolatePool(factory, 1);
await pool.initialize();
try {
pool.acquire();
const deadline = Date.now() + 3000;
while (attempts < 3 && Date.now() < deadline) await wait(50);
await wait(50);
expect(attempts).toBeGreaterThanOrEqual(3);
expect(leaked).toEqual([]);
} finally {
process.off('unhandledRejection', onUnhandled);
vi.mocked(globalThis.setTimeout).mockRestore();
await pool.dispose();
}
});
});
@@ -94,7 +94,8 @@ export class IdleScalingPool implements IPool {
if (this.idleTimeoutMs === undefined) return;
if (this.idleTimer) clearTimeout(this.idleTimer);
this.idleTimer = setTimeout(() => this.triggerScaleDown(), this.idleTimeoutMs);
this.idleTimer.unref();
// In a browser `setTimeout` returns a number, which has no `unref`.
if (typeof this.idleTimer === 'object') this.idleTimer.unref();
}
private triggerScaleUp(): void {
@@ -116,7 +116,9 @@ export class IsolatePool implements IPool {
if (attempt < IsolatePool.MAX_REPLENISH_RETRIES) {
const delay = IsolatePool.REPLENISH_RETRY_BASE_MS * 2 ** attempt;
setTimeout(() => this.replenish(attempt + 1), delay).unref();
const timer = setTimeout(() => this.replenish(attempt + 1), delay);
// In a browser `setTimeout` returns a number, which has no `unref`.
if (typeof timer === 'object') timer.unref();
}
});
this.replenishPromises.add(promise);
@@ -93,6 +93,13 @@ export interface BridgeConfig {
/** Optional logger. Falls back to no-op if not provided. */
logger?: Logger;
/**
* Pre-loaded runtime IIFE bundle source as a string.
* If provided, the bridge skips its own filesystem read.
* Required when running in environments without filesystem access (browser).
*/
runtimeBundle?: string;
/**
* Reuse V8 compile cache for the runtime bundle. isolated-vm only.
* Default: false
@@ -112,6 +119,7 @@ export const DEFAULT_BRIDGE_CONFIG: Required<BridgeConfig> = {
memoryLimit: 128,
timeout: 5000,
logger: NO_OP_LOGGER,
runtimeBundle: '',
compileCache: false,
};
@@ -38,6 +38,7 @@ describe('FrontendService', () => {
tags: { disabled: false },
collaboration: { crdt: 'off' },
logging: { level: 'info' },
expressionEngine: { frontendEngine: 'legacy' },
hiringBanner: { enabled: false },
versionNotifications: {
enabled: false,
@@ -780,6 +781,27 @@ describe('FrontendService', () => {
});
});
describe('expressionEngine setting', () => {
afterEach(() => {
globalConfig.expressionEngine.frontendEngine = 'legacy';
});
it('should surface the frontend expression engine from config', async () => {
const { service } = createMockService();
const settings = await service.getSettings();
expect(settings.expressionEngine).toBe('legacy');
});
// The default alone would still pass if the value were hard-coded.
it('should surface quickjs when the config selects it', async () => {
globalConfig.expressionEngine.frontendEngine = 'quickjs';
const { service } = createMockService();
const settings = await service.getSettings();
expect(settings.expressionEngine).toBe('quickjs');
});
});
describe('aiBuilder setting', () => {
it('should initialize aiBuilder setting as disabled by default', async () => {
const { service } = createMockService();
@@ -438,6 +438,7 @@ export class FrontendService {
crdt: this.globalConfig.collaboration.crdt,
},
envFeatureFlags: this.collectEnvFeatureFlags(),
expressionEngine: this.globalConfig.expressionEngine.frontendEngine,
};
}
@@ -195,6 +195,7 @@ export const defaultSettings: FrontendSettings = {
activeModules: [],
canvasOnly: false,
envFeatureFlags: {},
expressionEngine: 'legacy',
dynamicBanners: {
endpoint: 'https://api.n8n.io/api/banners',
enabled: true,
@@ -0,0 +1,52 @@
import { Expression } from 'n8n-workflow';
import { initializeExpressionEngine } from '@/app/init/expressionEngine';
vi.mock('n8n-workflow', () => ({
Expression: { initExpressionEngine: vi.fn(), getActiveImplementation: vi.fn(() => 'legacy') },
}));
vi.mock('@n8n/expression-runtime/runtime-bundle.iife.js?raw', () => ({
default: '/* runtime bundle */',
}));
describe('initializeExpressionEngine', () => {
beforeEach(() => {
vi.mocked(Expression.initExpressionEngine).mockClear();
vi.mocked(Expression.getActiveImplementation).mockReturnValue('legacy');
});
it('starts the quickjs engine with the runtime bundle when the setting selects it', async () => {
await initializeExpressionEngine('quickjs');
expect(Expression.initExpressionEngine).toHaveBeenCalledWith(
expect.objectContaining({
engine: 'quickjs',
runtimeBundle: '/* runtime bundle */',
sharedCaller: true,
lazyAcquire: true,
}),
);
});
it('leaves the legacy evaluator when the setting selects legacy', async () => {
await initializeExpressionEngine('legacy');
expect(Expression.initExpressionEngine).not.toHaveBeenCalled();
});
// initializeCore and the login hook both call this on an authenticated boot.
it('does not reload the engine when quickjs already runs', async () => {
vi.mocked(Expression.getActiveImplementation).mockReturnValue('quickjs');
await initializeExpressionEngine('quickjs');
expect(Expression.initExpressionEngine).not.toHaveBeenCalled();
});
// An older instance, or one whose settings call failed, sends no engine at all.
it('leaves the legacy evaluator when the setting is absent', async () => {
await initializeExpressionEngine(undefined);
expect(Expression.initExpressionEngine).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,54 @@
import type { FrontendSettings } from '@n8n/api-types';
import { Expression } from 'n8n-workflow';
/**
* Set up the editor's expression engine from the backend settings payload.
*
* The engine is read at runtime rather than baked in at build time, so the same
* image serves either engine. It is independent of the backend's own
* `N8N_EXPRESSION_ENGINE`: the editor can evaluate with quickjs while the
* backend evaluates with vm. `vm` is not an option here — isolated-vm is a
* native module — so anything other than `quickjs` leaves the legacy evaluator
* in place.
*
* A failure to load the engine leaves `Expression` without an evaluator, which
* falls back to the legacy path rather than breaking the editor. Under a policy
* that denies `'wasm-unsafe-eval'` the WASM module cannot instantiate, and this
* is the path that keeps the editor usable.
*/
export async function initializeExpressionEngine(
engine: FrontendSettings['expressionEngine'] | undefined,
): Promise<void> {
if (engine !== 'quickjs') return;
// Called once when the app boots and again from the login hook, because an
// unauthenticated boot cannot see the engine yet. Loading the bundle twice
// would be wasted work.
if (Expression.getActiveImplementation() === 'quickjs') return;
// The bridge needs the runtime bundle as a string: the browser has no
// node:fs to read it from disk. Imported here rather than at module scope so
// the ~470KB asset is only fetched when the engine is enabled. The
// runtime-bundle.iife.js sub-path is aliased in vite.config.mts to the built
// file in @n8n/expression-runtime.
const { default: runtimeBundle } = await import(
'@n8n/expression-runtime/runtime-bundle.iife.js?raw'
);
await Expression.initExpressionEngine({
engine: 'quickjs',
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
poolSize: 1,
maxCodeCacheSize: 1024,
runtimeBundle,
// The editor's evaluate() is synchronous, so it needs a caller that already
// holds a scope. One shared scope covers every Expression the editor builds.
sharedCaller: true,
// Defer which caller owns a bridge, not when one is built: the pool seeds
// `poolSize` bridges in initialize(), so the wasm runtime is up before the
// first expression. This only lets the synchronous evaluate() path build
// one itself if the pool is empty by then.
lazyAcquire: true,
});
}
@@ -23,6 +23,7 @@ import { mock } from 'vitest-mock-extended';
import { telemetry } from '@/app/plugins/telemetry';
import { registerToastNotifier } from '@/app/init/toastNotifier';
import * as moduleInitializer from '@/app/moduleInitializer/moduleInitializer';
import { initializeExpressionEngine } from '@/app/init/expressionEngine';
const showMessage = vi.fn();
const showToast = vi.fn();
@@ -68,6 +69,12 @@ vi.mock('@/app/init/toastNotifier', () => ({
registerToastNotifier: vi.fn(),
}));
// The real one dynamically imports the QuickJS runtime bundle, which has no
// place in this graph. It stays inert on the default `legacy` setting anyway.
vi.mock('@/app/init/expressionEngine', () => ({
initializeExpressionEngine: vi.fn(),
}));
vi.mock('@n8n/stores/users.store', () => ({
useUsersStore: vi.fn().mockReturnValue({
initialize: vi.fn(),
@@ -191,6 +198,29 @@ describe('Init', () => {
});
});
it('should start the expression engine from the settings payload', async () => {
await initializeCore();
expect(initializeExpressionEngine).toHaveBeenCalledWith('legacy');
});
// Public settings omit the engine, so an unauthenticated boot leaves the
// legacy evaluator in place and the login hook is the first point where
// the choice is known.
it('should start the expression engine in the login hook with authenticated settings', async () => {
settingsStore.getSettings.mockImplementation(async () => {
settingsStore.settings.expressionEngine = 'quickjs';
});
usersStore.registerLoginHook.mockImplementation(async (hook) => {
await hook(mock<CurrentUserResponse>({ id: 'userId' }));
});
await initializeCore();
expect(initializeExpressionEngine).toHaveBeenCalledTimes(2);
expect(initializeExpressionEngine).toHaveBeenLastCalledWith('quickjs');
});
it('should re-initialize ssoStore in login hook with authenticated settings', async () => {
const saml = { loginEnabled: false, loginLabel: '' };
const ldap = { loginEnabled: false, loginLabel: '' };
@@ -37,6 +37,7 @@ import { useRolesStore } from '@n8n/stores/roles.store';
import { useDataTableStore } from '@/features/core/dataTable/dataTable.store';
import { useFavoritesStore } from '@/app/stores/favorites.store';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { initializeExpressionEngine } from '@/app/init/expressionEngine';
export const state = {
initialized: false,
@@ -80,6 +81,14 @@ export async function initializeCore() {
});
}
// Must run before any view renders: expressions evaluate as soon as workflow
// data is displayed, and the engine has to be in place by then.
try {
await initializeExpressionEngine(settingsStore.settings.expressionEngine);
} catch (error) {
console.error('Failed to initialize the expression engine', error);
}
ssoStore.initialize({
authenticationMethod: settingsStore.userManagement.authenticationMethod as AuthenticationMethod,
managedByEnv: settingsStore.settings.sso.managedByEnv,
@@ -270,6 +279,16 @@ function registerAuthenticationHooks() {
usersStore.registerLoginHook(async (user) => {
await settingsStore.getSettings();
// Start the expression engine now if the app booted unauthenticated.
// Public settings omit the engine, so `initializeCore` left the legacy
// evaluator in place; this is the first point where the choice is known.
// Nothing evaluates an expression before login, so this is early enough.
try {
await initializeExpressionEngine(settingsStore.settings.expressionEngine);
} catch (error) {
console.error('Failed to initialize the expression engine', error);
}
// Re-initialize SSO store with authenticated settings.
// Before login, public settings omit callbackUrl, leaving it empty.
// Without this, navigating to SSO settings after login shows an empty redirect URL.
@@ -40,8 +40,44 @@ const alias = editorUiAliases(__dirname, packagesDir);
const { RELEASE: release, SENTRY_AUTH_TOKEN: sentryAuthToken } = process.env;
/**
* Shim `node:fs` for browser builds.
*
* `QuickJsBridge` imports `readFileSync` statically for its Node-only bundle
* read. The dev server externalizes `node:fs` behind a proxy that throws on any
* property access, which breaks the editor boot as soon as the module is
* evaluated — the production build tolerates the same import.
*
* The alias has to beat the generic `node:fs` alias that nodePolyfills installs
* in its own config() hook. mergeAlias prepends incoming aliases, so the last
* plugin to run config() ends up first in the array; `enforce: 'post'` puts this
* one last. A regex find keeps it to `node:fs` exactly: a string find is a
* prefix match and would also rewrite `node:fs/promises`.
*
* Not in test mode: `vitest.config.mts` merges this config, and unit tests run
* on Node, where `node:fs` is real and several of them use it.
*/
const nodeFsShimPlugin = (): UserConfig['plugins'][number] => ({
name: 'node-fs-shim',
enforce: 'post',
config(_config, env) {
if (env.mode === 'test') return {};
return {
resolve: {
alias: [
{
find: /^node:fs$/,
replacement: resolve(__dirname, 'vite/node-fs-shim.ts'),
},
],
},
};
},
});
const plugins: UserConfig['plugins'] = [
devServerPlugin(process.env),
nodeFsShimPlugin(),
nodePopularityPlugin(),
lucideIconsPlugin(),
icons({
@@ -20,6 +20,14 @@ export const appAliases = (editorUiDir: string): Alias[] => [
];
export const editorUiAliases = (editorUiDir: string, packagesDir: string): Alias[] => [
// Allow direct import of the runtime IIFE bundle (for ?raw use in editor-ui).
// Must precede the @n8n/expression-runtime stub in appAliases, or the stub
// (which matches the sub-path too) would intercept it. The regex preserves the
// ?raw query suffix so rolldown treats the import as a raw string asset.
{
find: /^@n8n\/expression-runtime\/runtime-bundle\.iife\.js(\?.*)?$/,
replacement: `${resolve(packagesDir, '@n8n', 'expression-runtime', 'dist', 'bundle', 'runtime.iife.js')}$1`,
},
...appAliases(editorUiDir),
...shellAliases(packagesDir),
];
@@ -1,14 +1,37 @@
/**
* 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.
* Browser shim for @n8n/expression-runtime.
*
* IsolatedVmBridge depends on isolated-vm (a Node.js-only native module),
* so we stub it with a throwing class. QuickJsBridge runs on WASM and works
* in the browser, so we re-export the real implementation from source.
*/
export class ExpressionEvaluator {
constructor(_config?: unknown) {
throw new Error('ExpressionEvaluator is not available in browser environments');
}
}
// Real exports from source — vite resolves these relative paths at build time.
// The @n8n/expression-runtime alias in vite.config.mts points to THIS file,
// so the relative paths here are relative to vite/expression-runtime-stub.ts,
// not to the original package consumers' viewpoint.
export { QuickJsBridge } from '../../../@n8n/expression-runtime/src/bridge/quickjs-bridge';
export { ExpressionEvaluator } from '../../../@n8n/expression-runtime/src/evaluator/expression-evaluator';
export {
ExpressionError,
MemoryLimitError,
TimeoutError,
SecurityViolationError,
SyntaxError,
} from '../../../@n8n/expression-runtime/src/types';
export {
extend,
extendOptional,
EXTENSION_OBJECTS,
} from '../../../@n8n/expression-runtime/src/extensions/extend';
export { ExpressionExtensionError } from '../../../@n8n/expression-runtime/src/extensions/expression-extension-error';
export { NoOpProvider } from '../../../@n8n/expression-runtime/src/observability/noop-provider';
export { EXPRESSION_METRICS } from '../../../@n8n/expression-runtime/src/observability/metrics';
export { classifyExpressionError } from '../../../@n8n/expression-runtime/src/evaluator/error-classification';
export { DEFAULT_BRIDGE_CONFIG, RuntimeError } from '../../../@n8n/expression-runtime/src/types';
export class IsolatedVmBridge {
constructor(_config?: unknown) {
@@ -16,39 +39,20 @@ export class IsolatedVmBridge {
}
}
export class ExpressionError extends Error {
constructor(
message: string,
public context: Record<string, unknown> = {},
) {
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 = {};
export { IsolateError } from '../../../@n8n/errors/src/isolate.error';
// Type-only exports (resolved by TypeScript, erased at runtime)
export type IExpressionEvaluator = never;
export type EvaluatorConfig = never;
export type WorkflowData = Record<string, unknown>;
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;
export type {
IExpressionEvaluator,
EvaluatorConfig,
WorkflowData,
EvaluateOptions,
RuntimeBridge,
BridgeConfig,
ObservabilityProvider,
MetricsAPI,
TracesAPI,
Span,
LogsAPI,
ExecuteOptions,
} from '../../../@n8n/expression-runtime/src/types';
@@ -0,0 +1,16 @@
/**
* Browser shim for `node:fs`.
*
* `QuickJsBridge` imports `readFileSync` to read the runtime bundle from disk.
* The browser never takes that path — `initialize()` seeds the module cache from
* the `runtimeBundle` option first — but the import is static, so it is
* evaluated whenever the module is.
*
* Without this shim the dev server externalizes `node:fs` behind a proxy that
* throws on any property access, so evaluating the module breaks the editor
* boot. Exporting a function that throws only when called keeps the import
* harmless and still fails loudly if the Node path is ever reached in a browser.
*/
export function readFileSync(_path: string, _encoding?: string): string {
throw new Error('node:fs is not available in browser environments');
}
@@ -360,6 +360,24 @@ export class ApiHelpers {
return data.activeModules ?? [];
}
/**
* The engine the editor evaluates expressions with
* (`N8N_EXPRESSION_ENGINE_FRONTEND`). Read from the instance rather than the
* env, so a spec can skip when the instance does not run the engine it needs.
*/
async getFrontendExpressionEngine(): Promise<string | undefined> {
const response = await this.request.get('/rest/settings');
if (!response.ok()) {
throw new TestError(
`GET /rest/settings failed (${response.status()}): ${await response.text()}`,
);
}
const { data } = await response.json();
return data.expressionEngine;
}
// ===== CONVENIENCE METHODS =====
async enableFeature(feature: string): Promise<void> {
@@ -0,0 +1,128 @@
import {
SCHEDULE_TRIGGER_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
} from '../../../../../config/constants';
import { test, expect } from '../../../../../fixtures/base';
import type { n8nPage } from '../../../../../pages/n8nPage';
/**
* Runs the editor on the QuickJS engine. Without this the container keeps the
* `legacy` default, and every assertion here would pass on the wrong engine —
* the two evaluators resolve the same expressions.
*/
test.use({ capability: { env: { N8N_EXPRESSION_ENGINE_FRONTEND: 'quickjs' } } });
const SCHEDULE_PARAMETER_NAME = 'daysInterval';
/** The counter the probe keeps on the page under test. */
type ProbeWindow = Window & { wasmInstantiations: number };
async function addEditFields(n8n: n8nPage): Promise<void> {
await n8n.canvas.addNode(EDIT_FIELDS_SET_NODE_NAME);
await n8n.ndv.getAssignmentCollectionAdd('assignments').click();
await n8n.ndv.clickAssignmentExpressionToggle('assignments');
}
test.describe(
'QuickJS expression engine',
{
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
// A local run points at an instance this harness does not configure, so
// the engine is whatever the developer started.
const engine = await n8n.api.getFrontendExpressionEngine();
test.skip(engine !== 'quickjs', 'instance does not run the quickjs frontend engine');
});
test('evaluates through the wasm runtime, not the legacy evaluator', async ({ n8n }) => {
// `n8n.page` is the page the fixture drives. The bare `page` fixture is a
// second, blank one in the same context, and sees none of this traffic.
const page = n8n.page;
await page.addInitScript(() => {
const probe = window as unknown as ProbeWindow;
probe.wasmInstantiations = 0;
for (const name of ['instantiate', 'instantiateStreaming'] as const) {
const original = WebAssembly[name];
// @ts-expect-error the probe replaces the host global on purpose
WebAssembly[name] = function (...args: unknown[]) {
probe.wasmInstantiations++;
// @ts-expect-error forwarding through the replaced global
return original.apply(WebAssembly, args);
};
}
});
// The fixture already loaded the app, so the init script needs a fresh load.
await page.reload();
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.activateParameterExpressionEditor(SCHEDULE_PARAMETER_NAME);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ 1 + 2');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('3');
const instantiations = await page.evaluate(
() => (window as unknown as ProbeWindow).wasmInstantiations,
);
expect(instantiations, 'the page instantiated the QuickJS wasm module').toBeGreaterThan(0);
});
test('resolves $json with n8n extensions, and the backend agrees', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.setPinnedData([{ myStr: 'Monday Morning' }]);
await n8n.ndv.close();
await addEditFields(n8n);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{$json.myStr.toSnakeCase()}}');
// The preview runs in the browser on QuickJS.
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toContainText('monday_morning');
// The execution runs on the backend engine. Both must agree, or the two
// evaluators have drifted.
await n8n.ndv.execute();
await expect(n8n.ndv.getOutputDataContainer()).toBeVisible();
await expect(n8n.ndv.getOutputDataContainer()).toContainText('monday_morning');
});
test('resolves luxon and Intl through the host bridge', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.activateParameterExpressionEditor(SCHEDULE_PARAMETER_NAME);
// QuickJS ships without ECMA-402, so Intl is delegated to the host.
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor("{{ new Intl.NumberFormat('de-DE').format(1234.5)");
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('1.234,5');
// Luxon reaches the same bridge for its locale and zone data.
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor(
"{{ DateTime.fromISO('2024-03-05').toFormat('yyyy LLLL dd')",
);
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('2024 March 05');
});
test('keeps resolving after the node view is reopened', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.activateParameterExpressionEditor(SCHEDULE_PARAMETER_NAME);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor('{{ 1 + 2');
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('3');
await n8n.ndv.close();
// The editor builds a new Expression for the reopened view. It has to
// evaluate without acquiring a bridge of its own.
await n8n.canvas.openNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.clearExpressionEditor();
await n8n.ndv.typeInExpressionEditor("{{ 'abc'.toUpperCase()");
await expect(n8n.ndv.getInlineExpressionEditorOutput()).toHaveText('ABC');
});
},
);
+78 -20
View File
@@ -222,6 +222,10 @@ export class Expression {
private static vmEvaluator?: IExpressionEvaluator;
private static readonly BROWSER_CALLER = {};
private static useSharedCaller = false;
constructor(private readonly timezone: string) {}
/**
@@ -229,11 +233,18 @@ export class Expression {
* @private
*/
private static shouldUseVm(): boolean {
return (
(this.expressionEngine === 'vm' || this.expressionEngine === 'quickjs') &&
!IS_FRONTEND &&
!!this.vmEvaluator
);
return !!this.vmEvaluator && this.isVmEngineSelected();
}
/**
* Whether an engine other than legacy is selected for this runtime, whether or
* not one started. `vm` is Node-only, because isolated-vm is a native module,
* so the browser can only select quickjs.
* @private
*/
private static isVmEngineSelected(): boolean {
if (this.expressionEngine === 'quickjs') return true;
return this.expressionEngine === 'vm' && !IS_FRONTEND;
}
/**
@@ -249,10 +260,20 @@ export class Expression {
maxCodeCacheSize: number;
observability?: ObservabilityProvider;
idleTimeoutMs?: number;
runtimeBundle?: string;
/**
* Evaluate every expression through one shared bridge rather than one per
* caller. The browser needs this: its synchronous `evaluate()` requires a
* caller that already holds a scope, and the editor creates a new
* Expression for every workflow it builds. Node leaves this off, so the
* pool hands each execution its own bridge and disposes it on release.
*/
sharedCaller?: boolean;
lazyAcquire?: boolean;
compileCache?: boolean;
}): Promise<void> {
if ((options.engine !== 'vm' && options.engine !== 'quickjs') || IS_FRONTEND) return;
if (options.engine === 'legacy') return;
if (options.engine === 'vm' && IS_FRONTEND) return;
this.expressionEngine = options.engine;
if (!this.vmEvaluator) {
@@ -265,6 +286,7 @@ export class Expression {
timeout: options.bridgeTimeout,
memoryLimit: options.bridgeMemoryLimit,
logger: LoggerProxy,
runtimeBundle: options.runtimeBundle,
})
: () =>
new runtime.IsolatedVmBridge({
@@ -273,7 +295,7 @@ export class Expression {
logger: LoggerProxy,
compileCache: options.compileCache,
});
this.vmEvaluator = new runtime.ExpressionEvaluator({
const evaluator = new runtime.ExpressionEvaluator({
createBridge,
maxCodeCacheSize: options.maxCodeCacheSize,
poolSize: options.poolSize,
@@ -283,7 +305,36 @@ export class Expression {
logger: LoggerProxy,
observability: options.observability,
});
await this.vmEvaluator.initialize();
// Publish the evaluator only once it is usable. A half-started one
// would leave `shouldUseVm` reporting the engine as active while no
// bridge is acquired, so callers could neither retry the start nor
// fall back to the legacy evaluator.
try {
await evaluator.initialize();
// Requested explicitly rather than inferred from `runtimeBundle`.
// That option only means "the bundle is already loaded, skip the disk
// read"; a backend that pre-loaded it to save an fs call per pooled
// bridge would otherwise be switched to one shared caller and lose
// per-execution isolation without asking for it. IS_FRONTEND is not
// usable as the signal either: vite-plugin-node-polyfills shims
// `process` with extra keys, which defeats its detection.
if (options.sharedCaller) {
// Under `lazyAcquire` this only opens the scope. The pool has already
// seeded its bridges in initialize(), so this does not decide when a
// runtime is built — only which caller owns one.
await evaluator.acquire(Expression.BROWSER_CALLER);
this.useSharedCaller = true;
}
this.vmEvaluator = evaluator;
} catch (error) {
// Tear down what the start already built. The pool replenishes in
// the background, so an orphaned one keeps creating bridges that
// nobody owns, and a retried start would add another.
await evaluator.dispose().catch(() => {});
this.useSharedCaller = false;
throw error;
}
}
}
@@ -318,9 +369,13 @@ export class Expression {
*/
static async disposeExpressionEngine(): Promise<void> {
if (this.vmEvaluator) {
// The browser holds one shared scope, and an acquired bridge leaves the
// pool, so disposing the pool alone would leave that runtime alive.
if (this.useSharedCaller) await this.vmEvaluator.release(Expression.BROWSER_CALLER);
await this.vmEvaluator.dispose();
this.vmEvaluator = undefined;
}
this.useSharedCaller = false;
}
/**
@@ -650,20 +705,12 @@ export class Expression {
}
private renderExpression(expression: string, data: IWorkflowDataProxyData) {
// The VM engines (isolated-vm, quickjs) are Node-only; the browser always
// uses the legacy path below.
if (
(Expression.expressionEngine === 'vm' || Expression.expressionEngine === 'quickjs') &&
!IS_FRONTEND
) {
if (!Expression.vmEvaluator) {
throw new UnexpectedError(
`The ${Expression.expressionEngine} expression engine has not been initialized. Call Expression.initExpressionEngine() during application startup.`,
);
}
const evaluator = Expression.vmEvaluator;
if (evaluator && Expression.isVmEngineSelected()) {
const caller = Expression.useSharedCaller ? Expression.BROWSER_CALLER : this;
try {
const result = Expression.vmEvaluator.evaluate(expression, data, this, {
const result = evaluator.evaluate(expression, data, caller, {
timezone: this.timezone,
});
return result as string | null | (() => unknown);
@@ -672,6 +719,17 @@ export class Expression {
}
}
// A host that recorded an engine but started none must not evaluate through
// `new Function` without saying so: the backend does this on purpose for
// commands that should never evaluate an expression. The editor is the
// exception — it falls back to legacy below, so a policy that blocks WASM
// leaves expression previews working instead of breaking the editor.
if (!evaluator && Expression.isVmEngineSelected() && !IS_FRONTEND) {
throw new UnexpectedError(
`The ${Expression.expressionEngine} expression engine has not been initialized. Call Expression.initExpressionEngine() during application startup.`,
);
}
// Fall back to current implementation
try {
return evaluateExpression(expression, data);
@@ -0,0 +1,109 @@
import { Expression } from '../src/expression';
describe('Expression.initExpressionEngine', () => {
beforeEach(async () => {
await Expression.disposeExpressionEngine();
});
afterAll(async () => {
await Expression.disposeExpressionEngine();
});
it('leaves the legacy evaluator in place when the runtime bundle does not load', async () => {
await expect(
Expression.initExpressionEngine({
engine: 'quickjs',
// The browser reads the bundle from an asset; a policy that blocks
// WASM, or a bad asset, fails the start the same way.
runtimeBundle: 'this is not javascript (((',
poolSize: 1,
maxCodeCacheSize: 1024,
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
}),
).rejects.toThrow();
// A half-started engine reports the selected engine as active while no
// bridge is acquired, which blocks both a retry and the legacy fallback.
expect(Expression.getActiveImplementation()).toBe('legacy');
});
it('fails loudly on Node when an engine is recorded but never started', async () => {
await Expression.disposeExpressionEngine();
// What base-command does for a command that should never evaluate an
// expression: record the engine without starting one, so an unexpected
// evaluation is reported instead of silently served by `new Function`.
Expression.setExpressionEngine('quickjs');
expect(() =>
new Expression('UTC').resolveSimpleParameterValue('={{ 1 + 1 }}', {
$json: {},
$thisRunIndex: 0,
$thisItemIndex: 0,
} as never),
).toThrow('has not been initialized');
Expression.setExpressionEngine('legacy');
});
});
describe('Expression.disposeExpressionEngine', () => {
afterAll(async () => {
await Expression.disposeExpressionEngine();
});
it('clears the shared browser caller, so a later start is not stuck on it', async () => {
const { createRequire } = await import('node:module');
const { readFile } = await import('node:fs/promises');
const require = createRequire(import.meta.url);
const runtimeBundle = await readFile(
require.resolve('@n8n/expression-runtime/runtime-bundle.iife.js'),
'utf8',
);
const options = {
engine: 'quickjs' as const,
poolSize: 1,
maxCodeCacheSize: 1024,
bridgeTimeout: 5000,
bridgeMemoryLimit: 128,
};
await Expression.disposeExpressionEngine();
// The browser shape: a pre-loaded bundle, one shared scope, and the runtime
// built on first use.
await Expression.initExpressionEngine({
...options,
runtimeBundle,
sharedCaller: true,
lazyAcquire: true,
});
expect(Expression.getActiveImplementation()).toBe('quickjs');
// The editor never calls acquireIsolate(): it evaluates straight away on a
// fresh Expression. Without the shared scope this throws "No bridge
// acquired for this context".
expect(
new Expression('UTC').resolveSimpleParameterValue('={{ 1 + 1 }}', {
$json: {},
$thisRunIndex: 0,
$thisItemIndex: 0,
} as never),
).toBe(2);
await Expression.disposeExpressionEngine();
// The node shape: per-caller isolates. A leftover shared-caller flag would
// send evaluation to a caller that never acquired a bridge.
await Expression.initExpressionEngine(options);
const expression = new Expression('UTC');
await expression.acquireIsolate();
expect(
expression.resolveSimpleParameterValue('={{ 1 + 1 }}', {
$json: {},
$thisRunIndex: 0,
$thisItemIndex: 0,
} as never),
).toBe(2);
});
});
+28 -2
View File
@@ -41,7 +41,33 @@ if (!match) {
// (NodeTestHarness) — the sanctioned way to write node workflow tests, so every
// new suite would otherwise ratchet the count up. Exempt the class; turbo's
// `implicitDependencies` only covers undeclared-package issues, not path leaves.
const exempted = (output.match(/import `@nodes-testing\/[^`]+` leaves the package/g) ?? []).length;
const exemptedHarness = (output.match(/import `@nodes-testing\/[^`]+` leaves the package/g) ?? [])
.length;
// The editor's browser shim for `@n8n/expression-runtime` re-exports that package's
// source directly: the barrel reaches IsolatedVmBridge, which requires isolated-vm, a
// native Node module that cannot be bundled for a browser. The shim is the sanctioned
// way to keep it out of the editor bundle, so every export added to it would otherwise
// ratchet the count up.
//
// Deliberately narrow: the exemption matches only a reach-in into the two sanctioned
// packages' source, only for the `leaves the package` rule, and only when the very
// next line locates it in the shim itself. Any other diagnostic in that file — a
// different rule, or a reach-in into some third package — still fails the ratchet.
//
// turbo wraps long diagnostics with a `|` continuation marker, so join those back
// before matching; otherwise the pattern depends on the runner's terminal width.
// The break can fall inside the path (`expression-` + `evaluator`, which rejoins
// correctly with no separator) or between words (`leaves` + `the package`, which
// rejoins as `leavesthe`), so the rule text below tolerates absent whitespace.
const unwrapped = output.replace(/\n[ \t]*\|[ \t]?/g, '');
const exemptedBrowserShim = (
unwrapped.match(
/import `(?:\.\.\/)+@n8n\/(?:expression-runtime|errors)\/src\/[^`]*`\s*leaves\s*the\s*package\n\s*,-\[[^\]]*editor-ui\/vite\/expression-runtime-stub\.ts:/g,
) ?? []
).length;
const exempted = exemptedHarness + exemptedBrowserShim;
const current = Number(match[1]) - exempted;
if (write) {
@@ -51,7 +77,7 @@ if (write) {
}
console.log(
`turbo boundaries: ${current} issues (baseline ${baseline}, ${exempted} node-test-harness imports exempted)`,
`turbo boundaries: ${current} issues (baseline ${baseline}, ${exemptedHarness} node-test-harness + ${exemptedBrowserShim} browser-shim imports exempted)`,
);
if (current > baseline) {