@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,19 @@
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 { isLuxonSentinel, rebuildLuxonValue } from '../runtime/luxon-transfer';
5
+ import { isEscapedTransferValue, isOpaqueTransferValue, TRANSFER_ESCAPED_KEY, TRANSFER_OPAQUE_KEY, TRANSFER_TYPE_KEY, } from '../runtime/transfer';
6
+ import { LruCache } from '../evaluator/lru-cache';
7
+ import { dispatchHostCall, getArrayElement, getValueAtPath, isErrorSentinel, reconstructError, serializeError, } from './host-functions';
5
8
  let _quickjs = null;
9
+ /**
10
+ * The instantiated WASM module, cached after the first async initialize().
11
+ * Creating runtimes/contexts from it is synchronous, so once it is cached
12
+ * (pool warmup does this) initializeSync() can build a bridge on demand.
13
+ */
14
+ let _quickjsWasm = null;
15
+ /** Runtime bundle source, read once per process by loadRuntimeBundle(). */
16
+ let _runtimeBundle = null;
6
17
  async function getQuickJSModule() {
7
18
  if (!_quickjs) {
8
19
  _quickjs = await import('quickjs-emscripten');
@@ -49,21 +60,43 @@ function isSetSentinel(value) {
49
60
  value !== null &&
50
61
  value.__isSet === true);
51
62
  }
52
- function isErrorSentinel(value) {
53
- return (typeof value === 'object' &&
54
- value !== null &&
55
- value.__isError === true);
56
- }
57
63
  function isEscapedObject(value) {
58
64
  return (typeof value === 'object' &&
59
65
  value !== null &&
60
66
  value.__isEscaped === true);
61
67
  }
68
+ /**
69
+ * Give back the payload of an escape wrapper.
70
+ *
71
+ * A walked payload only had its own keys collide, so the walk goes on and
72
+ * rebuilds the markers deeper in it. An opaque payload is a value the guest
73
+ * could not walk, so `opaque` keeps every marker in it as data.
74
+ */
75
+ function unescapeTransferValue(value) {
76
+ const inner = value.__value;
77
+ const opaque = isOpaqueTransferValue(value);
78
+ if (typeof inner !== 'object' || inner === null)
79
+ return inner;
80
+ if (isEscapedObject(inner))
81
+ return unwrapSentinels(inner, opaque);
82
+ if (Array.isArray(inner))
83
+ return inner.map((entry) => unwrapSentinels(entry, opaque));
84
+ const unescaped = {};
85
+ for (const [key, entry] of Object.entries(inner)) {
86
+ unescaped[key] = unwrapSentinels(entry, opaque);
87
+ }
88
+ return unescaped;
89
+ }
62
90
  /**
63
91
  * Recursively reconstruct Date objects, NaN values, Map, and Set from
64
92
  * sentinels produced by the QuickJS-side __prepareForTransfer wrapper.
93
+ *
94
+ * With `markersAsData` set, a transfer marker is left as the plain object it
95
+ * is, for the contents of a payload the guest marked opaque. The guest escapes
96
+ * most such objects itself, but one that also carries `__isError` leaves the
97
+ * guest walk before the escape, so the host must not read it as a marker.
65
98
  */
66
- function unwrapSentinels(value) {
99
+ function unwrapSentinels(value, markersAsData = false) {
67
100
  if (value === null || value === undefined)
68
101
  return value;
69
102
  if (typeof value !== 'object')
@@ -75,10 +108,16 @@ function unwrapSentinels(value) {
75
108
  const inner = value.__value;
76
109
  const result = {};
77
110
  for (const key of Object.keys(inner)) {
78
- result[key] = unwrapSentinels(inner[key]);
111
+ result[key] = unwrapSentinels(inner[key], markersAsData);
79
112
  }
80
113
  return result;
81
114
  }
115
+ if (!markersAsData) {
116
+ if (isLuxonSentinel(value))
117
+ return rebuildLuxonValue(value);
118
+ if (isEscapedTransferValue(value))
119
+ return unescapeTransferValue(value);
120
+ }
82
121
  if (isDateSentinel(value))
83
122
  return new Date(value.__isoString);
84
123
  if (isNaNSentinel(value))
@@ -95,26 +134,29 @@ function unwrapSentinels(value) {
95
134
  const err = new ErrorCtor(value.__message);
96
135
  if (value.__extra) {
97
136
  for (const [k, v] of Object.entries(value.__extra)) {
98
- err[k] = unwrapSentinels(v);
137
+ err[k] = unwrapSentinels(v, markersAsData);
99
138
  }
100
139
  }
101
140
  return err;
102
141
  }
103
142
  if (isMapSentinel(value)) {
104
- return new Map(value.__entries.map(([k, v]) => [unwrapSentinels(k), unwrapSentinels(v)]));
143
+ return new Map(value.__entries.map(([k, v]) => [
144
+ unwrapSentinels(k, markersAsData),
145
+ unwrapSentinels(v, markersAsData),
146
+ ]));
105
147
  }
106
148
  if (isSetSentinel(value)) {
107
- return new Set(value.__values.map(unwrapSentinels));
149
+ return new Set(value.__values.map((entry) => unwrapSentinels(entry, markersAsData)));
108
150
  }
109
151
  if (Array.isArray(value))
110
- return value.map(unwrapSentinels);
152
+ return value.map((entry) => unwrapSentinels(entry, markersAsData));
111
153
  // Pass error sentinels through untouched — execute() detects them after
112
154
  // unwrapping and reconstructs the Error on the host.
113
155
  if (isErrorSentinel(value))
114
156
  return value;
115
157
  const result = {};
116
158
  for (const key of Object.keys(value)) {
117
- result[key] = unwrapSentinels(value[key]);
159
+ result[key] = unwrapSentinels(value[key], markersAsData);
118
160
  }
119
161
  return result;
120
162
  }
@@ -173,38 +215,21 @@ function wrapSpecialValuesForGuest(value) {
173
215
  return collides ? { __isEscaped: true, __value: result } : result;
174
216
  }
175
217
  /**
176
- * Serialize an error into a transferable metadata object.
177
- *
178
- * Host-side callbacks (getValueAtPath, etc.) catch errors and return this
179
- * sentinel instead of letting the error cross the boundary (which strips
180
- * custom class identity and properties). The in-context proxy detects
181
- * __isError and throws the sentinel; the host reconstructs a real Error
182
- * after it round-trips back (see execute() / reconstructError).
183
- */
184
- function serializeError(err) {
185
- if (err instanceof Error) {
186
- const extra = Object.fromEntries(Object.entries(err).filter(([key]) => key !== 'name' && key !== 'message' && key !== 'stack'));
187
- return {
188
- __isError: true,
189
- name: err.name,
190
- message: err.message,
191
- stack: err.stack,
192
- extra,
193
- };
194
- }
195
- return { __isError: true, name: 'Error', message: String(err), extra: {} };
196
- }
218
+
197
219
  /**
198
220
  * Read the runtime IIFE bundle by walking up from `__dirname` until
199
221
  * `dist/bundle/runtime.iife.js` is found. Walking up (rather than a fixed
200
222
  * relative path) works from either compiled output dir — `dist/cjs/bridge/`
201
223
  * and `dist/esm/bridge/` sit at different depths from the bundle.
202
224
  */
203
- async function readRuntimeBundle() {
225
+ function loadRuntimeBundle() {
226
+ if (_runtimeBundle !== null)
227
+ return _runtimeBundle;
204
228
  let dir = __dirname;
205
229
  while (dir !== path.dirname(dir)) {
206
230
  try {
207
- return await readFile(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
231
+ _runtimeBundle = readFileSync(path.join(dir, BUNDLE_RELATIVE_PATH), 'utf-8');
232
+ return _runtimeBundle;
208
233
  }
209
234
  catch { }
210
235
  dir = path.dirname(dir);
@@ -212,6 +237,7 @@ async function readRuntimeBundle() {
212
237
  throw new Error(`Could not find runtime bundle (${BUNDLE_RELATIVE_PATH}) in any parent of ${__dirname}`);
213
238
  }
214
239
  /**
240
+
215
241
  * Convert a host JavaScript value to a JSON string suitable for round-tripping
216
242
  * into QuickJS via evalCode. Handles undefined (not valid JSON) by returning
217
243
  * the string "undefined".
@@ -235,165 +261,100 @@ function hostValueToJson(value) {
235
261
  return 'undefined';
236
262
  }
237
263
  }
238
- /**
239
- * Navigate data object by path and return metadata or primitive value.
240
- * Mirrors the IsolatedVmBridge getValueAtPath callback so QuickJS
241
- * matches isolated-vm semantics, including special-cased $/$item navigation.
242
- */
243
- function getValueAtPath(data, pathArr) {
244
- let value = data;
245
- let startIndex = 0;
246
- const itemFn = data.$item;
247
- if (pathArr.length >= 2 && pathArr[0] === '$item' && typeof itemFn === 'function') {
248
- const itemIndex = parseInt(pathArr[1], 10);
249
- if (!isNaN(itemIndex)) {
250
- value = itemFn(itemIndex);
251
- startIndex = 2;
252
- }
253
- }
254
- else {
255
- const dollarFn = data.$;
256
- if (pathArr.length >= 2 && pathArr[0] === '$' && typeof dollarFn === 'function') {
257
- value = dollarFn(pathArr[1]);
258
- startIndex = 2;
259
- }
260
- }
261
- for (let i = startIndex; i < pathArr.length; i++) {
262
- value = value?.[pathArr[i]];
263
- if (value === undefined || value === null) {
264
- return value;
265
- }
266
- }
267
- // Functions must never cross the boundary — resolve them as undefined,
268
- // matching IsolatedVmBridge (invariant: __tests__/host-fn-shadowing.test.ts).
269
- if (typeof value === 'function') {
270
- return undefined;
271
- }
272
- if (Array.isArray(value)) {
273
- return {
274
- __isArray: true,
275
- __length: value.length,
276
- __data: null,
277
- };
278
- }
279
- // Dates have no enumerable own keys; pass through instead of
280
- // marshaling as an empty object.
281
- if (value instanceof Date) {
282
- return value;
283
- }
284
- if (value !== null && typeof value === 'object') {
285
- return {
286
- __isObject: true,
287
- __keys: Object.keys(value),
288
- };
289
- }
290
- return value;
291
- }
292
- function getArrayElement(data, pathArr, index) {
293
- let arr = data;
294
- let startIndex = 0;
295
- const itemFn = data.$item;
296
- if (pathArr.length >= 2 && pathArr[0] === '$item' && typeof itemFn === 'function') {
297
- const itemIndex = parseInt(pathArr[1], 10);
298
- if (!isNaN(itemIndex)) {
299
- arr = itemFn(itemIndex);
300
- startIndex = 2;
301
- }
302
- }
303
- else {
304
- const dollarFn = data.$;
305
- if (pathArr.length >= 2 && pathArr[0] === '$' && typeof dollarFn === 'function') {
306
- arr = dollarFn(pathArr[1]);
307
- startIndex = 2;
308
- }
309
- }
310
- for (let i = startIndex; i < pathArr.length; i++) {
311
- arr = arr?.[pathArr[i]];
312
- if (arr === undefined || arr === null) {
313
- return undefined;
314
- }
315
- }
316
- if (!Array.isArray(arr)) {
317
- return undefined;
318
- }
319
- // Reject non-integer / negative indices so a crafted "index" can't read off
320
- // the prototype chain. Mirrors IsolatedVmBridge.getArrayElement.
321
- if (!Number.isInteger(index) || index < 0) {
322
- return undefined;
323
- }
324
- const element = arr[index];
325
- // Functions must never cross the boundary — resolve as undefined, matching
326
- // getValueAtPath and IsolatedVmBridge (invariant: host-fn-shadowing.test.ts).
327
- if (typeof element === 'function') {
328
- return undefined;
329
- }
330
- // Dates have no enumerable own keys; pass through instead of
331
- // marshaling as an empty object.
332
- if (element instanceof Date) {
333
- return element;
334
- }
335
- if (element !== null && typeof element === 'object') {
336
- if (Array.isArray(element)) {
337
- return {
338
- __isArray: true,
339
- __length: element.length,
340
- __data: null,
341
- };
342
- }
343
- return {
344
- __isObject: true,
345
- __keys: Object.keys(element),
346
- };
347
- }
348
- return element;
264
+ /** Formatter args arrive as dumped guest values; DateTimeFormat dates travel as timestamps. */
265
+ function toDateArg(ts) {
266
+ return typeof ts === 'number' ? new Date(ts) : new Date();
349
267
  }
350
268
  /**
351
- * Host-side dispatcher for the typed-RPC `callHost` channel.
352
- *
353
- * Mirrors IsolatedVmBridge's dispatcher — see isolated-vm-bridge.ts for the
354
- * per-message rationale. The two copies are kept in sync at compile time:
355
- * the `never` check in the default case fails to compile when a new schema
356
- * lands in bridge-messages.ts without a matching case here.
269
+ * Snapshot the properties Luxon and user code read from Intl.Locale into a
270
+ * plain object the guest copies onto its wrapper instance. weekInfo moved
271
+ * from a getter to getWeekInfo() across V8 versions — read both shapes.
357
272
  */
358
- function dispatchHostCall(rawMsg, data) {
359
- const msg = bridgeMessageSchema.parse(rawMsg);
360
- switch (msg.type) {
361
- case 'getNodeFirst':
362
- return data.$?.(msg.nodeName)?.first?.(msg.branchIndex, msg.runIndex);
363
- case 'getNodeLast':
364
- return data.$?.(msg.nodeName)?.last?.(msg.branchIndex, msg.runIndex);
365
- case 'getNodeAll':
366
- return data.$?.(msg.nodeName)?.all?.(msg.branchIndex, msg.runIndex);
367
- case 'getInputFirst':
368
- return data.$input?.first?.();
369
- case 'getInputLast':
370
- return data.$input?.last?.();
371
- case 'getInputAll':
372
- return data.$input?.all?.();
373
- case 'getItems':
374
- return data.$items?.(msg.nodeName, msg.outputIndex, msg.runIndex);
375
- case 'fromAi':
376
- return data.$fromAI?.(msg.name, msg.description, msg.valueType, msg.defaultValue);
377
- case 'getNodePairedItem':
378
- return data.$?.(msg.nodeName)?.pairedItem?.(msg.itemIndex);
379
- case 'getNodeItemMatching':
380
- return data.$?.(msg.nodeName)?.itemMatching?.(msg.itemIndex);
381
- case 'getNodeItem':
382
- // `.item` is a host getter — accessing it invokes the resolver.
383
- return data.$?.(msg.nodeName)?.item;
384
- case 'evaluateExpression':
385
- return data.$evaluateExpression?.(msg.expression, msg.itemIndex);
386
- case 'getPairedItem':
387
- return data.$getPairedItem?.(msg.destinationNodeName, msg.incomingSourceData, msg.initialPairedItem);
388
- default: {
389
- // Unreachable at runtime — zod rejects unknown `type` values before
390
- // the switch. The `never` assignment is the compile-time guard.
391
- const exhaustive = msg;
392
- void exhaustive;
393
- throw new Error('Unhandled bridge message');
394
- }
395
- }
273
+ function dumpLocale(loc) {
274
+ const withWeekInfo = loc;
275
+ return {
276
+ tag: loc.toString(),
277
+ baseName: loc.baseName,
278
+ language: loc.language,
279
+ script: loc.script,
280
+ region: loc.region,
281
+ calendar: loc.calendar,
282
+ caseFirst: loc.caseFirst,
283
+ collation: loc.collation,
284
+ hourCycle: loc.hourCycle,
285
+ numberingSystem: loc.numberingSystem,
286
+ numeric: loc.numeric,
287
+ weekInfo: withWeekInfo.getWeekInfo?.() ?? withWeekInfo.weekInfo,
288
+ };
396
289
  }
290
+ const INTL_DISPATCH = {
291
+ DateTimeFormat: {
292
+ create: (locales, options) => new Intl.DateTimeFormat(locales, options),
293
+ supportedLocalesOf: (locales) => Intl.DateTimeFormat.supportedLocalesOf(locales ?? []),
294
+ ops: {
295
+ format: (fmt, args) => fmt.format(toDateArg(args[0])),
296
+ formatToParts: (fmt, args) => fmt.formatToParts(toDateArg(args[0])),
297
+ resolvedOptions: (fmt) => fmt.resolvedOptions(),
298
+ },
299
+ },
300
+ NumberFormat: {
301
+ create: (locales, options) => new Intl.NumberFormat(locales, options),
302
+ supportedLocalesOf: (locales) => Intl.NumberFormat.supportedLocalesOf(locales ?? []),
303
+ ops: {
304
+ format: (fmt, args) => fmt.format(args[0]),
305
+ formatToParts: (fmt, args) => fmt.formatToParts(args[0]),
306
+ resolvedOptions: (fmt) => fmt.resolvedOptions(),
307
+ },
308
+ },
309
+ RelativeTimeFormat: {
310
+ create: (locales, options) => new Intl.RelativeTimeFormat(locales, options),
311
+ supportedLocalesOf: (locales) => Intl.RelativeTimeFormat.supportedLocalesOf(locales ?? []),
312
+ ops: {
313
+ format: (fmt, args) => fmt.format(args[0], args[1]),
314
+ formatToParts: (fmt, args) => fmt.formatToParts(args[0], args[1]),
315
+ resolvedOptions: (fmt) => fmt.resolvedOptions(),
316
+ },
317
+ },
318
+ ListFormat: {
319
+ create: (locales, options) => new Intl.ListFormat(locales, options),
320
+ supportedLocalesOf: (locales) => Intl.ListFormat.supportedLocalesOf(locales ?? []),
321
+ ops: {
322
+ format: (fmt, args) => fmt.format(args[0]),
323
+ formatToParts: (fmt, args) => fmt.formatToParts(args[0]),
324
+ resolvedOptions: (fmt) => fmt.resolvedOptions(),
325
+ },
326
+ },
327
+ Collator: {
328
+ create: (locales, options) => new Intl.Collator(locales, options),
329
+ supportedLocalesOf: (locales) => Intl.Collator.supportedLocalesOf(locales ?? []),
330
+ ops: {
331
+ compare: (col, args) => col.compare(args[0], args[1]),
332
+ resolvedOptions: (col) => col.resolvedOptions(),
333
+ },
334
+ },
335
+ PluralRules: {
336
+ create: (locales, options) => new Intl.PluralRules(locales, options),
337
+ supportedLocalesOf: (locales) => Intl.PluralRules.supportedLocalesOf(locales ?? []),
338
+ ops: {
339
+ select: (pr, args) => pr.select(args[0]),
340
+ resolvedOptions: (pr) => pr.resolvedOptions(),
341
+ },
342
+ },
343
+ DisplayNames: {
344
+ create: (locales, options) => new Intl.DisplayNames(locales, options),
345
+ supportedLocalesOf: (locales) => Intl.DisplayNames.supportedLocalesOf(locales ?? []),
346
+ ops: {
347
+ of: (dn, args) => dn.of(args[0]),
348
+ resolvedOptions: (dn) => dn.resolvedOptions(),
349
+ },
350
+ },
351
+ Locale: {
352
+ create: (locales, options) => new Intl.Locale(locales, options),
353
+ ops: {
354
+ dump: (loc) => dumpLocale(loc),
355
+ },
356
+ },
357
+ };
397
358
  /**
398
359
  * QuickJsBridge - Runtime bridge using quickjs-emscripten for expression evaluation.
399
360
  *
@@ -417,6 +378,11 @@ export class QuickJsBridge {
417
378
  logger;
418
379
  // Long-lived host-callback handles (Intl polyfills) — disposed on dispose()
419
380
  intlHandles = [];
381
+ // Memoized host Intl formatters keyed by ctor + locales + options.
382
+ // Construction dominates per-call cost (~39µs for a DateTimeFormat vs ~1µs
383
+ // for format on an existing one). Capped because locales/options are
384
+ // user-influenced — unbounded, this would be a memory leak by user input.
385
+ intlFormatterCache = new LruCache(200);
420
386
  // Wall-clock deadlines of the execute() calls currently in flight, outer to
421
387
  // inner. execute() can re-enter (e.g. $evaluateExpression), and the runtime
422
388
  // has a single interrupt handler; the handler interrupts once the earliest
@@ -437,6 +403,31 @@ export class QuickJsBridge {
437
403
  return;
438
404
  const { getQuickJS } = await getQuickJSModule();
439
405
  const QuickJS = await getQuickJS();
406
+ _quickjsWasm = QuickJS;
407
+ this.setupContext(QuickJS, loadRuntimeBundle());
408
+ }
409
+ /**
410
+ * Synchronous variant of initialize(), for on-demand creation inside the
411
+ * synchronous evaluate() path (lazy acquisition with an exhausted pool).
412
+ * Requires the WASM module to have been instantiated by an earlier async
413
+ * initialize() in this process — pool warmup provides that.
414
+ */
415
+ initializeSync() {
416
+ if (this.disposed)
417
+ throw new Error('Bridge has been disposed and cannot be reinitialized.');
418
+ if (this.initialized)
419
+ return;
420
+ // Both caches are populated by the same async initialize() (pool
421
+ // warmup), so the sync path never touches the filesystem or the
422
+ // event loop beyond the context setup itself.
423
+ if (_quickjsWasm === null || _runtimeBundle === null) {
424
+ throw new Error('QuickJS WASM module and runtime bundle are not loaded yet: an async initialize() ' +
425
+ 'must run once (pool warmup) before bridges can be created synchronously');
426
+ }
427
+ this.setupContext(_quickjsWasm, _runtimeBundle);
428
+ }
429
+ /** Everything after module/bundle acquisition is synchronous and shared. */
430
+ setupContext(QuickJS, runtimeBundle) {
440
431
  // Create runtime with memory limit (MB → bytes)
441
432
  this.runtime = QuickJS.newRuntime();
442
433
  this.runtime.setMemoryLimit(this.config.memoryLimit * 1024 * 1024);
@@ -452,7 +443,7 @@ export class QuickJsBridge {
452
443
  // Inject Intl polyfill — QuickJS doesn't include Intl, but Luxon needs it
453
444
  this.injectIntlPolyfill();
454
445
  // Load runtime bundle (DateTime, extend, SafeObject, proxy system, buildContext)
455
- await this.loadRuntimeBundle();
446
+ this.loadRuntimeBundle(runtimeBundle);
456
447
  // Wrap __prepareForTransfer to mark Date/NaN/Map/Set/Error so they survive vm.dump()
457
448
  this.injectTransferWrapper();
458
449
  // Inject E() error handler matching IsolatedVmBridge's E() semantics
@@ -464,10 +455,9 @@ export class QuickJsBridge {
464
455
  /**
465
456
  * Load the runtime IIFE bundle and verify required globals are present.
466
457
  */
467
- async loadRuntimeBundle() {
458
+ loadRuntimeBundle(runtimeBundle) {
468
459
  if (!this.vm)
469
460
  throw new Error('Context not initialized');
470
- const runtimeBundle = await readRuntimeBundle();
471
461
  const result = this.vm.evalCode(runtimeBundle);
472
462
  if (result.error) {
473
463
  const errorStr = this.vm.dump(result.error);
@@ -483,105 +473,20 @@ export class QuickJsBridge {
483
473
  /**
484
474
  * Inject a polyfill for the Intl API.
485
475
  *
486
- * QuickJS doesn't include Intl, but Luxon (bundled in the runtime) uses
487
- * Intl.DateTimeFormat, Intl.NumberFormat, Intl.RelativeTimeFormat, and
488
- * Intl.ListFormat extensively. Rather than shimming all of this in pure JS,
489
- * we register host callback functions that delegate to Node.js's real Intl
490
- * implementation, then build lightweight JS wrapper classes that call them.
476
+ * QuickJS doesn't include Intl, but Luxon (bundled in the runtime) and user
477
+ * expressions use it extensively. Rather than shimming ECMA-402 in pure JS,
478
+ * we register a single `__intl` host callback that dispatches to Node.js's
479
+ * real Intl implementation (see INTL_DISPATCH), then build lightweight JS
480
+ * wrapper classes that call it.
491
481
  */
492
482
  injectIntlPolyfill() {
493
483
  if (!this.vm)
494
484
  throw new Error('Context not initialized');
495
485
  const vm = this.vm;
496
- const dtfFn = vm.newFunction('__intl_dtf', (...handles) => {
497
- const args = handles.map((h) => vm.dump(h));
498
- const op = args[0];
499
- const locales = args[1];
500
- const options = args[2];
501
- try {
502
- if (op === 'resolvedOptions') {
503
- const fmt = new Intl.DateTimeFormat(locales, options);
504
- return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
505
- }
506
- if (op === 'format') {
507
- const fmt = new Intl.DateTimeFormat(locales, options);
508
- const ts = args[3];
509
- const date = ts !== undefined ? new Date(ts) : new Date();
510
- return this.hostValueToQuickJSHandle(fmt.format(date));
511
- }
512
- if (op === 'formatToParts') {
513
- const fmt = new Intl.DateTimeFormat(locales, options);
514
- const ts = args[3];
515
- const date = ts !== undefined ? new Date(ts) : new Date();
516
- return this.hostValueToQuickJSHandle(fmt.formatToParts(date));
517
- }
518
- if (op === 'supportedLocalesOf') {
519
- return this.hostValueToQuickJSHandle(Intl.DateTimeFormat.supportedLocalesOf(locales ?? []));
520
- }
521
- }
522
- catch (err) {
523
- return this.hostValueToQuickJSHandle({
524
- __intlError: true,
525
- message: err instanceof Error ? err.message : String(err),
526
- });
527
- }
528
- return vm.undefined;
529
- });
530
- vm.setProp(vm.global, '__intl_dtf', dtfFn);
531
- this.intlHandles.push(dtfFn);
532
- const nfFn = vm.newFunction('__intl_nf', (...handles) => {
533
- const args = handles.map((h) => vm.dump(h));
534
- const op = args[0];
535
- const locales = args[1];
536
- const options = args[2];
537
- try {
538
- if (op === 'resolvedOptions') {
539
- const fmt = new Intl.NumberFormat(locales, options);
540
- return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
541
- }
542
- if (op === 'format') {
543
- const fmt = new Intl.NumberFormat(locales, options);
544
- const num = args[3];
545
- return this.hostValueToQuickJSHandle(fmt.format(num));
546
- }
547
- if (op === 'formatToParts') {
548
- const fmt = new Intl.NumberFormat(locales, options);
549
- const num = args[3];
550
- return this.hostValueToQuickJSHandle(fmt.formatToParts(num));
551
- }
552
- }
553
- catch (err) {
554
- return this.hostValueToQuickJSHandle({
555
- __intlError: true,
556
- message: err instanceof Error ? err.message : String(err),
557
- });
558
- }
559
- return vm.undefined;
560
- });
561
- vm.setProp(vm.global, '__intl_nf', nfFn);
562
- this.intlHandles.push(nfFn);
563
- const rtfFn = vm.newFunction('__intl_rtf', (...handles) => {
486
+ const intlFn = vm.newFunction('__intl', (...handles) => {
564
487
  const args = handles.map((h) => vm.dump(h));
565
- const op = args[0];
566
- const locales = args[1];
567
- const options = args[2];
568
488
  try {
569
- if (op === 'resolvedOptions') {
570
- const fmt = new Intl.RelativeTimeFormat(locales, options);
571
- return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
572
- }
573
- if (op === 'format') {
574
- const fmt = new Intl.RelativeTimeFormat(locales, options);
575
- const value = args[3];
576
- const unit = args[4];
577
- return this.hostValueToQuickJSHandle(fmt.format(value, unit));
578
- }
579
- if (op === 'formatToParts') {
580
- const fmt = new Intl.RelativeTimeFormat(locales, options);
581
- const value = args[3];
582
- const unit = args[4];
583
- return this.hostValueToQuickJSHandle(fmt.formatToParts(value, unit));
584
- }
489
+ return this.hostValueToQuickJSHandle(this.dispatchIntl(args[0], args[1], args[2], args[3], args.slice(4)));
585
490
  }
586
491
  catch (err) {
587
492
  return this.hostValueToQuickJSHandle({
@@ -589,41 +494,9 @@ export class QuickJsBridge {
589
494
  message: err instanceof Error ? err.message : String(err),
590
495
  });
591
496
  }
592
- return vm.undefined;
593
497
  });
594
- vm.setProp(vm.global, '__intl_rtf', rtfFn);
595
- this.intlHandles.push(rtfFn);
596
- const lfFn = vm.newFunction('__intl_lf', (...handles) => {
597
- const args = handles.map((h) => vm.dump(h));
598
- const op = args[0];
599
- const locales = args[1];
600
- const options = args[2];
601
- try {
602
- if (op === 'format') {
603
- const fmt = new Intl.ListFormat(locales, options);
604
- const list = args[3];
605
- return this.hostValueToQuickJSHandle(fmt.format(list));
606
- }
607
- if (op === 'formatToParts') {
608
- const fmt = new Intl.ListFormat(locales, options);
609
- const list = args[3];
610
- return this.hostValueToQuickJSHandle(fmt.formatToParts(list));
611
- }
612
- if (op === 'resolvedOptions') {
613
- const fmt = new Intl.ListFormat(locales, options);
614
- return this.hostValueToQuickJSHandle(fmt.resolvedOptions());
615
- }
616
- }
617
- catch (err) {
618
- return this.hostValueToQuickJSHandle({
619
- __intlError: true,
620
- message: err instanceof Error ? err.message : String(err),
621
- });
622
- }
623
- return vm.undefined;
624
- });
625
- vm.setProp(vm.global, '__intl_lf', lfFn);
626
- this.intlHandles.push(lfFn);
498
+ vm.setProp(vm.global, '__intl', intlFn);
499
+ this.intlHandles.push(intlFn);
627
500
  const shimCode = `
628
501
  (function() {
629
502
  function checkError(result) {
@@ -633,73 +506,107 @@ export class QuickJsBridge {
633
506
  return result;
634
507
  }
635
508
 
636
- function DateTimeFormat(locales, options) {
637
- this._locales = locales;
638
- this._options = options || {};
509
+ // Shallow-copy locales/options at construction — V8 snapshots them when a
510
+ // formatter is created, so mutating them afterwards must not change output.
511
+ // The stable snapshot also keys the host-side formatter memo.
512
+ function snapshotOptions(options) {
513
+ if (options === null || options === undefined) return undefined;
514
+ var copy = {};
515
+ for (var k in options) copy[k] = options[k];
516
+ return copy;
517
+ }
518
+ function snapshotLocales(locales) {
519
+ return Array.isArray(locales) ? locales.slice() : locales;
639
520
  }
640
- DateTimeFormat.prototype.resolvedOptions = function() {
641
- return checkError(__intl_dtf('resolvedOptions', this._locales, this._options));
642
- };
643
- DateTimeFormat.prototype.format = function(date) {
644
- var ts = date instanceof Date ? date.getTime() : (typeof date === 'number' ? date : Date.now());
645
- return checkError(__intl_dtf('format', this._locales, this._options, ts));
646
- };
647
- DateTimeFormat.prototype.formatToParts = function(date) {
648
- var ts = date instanceof Date ? date.getTime() : (typeof date === 'number' ? date : Date.now());
649
- return checkError(__intl_dtf('formatToParts', this._locales, this._options, ts));
650
- };
651
- DateTimeFormat.supportedLocalesOf = function(locales) {
652
- return checkError(__intl_dtf('supportedLocalesOf', locales));
653
- };
654
521
 
655
- function NumberFormat(locales, options) {
656
- this._locales = locales;
657
- this._options = options || {};
522
+ function callIntl(inst, ctorName, op, args, transform) {
523
+ var callArgs = [ctorName, op, inst._locales, inst._options];
524
+ var i = 0;
525
+ if (transform) {
526
+ callArgs.push(transform(args[0]));
527
+ i = 1;
528
+ }
529
+ for (; i < args.length; i++) callArgs.push(args[i]);
530
+ return checkError(__intl.apply(null, callArgs));
658
531
  }
659
- NumberFormat.prototype.resolvedOptions = function() {
660
- return checkError(__intl_nf('resolvedOptions', this._locales, this._options));
661
- };
662
- NumberFormat.prototype.format = function(num) {
663
- return checkError(__intl_nf('format', this._locales, this._options, num));
664
- };
665
- NumberFormat.prototype.formatToParts = function(num) {
666
- return checkError(__intl_nf('formatToParts', this._locales, this._options, num));
667
- };
668
532
 
669
- function RelativeTimeFormat(locales, options) {
670
- this._locales = locales;
671
- this._options = options || {};
533
+ // Build a wrapper class that delegates each op to the host dispatcher.
534
+ // \`ops\` maps op name → optional first-arg transform. \`boundOp\` is also
535
+ // installed as an instance function: V8 models format/compare as
536
+ // bound-function getters, so e.g. dates.map(dtf.format) must work unbound.
537
+ function makeWrapper(ctorName, ops, boundOp) {
538
+ function Wrapper(locales, options) {
539
+ this._locales = snapshotLocales(locales);
540
+ this._options = snapshotOptions(options);
541
+ if (boundOp) {
542
+ var self = this;
543
+ this[boundOp] = function () {
544
+ return callIntl(self, ctorName, boundOp, arguments, ops[boundOp]);
545
+ };
546
+ }
547
+ }
548
+ var opNames = Object.keys(ops);
549
+ for (var i = 0; i < opNames.length; i++) {
550
+ (function (op) {
551
+ Wrapper.prototype[op] = function () {
552
+ return callIntl(this, ctorName, op, arguments, ops[op]);
553
+ };
554
+ })(opNames[i]);
555
+ }
556
+ Wrapper.supportedLocalesOf = function (locales) {
557
+ return checkError(__intl(ctorName, 'supportedLocalesOf', locales));
558
+ };
559
+ return Wrapper;
672
560
  }
673
- RelativeTimeFormat.prototype.resolvedOptions = function() {
674
- return checkError(__intl_rtf('resolvedOptions', this._locales, this._options));
675
- };
676
- RelativeTimeFormat.prototype.format = function(value, unit) {
677
- return checkError(__intl_rtf('format', this._locales, this._options, value, unit));
678
- };
679
- RelativeTimeFormat.prototype.formatToParts = function(value, unit) {
680
- return checkError(__intl_rtf('formatToParts', this._locales, this._options, value, unit));
681
- };
682
561
 
683
- function ListFormat(locales, options) {
684
- this._locales = locales;
685
- this._options = options || {};
562
+ // DateTimeFormat dates travel as timestamps — guest Dates don't survive vm.dump().
563
+ function toTs(date) {
564
+ return date instanceof Date ? date.getTime() : (typeof date === 'number' ? date : Date.now());
686
565
  }
687
- ListFormat.prototype.format = function(list) {
688
- return checkError(__intl_lf('format', this._locales, this._options, list));
689
- };
690
- ListFormat.prototype.formatToParts = function(list) {
691
- return checkError(__intl_lf('formatToParts', this._locales, this._options, list));
692
- };
693
- ListFormat.prototype.resolvedOptions = function() {
694
- return checkError(__intl_lf('resolvedOptions', this._locales, this._options));
695
- };
566
+
567
+ var DateTimeFormat = makeWrapper('DateTimeFormat', { format: toTs, formatToParts: toTs, resolvedOptions: null }, 'format');
568
+ var NumberFormat = makeWrapper('NumberFormat', { format: null, formatToParts: null, resolvedOptions: null }, 'format');
569
+ var RelativeTimeFormat = makeWrapper('RelativeTimeFormat', { format: null, formatToParts: null, resolvedOptions: null }, null);
570
+ var ListFormat = makeWrapper('ListFormat', { format: null, formatToParts: null, resolvedOptions: null }, null);
571
+ var Collator = makeWrapper('Collator', { compare: null, resolvedOptions: null }, 'compare');
572
+ var PluralRules = makeWrapper('PluralRules', { select: null, resolvedOptions: null }, null);
573
+ var DisplayNames = makeWrapper('DisplayNames', { of: null, resolvedOptions: null }, null);
574
+
575
+ // Host-backed Intl.Locale: the host dumps the locale's properties once at
576
+ // construction. Luxon feature-detects week support via ('weekInfo' in
577
+ // Intl.Locale.prototype || 'getWeekInfo' in Intl.Locale.prototype) and
578
+ // prefers getWeekInfo() — expose both, like current V8.
579
+ function Locale(tag, options) {
580
+ var dumped = checkError(__intl('Locale', 'dump', tag, snapshotOptions(options)));
581
+ var keys = Object.keys(dumped);
582
+ for (var i = 0; i < keys.length; i++) {
583
+ var k = keys[i];
584
+ if (k === 'weekInfo') this._weekInfo = dumped[k];
585
+ else if (k === 'tag') this._tag = dumped[k];
586
+ else this[k] = dumped[k];
587
+ }
588
+ }
589
+ Object.defineProperty(Locale.prototype, 'weekInfo', {
590
+ get: function () { return this._weekInfo; },
591
+ });
592
+ Locale.prototype.getWeekInfo = function () { return this._weekInfo; };
593
+ Locale.prototype.toString = function () { return this._tag; };
696
594
 
697
595
  globalThis.Intl = {
698
596
  DateTimeFormat: DateTimeFormat,
699
597
  NumberFormat: NumberFormat,
700
598
  RelativeTimeFormat: RelativeTimeFormat,
701
599
  ListFormat: ListFormat,
702
- Locale: function Locale(tag) { this.baseName = tag; this.language = tag.split('-')[0]; }
600
+ Collator: Collator,
601
+ PluralRules: PluralRules,
602
+ DisplayNames: DisplayNames,
603
+ Locale: Locale,
604
+ getCanonicalLocales: function (locales) {
605
+ return checkError(__intl('Intl', 'getCanonicalLocales', locales));
606
+ },
607
+ supportedValuesOf: function (key) {
608
+ return checkError(__intl('Intl', 'supportedValuesOf', undefined, undefined, key));
609
+ },
703
610
  };
704
611
 
705
612
  // QuickJS's built-in toLocale* methods are locale-unaware (no ECMA-402) and
@@ -755,6 +662,43 @@ export class QuickJsBridge {
755
662
  result.value.dispose();
756
663
  this.logger.debug('[QuickJsBridge] Intl polyfill injected');
757
664
  }
665
+ /**
666
+ * Host side of the guest's `__intl(ctorName, op, locales, options, ...args)`
667
+ * callback. Resolves the constructor and op against INTL_DISPATCH (rejecting
668
+ * anything not in the table) and memoizes constructed formatters.
669
+ */
670
+ dispatchIntl(ctorName, op, locales, options, args) {
671
+ // Pure static Intl functions — no constructed instance involved.
672
+ if (ctorName === 'Intl') {
673
+ if (op === 'getCanonicalLocales')
674
+ return Intl.getCanonicalLocales(locales);
675
+ if (op === 'supportedValuesOf') {
676
+ return Intl.supportedValuesOf(args[0]);
677
+ }
678
+ throw new TypeError(`Unsupported Intl operation: ${op}`);
679
+ }
680
+ const entry = INTL_DISPATCH[ctorName];
681
+ if (!entry)
682
+ throw new TypeError(`Unsupported Intl constructor: ${ctorName}`);
683
+ if (op === 'supportedLocalesOf') {
684
+ if (!entry.supportedLocalesOf) {
685
+ throw new TypeError(`Intl.${ctorName} has no supportedLocalesOf`);
686
+ }
687
+ return entry.supportedLocalesOf(locales);
688
+ }
689
+ const opFn = entry.ops[op];
690
+ if (!opFn)
691
+ throw new TypeError(`Unsupported Intl.${ctorName} operation: ${op}`);
692
+ // Guest wrappers snapshot options at construction, so the JSON key is
693
+ // stable across calls on the same wrapper instance.
694
+ const key = `${ctorName}${JSON.stringify(locales) ?? ''}${JSON.stringify(options) ?? ''}`;
695
+ let instance = this.intlFormatterCache.get(key);
696
+ if (instance === undefined) {
697
+ instance = entry.create(locales, options);
698
+ this.intlFormatterCache.set(key, instance);
699
+ }
700
+ return opFn(instance, args);
701
+ }
758
702
  /**
759
703
  * Wrap __prepareForTransfer so values that don't survive vm.dump()
760
704
  * (Date, NaN, Map, Set, Error) are converted to sentinel objects.
@@ -773,7 +717,10 @@ export class QuickJsBridge {
773
717
  var prepared = original(value);
774
718
  return wrapSpecialValues(prepared);
775
719
  };
776
- function wrapSpecialValues(v) {
720
+ // __prepareForTransfer does not walk into a Map, a Set or the extra keys of an
721
+ // Error, and it does not walk an opaque payload. The inCollection flag marks
722
+ // those places, where a transfer marker can only come from user data.
723
+ function wrapSpecialValues(v, inCollection) {
777
724
  if (v === null || v === undefined) return v;
778
725
  // Functions and Promises must not leave the sandbox as results.
779
726
  // isolated-vm's structured clone rejects them; match its error.
@@ -796,7 +743,7 @@ export class QuickJsBridge {
796
743
  var errKeys = Object.keys(v);
797
744
  for (var ei = 0; ei < errKeys.length; ei++) {
798
745
  if (errKeys[ei] !== 'name' && errKeys[ei] !== 'message' && errKeys[ei] !== 'stack') {
799
- errExtra[errKeys[ei]] = wrapSpecialValues(v[errKeys[ei]]);
746
+ errExtra[errKeys[ei]] = wrapSpecialValues(v[errKeys[ei]], true);
800
747
  }
801
748
  }
802
749
  return { __isErrorValue: true, __name: v.name || 'Error', __message: v.message || '', __extra: errExtra };
@@ -804,20 +751,48 @@ export class QuickJsBridge {
804
751
  if (v instanceof Map) {
805
752
  var entries = [];
806
753
  v.forEach(function(val, key) {
807
- entries.push([wrapSpecialValues(key), wrapSpecialValues(val)]);
754
+ entries.push([wrapSpecialValues(key, true), wrapSpecialValues(val, true)]);
808
755
  });
809
756
  return { __isMap: true, __entries: entries };
810
757
  }
811
758
  if (v instanceof Set) {
812
759
  var values = [];
813
760
  v.forEach(function(val) {
814
- values.push(wrapSpecialValues(val));
761
+ values.push(wrapSpecialValues(val, true));
815
762
  });
816
763
  return { __isSet: true, __values: values };
817
764
  }
818
- if (Array.isArray(v)) return v.map(wrapSpecialValues);
765
+ if (Array.isArray(v)) return v.map(function(item) { return wrapSpecialValues(item, inCollection); });
819
766
  // Error sentinels are already in transfer shape — leave them intact.
820
767
  if (v.__isError) return v;
768
+ // Outside a collection, these markers come from __prepareForTransfer above,
769
+ // so pass them to the host as they are.
770
+ if (!inCollection) {
771
+ if (typeof v['${TRANSFER_TYPE_KEY}'] === 'string') return v;
772
+ if (v['${TRANSFER_ESCAPED_KEY}'] === true) {
773
+ var payload = v.__value;
774
+ if (v['${TRANSFER_OPAQUE_KEY}'] === true) {
775
+ // The host gives an opaque payload back as data, so wrap its contents
776
+ // as a collection and keep any marker in them as data too.
777
+ var opaqueWrapper = { __value: wrapOwnKeys(payload, true) };
778
+ opaqueWrapper['${TRANSFER_ESCAPED_KEY}'] = true;
779
+ opaqueWrapper['${TRANSFER_OPAQUE_KEY}'] = true;
780
+ return opaqueWrapper;
781
+ }
782
+ if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)) {
783
+ var walkedWrapper = { __value: wrapOwnKeys(payload, inCollection) };
784
+ walkedWrapper['${TRANSFER_ESCAPED_KEY}'] = true;
785
+ return walkedWrapper;
786
+ }
787
+ var plainWrapper = { __value: wrapSpecialValues(payload, inCollection) };
788
+ plainWrapper['${TRANSFER_ESCAPED_KEY}'] = true;
789
+ return plainWrapper;
790
+ }
791
+ }
792
+ return wrapOwnKeys(v, inCollection);
793
+ }
794
+
795
+ function wrapOwnKeys(v, inCollection) {
821
796
  var result = {};
822
797
  var keys = Object.keys(v);
823
798
  var collides = false;
@@ -829,7 +804,18 @@ export class QuickJsBridge {
829
804
  ) {
830
805
  collides = true;
831
806
  }
832
- result[key] = wrapSpecialValues(v[key]);
807
+ // Inside a collection a transfer marker is user data, so escape the object
808
+ // and the host reads the keys as the plain data they are.
809
+ if (
810
+ inCollection && (
811
+ key === '${TRANSFER_TYPE_KEY}' ||
812
+ key === '${TRANSFER_ESCAPED_KEY}' ||
813
+ key === '${TRANSFER_OPAQUE_KEY}'
814
+ )
815
+ ) {
816
+ collides = true;
817
+ }
818
+ result[key] = wrapSpecialValues(v[key], inCollection);
833
819
  }
834
820
  // User objects whose keys collide with transfer markers are escaped so
835
821
  // the host returns them as plain data (as isolated-vm does) instead of
@@ -1117,7 +1103,7 @@ export class QuickJsBridge {
1117
1103
  execResult.value.dispose();
1118
1104
  const result = unwrapSentinels(rawResult);
1119
1105
  if (isErrorSentinel(result)) {
1120
- throw this.reconstructError(result);
1106
+ throw reconstructError(result);
1121
1107
  }
1122
1108
  this.logger.debug('[QuickJsBridge] Expression executed successfully');
1123
1109
  return result;
@@ -1156,17 +1142,6 @@ export class QuickJsBridge {
1156
1142
  ? `Nested expressions timed out after sharing the ${this.config.timeout}ms limit`
1157
1143
  : `Expression timed out after ${this.config.timeout}ms`, {});
1158
1144
  }
1159
- reconstructError(data) {
1160
- const error = new Error(data.message);
1161
- error.name = data.name || 'Error';
1162
- if (data.stack) {
1163
- error.stack = data.stack;
1164
- }
1165
- if (data.extra) {
1166
- Object.assign(error, data.extra);
1167
- }
1168
- return error;
1169
- }
1170
1145
  evalCodeOrThrow(code, label) {
1171
1146
  if (!this.vm)
1172
1147
  throw new Error('Context not initialized');