@n8n/expression-runtime 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/bundle/runtime.esm.js +17 -17
  2. package/dist/bundle/runtime.esm.js.map +4 -4
  3. package/dist/bundle/runtime.iife.js +17 -17
  4. package/dist/bundle/runtime.iife.js.map +4 -4
  5. package/dist/cjs/bridge/isolated-vm-bridge.d.ts +48 -34
  6. package/dist/cjs/bridge/isolated-vm-bridge.d.ts.map +1 -1
  7. package/dist/cjs/bridge/isolated-vm-bridge.js +285 -236
  8. package/dist/cjs/bridge/isolated-vm-bridge.js.map +1 -1
  9. package/dist/cjs/build.tsbuildinfo +1 -1
  10. package/dist/cjs/evaluator/expression-evaluator.d.ts +8 -1
  11. package/dist/cjs/evaluator/expression-evaluator.d.ts.map +1 -1
  12. package/dist/cjs/evaluator/expression-evaluator.js +62 -6
  13. package/dist/cjs/evaluator/expression-evaluator.js.map +1 -1
  14. package/dist/cjs/extensions/boolean-extensions.d.ts +1 -1
  15. package/dist/cjs/extensions/object-extensions.d.ts.map +1 -1
  16. package/dist/cjs/extensions/object-extensions.js +8 -4
  17. package/dist/cjs/extensions/object-extensions.js.map +1 -1
  18. package/dist/cjs/index.d.ts +2 -1
  19. package/dist/cjs/index.d.ts.map +1 -1
  20. package/dist/cjs/index.js +4 -2
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/pool/isolate-pool.d.ts +35 -0
  23. package/dist/cjs/pool/isolate-pool.d.ts.map +1 -0
  24. package/dist/cjs/pool/isolate-pool.js +123 -0
  25. package/dist/cjs/pool/isolate-pool.js.map +1 -0
  26. package/dist/cjs/runtime/context.d.ts +18 -0
  27. package/dist/cjs/runtime/context.d.ts.map +1 -0
  28. package/dist/cjs/runtime/{reset.js → context.js} +95 -100
  29. package/dist/cjs/runtime/context.js.map +1 -0
  30. package/dist/cjs/runtime/index.d.ts +1 -19
  31. package/dist/cjs/runtime/index.d.ts.map +1 -1
  32. package/dist/cjs/runtime/index.js +3 -5
  33. package/dist/cjs/runtime/index.js.map +1 -1
  34. package/dist/cjs/runtime/lazy-proxy.d.ts +28 -3
  35. package/dist/cjs/runtime/lazy-proxy.d.ts.map +1 -1
  36. package/dist/cjs/runtime/lazy-proxy.js +84 -16
  37. package/dist/cjs/runtime/lazy-proxy.js.map +1 -1
  38. package/dist/cjs/types/bridge.d.ts +12 -7
  39. package/dist/cjs/types/bridge.d.ts.map +1 -1
  40. package/dist/cjs/types/bridge.js +7 -1
  41. package/dist/cjs/types/bridge.js.map +1 -1
  42. package/dist/cjs/types/evaluator.d.ts +24 -14
  43. package/dist/cjs/types/evaluator.d.ts.map +1 -1
  44. package/dist/cjs/types/evaluator.js.map +1 -1
  45. package/dist/cjs/types/index.d.ts +1 -1
  46. package/dist/cjs/types/index.d.ts.map +1 -1
  47. package/dist/esm/bridge/isolated-vm-bridge.d.ts +48 -34
  48. package/dist/esm/bridge/isolated-vm-bridge.d.ts.map +1 -1
  49. package/dist/esm/bridge/isolated-vm-bridge.js +285 -236
  50. package/dist/esm/bridge/isolated-vm-bridge.js.map +1 -1
  51. package/dist/esm/build.tsbuildinfo +1 -1
  52. package/dist/esm/evaluator/expression-evaluator.d.ts +8 -1
  53. package/dist/esm/evaluator/expression-evaluator.d.ts.map +1 -1
  54. package/dist/esm/evaluator/expression-evaluator.js +61 -5
  55. package/dist/esm/evaluator/expression-evaluator.js.map +1 -1
  56. package/dist/esm/extensions/object-extensions.d.ts.map +1 -1
  57. package/dist/esm/extensions/object-extensions.js +8 -4
  58. package/dist/esm/extensions/object-extensions.js.map +1 -1
  59. package/dist/esm/index.d.ts +2 -1
  60. package/dist/esm/index.d.ts.map +1 -1
  61. package/dist/esm/index.js +1 -0
  62. package/dist/esm/index.js.map +1 -1
  63. package/dist/esm/pool/isolate-pool.d.ts +35 -0
  64. package/dist/esm/pool/isolate-pool.d.ts.map +1 -0
  65. package/dist/esm/pool/isolate-pool.js +107 -0
  66. package/dist/esm/pool/isolate-pool.js.map +1 -0
  67. package/dist/esm/runtime/context.d.ts +18 -0
  68. package/dist/esm/runtime/context.d.ts.map +1 -0
  69. package/dist/esm/runtime/{reset.js → context.js} +95 -100
  70. package/dist/esm/runtime/context.js.map +1 -0
  71. package/dist/esm/runtime/index.d.ts +1 -19
  72. package/dist/esm/runtime/index.d.ts.map +1 -1
  73. package/dist/esm/runtime/index.js +2 -4
  74. package/dist/esm/runtime/index.js.map +1 -1
  75. package/dist/esm/runtime/lazy-proxy.d.ts +28 -3
  76. package/dist/esm/runtime/lazy-proxy.d.ts.map +1 -1
  77. package/dist/esm/runtime/lazy-proxy.js +83 -16
  78. package/dist/esm/runtime/lazy-proxy.js.map +1 -1
  79. package/dist/esm/types/bridge.d.ts +12 -7
  80. package/dist/esm/types/bridge.d.ts.map +1 -1
  81. package/dist/esm/types/bridge.js +7 -1
  82. package/dist/esm/types/bridge.js.map +1 -1
  83. package/dist/esm/types/evaluator.d.ts +24 -14
  84. package/dist/esm/types/evaluator.d.ts.map +1 -1
  85. package/dist/esm/types/evaluator.js.map +1 -1
  86. package/dist/esm/types/index.d.ts +1 -1
  87. package/dist/esm/types/index.d.ts.map +1 -1
  88. package/package.json +10 -7
  89. package/dist/cjs/runtime/reset.d.ts +0 -16
  90. package/dist/cjs/runtime/reset.d.ts.map +0 -1
  91. package/dist/cjs/runtime/reset.js.map +0 -1
  92. package/dist/esm/runtime/reset.d.ts +0 -16
  93. package/dist/esm/runtime/reset.d.ts.map +0 -1
  94. package/dist/esm/runtime/reset.js.map +0 -1
@@ -10,6 +10,33 @@ function getIvm() {
10
10
  return _ivm;
11
11
  }
12
12
  const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
13
+ /** Check if a value is an error sentinel returned by serializeError. */
14
+ function isErrorSentinel(value) {
15
+ return (typeof value === 'object' &&
16
+ value !== null &&
17
+ value.__isError === true);
18
+ }
19
+ /**
20
+ * Serialize an error into a transferable metadata object.
21
+ *
22
+ * Host-side callbacks (getValueAtPath, etc.) catch errors and return this
23
+ * sentinel instead of letting the error cross the isolate boundary (which
24
+ * strips custom class identity and properties). The isolate-side proxy
25
+ * detects __isError and reconstructs a proper Error to throw.
26
+ */
27
+ function serializeError(err) {
28
+ if (err instanceof Error) {
29
+ const extra = Object.fromEntries(Object.entries(err).filter(([key]) => key !== 'name' && key !== 'message' && key !== 'stack'));
30
+ return {
31
+ __isError: true,
32
+ name: err.name,
33
+ message: err.message,
34
+ stack: err.stack,
35
+ extra,
36
+ };
37
+ }
38
+ return { __isError: true, name: 'Error', message: String(err), extra: {} };
39
+ }
13
40
  /**
14
41
  * Read the runtime IIFE bundle by walking up from `__dirname` until
15
42
  * `dist/bundle/runtime.iife.js` is found.
@@ -46,23 +73,13 @@ export class IsolatedVmBridge {
46
73
  initialized = false;
47
74
  disposed = false;
48
75
  config;
49
- // Script compilation cache for performance
50
- // Maps expression code -> compiled ivm.Script
51
- scriptCache = new Map();
52
- // Active ivm.Reference callbacks — released before each re-registration
53
- // to prevent reference accumulation across execute() calls
54
- valueAtPathRef;
55
- arrayElementRef;
56
- callFunctionRef;
57
- // Pre-resolved reference to resetDataProxies() inside the isolate.
58
- // Using applySync on a stored reference avoids the per-call
59
- // ScriptCompiler::Compile() cost that evalSync incurs.
60
- resetDataProxiesRef;
76
+ logger;
61
77
  constructor(config = {}) {
62
78
  this.config = {
63
79
  ...DEFAULT_BRIDGE_CONFIG,
64
80
  ...config,
65
81
  };
82
+ this.logger = this.config.logger;
66
83
  // Create isolate with memory limit
67
84
  // Note: memoryLimit is in MB
68
85
  this.isolate = new (getIvm().Isolate)({ memoryLimit: this.config.memoryLimit });
@@ -96,14 +113,8 @@ export class IsolatedVmBridge {
96
113
  await this.verifyProxySystem();
97
114
  // Inject E() error handler needed by tournament-generated try-catch code
98
115
  await this.injectErrorHandler();
99
- // Store a reference to resetDataProxies for efficient per-call invocation
100
- this.resetDataProxiesRef = await this.context.global.get('resetDataProxies', {
101
- reference: true,
102
- });
103
116
  this.initialized = true;
104
- if (this.config.debug) {
105
- console.log('[IsolatedVmBridge] Initialized successfully');
106
- }
117
+ this.logger.info('[IsolatedVmBridge] Initialized successfully');
107
118
  }
108
119
  /**
109
120
  * Load runtime bundle into the isolate.
@@ -112,7 +123,7 @@ export class IsolatedVmBridge {
112
123
  * - DateTime, extend, extendOptional (expression engine globals)
113
124
  * - SafeObject and SafeError wrappers
114
125
  * - createDeepLazyProxy function
115
- * - __data object initialization
126
+ * - buildContext function
116
127
  *
117
128
  * @private
118
129
  * @throws {Error} If context not initialized or bundle loading fails
@@ -125,20 +136,16 @@ export class IsolatedVmBridge {
125
136
  // Load runtime bundle (includes vendor libraries + proxy system)
126
137
  const runtimeBundle = await readRuntimeBundle();
127
138
  // Evaluate bundle in isolate context
128
- // This makes all exported globals available (DateTime, extend, extendOptional, SafeObject, SafeError, createDeepLazyProxy, resetDataProxies, __data)
139
+ // This makes all exported globals available (DateTime, extend, extendOptional, SafeObject, SafeError, createDeepLazyProxy, buildContext)
129
140
  await this.context.eval(runtimeBundle);
130
- if (this.config.debug) {
131
- console.log('[IsolatedVmBridge] Runtime bundle loaded');
132
- }
141
+ this.logger.info('[IsolatedVmBridge] Runtime bundle loaded');
133
142
  // Verify vendor libraries loaded correctly
134
143
  const hasDateTime = await this.context.eval('typeof DateTime !== "undefined"');
135
144
  const hasExtend = await this.context.eval('typeof extend !== "undefined"');
136
145
  if (!hasDateTime || !hasExtend) {
137
146
  throw new Error(`Library verification failed: DateTime=${hasDateTime}, extend=${hasExtend}`);
138
147
  }
139
- if (this.config.debug) {
140
- console.log('[IsolatedVmBridge] Vendor libraries verified successfully');
141
- }
148
+ this.logger.info('[IsolatedVmBridge] Vendor libraries verified successfully');
142
149
  }
143
150
  catch (error) {
144
151
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -161,19 +168,16 @@ export class IsolatedVmBridge {
161
168
  try {
162
169
  // Verify proxy system components loaded correctly
163
170
  const hasProxyCreator = await this.context.eval('typeof createDeepLazyProxy !== "undefined"');
164
- const hasData = await this.context.eval('typeof __data !== "undefined"');
165
171
  const hasSafeObject = await this.context.eval('typeof SafeObject !== "undefined"');
166
172
  const hasSafeError = await this.context.eval('typeof SafeError !== "undefined"');
167
- const hasResetFunction = await this.context.eval('typeof resetDataProxies !== "undefined"');
168
- if (!hasProxyCreator || !hasData || !hasSafeObject || !hasSafeError || !hasResetFunction) {
173
+ const hasBuildContext = await this.context.eval('typeof buildContext !== "undefined"');
174
+ if (!hasProxyCreator || !hasSafeObject || !hasSafeError || !hasBuildContext) {
169
175
  throw new Error(`Proxy system verification failed: ` +
170
- `createDeepLazyProxy=${hasProxyCreator}, __data=${hasData}, ` +
176
+ `createDeepLazyProxy=${hasProxyCreator}, ` +
171
177
  `SafeObject=${hasSafeObject}, SafeError=${hasSafeError}, ` +
172
- `resetDataProxies=${hasResetFunction}`);
173
- }
174
- if (this.config.debug) {
175
- console.log('[IsolatedVmBridge] Proxy system verified successfully');
178
+ `buildContext=${hasBuildContext}`);
176
179
  }
180
+ this.logger.info('[IsolatedVmBridge] Proxy system verified successfully');
177
181
  }
178
182
  catch (error) {
179
183
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -183,11 +187,22 @@ export class IsolatedVmBridge {
183
187
  /**
184
188
  * Inject the E() error handler into the isolate context.
185
189
  *
186
- * Tournament wraps expressions with try-catch that calls E(error, this).
187
- * This handler:
188
- * - Re-throws security violations from __sanitize
189
- * - Swallows TypeErrors (failed attack attempts return undefined)
190
- * - Re-throws all other errors
190
+ * There are two exception-handling layers inside the isolate:
191
+ *
192
+ * 1. **Inner layer (this handler, `E()`)** — Tournament wraps each
193
+ * expression with try-catch that calls `E(error, this)`. This handler
194
+ * must match the legacy engine's behavior (set in expression.ts via
195
+ * setErrorHandler):
196
+ * - Re-throw ExpressionError / ExpressionExtensionError
197
+ * - Swallow everything else (TypeErrors, generic Errors, etc.)
198
+ *
199
+ * 2. **Outer layer (`wrappedCode` try-catch in `execute()`)** — Catches
200
+ * anything that escaped `E()` (e.g. re-thrown ExpressionErrors) and
201
+ * serializes it into a sentinel object so the host can reconstruct it.
202
+ *
203
+ * Inside the isolate, errors from host callbacks arrive as sentinel
204
+ * objects ({ __isError, name, message, ... }) rather than class instances,
205
+ * so we match by name instead of instanceof.
191
206
  *
192
207
  * @private
193
208
  * @throws {Error} If context not initialized
@@ -199,208 +214,203 @@ export class IsolatedVmBridge {
199
214
  await this.context.eval(`
200
215
  if (typeof E === 'undefined') {
201
216
  globalThis.E = function(error, _context) {
202
- // Re-throw security violations from __sanitize
203
- if (error && error.message && error.message.includes('due to security concerns')) {
217
+ // Re-throw ExpressionError / ExpressionExtensionError to match
218
+ // the legacy handler in expression.ts. Errors from host callbacks
219
+ // arrive as sentinels (not class instances), so check by name.
220
+ const name = error?.name;
221
+ if (name === 'ExpressionError' || name === 'ExpressionExtensionError') {
204
222
  throw error;
205
223
  }
206
- // Swallow TypeErrors (failed attack attempts return undefined)
207
- if (error instanceof TypeError) {
208
- return undefined;
209
- }
210
- throw error;
224
+ // Swallow everything else (TypeErrors, generic Errors, etc.)
225
+ return undefined;
211
226
  };
212
227
  }
213
228
  `);
214
- if (this.config.debug) {
215
- console.log('[IsolatedVmBridge] Error handler injected successfully');
216
- }
229
+ this.logger.info('[IsolatedVmBridge] Error handler injected successfully');
217
230
  }
218
231
  /**
219
- * Reset data proxies in the isolate context.
232
+ * Create an ivm.Reference callback for getting value/metadata at a path.
220
233
  *
221
- * This method should be called before each execute() to:
222
- * 1. Clear proxy caches from previous evaluations
223
- * 2. Initialize fresh workflow data references
224
- * 3. Expose workflow properties to globalThis
225
- *
226
- * The reset function runs in the isolate and calls back to the host
227
- * via ivm.Reference callbacks to fetch workflow data.
234
+ * Used by createDeepLazyProxy when accessing properties. Returns metadata
235
+ * markers for functions, arrays, and objects, or the primitive value directly.
228
236
  *
237
+ * @param data - Current workflow data to use for callback responses
229
238
  * @private
230
- * @throws {Error} If context not initialized or reset fails
231
239
  */
232
- resetDataProxies(timezone) {
233
- if (!this.resetDataProxiesRef) {
234
- throw new Error('Context not initialized');
235
- }
236
- try {
237
- this.resetDataProxiesRef.applySync(null, [timezone ?? null], {
238
- arguments: { copy: true },
239
- });
240
- if (this.config.debug) {
241
- console.log('[IsolatedVmBridge] Data proxies reset successfully');
240
+ createGetValueAtPathRef(data) {
241
+ return new (getIvm().Reference)((path) => {
242
+ try {
243
+ // Navigate to value
244
+ // Special-case: paths starting with ['$item', index] call data.$item(index)
245
+ // to get the sub-proxy for that item, then continue navigating the rest.
246
+ let value = data;
247
+ let startIndex = 0;
248
+ const itemFn = data.$item;
249
+ if (path.length >= 2 && path[0] === '$item' && typeof itemFn === 'function') {
250
+ const itemIndex = parseInt(path[1], 10);
251
+ if (!isNaN(itemIndex)) {
252
+ value = itemFn(itemIndex);
253
+ startIndex = 2;
254
+ }
255
+ }
256
+ else {
257
+ const dollarFn = data.$;
258
+ if (path.length >= 2 && path[0] === '$' && typeof dollarFn === 'function') {
259
+ value = dollarFn(path[1]);
260
+ startIndex = 2;
261
+ }
262
+ }
263
+ for (let i = startIndex; i < path.length; i++) {
264
+ value = value?.[path[i]];
265
+ if (value === undefined || value === null) {
266
+ return value;
267
+ }
268
+ }
269
+ // Handle functions - return metadata marker
270
+ if (typeof value === 'function') {
271
+ const fnString = value.toString();
272
+ // Block native functions for security
273
+ if (fnString.includes('[native code]')) {
274
+ return undefined;
275
+ }
276
+ return { __isFunction: true, __name: path[path.length - 1] };
277
+ }
278
+ // Handle arrays - always lazy, only transfer length
279
+ if (Array.isArray(value)) {
280
+ return {
281
+ __isArray: true,
282
+ __length: value.length,
283
+ __data: null,
284
+ };
285
+ }
286
+ // Handle objects - return metadata with keys
287
+ if (value !== null && typeof value === 'object') {
288
+ return {
289
+ __isObject: true,
290
+ __keys: Object.keys(value),
291
+ };
292
+ }
293
+ // Primitive value
294
+ return value;
242
295
  }
243
- }
244
- catch (error) {
245
- const errorMessage = error instanceof Error ? error.message : String(error);
246
- throw new Error(`Failed to reset data proxies: ${errorMessage}`);
247
- }
296
+ catch (err) {
297
+ return serializeError(err);
298
+ }
299
+ });
248
300
  }
249
301
  /**
250
- * Register callback functions for cross-isolate communication.
251
- *
252
- * Creates three ivm.Reference callbacks that the runtime bundle uses
253
- * to fetch data from the host process:
302
+ * Create an ivm.Reference callback for getting array elements at an index.
254
303
  *
255
- * - __getValueAtPath: Returns metadata or primitive for a property path
256
- * - __getArrayElement: Returns individual array elements
257
- * - __callFunctionAtPath: Executes functions in host context
258
- *
259
- * These callbacks are called synchronously from isolate proxy traps.
304
+ * Used by array proxy when accessing numeric indices.
260
305
  *
261
306
  * @param data - Current workflow data to use for callback responses
262
307
  * @private
263
308
  */
264
- registerCallbacks(data) {
265
- if (!this.context) {
266
- throw new Error('Context not initialized');
267
- }
268
- // Callback 1: Get value/metadata at path
269
- // Used by createDeepLazyProxy when accessing properties
270
- const getValueAtPath = new (getIvm().Reference)((path) => {
271
- // Navigate to value
272
- // Special-case: paths starting with ['$item', index] call data.$item(index)
273
- // to get the sub-proxy for that item, then continue navigating the rest.
274
- let value = data;
275
- let startIndex = 0;
276
- const itemFn = data.$item;
277
- if (path.length >= 2 && path[0] === '$item' && typeof itemFn === 'function') {
278
- const itemIndex = parseInt(path[1], 10);
279
- if (!isNaN(itemIndex)) {
280
- value = itemFn(itemIndex);
281
- startIndex = 2;
309
+ createGetArrayElementRef(data) {
310
+ return new (getIvm().Reference)((path, index) => {
311
+ try {
312
+ // Navigate to array
313
+ // Special-case: paths starting with ['$item', index] call data.$item(index)
314
+ let arr = data;
315
+ let startIndex = 0;
316
+ const itemFn = data.$item;
317
+ if (path.length >= 2 && path[0] === '$item' && typeof itemFn === 'function') {
318
+ const itemIndex = parseInt(path[1], 10);
319
+ if (!isNaN(itemIndex)) {
320
+ arr = itemFn(itemIndex);
321
+ startIndex = 2;
322
+ }
282
323
  }
283
- }
284
- for (let i = startIndex; i < path.length; i++) {
285
- value = value?.[path[i]];
286
- if (value === undefined || value === null) {
287
- return value;
324
+ else {
325
+ const dollarFn = data.$;
326
+ if (path.length >= 2 && path[0] === '$' && typeof dollarFn === 'function') {
327
+ arr = dollarFn(path[1]);
328
+ startIndex = 2;
329
+ }
288
330
  }
289
- }
290
- // Handle functions - return metadata marker
291
- if (typeof value === 'function') {
292
- const fnString = value.toString();
293
- // Block native functions for security
294
- if (fnString.includes('[native code]')) {
331
+ for (let i = startIndex; i < path.length; i++) {
332
+ arr = arr?.[path[i]];
333
+ if (arr === undefined || arr === null) {
334
+ return undefined;
335
+ }
336
+ }
337
+ if (!Array.isArray(arr)) {
295
338
  return undefined;
296
339
  }
297
- return { __isFunction: true, __name: path[path.length - 1] };
298
- }
299
- // Handle arrays - always lazy, only transfer length
300
- if (Array.isArray(value)) {
301
- return {
302
- __isArray: true,
303
- __length: value.length,
304
- __data: null,
305
- };
340
+ const element = arr[index];
341
+ // If element is object/array, return metadata
342
+ if (element !== null && typeof element === 'object') {
343
+ if (Array.isArray(element)) {
344
+ return {
345
+ __isArray: true,
346
+ __length: element.length,
347
+ __data: null,
348
+ };
349
+ }
350
+ return {
351
+ __isObject: true,
352
+ __keys: Object.keys(element),
353
+ };
354
+ }
355
+ // Primitive element
356
+ return element;
306
357
  }
307
- // Handle objects - return metadata with keys
308
- if (value !== null && typeof value === 'object') {
309
- return {
310
- __isObject: true,
311
- __keys: Object.keys(value),
312
- };
358
+ catch (err) {
359
+ return serializeError(err);
313
360
  }
314
- // Primitive value
315
- return value;
316
361
  });
317
- // Callback 2: Get array element at index
318
- // Used by array proxy when accessing numeric indices
319
- const getArrayElement = new (getIvm().Reference)((path, index) => {
320
- // Navigate to array
321
- // Special-case: paths starting with ['$item', index] call data.$item(index)
322
- let arr = data;
323
- let startIndex = 0;
324
- const itemFn = data.$item;
325
- if (path.length >= 2 && path[0] === '$item' && typeof itemFn === 'function') {
326
- const itemIndex = parseInt(path[1], 10);
327
- if (!isNaN(itemIndex)) {
328
- arr = itemFn(itemIndex);
362
+ }
363
+ /**
364
+ * Create an ivm.Reference callback for calling functions at a path.
365
+ *
366
+ * Used when expressions invoke functions from workflow data.
367
+ *
368
+ * @param data - Current workflow data to use for callback responses
369
+ * @private
370
+ */
371
+ createCallFunctionAtPathRef(data) {
372
+ return new (getIvm().Reference)((path, ...args) => {
373
+ try {
374
+ // Navigate to function, tracking parent to preserve `this` context
375
+ let fn = data;
376
+ let parent = undefined;
377
+ let startIndex = 0;
378
+ const dollarFn = data.$;
379
+ if (path.length >= 2 && path[0] === '$' && typeof dollarFn === 'function') {
380
+ fn = dollarFn(path[1]);
329
381
  startIndex = 2;
330
382
  }
331
- }
332
- for (let i = startIndex; i < path.length; i++) {
333
- arr = arr?.[path[i]];
334
- if (arr === undefined || arr === null) {
335
- return undefined;
383
+ for (let i = startIndex; i < path.length; i++) {
384
+ parent = fn;
385
+ fn = fn?.[path[i]];
336
386
  }
337
- }
338
- if (!Array.isArray(arr)) {
339
- return undefined;
340
- }
341
- const element = arr[index];
342
- // If element is object/array, return metadata
343
- if (element !== null && typeof element === 'object') {
344
- if (Array.isArray(element)) {
345
- return {
346
- __isArray: true,
347
- __length: element.length,
348
- __data: null,
349
- };
387
+ if (typeof fn !== 'function') {
388
+ throw new Error(`${path.join('.')} is not a function`);
350
389
  }
351
- return {
352
- __isObject: true,
353
- __keys: Object.keys(element),
354
- };
355
- }
356
- // Primitive element
357
- return element;
358
- });
359
- // Callback 3: Call function at path with arguments
360
- // Used when expressions invoke functions from workflow data
361
- const callFunctionAtPath = new (getIvm().Reference)((path, ...args) => {
362
- // Navigate to function, tracking parent to preserve `this` context
363
- let fn = data;
364
- let parent = undefined;
365
- for (const key of path) {
366
- parent = fn;
367
- fn = fn?.[key];
368
- }
369
- if (typeof fn !== 'function') {
370
- throw new Error(`${path.join('.')} is not a function`);
390
+ // Block native functions for security (same check as getValueAtPath)
391
+ if (fn.toString().includes('[native code]')) {
392
+ throw new Error(`${path.join('.')} is a native function and cannot be called`);
393
+ }
394
+ // Execute function with parent as `this` to preserve method context
395
+ return fn.call(parent, ...args);
371
396
  }
372
- // Block native functions for security (same check as getValueAtPath)
373
- if (fn.toString().includes('[native code]')) {
374
- throw new Error(`${path.join('.')} is a native function and cannot be called`);
397
+ catch (err) {
398
+ return serializeError(err);
375
399
  }
376
- // Execute function with parent as `this` to preserve method context
377
- return fn.call(parent, ...args);
378
400
  });
379
- // Release previous references before replacing to avoid accumulation
380
- this.valueAtPathRef?.release();
381
- this.arrayElementRef?.release();
382
- this.callFunctionRef?.release();
383
- // Store references so they can be released on the next call or on dispose()
384
- this.valueAtPathRef = getValueAtPath;
385
- this.arrayElementRef = getArrayElement;
386
- this.callFunctionRef = callFunctionAtPath;
387
- // Register all callbacks in isolate global context
388
- this.context.global.setSync('__getValueAtPath', getValueAtPath);
389
- this.context.global.setSync('__getArrayElement', getArrayElement);
390
- this.context.global.setSync('__callFunctionAtPath', callFunctionAtPath);
391
- if (this.config.debug) {
392
- console.log('[IsolatedVmBridge] Callbacks registered successfully');
393
- }
394
401
  }
395
402
  /**
396
403
  * Execute JavaScript code in the isolated context.
397
404
  *
398
405
  * Flow:
399
- * 1. Register callbacks as ivm.Reference for cross-isolate communication
400
- * 2. Call resetDataProxies() to initialize workflow data proxies
401
- * 3. Compile script (with caching for performance)
402
- * 4. Execute with timeout enforcement
403
- * 5. Return result (copied from isolate)
406
+ * 1. Create three ivm.Reference callbacks scoped to the current data
407
+ * 2. Use evalClosureSync to run the code in a closure where $0/$1/$2
408
+ * are the callback references no global mutable state
409
+ * 3. buildContext() inside the isolate creates a fresh evaluation context
410
+ * from the closure-scoped references
411
+ *
412
+ * Each call gets its own closure, so nested and concurrent evaluations
413
+ * cannot interfere with each other.
404
414
  *
405
415
  * @param code - JavaScript expression to evaluate
406
416
  * @param data - Workflow data (e.g., { $json: {...}, $runIndex: 0 })
@@ -411,35 +421,58 @@ export class IsolatedVmBridge {
411
421
  if (!this.initialized || !this.context) {
412
422
  throw new Error('Bridge not initialized. Call initialize() first.');
413
423
  }
424
+ const getValueAtPath = this.createGetValueAtPathRef(data);
425
+ const getArrayElement = this.createGetArrayElementRef(data);
426
+ const callFunctionAtPath = this.createCallFunctionAtPathRef(data);
414
427
  try {
415
- // Step 1: Register callbacks with current data context
416
- this.registerCallbacks(data);
417
- // Step 2: Reset proxies for this evaluation
418
- // This initializes $json, $binary, etc. as lazy proxies
419
- this.resetDataProxies(options?.timezone);
420
- // Step 3: Wrap transformed code so 'this' === __data in the isolate.
428
+ const timezone = options?.timezone ? JSON.stringify(options.timezone) : 'undefined';
429
+ // Wrap transformed code so 'this' === the closure-scoped context.
421
430
  // Tournament generates: this.$json.email, this.$items(), etc.
422
- // __data has $json, $items, etc. as lazy proxies (set in resetDataProxies).
423
- const wrappedCode = `(function() { var __result = (function() {\n${code}\n}).call(__data); return __prepareForTransfer(__result); })()`;
424
- let script = this.scriptCache.get(code);
425
- if (!script) {
426
- script = this.isolate.compileScriptSync(wrappedCode);
427
- this.scriptCache.set(code, script);
428
- if (this.config.debug) {
429
- console.log('[IsolatedVmBridge] Script compiled and cached');
430
- }
431
- }
432
- // Step 4: Execute with timeout and copy result back
433
- const result = script.runSync(this.context, {
434
- timeout: this.config.timeout,
435
- copy: true,
436
- });
437
- if (this.config.debug) {
438
- console.log('[IsolatedVmBridge] Expression executed successfully');
431
+ // buildContext() creates a fresh context with lazy proxies from the
432
+ // closure-scoped callback references ($0, $1, $2) no globals touched.
433
+ // The outer try-catch serializes errors into a sentinel object and returns
434
+ // it as the result. Errors from host callbacks arrive as sentinels already
435
+ // (via serializeError), so we pass them through. This avoids a round-trip
436
+ // callback and keeps Error reconstruction on the host side only.
437
+ const wrappedCode = `
438
+ var __ctx = buildContext($0, $1, $2, ${timezone});
439
+ try {
440
+ var __result = (function() {
441
+ ${code}
442
+ }).call(__ctx);
443
+ return __prepareForTransfer(__result);
444
+ } catch(e) {
445
+ if (e && e.__isError) return e;
446
+ if (e == null) return { __isError: true, name: "Error", message: String(e), stack: "", extra: {} };
447
+ var extra = {};
448
+ for (var k in e) {
449
+ if (Object.prototype.hasOwnProperty.call(e, k) && k !== "name" && k !== "message" && k !== "stack") extra[k] = e[k];
450
+ }
451
+ return {
452
+ __isError: true,
453
+ name: e.name || "Error",
454
+ message: e.message || "",
455
+ stack: e.stack || "",
456
+ extra: extra
457
+ };
458
+ }`;
459
+ const result = this.context.evalClosureSync(wrappedCode, [getValueAtPath, getArrayElement, callFunctionAtPath], { result: { copy: true }, timeout: this.config.timeout });
460
+ if (isErrorSentinel(result)) {
461
+ throw this.reconstructError(result);
439
462
  }
463
+ this.logger.debug('[IsolatedVmBridge] Expression executed successfully');
440
464
  return result;
441
465
  }
442
466
  catch (error) {
467
+ // Re-throw reconstructed errors as-is.
468
+ // Note: TypeError is intentionally NOT included here — the isolate's
469
+ // E() handler swallows TypeErrors (failed attack attempts return undefined),
470
+ // so TypeErrors from host callbacks should also go through the generic
471
+ // wrapping for consistent behavior.
472
+ if (error instanceof Error &&
473
+ (error.name === 'ExpressionError' || error.name === 'ExpressionExtensionError')) {
474
+ throw error;
475
+ }
443
476
  const errorMessage = error instanceof Error ? error.message : String(error);
444
477
  if (errorMessage.includes('Script execution timed out')) {
445
478
  throw new TimeoutError(`Expression timed out after ${this.config.timeout}ms`, {});
@@ -449,6 +482,29 @@ export class IsolatedVmBridge {
449
482
  }
450
483
  throw new Error(`Expression evaluation failed: ${errorMessage}`);
451
484
  }
485
+ finally {
486
+ getValueAtPath.release();
487
+ getArrayElement.release();
488
+ callFunctionAtPath.release();
489
+ }
490
+ }
491
+ /**
492
+ * Reconstruct an error from serialized isolate data.
493
+ *
494
+ * Maps error names back to their host-side classes and restores
495
+ * custom properties that would otherwise be lost crossing the boundary.
496
+ */
497
+ reconstructError(data) {
498
+ const error = new Error(data.message);
499
+ error.name = data.name || 'Error';
500
+ if (data.stack) {
501
+ error.stack = data.stack;
502
+ }
503
+ // Restore custom properties transferred via copy: true
504
+ if (data.extra) {
505
+ Object.assign(error, data.extra);
506
+ }
507
+ return error;
452
508
  }
453
509
  /**
454
510
  * Dispose of the isolate and free resources.
@@ -463,16 +519,9 @@ export class IsolatedVmBridge {
463
519
  if (!this.isolate.isDisposed) {
464
520
  this.isolate.dispose();
465
521
  }
466
- // Release callback references
467
- this.valueAtPathRef?.release();
468
- this.arrayElementRef?.release();
469
- this.callFunctionRef?.release();
470
522
  this.disposed = true;
471
523
  this.initialized = false;
472
- this.scriptCache.clear();
473
- if (this.config.debug) {
474
- console.log('[IsolatedVmBridge] Disposed');
475
- }
524
+ this.logger.info('[IsolatedVmBridge] Disposed');
476
525
  }
477
526
  /**
478
527
  * Check if the bridge has been disposed.
@@ -480,7 +529,7 @@ export class IsolatedVmBridge {
480
529
  * @returns true if disposed, false otherwise
481
530
  */
482
531
  isDisposed() {
483
- return this.disposed;
532
+ return this.disposed || this.isolate.isDisposed;
484
533
  }
485
534
  }
486
535
  //# sourceMappingURL=isolated-vm-bridge.js.map