@n8n/expression-runtime 0.29.1 → 0.31.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 (120) hide show
  1. package/README.md +4 -0
  2. package/dist/bundle/runtime.esm.js +17 -17
  3. package/dist/bundle/runtime.esm.js.map +4 -4
  4. package/dist/bundle/runtime.iife.js +17 -17
  5. package/dist/bundle/runtime.iife.js.map +4 -4
  6. package/dist/cjs/bridge/host-functions.d.ts +74 -0
  7. package/dist/cjs/bridge/host-functions.d.ts.map +1 -0
  8. package/dist/cjs/bridge/host-functions.js +336 -0
  9. package/dist/cjs/bridge/host-functions.js.map +1 -0
  10. package/dist/cjs/bridge/isolated-vm-bridge.d.ts +24 -150
  11. package/dist/cjs/bridge/isolated-vm-bridge.d.ts.map +1 -1
  12. package/dist/cjs/bridge/isolated-vm-bridge.js +130 -429
  13. package/dist/cjs/bridge/isolated-vm-bridge.js.map +1 -1
  14. package/dist/cjs/bridge/quickjs-bridge.d.ts +21 -6
  15. package/dist/cjs/bridge/quickjs-bridge.d.ts.map +1 -1
  16. package/dist/cjs/bridge/quickjs-bridge.js +377 -402
  17. package/dist/cjs/bridge/quickjs-bridge.js.map +1 -1
  18. package/dist/cjs/build.tsbuildinfo +1 -1
  19. package/dist/cjs/evaluator/expression-evaluator.d.ts +17 -0
  20. package/dist/cjs/evaluator/expression-evaluator.d.ts.map +1 -1
  21. package/dist/cjs/evaluator/expression-evaluator.js +61 -1
  22. package/dist/cjs/evaluator/expression-evaluator.js.map +1 -1
  23. package/dist/cjs/extensions/array-extensions.d.ts.map +1 -1
  24. package/dist/cjs/extensions/array-extensions.js +7 -15
  25. package/dist/cjs/extensions/array-extensions.js.map +1 -1
  26. package/dist/cjs/extensions/function-extensions.d.ts.map +1 -1
  27. package/dist/cjs/extensions/function-extensions.js +2 -10
  28. package/dist/cjs/extensions/function-extensions.js.map +1 -1
  29. package/dist/cjs/extensions/object-extensions.d.ts.map +1 -1
  30. package/dist/cjs/extensions/object-extensions.js +3 -11
  31. package/dist/cjs/extensions/object-extensions.js.map +1 -1
  32. package/dist/cjs/extensions/utils.d.ts +1 -0
  33. package/dist/cjs/extensions/utils.d.ts.map +1 -1
  34. package/dist/cjs/extensions/utils.js +10 -0
  35. package/dist/cjs/extensions/utils.js.map +1 -1
  36. package/dist/cjs/observability/metrics.d.ts +12 -0
  37. package/dist/cjs/observability/metrics.d.ts.map +1 -1
  38. package/dist/cjs/observability/metrics.js +12 -0
  39. package/dist/cjs/observability/metrics.js.map +1 -1
  40. package/dist/cjs/runtime/lazy-proxy.d.ts +2 -0
  41. package/dist/cjs/runtime/lazy-proxy.d.ts.map +1 -1
  42. package/dist/cjs/runtime/lazy-proxy.js +29 -15
  43. package/dist/cjs/runtime/lazy-proxy.js.map +1 -1
  44. package/dist/cjs/runtime/luxon-transfer.d.ts +62 -0
  45. package/dist/cjs/runtime/luxon-transfer.d.ts.map +1 -0
  46. package/dist/cjs/runtime/luxon-transfer.js +200 -0
  47. package/dist/cjs/runtime/luxon-transfer.js.map +1 -0
  48. package/dist/cjs/runtime/serialize.d.ts +7 -7
  49. package/dist/cjs/runtime/serialize.d.ts.map +1 -1
  50. package/dist/cjs/runtime/serialize.js +42 -30
  51. package/dist/cjs/runtime/serialize.js.map +1 -1
  52. package/dist/cjs/runtime/transfer.d.ts +77 -0
  53. package/dist/cjs/runtime/transfer.d.ts.map +1 -0
  54. package/dist/cjs/runtime/transfer.js +183 -0
  55. package/dist/cjs/runtime/transfer.js.map +1 -0
  56. package/dist/cjs/types/bridge.d.ts +16 -0
  57. package/dist/cjs/types/bridge.d.ts.map +1 -1
  58. package/dist/cjs/types/bridge.js +1 -0
  59. package/dist/cjs/types/bridge.js.map +1 -1
  60. package/dist/cjs/types/evaluator.d.ts +9 -0
  61. package/dist/cjs/types/evaluator.d.ts.map +1 -1
  62. package/dist/cjs/types/evaluator.js.map +1 -1
  63. package/dist/esm/bridge/host-functions.d.ts +74 -0
  64. package/dist/esm/bridge/host-functions.d.ts.map +1 -0
  65. package/dist/esm/bridge/host-functions.js +328 -0
  66. package/dist/esm/bridge/host-functions.js.map +1 -0
  67. package/dist/esm/bridge/isolated-vm-bridge.d.ts +24 -150
  68. package/dist/esm/bridge/isolated-vm-bridge.d.ts.map +1 -1
  69. package/dist/esm/bridge/isolated-vm-bridge.js +126 -425
  70. package/dist/esm/bridge/isolated-vm-bridge.js.map +1 -1
  71. package/dist/esm/bridge/quickjs-bridge.d.ts +21 -6
  72. package/dist/esm/bridge/quickjs-bridge.d.ts.map +1 -1
  73. package/dist/esm/bridge/quickjs-bridge.js +368 -393
  74. package/dist/esm/bridge/quickjs-bridge.js.map +1 -1
  75. package/dist/esm/build.tsbuildinfo +1 -1
  76. package/dist/esm/evaluator/expression-evaluator.d.ts +17 -0
  77. package/dist/esm/evaluator/expression-evaluator.d.ts.map +1 -1
  78. package/dist/esm/evaluator/expression-evaluator.js +61 -1
  79. package/dist/esm/evaluator/expression-evaluator.js.map +1 -1
  80. package/dist/esm/extensions/array-extensions.d.ts.map +1 -1
  81. package/dist/esm/extensions/array-extensions.js +1 -9
  82. package/dist/esm/extensions/array-extensions.js.map +1 -1
  83. package/dist/esm/extensions/function-extensions.d.ts.map +1 -1
  84. package/dist/esm/extensions/function-extensions.js +1 -9
  85. package/dist/esm/extensions/function-extensions.js.map +1 -1
  86. package/dist/esm/extensions/object-extensions.d.ts.map +1 -1
  87. package/dist/esm/extensions/object-extensions.js +1 -9
  88. package/dist/esm/extensions/object-extensions.js.map +1 -1
  89. package/dist/esm/extensions/utils.d.ts +1 -0
  90. package/dist/esm/extensions/utils.d.ts.map +1 -1
  91. package/dist/esm/extensions/utils.js +9 -0
  92. package/dist/esm/extensions/utils.js.map +1 -1
  93. package/dist/esm/observability/metrics.d.ts +12 -0
  94. package/dist/esm/observability/metrics.d.ts.map +1 -1
  95. package/dist/esm/observability/metrics.js +12 -0
  96. package/dist/esm/observability/metrics.js.map +1 -1
  97. package/dist/esm/runtime/lazy-proxy.d.ts +2 -0
  98. package/dist/esm/runtime/lazy-proxy.d.ts.map +1 -1
  99. package/dist/esm/runtime/lazy-proxy.js +29 -15
  100. package/dist/esm/runtime/lazy-proxy.js.map +1 -1
  101. package/dist/esm/runtime/luxon-transfer.d.ts +62 -0
  102. package/dist/esm/runtime/luxon-transfer.d.ts.map +1 -0
  103. package/dist/esm/runtime/luxon-transfer.js +183 -0
  104. package/dist/esm/runtime/luxon-transfer.js.map +1 -0
  105. package/dist/esm/runtime/serialize.d.ts +7 -7
  106. package/dist/esm/runtime/serialize.d.ts.map +1 -1
  107. package/dist/esm/runtime/serialize.js +42 -30
  108. package/dist/esm/runtime/serialize.js.map +1 -1
  109. package/dist/esm/runtime/transfer.d.ts +77 -0
  110. package/dist/esm/runtime/transfer.d.ts.map +1 -0
  111. package/dist/esm/runtime/transfer.js +173 -0
  112. package/dist/esm/runtime/transfer.js.map +1 -0
  113. package/dist/esm/types/bridge.d.ts +16 -0
  114. package/dist/esm/types/bridge.d.ts.map +1 -1
  115. package/dist/esm/types/bridge.js +1 -0
  116. package/dist/esm/types/bridge.js.map +1 -1
  117. package/dist/esm/types/evaluator.d.ts +9 -0
  118. package/dist/esm/types/evaluator.d.ts.map +1 -1
  119. package/dist/esm/types/evaluator.js.map +1 -1
  120. package/package.json +8 -8
@@ -1,8 +1,11 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFileSync } from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { DEFAULT_BRIDGE_CONFIG, TimeoutError, MemoryLimitError } from '../types';
4
- import { bridgeMessageSchema } from './bridge-messages';
4
+ import { unwrapLuxonValues } from '../runtime/luxon-transfer';
5
+ import { dispatchHostCall, getArrayElement, getValueAtPath, isErrorSentinel, reconstructError, serializeError, } from './host-functions';
5
6
  let _ivm = null;
7
+ /** Runtime bundle source, read once per process by loadRuntimeBundle(). */
8
+ let _runtimeBundle = null;
6
9
  function getIvm() {
7
10
  if (!_ivm) {
8
11
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -14,33 +17,52 @@ const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js');
14
17
  // Captured at module load so values rendered into generated code stay stable
15
18
  // even if the global is later replaced.
16
19
  const safeStringify = JSON.stringify;
17
- /** Check if a value is an error sentinel returned by serializeError. */
18
- function isErrorSentinel(value) {
19
- return (typeof value === 'object' &&
20
- value !== null &&
21
- value.__isError === true);
20
+ // V8 compile cache for the runtime bundle (BridgeConfig.compileCache): the
21
+ // first bundle compile produces it, later builds consume it and skip
22
+ // re-parsing. V8 validates the data and recompiles when it is stale.
23
+ let _bundleCachedData = null;
24
+ // The runtime sets Script.cachedData when produceCachedData is passed, but
25
+ // ivm's typings omit the property (CachedDataResult exists unattached).
26
+ function producedCachedData(script) {
27
+ const data = Reflect.get(script, 'cachedData');
28
+ return data instanceof getIvm().ExternalCopy ? data : null;
22
29
  }
23
- /**
24
- * Serialize an error into a transferable metadata object.
25
- *
26
- * Host-side callbacks (getValueAtPath, etc.) catch errors and return this
27
- * sentinel instead of letting the error cross the isolate boundary (which
28
- * strips custom class identity and properties). The isolate-side proxy
29
- * detects __isError and reconstructs a proper Error to throw.
30
- */
31
- function serializeError(err) {
32
- if (err instanceof Error) {
33
- const extra = Object.fromEntries(Object.entries(err).filter(([key]) => key !== 'name' && key !== 'message' && key !== 'stack'));
34
- return {
35
- __isError: true,
36
- name: err.name,
37
- message: err.message,
38
- stack: err.stack,
39
- extra,
40
- };
30
+ /** Globals the runtime bundle must define. Verified after every bundle load. */
31
+ const RUNTIME_GLOBALS = [
32
+ 'DateTime',
33
+ 'extend',
34
+ 'createDeepLazyProxy',
35
+ 'SafeObject',
36
+ 'SafeError',
37
+ 'buildContext',
38
+ ];
39
+ /** Evaluates inside the isolate to a JSON array of the RUNTIME_GLOBALS that are missing. */
40
+ const MISSING_RUNTIME_GLOBALS_SOURCE = `JSON.stringify(${JSON.stringify(RUNTIME_GLOBALS)}.filter((name) => typeof globalThis[name] === 'undefined'))`;
41
+ function assertRuntimeGlobals(missingJson) {
42
+ const missing = typeof missingJson === 'string' ? JSON.parse(missingJson) : missingJson;
43
+ if (Array.isArray(missing) && missing.length > 0) {
44
+ throw new Error(`Runtime bundle verification failed: missing ${missing.join(', ')}`);
41
45
  }
42
- return { __isError: true, name: 'Error', message: String(err), extra: {} };
43
46
  }
47
+ /**
48
+ * The E() error handler injected into every isolate; see injectErrorHandler()
49
+ * for the two exception-handling layers it participates in.
50
+ */
51
+ const ERROR_HANDLER_SOURCE = `
52
+ if (typeof E === 'undefined') {
53
+ globalThis.E = function(error, _context) {
54
+ // Re-throw ExpressionError / ExpressionExtensionError to match
55
+ // the legacy handler in expression.ts. Errors from host callbacks
56
+ // arrive as sentinels (not class instances), so check by name.
57
+ const name = error?.name;
58
+ if (name === 'ExpressionError' || name === 'ExpressionExtensionError') {
59
+ throw error;
60
+ }
61
+ // Swallow everything else (TypeErrors, generic Errors, etc.)
62
+ return undefined;
63
+ };
64
+ }
65
+ `;
44
66
  /**
45
67
  * Read the runtime IIFE bundle by walking up from `__dirname` until
46
68
  * `dist/bundle/runtime.iife.js` is found.
@@ -49,11 +71,14 @@ function serializeError(err) {
49
71
  * - `src/bridge/` (vitest running against source)
50
72
  * - `dist/cjs/bridge/` (CJS build)
51
73
  */
52
- async function readRuntimeBundle() {
74
+ function loadRuntimeBundle() {
75
+ if (_runtimeBundle !== null)
76
+ return _runtimeBundle;
53
77
  let dir = __dirname;
54
78
  while (dir !== path.dirname(dir)) {
55
79
  try {
56
- return await readFile(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
80
+ _runtimeBundle = readFileSync(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
81
+ return _runtimeBundle;
57
82
  }
58
83
  catch { }
59
84
  dir = path.dirname(dir);
@@ -113,8 +138,6 @@ export class IsolatedVmBridge {
113
138
  await jail.set('global', jail.derefInto());
114
139
  // Load runtime bundle (DateTime, extend, SafeObject, proxy system)
115
140
  await this.loadVendorLibraries();
116
- // Verify proxy system loaded correctly
117
- await this.verifyProxySystem();
118
141
  // Inject E() error handler needed by tournament-generated try-catch code
119
142
  await this.injectErrorHandler();
120
143
  this.initialized = true;
@@ -138,54 +161,24 @@ export class IsolatedVmBridge {
138
161
  }
139
162
  try {
140
163
  // Load runtime bundle (includes vendor libraries + proxy system)
141
- const runtimeBundle = await readRuntimeBundle();
164
+ const runtimeBundle = loadRuntimeBundle();
142
165
  // Evaluate bundle in isolate context
143
166
  // This makes all exported globals available (DateTime, extend, extendOptional, SafeObject, SafeError, createDeepLazyProxy, buildContext)
144
- await this.context.eval(runtimeBundle);
145
- this.logger.debug('[IsolatedVmBridge] Runtime bundle loaded');
146
- // Verify vendor libraries loaded correctly
147
- const hasDateTime = await this.context.eval('typeof DateTime !== "undefined"');
148
- const hasExtend = await this.context.eval('typeof extend !== "undefined"');
149
- if (!hasDateTime || !hasExtend) {
150
- throw new Error(`Library verification failed: DateTime=${hasDateTime}, extend=${hasExtend}`);
167
+ if (this.config.compileCache) {
168
+ const script = await this.isolate.compileScript(runtimeBundle, _bundleCachedData ? { cachedData: _bundleCachedData } : { produceCachedData: true });
169
+ if (!_bundleCachedData)
170
+ _bundleCachedData = producedCachedData(script);
171
+ await script.run(this.context);
151
172
  }
152
- this.logger.debug('[IsolatedVmBridge] Vendor libraries verified successfully');
153
- }
154
- catch (error) {
155
- const errorMessage = error instanceof Error ? error.message : String(error);
156
- throw new Error(`Failed to load runtime bundle: ${errorMessage}`);
157
- }
158
- }
159
- /**
160
- * Verify the proxy system loaded correctly.
161
- *
162
- * The proxy system is loaded as part of the runtime bundle in loadVendorLibraries().
163
- * This method verifies all required components are available.
164
- *
165
- * @private
166
- * @throws {Error} If context not initialized or proxy system verification fails
167
- */
168
- async verifyProxySystem() {
169
- if (!this.context) {
170
- throw new Error('Context not initialized');
171
- }
172
- try {
173
- // Verify proxy system components loaded correctly
174
- const hasProxyCreator = await this.context.eval('typeof createDeepLazyProxy !== "undefined"');
175
- const hasSafeObject = await this.context.eval('typeof SafeObject !== "undefined"');
176
- const hasSafeError = await this.context.eval('typeof SafeError !== "undefined"');
177
- const hasBuildContext = await this.context.eval('typeof buildContext !== "undefined"');
178
- if (!hasProxyCreator || !hasSafeObject || !hasSafeError || !hasBuildContext) {
179
- throw new Error(`Proxy system verification failed: ` +
180
- `createDeepLazyProxy=${hasProxyCreator}, ` +
181
- `SafeObject=${hasSafeObject}, SafeError=${hasSafeError}, ` +
182
- `buildContext=${hasBuildContext}`);
173
+ else {
174
+ await this.context.eval(runtimeBundle);
183
175
  }
184
- this.logger.debug('[IsolatedVmBridge] Proxy system verified successfully');
176
+ assertRuntimeGlobals(await this.context.eval(MISSING_RUNTIME_GLOBALS_SOURCE));
177
+ this.logger.debug('[IsolatedVmBridge] Runtime bundle loaded and verified');
185
178
  }
186
179
  catch (error) {
187
180
  const errorMessage = error instanceof Error ? error.message : String(error);
188
- throw new Error(`Failed to verify proxy system: ${errorMessage}`);
181
+ throw new Error(`Failed to load runtime bundle: ${errorMessage}`);
189
182
  }
190
183
  }
191
184
  /**
@@ -215,97 +208,59 @@ export class IsolatedVmBridge {
215
208
  if (!this.context) {
216
209
  throw new Error('Context not initialized');
217
210
  }
218
- await this.context.eval(`
219
- if (typeof E === 'undefined') {
220
- globalThis.E = function(error, _context) {
221
- // Re-throw ExpressionError / ExpressionExtensionError to match
222
- // the legacy handler in expression.ts. Errors from host callbacks
223
- // arrive as sentinels (not class instances), so check by name.
224
- const name = error?.name;
225
- if (name === 'ExpressionError' || name === 'ExpressionExtensionError') {
226
- throw error;
227
- }
228
- // Swallow everything else (TypeErrors, generic Errors, etc.)
229
- return undefined;
230
- };
231
- }
232
- `);
211
+ await this.context.eval(ERROR_HANDLER_SOURCE);
233
212
  this.logger.debug('[IsolatedVmBridge] Error handler injected successfully');
234
213
  }
214
+ /**
215
+ * Synchronous variant of initialize(): the same steps through isolated-vm's
216
+ * sync APIs, for on-demand creation inside the synchronous evaluate() path
217
+ * (lazy acquisition with an exhausted pool). Blocks the event loop for the
218
+ * duration of one isolate setup — pool warmup should stay on the async
219
+ * initialize().
220
+ */
221
+ initializeSync() {
222
+ if (this.initialized)
223
+ return;
224
+ try {
225
+ this.context = this.isolate.createContextSync();
226
+ const jail = this.context.global;
227
+ jail.setSync('global', jail.derefInto());
228
+ if (this.config.compileCache) {
229
+ const script = this.isolate.compileScriptSync(loadRuntimeBundle(), _bundleCachedData ? { cachedData: _bundleCachedData } : { produceCachedData: true });
230
+ if (!_bundleCachedData)
231
+ _bundleCachedData = producedCachedData(script);
232
+ script.runSync(this.context);
233
+ }
234
+ else {
235
+ this.context.evalSync(loadRuntimeBundle());
236
+ }
237
+ assertRuntimeGlobals(this.context.evalSync(MISSING_RUNTIME_GLOBALS_SOURCE));
238
+ this.context.evalSync(ERROR_HANDLER_SOURCE);
239
+ }
240
+ catch (error) {
241
+ // A failed cold start must not retain the native isolate. dispose()
242
+ // has no awaits before the isolate is released, so not awaiting it
243
+ // here is safe.
244
+ void this.dispose();
245
+ throw error;
246
+ }
247
+ this.initialized = true;
248
+ this.logger.debug('[IsolatedVmBridge] Initialized synchronously');
249
+ }
235
250
  /**
236
251
  * Create an ivm.Callback for getting value/metadata at a path.
237
252
  *
238
- * Used by createDeepLazyProxy when accessing properties. Returns metadata
239
- * markers for arrays and objects, or the primitive value directly.
240
- *
241
- * Function-typed values are returned as `undefined` — every callable on
242
- * the host data surface (`$('Foo').first()`, `$items()`, `$fromAI()`,
243
- * `$evaluateExpression()`, `$getPairedItem()`) is wired in-isolate via
244
- * the typed-RPC dispatcher (`callHost`). No expression form should
245
- * reach a function through this path.
253
+ * Thin wrapper around the shared getValueAtPath (see host-functions.ts
254
+ * for navigation and guard semantics); errors are caught and returned
255
+ * as sentinels instead of crossing the isolate boundary.
246
256
  *
247
257
  * @param data - Current workflow data to use for callback responses
248
258
  * @private
249
259
  */
250
260
  createGetValueAtPathRef(data) {
251
- return new (getIvm().Callback)((path) => {
261
+ return new (getIvm().Callback)((pathArr) => {
252
262
  try {
253
- // Navigate to value
254
- // Special-case: paths starting with ['$item', index] call data.$item(index)
255
- // to get the sub-proxy for that item, then continue navigating the rest.
256
- let value = data;
257
- let startIndex = 0;
258
- const itemFn = data.$item;
259
- if (path.length >= 2 && path[0] === '$item' && typeof itemFn === 'function') {
260
- const itemIndex = parseInt(path[1], 10);
261
- if (!isNaN(itemIndex)) {
262
- value = itemFn(itemIndex);
263
- startIndex = 2;
264
- }
265
- }
266
- else {
267
- const dollarFn = data.$;
268
- if (path.length >= 2 && path[0] === '$' && typeof dollarFn === 'function') {
269
- value = dollarFn(path[1]);
270
- startIndex = 2;
271
- }
272
- }
273
- for (let i = startIndex; i < path.length; i++) {
274
- value = value?.[path[i]];
275
- if (value === undefined || value === null) {
276
- return value;
277
- }
278
- }
279
- // Functions are not reachable via the lazy-proxy data path —
280
- // every callable on the host data surface routes through the
281
- // typed-RPC dispatcher. Return undefined so any residual
282
- // access surfaces as missing rather than as a stale metadata
283
- // marker the runtime no longer knows how to interpret.
284
- if (typeof value === 'function') {
285
- return undefined;
286
- }
287
- // Handle arrays - always lazy, only transfer length
288
- if (Array.isArray(value)) {
289
- return {
290
- __isArray: true,
291
- __length: value.length,
292
- __data: null,
293
- };
294
- }
295
- // Dates have no enumerable own keys; pass through instead of
296
- // marshaling as an empty object.
297
- if (value instanceof Date) {
298
- return value;
299
- }
300
- // Handle objects - return metadata with keys
301
- if (value !== null && typeof value === 'object') {
302
- return {
303
- __isObject: true,
304
- __keys: Object.keys(value),
305
- };
306
- }
307
- // Primitive value
308
- return value;
263
+ return getValueAtPath(data, pathArr);
309
264
  }
310
265
  catch (err) {
311
266
  return serializeError(err);
@@ -315,75 +270,17 @@ export class IsolatedVmBridge {
315
270
  /**
316
271
  * Create an ivm.Callback for getting array elements at an index.
317
272
  *
318
- * Used by array proxy when accessing numeric indices.
273
+ * Thin wrapper around the shared getArrayElement (see host-functions.ts
274
+ * for navigation and guard semantics); errors are caught and returned
275
+ * as sentinels instead of crossing the isolate boundary.
319
276
  *
320
277
  * @param data - Current workflow data to use for callback responses
321
278
  * @private
322
279
  */
323
280
  createGetArrayElementRef(data) {
324
- return new (getIvm().Callback)((path, index) => {
281
+ return new (getIvm().Callback)((pathArr, index) => {
325
282
  try {
326
- // Navigate to array
327
- // Special-case: paths starting with ['$item', index] call data.$item(index)
328
- let arr = data;
329
- let startIndex = 0;
330
- const itemFn = data.$item;
331
- if (path.length >= 2 && path[0] === '$item' && typeof itemFn === 'function') {
332
- const itemIndex = parseInt(path[1], 10);
333
- if (!isNaN(itemIndex)) {
334
- arr = itemFn(itemIndex);
335
- startIndex = 2;
336
- }
337
- }
338
- else {
339
- const dollarFn = data.$;
340
- if (path.length >= 2 && path[0] === '$' && typeof dollarFn === 'function') {
341
- arr = dollarFn(path[1]);
342
- startIndex = 2;
343
- }
344
- }
345
- for (let i = startIndex; i < path.length; i++) {
346
- arr = arr?.[path[i]];
347
- if (arr === undefined || arr === null) {
348
- return undefined;
349
- }
350
- }
351
- if (!Array.isArray(arr)) {
352
- return undefined;
353
- }
354
- // Only genuine array indices are reachable; anything else (e.g.
355
- // 'constructor', '__lookupGetter__') would read off the prototype
356
- // chain and could leak a host function reference across the boundary.
357
- if (!Number.isInteger(index) || index < 0) {
358
- return undefined;
359
- }
360
- const element = arr[index];
361
- // Functions are never reachable through the data surface — mirror the
362
- // guard in getValueAtPath so a host callable can't cross the boundary.
363
- if (typeof element === 'function') {
364
- return undefined;
365
- }
366
- // Dates have no enumerable own keys; pass through instead of
367
- // marshaling as an empty object.
368
- if (element instanceof Date) {
369
- return element;
370
- }
371
- // If element is object/array, return metadata
372
- if (element !== null && typeof element === 'object') {
373
- if (Array.isArray(element)) {
374
- return {
375
- __isArray: true,
376
- __length: element.length,
377
- __data: null,
378
- };
379
- }
380
- return {
381
- __isObject: true,
382
- __keys: Object.keys(element),
383
- };
384
- }
385
- // Primitive element
386
- return element;
283
+ return getArrayElement(data, pathArr, index);
387
284
  }
388
285
  catch (err) {
389
286
  return serializeError(err);
@@ -391,28 +288,18 @@ export class IsolatedVmBridge {
391
288
  });
392
289
  }
393
290
  /**
394
- * Create the single typed-RPC dispatcher.
395
- *
396
- * The isolate sends one envelope per typed RPC invocation:
397
- * `callHost({ type: 'getNodeFirst', nodeName, branchIndex?, runIndex? })`
398
- *
399
- * Inputs cross a trust boundary, so the dispatcher parses every envelope
400
- * with the host-side zod schema (`bridgeMessageSchema`) before any
401
- * dispatch happens. Anything that deviates from the declared shape —
402
- * unknown `type`, missing required fields, extra unexpected fields,
403
- * wrong field types — fails the parse and an error sentinel is returned
404
- * to the caller.
291
+ * Create the ivm.Callback for the typed-RPC `callHost` channel.
405
292
  *
406
- * After parsing, `switch (msg.type)` dispatches to a private handler with
407
- * a fully narrowed message type. The operation set is exactly the cases
408
- * in this switch; the `type` field selects a static branch in source,
409
- * not a property lookup on a runtime object.
293
+ * Thin wrapper around the shared dispatchHostCall (see host-functions.ts
294
+ * for envelope validation and per-message rationale); errors — including
295
+ * zod parse failures — are caught and returned as sentinels instead of
296
+ * crossing the isolate boundary.
410
297
  *
411
- * Return-value note: handlers must return plain, structured-clone-able
412
- * data. Results cross into the isolate through an ivm.Callback, which copies
413
- * them via the structured-clone algorithm — return JSON-shaped values, not
414
- * isolated-vm objects (`Reference`/`ExternalCopy`) or other non-cloneable
415
- * values.
298
+ * Return-value note: the dispatcher returns plain, structured-clone-able
299
+ * data. Results cross into the isolate through an ivm.Callback, which
300
+ * copies them via the structured-clone algorithm — JSON-shaped values,
301
+ * not isolated-vm objects (`Reference`/`ExternalCopy`) or other
302
+ * non-cloneable values.
416
303
  *
417
304
  * @param data - Current workflow data
418
305
  * @private
@@ -420,183 +307,13 @@ export class IsolatedVmBridge {
420
307
  createCallHostRef(data) {
421
308
  return new (getIvm().Callback)((rawMsg) => {
422
309
  try {
423
- const msg = bridgeMessageSchema.parse(rawMsg);
424
- switch (msg.type) {
425
- case 'getNodeFirst':
426
- return this.handleGetNodeFirst(msg, data);
427
- case 'getNodeLast':
428
- return this.handleGetNodeLast(msg, data);
429
- case 'getNodeAll':
430
- return this.handleGetNodeAll(msg, data);
431
- case 'getInputFirst':
432
- return this.handleGetInputFirst(data);
433
- case 'getInputLast':
434
- return this.handleGetInputLast(data);
435
- case 'getInputAll':
436
- return this.handleGetInputAll(data);
437
- case 'getItems':
438
- return this.handleGetItems(msg, data);
439
- case 'fromAi':
440
- return this.handleFromAi(msg, data);
441
- case 'getNodePairedItem':
442
- return this.handleGetNodePairedItem(msg, data);
443
- case 'getNodeItemMatching':
444
- return this.handleGetNodeItemMatching(msg, data);
445
- case 'getNodeItem':
446
- return this.handleGetNodeItem(msg, data);
447
- case 'evaluateExpression':
448
- return this.handleEvaluateExpression(msg, data);
449
- case 'getPairedItem':
450
- return this.handleGetPairedItem(msg, data);
451
- default: {
452
- // Unreachable at runtime — zod rejects unknown `type` values
453
- // before the switch. The `never` assignment is the compile-time
454
- // guard: a new schema added to `bridgeMessageSchema` without a
455
- // matching case here becomes a type error.
456
- const exhaustive = msg;
457
- void exhaustive;
458
- throw new Error('Unhandled bridge message');
459
- }
460
- }
310
+ return dispatchHostCall(rawMsg, data);
461
311
  }
462
312
  catch (err) {
463
313
  return serializeError(err);
464
314
  }
465
315
  });
466
316
  }
467
- /**
468
- * Handlers for the `$('Foo').{first,last,all}` typed RPCs.
469
- *
470
- * Each handler reads a fixed literal property name off the host-side node
471
- * proxy — the isolate cannot influence which property is dereferenced.
472
- * Eliminating `data.$` as a host-callable entirely would require reaching
473
- * the `WorkflowDataProxy` internals (e.g. `getNodeExecutionOrPinnedData`)
474
- * rather than the public `$()` API; that's a follow-up.
475
- *
476
- * `data.$` is a host-wired function (`WorkflowDataProxy`'s `$`). If it
477
- * ever isn't, optional chaining short-circuits to `undefined` — the same
478
- * observable result the runtime's `E()` handler produces from any thrown
479
- * error here.
480
- *
481
- * @private
482
- */
483
- handleGetNodeFirst(msg, data) {
484
- return data.$?.(msg.nodeName)?.first?.(msg.branchIndex, msg.runIndex);
485
- }
486
- handleGetNodeLast(msg, data) {
487
- return data.$?.(msg.nodeName)?.last?.(msg.branchIndex, msg.runIndex);
488
- }
489
- handleGetNodeAll(msg, data) {
490
- return data.$?.(msg.nodeName)?.all?.(msg.branchIndex, msg.runIndex);
491
- }
492
- /**
493
- * Handlers for the `$input.{first,last,all}` typed RPCs.
494
- *
495
- * Each reads a fixed literal property name off `data.$input` (the host's
496
- * `WorkflowDataProxy` input proxy). The host enforces zero arguments on
497
- * these methods — the schemas have no fields besides `type`, so the
498
- * isolate cannot pass anything that would trigger the "should have no
499
- * arguments" error path on the host side.
500
- *
501
- * @private
502
- */
503
- handleGetInputFirst(data) {
504
- return data.$input?.first?.();
505
- }
506
- handleGetInputLast(data) {
507
- return data.$input?.last?.();
508
- }
509
- handleGetInputAll(data) {
510
- return data.$input?.all?.();
511
- }
512
- /**
513
- * Handler for `$items(nodeName?, outputIndex?, runIndex?)` — the
514
- * global accessor for a node's execution data. Reads the literal
515
- * `$items` property off `data` (host-wired by `WorkflowDataProxy`)
516
- * and forwards the validated args verbatim. The host applies its own
517
- * defaults when fields are `undefined`.
518
- *
519
- * @private
520
- */
521
- handleGetItems(msg, data) {
522
- return data.$items?.(msg.nodeName, msg.outputIndex, msg.runIndex);
523
- }
524
- /**
525
- * Handler for `$fromAI(name, description?, type?, defaultValue?)` and its
526
- * `$fromAi` / `$fromai` aliases. Reads the literal `$fromAI` property
527
- * off `data` (host-wired) and forwards the args. The host validates
528
- * `name` (required + regex) and applies its own resolution / fallback
529
- * logic, so empty / invalid names surface as the host's structured
530
- * `ExpressionError` rather than a generic zod parse error.
531
- *
532
- * Note: `msg.valueType` maps to the host's third positional parameter
533
- * (`_type` in `WorkflowDataProxy.handleFromAi`). The bridge protocol
534
- * renames it to avoid collision with the `type` discriminator on the
535
- * envelope — the host parameter currently goes unused, but if it ever
536
- * gains a name (`type`), this mapping should stay explicit.
537
- *
538
- * @private
539
- */
540
- handleFromAi(msg, data) {
541
- return data.$fromAI?.(msg.name, msg.description, msg.valueType, msg.defaultValue);
542
- }
543
- /**
544
- * Handlers for the `$('Foo').pairedItem(itemIndex?)` / `.itemMatching(...)` /
545
- * `.item` cluster. Three separate typed RPCs, each reading exactly one
546
- * literal property off the host node proxy.
547
- *
548
- * The split is load-bearing: the host's `pairedItemMethod` closure
549
- * captures which property name the proxy `get` trap saw, and uses
550
- * that to pick the right error message (e.g. "Missing item index for
551
- * .itemMatching()") and to decide between method-call vs getter
552
- * semantics for `.item`. Reading the matching property here lets
553
- * those host-side branches fire exactly as they do in the legacy
554
- * engine; no in-isolate validation needed.
555
- *
556
- * @private
557
- */
558
- handleGetNodePairedItem(msg, data) {
559
- return data.$?.(msg.nodeName)?.pairedItem?.(msg.itemIndex);
560
- }
561
- handleGetNodeItemMatching(msg, data) {
562
- return data.$?.(msg.nodeName)?.itemMatching?.(msg.itemIndex);
563
- }
564
- handleGetNodeItem(msg, data) {
565
- // `.item` is a host getter — accessing it invokes the resolver and
566
- // returns the value immediately. Optional chaining only short-
567
- // circuits on null/undefined; the getter still fires on access.
568
- return data.$?.(msg.nodeName)?.item;
569
- }
570
- /**
571
- * Handler for `$evaluateExpression(expression, itemIndex?)`. Forwards
572
- * the string to the host's nested-evaluation helper, which re-enters
573
- * the expression engine on the inner expression. Under the VM engine
574
- * this round-trips through the bridge again as a new evaluation on the
575
- * enclosing call's time budget, which is the same shape the legacy
576
- * engine supports.
577
- *
578
- * @private
579
- */
580
- handleEvaluateExpression(msg, data) {
581
- return data.$evaluateExpression?.(msg.expression, msg.itemIndex);
582
- }
583
- /**
584
- * Handler for `$getPairedItem(destinationNodeName, incomingSourceData,
585
- * initialPairedItem)`. Forwards directly to the host binding, which
586
- * walks the paired-item ancestry chain back to the named upstream node
587
- * and returns the matching execution item.
588
- *
589
- * The two trailing host parameters — `usedMethodName` and
590
- * `nodeBeforeLast` — are deliberately not part of the wire protocol:
591
- * the host's default for `usedMethodName` is already `$getPairedItem`,
592
- * and `nodeBeforeLast` is an internal recursion argument the host sets
593
- * during traversal.
594
- *
595
- * @private
596
- */
597
- handleGetPairedItem(msg, data) {
598
- return data.$getPairedItem?.(msg.destinationNodeName, msg.incomingSourceData, msg.initialPairedItem);
599
- }
600
317
  /**
601
318
  * Execute JavaScript code in the isolated context.
602
319
  *
@@ -686,10 +403,12 @@ try {
686
403
  }`;
687
404
  const result = this.context.evalClosureSync(wrappedCode, [getValueAtPath, getArrayElement, callHost], { result: { copy: true }, timeout });
688
405
  if (isErrorSentinel(result)) {
689
- throw this.reconstructError(result);
406
+ throw reconstructError(result);
690
407
  }
691
408
  this.logger.debug('[IsolatedVmBridge] Expression executed successfully');
692
- return result;
409
+ // The structured clone above drops the prototype of a class instance, so
410
+ // rebuild the luxon instances from the markers the isolate sent.
411
+ return unwrapLuxonValues(result);
693
412
  }
694
413
  catch (error) {
695
414
  // Re-throw reconstructed errors as-is.
@@ -720,24 +439,6 @@ try {
720
439
  ? `Nested expressions timed out after sharing the ${this.config.timeout}ms limit`
721
440
  : `Expression timed out after ${this.config.timeout}ms`, {});
722
441
  }
723
- /**
724
- * Reconstruct an error from serialized isolate data.
725
- *
726
- * Maps error names back to their host-side classes and restores
727
- * custom properties that would otherwise be lost crossing the boundary.
728
- */
729
- reconstructError(data) {
730
- const error = new Error(data.message);
731
- error.name = data.name || 'Error';
732
- if (data.stack) {
733
- error.stack = data.stack;
734
- }
735
- // Restore custom properties transferred via copy: true
736
- if (data.extra) {
737
- Object.assign(error, data.extra);
738
- }
739
- return error;
740
- }
741
442
  /**
742
443
  * Dispose of the isolate and free resources.
743
444
  *