mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Add ExpressionEvaluator and integration tests (no-changelog) (#26230)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,10 +11,10 @@ Implemented so far:
|
||||
- ✅ Core architecture documentation (PR 1)
|
||||
- ✅ Runtime bundle: extension functions, deep lazy proxy system (PR 2)
|
||||
- ✅ `IsolatedVmBridge`: V8 isolate management via `isolated-vm` (PR 3)
|
||||
- ✅ `ExpressionEvaluator`: tournament integration, expression code caching (PR 4)
|
||||
- ✅ Integration tests (PR 4)
|
||||
|
||||
Coming in later PRs:
|
||||
- 🚧 `ExpressionEvaluator`: tournament integration, expression code caching (PR 4)
|
||||
- 🚧 Integration tests (PR 4)
|
||||
- 🚧 Workflow integration behind `N8N_EXPRESSION_ENGINE=vm` flag (PR 5)
|
||||
- 🚧 Web Worker support (Phase 2+)
|
||||
- 🚧 Performance optimizations (Phase 3)
|
||||
@@ -181,7 +181,7 @@ interface RuntimeBridge {
|
||||
interface EvaluatorConfig {
|
||||
bridge: RuntimeBridge; // required
|
||||
observability?: ObservabilityProvider; // optional - interfaces defined, providers not yet implemented
|
||||
hooks?: TournamentHooks; // optional - AST security hooks for tournament (PR 4)
|
||||
hooks?: TournamentHooks; // optional - AST security hooks for tournament
|
||||
}
|
||||
|
||||
interface BridgeConfig {
|
||||
|
||||
@@ -207,11 +207,14 @@ creates nested proxies for objects or arrays as needed.
|
||||
### Array iteration is slow for large arrays
|
||||
|
||||
```
|
||||
{{ _.sum($json.items) }}
|
||||
{{ $json.items.reduce((sum, x) => sum + x, 0) }}
|
||||
// items has 10 000 elements → length transferred, then 10 000 callback
|
||||
// calls to fetch each element. Prefer accessing specific indices.
|
||||
```
|
||||
|
||||
Note: lodash (`_`) is not available in expressions — it is bundled internally for
|
||||
use by extension functions but not exposed on `globalThis`.
|
||||
|
||||
## Contributing
|
||||
|
||||
When modifying the proxy implementation:
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
],
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"@n8n/tournament": "1.0.6",
|
||||
"isolated-vm": "^6.0.2",
|
||||
"js-base64": "catalog:",
|
||||
"jssha": "3.3.1",
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { ExpressionEvaluator } from '../evaluator/expression-evaluator';
|
||||
import { IsolatedVmBridge } from '../bridge/isolated-vm-bridge';
|
||||
import { TimeoutError, MemoryLimitError } from '../types';
|
||||
|
||||
describe('Integration: ExpressionEvaluator + IsolatedVmBridge', () => {
|
||||
let evaluator: ExpressionEvaluator;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bridge = new IsolatedVmBridge({ timeout: 5000 });
|
||||
evaluator = new ExpressionEvaluator({ bridge });
|
||||
await evaluator.initialize();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await evaluator.dispose();
|
||||
});
|
||||
|
||||
it('should evaluate simple property access', async () => {
|
||||
const data = {
|
||||
$json: { email: 'test@example.com' },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.email }}', data);
|
||||
|
||||
expect(result).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should evaluate nested property access', async () => {
|
||||
const data = {
|
||||
$json: {
|
||||
user: {
|
||||
profile: {
|
||||
name: 'John Doe',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.user.profile.name }}', data);
|
||||
|
||||
expect(result).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('should evaluate array access', async () => {
|
||||
const data = {
|
||||
$json: {
|
||||
items: [{ id: 1 }, { id: 2 }, { id: 3 }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.items[1].id }}', data);
|
||||
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it('should evaluate math operations', async () => {
|
||||
const data = {
|
||||
$json: {
|
||||
price: 100,
|
||||
quantity: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.price * $json.quantity }}', data);
|
||||
|
||||
expect(result).toBe(300);
|
||||
});
|
||||
|
||||
it('should use luxon DateTime', async () => {
|
||||
const data = {
|
||||
$json: {
|
||||
date: '2024-01-15',
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(
|
||||
'{{ DateTime.fromISO($json.date).toFormat("MMMM dd, yyyy") }}',
|
||||
data,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result).toBe('January 15, 2024');
|
||||
});
|
||||
|
||||
it('should invoke functions from workflow data', async () => {
|
||||
const data = {
|
||||
$items: function () {
|
||||
return 'items-result';
|
||||
},
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $items() }}', data);
|
||||
|
||||
expect(result).toBe('items-result');
|
||||
});
|
||||
|
||||
it('should evaluate zero values', async () => {
|
||||
const data = {
|
||||
$json: { zero: 0 },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.zero }}', data);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should evaluate empty string values', async () => {
|
||||
const data = {
|
||||
$json: { empty: '' },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.empty }}', data);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should evaluate array index 0 (falsy index)', async () => {
|
||||
const data = {
|
||||
$json: { items: ['first', 'second'] },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.items[0] }}', data);
|
||||
|
||||
expect(result).toBe('first');
|
||||
});
|
||||
|
||||
it('should evaluate primitive array elements', async () => {
|
||||
const data = {
|
||||
$json: { numbers: [42, 99] },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.numbers[0] }}', data);
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should evaluate array .length', async () => {
|
||||
const data = {
|
||||
$json: { items: [1, 2, 3] },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.items.length }}', data);
|
||||
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it('should evaluate null values', async () => {
|
||||
const data = {
|
||||
$json: { field: null },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.field }}', data);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should evaluate boolean values', async () => {
|
||||
const data = {
|
||||
$json: { active: true },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate('{{ $json.active }}', data);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large arrays with lazy loading', async () => {
|
||||
const data = {
|
||||
$json: {
|
||||
// Create array with 200 items to exercise lazy loading
|
||||
items: Array.from({ length: 200 }, (_, i) => ({ id: i })),
|
||||
},
|
||||
};
|
||||
|
||||
// Access element deep in the array via lazy proxy
|
||||
const result = evaluator.evaluate('{{ $json.items[150].id }}', data);
|
||||
|
||||
expect(result).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: IsolatedVmBridge error handling', () => {
|
||||
it('should throw TimeoutError when expression exceeds timeout', async () => {
|
||||
const bridge = new IsolatedVmBridge({ timeout: 100 });
|
||||
await bridge.initialize();
|
||||
try {
|
||||
expect(() => bridge.execute('while(true){}', {})).toThrow(TimeoutError);
|
||||
} finally {
|
||||
await bridge.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw MemoryLimitError when expression exceeds memory limit', async () => {
|
||||
const bridge = new IsolatedVmBridge({ memoryLimit: 8 });
|
||||
await bridge.initialize();
|
||||
try {
|
||||
expect(() =>
|
||||
bridge.execute('let a=[]; while(true){a.push(new Array(1000000).fill(1))}', {}),
|
||||
).toThrow(MemoryLimitError);
|
||||
} finally {
|
||||
await bridge.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { RuntimeBridge, BridgeConfig } from '../types';
|
||||
import { DEFAULT_BRIDGE_CONFIG, TimeoutError, MemoryLimitError } from '../types';
|
||||
|
||||
// Get __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -38,9 +39,8 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
||||
|
||||
constructor(config: BridgeConfig = {}) {
|
||||
this.config = {
|
||||
memoryLimit: config.memoryLimit ?? 128,
|
||||
timeout: config.timeout ?? 5000,
|
||||
debug: config.debug ?? false,
|
||||
...DEFAULT_BRIDGE_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
// Create isolate with memory limit
|
||||
@@ -322,6 +322,9 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
||||
let arr: unknown = data;
|
||||
for (const key of path) {
|
||||
arr = (arr as Record<string, unknown>)?.[key];
|
||||
if (arr === undefined || arr === null) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(arr)) {
|
||||
@@ -449,6 +452,15 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('Script execution timed out')) {
|
||||
throw new TimeoutError(`Expression timed out after ${this.config.timeout}ms`, {});
|
||||
}
|
||||
if (errorMessage.includes('memory limit')) {
|
||||
throw new MemoryLimitError(
|
||||
`Expression exceeded memory limit of ${this.config.memoryLimit}MB`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
throw new Error(`Expression evaluation failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Tournament } from '@n8n/tournament';
|
||||
import type {
|
||||
IExpressionEvaluator,
|
||||
EvaluatorConfig,
|
||||
WorkflowData,
|
||||
EvaluateOptions,
|
||||
} from '../types';
|
||||
|
||||
export class ExpressionEvaluator implements IExpressionEvaluator {
|
||||
private config: EvaluatorConfig;
|
||||
|
||||
private disposed = false;
|
||||
|
||||
// Lazy-initialized tournament instance (expensive to create, reused across evaluations)
|
||||
private tournament?: Tournament;
|
||||
|
||||
// Cache: template expression → tournament-transformed JavaScript code
|
||||
// Cache hit rate in production: ~99.9% (same expressions repeat within a workflow)
|
||||
private codeCache = new Map<string, string>();
|
||||
|
||||
constructor(config: EvaluatorConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.config.bridge.initialize();
|
||||
}
|
||||
|
||||
evaluate(expression: string, data: WorkflowData, _options?: EvaluateOptions): unknown {
|
||||
if (this.disposed) throw new Error('Evaluator disposed');
|
||||
|
||||
// Transform template expression → sanitized JavaScript (cached)
|
||||
const transformedCode = this.getTransformedCode(expression);
|
||||
|
||||
try {
|
||||
const result = this.config.bridge.execute(transformedCode, data);
|
||||
|
||||
if (this.config.observability) {
|
||||
this.config.observability.metrics.counter('expression.evaluation.success', 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (this.config.observability) {
|
||||
this.config.observability.metrics.counter('expression.evaluation.error', 1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a template expression to executable JavaScript via tournament.
|
||||
*
|
||||
* Input: "{{ $json.email }}"
|
||||
* Output: JavaScript string with tournament security transforms applied
|
||||
* ($json → this.$json, computed access wrapped in this.__sanitize(), etc.)
|
||||
*
|
||||
* Result is cached by expression string (tournament AST parsing is expensive).
|
||||
*/
|
||||
private getTransformedCode(expression: string): string {
|
||||
const cached = this.codeCache.get(expression);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (!this.tournament) {
|
||||
// Tournament requires an errorHandler but we only use getExpressionCode()
|
||||
// for AST transformation — we never call tournament.execute(), so this
|
||||
// handler is never invoked. Runtime errors are handled by the bridge's
|
||||
// own E() injection in injectErrorHandler().
|
||||
const errorHandler = () => {};
|
||||
this.tournament = new Tournament(errorHandler, undefined, undefined, {
|
||||
before: this.config.hooks?.before ?? [],
|
||||
after: this.config.hooks?.after ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
const [transformedCode] = this.tournament.getExpressionCode(expression);
|
||||
this.codeCache.set(expression, transformedCode);
|
||||
return transformedCode;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
this.codeCache.clear();
|
||||
await this.config.bridge.dispose();
|
||||
}
|
||||
|
||||
isDisposed(): boolean {
|
||||
return this.disposed;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
// Types — full public API surface
|
||||
// Implementations (ExpressionEvaluator, IsolatedVmBridge) are added in later PRs.
|
||||
// Main exports
|
||||
export { ExpressionEvaluator } from './evaluator/expression-evaluator';
|
||||
|
||||
// Bridge exports
|
||||
export { IsolatedVmBridge } from './bridge/isolated-vm-bridge';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
IExpressionEvaluator,
|
||||
EvaluatorConfig,
|
||||
@@ -12,9 +17,9 @@ export type {
|
||||
TracesAPI,
|
||||
Span,
|
||||
LogsAPI,
|
||||
TournamentHooks,
|
||||
} from './types';
|
||||
|
||||
// Error types
|
||||
export {
|
||||
ExpressionError,
|
||||
MemoryLimitError,
|
||||
@@ -22,3 +27,7 @@ export {
|
||||
SecurityViolationError,
|
||||
SyntaxError,
|
||||
} from './types';
|
||||
|
||||
// Extension runtime exports
|
||||
export { extend, extendOptional, EXTENSION_OBJECTS } from './extensions/extend';
|
||||
export { ExpressionExtensionError } from './extensions/expression-extension-error';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createDeepLazyProxy } from '../lazy-proxy';
|
||||
import { createDeepLazyProxy, isLazyProxy, getProxyPath } from '../lazy-proxy';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -68,24 +68,6 @@ describe('createDeepLazyProxy', () => {
|
||||
expect(mocks.getValueAtPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns true for __isProxy', () => {
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.__isProxy).toBe(true);
|
||||
expect(mocks.getValueAtPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array for __path when basePath is default', () => {
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.__path).toEqual([]);
|
||||
expect(mocks.getValueAtPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns basePath for __path when basePath is provided', () => {
|
||||
const proxy = createDeepLazyProxy(['$json', 'user']);
|
||||
expect(proxy.__path).toEqual(['$json', 'user']);
|
||||
expect(mocks.getValueAtPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toString() returns "[object Object]"', () => {
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.toString()).toBe('[object Object]');
|
||||
@@ -101,53 +83,47 @@ describe('createDeepLazyProxy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1b. Proxy identity helpers (isLazyProxy / getProxyPath)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('proxy identity helpers', () => {
|
||||
it('isLazyProxy() returns true for a proxy', () => {
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(isLazyProxy(proxy)).toBe(true);
|
||||
});
|
||||
|
||||
it('isLazyProxy() returns false for plain objects', () => {
|
||||
expect(isLazyProxy({})).toBe(false);
|
||||
expect(isLazyProxy(null)).toBe(false);
|
||||
expect(isLazyProxy('string')).toBe(false);
|
||||
});
|
||||
|
||||
it('getProxyPath() returns [] when no basePath is provided', () => {
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(getProxyPath(proxy)).toEqual([]);
|
||||
});
|
||||
|
||||
it('getProxyPath() returns the provided basePath', () => {
|
||||
const proxy = createDeepLazyProxy(['$json', 'user']);
|
||||
expect(getProxyPath(proxy)).toEqual(['$json', 'user']);
|
||||
});
|
||||
|
||||
it('getProxyPath() returns undefined for non-proxies', () => {
|
||||
expect(getProxyPath({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Primitive values
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('primitive values', () => {
|
||||
it('fetches and returns a string', () => {
|
||||
mocks.getValueAtPath.mockReturnValue('hello');
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.name).toBe('hello');
|
||||
expect(mocks.getValueAtPath).toHaveBeenCalledWith(null, [['name']], ivmCallOpts);
|
||||
});
|
||||
|
||||
it('fetches and returns a number', () => {
|
||||
mocks.getValueAtPath.mockReturnValue(42);
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.count).toBe(42);
|
||||
});
|
||||
|
||||
it('fetches and returns a boolean', () => {
|
||||
mocks.getValueAtPath.mockReturnValue(true);
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.active).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches and returns null', () => {
|
||||
mocks.getValueAtPath.mockReturnValue(null);
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.field).toBeNull();
|
||||
});
|
||||
|
||||
it('fetches and returns undefined', () => {
|
||||
mocks.getValueAtPath.mockReturnValue(undefined);
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('fetches and returns an empty string', () => {
|
||||
mocks.getValueAtPath.mockReturnValue('');
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.empty).toBe('');
|
||||
});
|
||||
|
||||
it('fetches and returns zero', () => {
|
||||
mocks.getValueAtPath.mockReturnValue(0);
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.zero).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -199,13 +175,6 @@ describe('createDeepLazyProxy', () => {
|
||||
expect(mocks.callFunctionAtPath).toHaveBeenCalledWith(null, [['myFn'], 'a', 1], ivmCallOpts);
|
||||
});
|
||||
|
||||
it('returns the callback result from the function wrapper', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isFunction: true, __name: 'myFn' });
|
||||
mocks.callFunctionAtPath.mockReturnValue(99);
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.myFn()).toBe(99);
|
||||
});
|
||||
|
||||
it('caches the function wrapper', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isFunction: true, __name: 'myFn' });
|
||||
const proxy = createDeepLazyProxy();
|
||||
@@ -227,12 +196,6 @@ describe('createDeepLazyProxy', () => {
|
||||
expect(proxy.items).toBeDefined();
|
||||
});
|
||||
|
||||
it('array proxy .length returns __length', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isArray: true, __length: 100 });
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.items.length).toBe(100);
|
||||
});
|
||||
|
||||
it('caches the array proxy', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isArray: true, __length: 3 });
|
||||
const proxy = createDeepLazyProxy();
|
||||
@@ -253,27 +216,20 @@ describe('createDeepLazyProxy', () => {
|
||||
return createDeepLazyProxy();
|
||||
}
|
||||
|
||||
it('fetches a primitive element via __getArrayElement', () => {
|
||||
const proxy = proxyWithLargeArray();
|
||||
mocks.getArrayElement.mockReturnValue(42);
|
||||
expect(proxy.items[0]).toBe(42);
|
||||
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['items'], 0], ivmCallOpts);
|
||||
});
|
||||
|
||||
it('creates a nested proxy for object elements', () => {
|
||||
const proxy = proxyWithLargeArray();
|
||||
mocks.getArrayElement.mockReturnValue({ __isObject: true, __keys: ['a'] });
|
||||
const element = proxy.items[0];
|
||||
expect(element.__isProxy).toBe(true);
|
||||
expect(element.__path).toEqual(['items', '0']);
|
||||
expect(isLazyProxy(element)).toBe(true);
|
||||
expect(getProxyPath(element)).toEqual(['items', '0']);
|
||||
});
|
||||
|
||||
it('creates a nested proxy for array elements that are arrays', () => {
|
||||
const proxy = proxyWithLargeArray();
|
||||
mocks.getArrayElement.mockReturnValue({ __isArray: true, __length: 5 });
|
||||
const element = proxy.items[0];
|
||||
expect(element.__isProxy).toBe(true);
|
||||
expect(element.__path).toEqual(['items', '0']);
|
||||
expect(isLazyProxy(element)).toBe(true);
|
||||
expect(getProxyPath(element)).toEqual(['items', '0']);
|
||||
});
|
||||
|
||||
it('caches elements after first access', () => {
|
||||
@@ -314,13 +270,13 @@ describe('createDeepLazyProxy', () => {
|
||||
it('creates a nested proxy for object metadata', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isObject: true, __keys: ['a', 'b'] });
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.obj.__isProxy).toBe(true);
|
||||
expect(isLazyProxy(proxy.obj)).toBe(true);
|
||||
});
|
||||
|
||||
it('nested proxy has the correct path', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isObject: true, __keys: ['a'] });
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.obj.__path).toEqual(['obj']);
|
||||
expect(getProxyPath(proxy.obj)).toEqual(['obj']);
|
||||
});
|
||||
|
||||
it('deep nesting builds correct paths', () => {
|
||||
@@ -339,7 +295,7 @@ describe('createDeepLazyProxy', () => {
|
||||
// a.b.c -> returns object metadata
|
||||
const c = b.c;
|
||||
expect(mocks.getValueAtPath).toHaveBeenLastCalledWith(null, [['a', 'b', 'c']], ivmCallOpts);
|
||||
expect(c.__path).toEqual(['a', 'b', 'c']);
|
||||
expect(getProxyPath(c)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('caches the nested proxy', () => {
|
||||
@@ -368,7 +324,7 @@ describe('createDeepLazyProxy', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isObject: true, __keys: ['name'] });
|
||||
const proxy = createDeepLazyProxy(['$json']);
|
||||
const user = proxy.user;
|
||||
expect(user.__path).toEqual(['$json', 'user']);
|
||||
expect(getProxyPath(user)).toEqual(['$json', 'user']);
|
||||
|
||||
// Accessing a property on the nested proxy should build the full path
|
||||
mocks.getValueAtPath.mockReturnValue('Alice');
|
||||
@@ -454,29 +410,6 @@ describe('createDeepLazyProxy', () => {
|
||||
expect(val.data).toBe('x');
|
||||
});
|
||||
|
||||
it('handles multiple independent properties', () => {
|
||||
mocks.getValueAtPath
|
||||
.mockReturnValueOnce('a-val')
|
||||
.mockReturnValueOnce('b-val')
|
||||
.mockReturnValueOnce('c-val');
|
||||
const proxy = createDeepLazyProxy();
|
||||
expect(proxy.a).toBe('a-val');
|
||||
expect(proxy.b).toBe('b-val');
|
||||
expect(proxy.c).toBe('c-val');
|
||||
|
||||
expect(mocks.getValueAtPath).toHaveBeenNthCalledWith(1, null, [['a']], ivmCallOpts);
|
||||
expect(mocks.getValueAtPath).toHaveBeenNthCalledWith(2, null, [['b']], ivmCallOpts);
|
||||
expect(mocks.getValueAtPath).toHaveBeenNthCalledWith(3, null, [['c']], ivmCallOpts);
|
||||
});
|
||||
|
||||
it('array proxy index 0 works correctly (falsy index)', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isArray: true, __length: 3 });
|
||||
const proxy = createDeepLazyProxy();
|
||||
mocks.getArrayElement.mockReturnValue('first');
|
||||
expect(proxy.arr[0]).toBe('first');
|
||||
expect(mocks.getArrayElement).toHaveBeenCalledWith(null, [['arr'], 0], ivmCallOpts);
|
||||
});
|
||||
|
||||
it('array proxy does not intercept negative indices', () => {
|
||||
mocks.getValueAtPath.mockReturnValue({ __isArray: true, __length: 3 });
|
||||
const proxy = createDeepLazyProxy();
|
||||
|
||||
@@ -4,6 +4,22 @@
|
||||
// For more information about Proxies see
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proxy registry — used only for testing/introspection, not accessible from
|
||||
// expression code. Avoids shadowing user data keys like __isProxy / __path.
|
||||
// ---------------------------------------------------------------------------
|
||||
const proxyPaths = new WeakMap<object, string[]>();
|
||||
|
||||
/** Returns true if `obj` is a deep lazy proxy created by createDeepLazyProxy. */
|
||||
export function isLazyProxy(obj: unknown): boolean {
|
||||
return typeof obj === 'object' && obj !== null && proxyPaths.has(obj as object);
|
||||
}
|
||||
|
||||
/** Returns the basePath the proxy was created with, or undefined if not a proxy. */
|
||||
export function getProxyPath(obj: object): string[] | undefined {
|
||||
return proxyPaths.get(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deep lazy-loading proxy for workflow data.
|
||||
*
|
||||
@@ -21,7 +37,7 @@
|
||||
* @returns Proxy object with lazy loading behavior
|
||||
*/
|
||||
export function createDeepLazyProxy(basePath: string[] = []): any {
|
||||
return new Proxy({} as Record<string, unknown>, {
|
||||
const proxy = new Proxy({} as Record<string, unknown>, {
|
||||
get(target: any, prop: string | symbol): unknown {
|
||||
// Handle Symbol properties - return undefined
|
||||
// Symbols like Symbol.toStringTag are accessed internally
|
||||
@@ -30,10 +46,6 @@ export function createDeepLazyProxy(basePath: string[] = []): any {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Special properties for introspection
|
||||
if (prop === '__isProxy') return true;
|
||||
if (prop === '__path') return basePath;
|
||||
|
||||
// Handle common Object.prototype methods within isolate
|
||||
// Don't fetch from parent to avoid native function transfer issues
|
||||
if (prop === 'toString') {
|
||||
@@ -84,6 +96,11 @@ export function createDeepLazyProxy(basePath: string[] = []): any {
|
||||
if (value && typeof value === 'object' && value.__isArray) {
|
||||
const arrayProxy = new Proxy([] as any[], {
|
||||
get(arrTarget: any, arrProp: string | symbol): unknown {
|
||||
// Symbols can't be transferred via isolated-vm; return undefined
|
||||
if (typeof arrProp === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Handle array length
|
||||
if (arrProp === 'length') {
|
||||
return value.__length;
|
||||
@@ -160,4 +177,7 @@ export function createDeepLazyProxy(basePath: string[] = []): any {
|
||||
return value !== undefined;
|
||||
},
|
||||
});
|
||||
|
||||
proxyPaths.set(proxy, basePath);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
@@ -71,3 +71,10 @@ export interface BridgeConfig {
|
||||
*/
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/** Default values for BridgeConfig. Bridge implementations should use this as their baseline. */
|
||||
export const DEFAULT_BRIDGE_CONFIG: Required<BridgeConfig> = {
|
||||
memoryLimit: 128,
|
||||
timeout: 5000,
|
||||
debug: false,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TournamentHooks } from '@n8n/tournament';
|
||||
|
||||
import type { RuntimeBridge } from './bridge';
|
||||
|
||||
// ============================================================================
|
||||
@@ -5,14 +7,6 @@ import type { RuntimeBridge } from './bridge';
|
||||
// These are the minimal interfaces needed to evaluate expressions.
|
||||
// ============================================================================
|
||||
|
||||
// TournamentHooks is imported from '@n8n/tournament' once that dependency is
|
||||
// added (PR 4). Defined locally here so the type surface is complete from PR 1.
|
||||
// See: packages/@n8n/expression-runtime/src/evaluator/expression-evaluator.ts
|
||||
export interface TournamentHooks {
|
||||
before?: Array<(ast: unknown) => unknown>;
|
||||
after?: Array<(ast: unknown) => unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for ExpressionEvaluator.
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
// Bridge types
|
||||
export type { RuntimeBridge, BridgeConfig } from './bridge';
|
||||
export { DEFAULT_BRIDGE_CONFIG } from './bridge';
|
||||
|
||||
// Runtime types
|
||||
export { RuntimeError } from './runtime';
|
||||
@@ -21,7 +22,6 @@ export type {
|
||||
TracesAPI,
|
||||
Span,
|
||||
LogsAPI,
|
||||
TournamentHooks,
|
||||
} from './evaluator';
|
||||
|
||||
export {
|
||||
|
||||
Generated
+3
@@ -1220,6 +1220,9 @@ importers:
|
||||
|
||||
packages/@n8n/expression-runtime:
|
||||
dependencies:
|
||||
'@n8n/tournament':
|
||||
specifier: 1.0.6
|
||||
version: 1.0.6
|
||||
isolated-vm:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
|
||||
Reference in New Issue
Block a user