mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
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:
co-authored by
Claude Opus 4.7
Luigi Gubello
Luigi Gubello
Emilia
parent
e3c3cb8f5a
commit
c5534b54a7
@@ -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;
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+128
@@ -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');
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user