diff --git a/.changeset/quickjs-host-serde.md b/.changeset/quickjs-host-serde.md new file mode 100644 index 0000000000..c430baca05 --- /dev/null +++ b/.changeset/quickjs-host-serde.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +QuickJS engine: serialization now runs entirely on the host through `JSValueHandle`s (quickjs-wasi 3.3 introspection primitives + devalue 5.9 pluggable operations), replacing the serde bundle previously evaluated inside the VM. Wire format is unchanged; classification and extraction are side-effect free (engine brand checks, boot-captured intrinsics, descriptor reads), matching the node:vm engine's architecture. diff --git a/packages/core/.gitignore b/packages/core/.gitignore index 51bcd14a90..ec78a963f2 100644 --- a/packages/core/.gitignore +++ b/packages/core/.gitignore @@ -4,6 +4,3 @@ src/version.ts # Auto-generated quickjs-wasi binary assets (base64-encoded WASM + .so files) src/runtime/quickjs-assets.generated.ts -# Auto-generated VM serde bundle (devalue + format-prefix + reducers, -# packaged as an ES-module string for evaluation inside the QuickJS VM) -src/runtime/vm-serde-bundle.generated.ts diff --git a/packages/core/package.json b/packages/core/package.json index 4d52b2b741..8ebad82d92 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "./_workflow": "./dist/workflow/index.js" }, "scripts": { - "build": "genversion --es6 src/version.ts && node scripts/build-vm-serde-bundle.js && node scripts/build-quickjs-assets.js && tsc", + "build": "genversion --es6 src/version.ts && node scripts/build-quickjs-assets.js && tsc", "dev": "genversion --es6 src/version.ts && tsc --watch", "clean": "tsc --build --clean && rm -rf dist src/version.ts docs ||:", "test": "cross-env WORKFLOW_TARGET_WORLD=local vitest run src", @@ -105,7 +105,7 @@ "devalue": "5.9.0", "ms": "2.1.3", "nanoid": "5.1.6", - "quickjs-wasi": "3.1.0", + "quickjs-wasi": "3.3.0", "seedrandom": "3.0.5", "semver": "catalog:", "ulid": "catalog:", diff --git a/packages/core/scripts/build-vm-serde-bundle.js b/packages/core/scripts/build-vm-serde-bundle.js deleted file mode 100644 index 2574b542fb..0000000000 --- a/packages/core/scripts/build-vm-serde-bundle.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Build script: generates the VM serialization bundle. - * - * Uses esbuild to bundle workflow-vm.ts into a self-contained IIFE. - * The output is written as a TypeScript file containing the bundle as - * a string constant, which can be imported by the snapshot runtime. - * - * TextEncoder, TextDecoder, and Headers are provided by native C - * extensions in quickjs-wasi, so no JS polyfills are needed. - */ - -import { buildSync } from 'esbuild'; -import { writeFileSync } from 'fs'; -import { dirname, resolve } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const srcDir = resolve(__dirname, '../src'); - -const result = buildSync({ - entryPoints: [resolve(srcDir, 'serialization/vm-bundle-entry.ts')], - // NOTE: TextEncoder, TextDecoder, and Headers are provided by native - // C extensions (encoding, headers) in quickjs-wasi, so the polyfill - // injection that was previously here has been removed. - bundle: true, - format: 'iife', - platform: 'neutral', - target: 'es2020', - write: false, - minify: true, -}); - -const bundleCode = result.outputFiles[0].text; - -// Write as a TS module using a template literal. Template literals avoid -// the escaping issues that occur with regular string literals — esbuild's -// minifier produces patterns like `typeof x<"u"` whose escaped quotes -// inside a JSON-stringified string break when downstream esbuild (e.g., -// Nitro) re-processes the compiled JS output. Template literals don't -// have this problem since backticks don't conflict with inner quotes. -const escaped = bundleCode - .replace(/\\/g, '\\\\') - .replace(/`/g, '\\`') - .replace(/\$\{/g, '\\${'); - -const outPath = resolve(srcDir, 'runtime/vm-serde-bundle.generated.ts'); -writeFileSync( - outPath, - `/** - * Auto-generated by scripts/build-vm-serde-bundle.js - * Do not edit manually. - * - * This is the VM serialization bundle — a self-contained IIFE that sets up - * the serialize/deserialize functions inside the QuickJS WASM VM. It - * includes devalue and all workflow-mode reducers/revivers. (TextEncoder, - * TextDecoder, and Headers are provided by quickjs-wasi's native C - * extensions — no JS polyfills are bundled.) - * - * Size: ${(bundleCode.length / 1024).toFixed(1)} KB minified - */ -export const VM_SERDE_BUNDLE: string = \`${escaped}\`; -` -); - -console.log( - `Generated vm-serde-bundle.generated.ts (${(bundleCode.length / 1024).toFixed(1)} KB)` -); diff --git a/packages/core/src/runtime/quickjs-runtime.ts b/packages/core/src/runtime/quickjs-runtime.ts index 7bf8bb69e6..6cec056996 100644 --- a/packages/core/src/runtime/quickjs-runtime.ts +++ b/packages/core/src/runtime/quickjs-runtime.ts @@ -15,7 +15,9 @@ * * The VM bootstrap is deliberately split into two phases: * 1. Static initialization (`initWorkflowVM`) — run-independent setup: - * VM creation, the serde bundle, and the workflow primitives. + * VM creation and the workflow primitives. (Serialization lives on + * the host — see quickjs-serde.ts — so no serde code is evaluated + * in the VM.) * 2. Per-run initialization (inline in `runQuickJSWorkflow`) — seeded * PRNG/ULID host functions, workflow bundle evaluation, run metadata, * workflow input, and start. @@ -36,14 +38,15 @@ import { type WasiOptions, } from 'quickjs-wasi'; import seedrandom from 'seedrandom'; +import { monotonicFactory } from 'ulid'; import { runtimeLogger } from '../logger.js'; import { decompress } from '../serialization/compression.js'; import type { DecryptionKey } from '../serialization/encryption.js'; import { decrypt } from '../serialization/encryption.js'; import { getReplayTimeoutMs } from './constants.js'; import { quickjsExtensions, quickjsWasm } from './quickjs-assets.generated.js'; +import { createQuickJSSerde, type QuickJSSerde } from './quickjs-serde.js'; import { runIdCreatedAt } from './run-id-time.js'; -import { VM_SERDE_BUNDLE } from './vm-serde-bundle.generated.js'; // ---- Host -> VM payload preparation ---- @@ -257,17 +260,17 @@ globalThis.__terminalBuffer = {}; // Registers a resolver for an awaited primitive, first draining any // buffered terminal recorded for the correlationId. Entries are prepared -// host-side (bytes already decrypted; see processEvents). +// host-side: bytes are decrypted AND deserialized into VM values by the +// host serde before buffering (the VM has no in-guest deserializer on +// the host-serde engine), so draining only forwards the stored value. globalThis.__registerResolver = function(correlationId, resolve, reject) { var buffered = globalThis.__terminalBuffer[correlationId]; if (buffered) { delete globalThis.__terminalBuffer[correlationId]; - if (buffered.kind === "resolve_bytes") { - resolve(globalThis[Symbol.for("workflow-deserialize")](buffered.bytes)); - } else if (buffered.kind === "resolve_value") { + if (buffered.kind === "resolve_value") { resolve(buffered.value); - } else if (buffered.kind === "reject_bytes") { - reject(globalThis[Symbol.for("workflow-deserialize")](buffered.bytes)); + } else if (buffered.kind === "reject_value") { + reject(buffered.value); } else if (buffered.kind === "reject_error") { var e = new Error(buffered.message); e.name = "FatalError"; @@ -423,13 +426,14 @@ globalThis[Symbol.for("WORKFLOW_USE_STEP")] = function(stepId, closureVarsFn) { var correlationId = "step_" + globalThis.__generateUlid(); // Capture 'this' for method invocations (e.g., MyClass.method()) var thisVal = (this !== undefined && this !== null && this !== globalThis) ? this : undefined; - // Serialize step input using the host-provided devalue serializer. - // This produces a format-prefixed Uint8Array ("devl" + devalue.stringify). - var input = globalThis[Symbol.for("workflow-serialize")]({ + // The RAW input value. Serialization happens on the host, which reads + // this through a handle when it collects the pending op — no + // serializer code runs inside the VM. + var input = { args: args, closureVars: closureVarsFn ? closureVarsFn() : undefined, thisVal: thisVal, - }); + }; globalThis.__pending.push({ type: "step", correlationId: correlationId, @@ -614,15 +618,14 @@ globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")] = function(options) { var isDisposed = false; var hasCreatedEvent = false; - // Register in pending operations. - // Serialize metadata inside the VM so Response/Request objects are - // properly handled by the devalue reducers before crossing the boundary. + // Register in pending operations. Metadata stays a RAW value; the host + // serializes it through a handle when it collects the pending op. var pendingOp = { type: "hook", correlationId: correlationId, token: token, isWebhook: !!options.isWebhook, - metadata: options.metadata ? globalThis[Symbol.for("workflow-serialize")](options.metadata) : undefined, + metadata: options.metadata, hasCreatedEvent: false, }; globalThis.__pending.push(pendingOp); @@ -823,8 +826,8 @@ WorkflowAbortSignal.prototype.throwIfAborted = function() { : __makeAbortError(); } }; -// Expose for the serde bundle's revivers (evaluated before this bootstrap; -// they look the class up lazily at revive time). +// Expose for the host serde's revivers (they look the class up lazily, +// through a handle, at revive time). globalThis.__WorkflowAbortSignal = WorkflowAbortSignal; // Registry of live abort signals keyed by their hook correlationId. The @@ -855,17 +858,17 @@ globalThis.AbortController.prototype.abort = function(reason) { if (this.signal.aborted) return; // already aborted (e.g. from replay) this.signal._setAborted(reason); // Mark the pending hook op so the host records the abort. The payload - // is serialized in the VM so the reason crosses the boundary with + // stays a RAW value; the host serializes it through a handle with full // type fidelity (Errors, DOMException, custom values). var token = this[__ABORT_HOOK_TOKEN]; for (var i = 0; i < globalThis.__pending.length; i++) { var item = globalThis.__pending[i]; if (item.type === "hook" && item.token === token) { item.abortRequested = true; - item.abortPayload = globalThis[Symbol.for("workflow-serialize")]({ + item.abortPayload = { aborted: true, reason: reason, - }); + }; break; } } @@ -938,9 +941,10 @@ globalThis[Symbol.for("WORKFLOW_GET_STREAM_ID")] = function(namespace) { * Phase 1 — static (run-independent) VM initialization. * * Creates a QuickJS VM and loads everything that does not depend on a - * specific workflow run: the serde bundle (devalue-based serialization - * used at the host/VM boundary) and the workflow-primitive bootstrap - * (useStep / sleep / createHook / Response-Request polyfills). + * specific workflow run: the workflow-primitive bootstrap (useStep / + * sleep / createHook / Response-Request polyfills). Serialization is + * host-side (quickjs-serde.ts) and captures its intrinsics from the VM + * right after this returns. * * `getNowMs` backs the VM's WASI clock (`Date.now()` / `new Date()` * inside the VM). The callback itself is static — the per-run state it @@ -1033,9 +1037,6 @@ async function initWorkflowVM( wasi, }); - // Evaluate the VM serde bundle - vm.evalCode(VM_SERDE_BUNDLE, 'vm-serde.js').dispose(); - // Bootstrap workflow primitives vm.evalCode(VM_BOOTSTRAP, 'bootstrap.js').dispose(); @@ -1129,6 +1130,11 @@ export async function startQuickJSWorkflow( const interruptBudget: InterruptBudget = { start: Date.now() }; const vm = await initWorkflowVM(() => vmNowMs, interruptBudget); + // Host-side serde: captures the VM's intrinsics (bootstrap included) + // before any user code runs. All serialization now happens on the host + // through handles — no serializer code is evaluated inside the VM. + const serde = createQuickJSSerde(vm); + // Any throw between here and the terminal paths (which dispose the VM // inside checkWorkflowState / extractError before RETURNING) would leak // a live QuickJS instance and its WASM linear memory for the lifetime @@ -1162,18 +1168,27 @@ export async function startQuickJSWorkflow( vm.setProp(vm.global, '__generateNanoid', nanoidFn); } - // Inject a deterministic timestamp for the VM's ULID factory. ULIDs - // produced inside the VM use this as their time prefix instead of - // Date.now(), so two concurrent workflow invocations of the same run - // produce IDENTICAL correlationIds (the random portion also matches - // because the PRNG is seeded the same way) and the world's + // Host-side deterministic ULID generator for correlationIds. Uses the + // same `ulid` package and monotonic factory as before, drawing from + // the SAME seeded PRNG instance as the VM's Math.random — so the + // interleaved draw sequence (and therefore every correlationId) is + // byte-identical to what the previous in-VM ULID factory produced for + // the same run. The time prefix is derived from the runId's embedded + // ULID (stable across invocations by construction — unlike + // `startedAt`, which differs between turbo's synthesized run object + // and the durably stored run), so two concurrent invocations of the + // same run produce IDENTICAL correlationIds and the world's // EntityConflictError on `events.create` dedups one of each pair. - // Derived from the runId's embedded ULID (stable across invocations by - // construction — unlike `startedAt`, which differs between turbo's - // synthesized run object and the durably stored run). - vm.evalCode( - `globalThis.__ulidTimestamp = ${runIdCreatedAt(workflowRun.runId) ?? (+workflowRun.createdAt || startedAt)};` - ).dispose(); + { + const ulidFactory = monotonicFactory(() => rng()); + const ulidTimestamp = + runIdCreatedAt(workflowRun.runId) ?? + (+workflowRun.createdAt || startedAt); + using ulidFn = vm.newFunction('__generateUlid', () => + vm.newString(ulidFactory(ulidTimestamp)) + ); + vm.setProp(vm.global, '__generateUlid', ulidFn); + } // `process.env` — parity with the node:vm engine, which exposes a frozen // copy of the host env (vm/index.ts). Injected per run so the snapshot of @@ -1226,7 +1241,9 @@ export async function startQuickJSWorkflow( byteLength: decryptedInput.byteLength, source: runCreatedInput ? 'run_created' : 'queueMessage.runInput', }); - const inputHandle = vm.newUint8Array(decryptedInput); + // Build the argument value directly in the VM via the host-side + // serde (guest code never sees the wire bytes). + const inputHandle = serde.deserialize(decryptedInput); vm.setProp(vm.global, '__wdk_input', inputHandle); inputHandle.dispose(); } else if (runInput === undefined && events.length > 0) { @@ -1285,24 +1302,30 @@ export async function startQuickJSWorkflow( __wfnErr.name = "WorkflowNotRegisteredError"; throw __wfnErr; } - var __args = globalThis.__wdk_input - ? globalThis[Symbol.for("workflow-deserialize")](globalThis.__wdk_input) + var __args = globalThis.__wdk_input !== undefined + ? globalThis.__wdk_input : []; delete globalThis.__wdk_input; if (!Array.isArray(__args)) __args = [__args]; __wfn.apply(null, __args).then( - function(result) { globalThis.__workflowResult = globalThis[Symbol.for("workflow-serialize")](result); }, + function(result) { + // Store the RAW result; the host serializes it through a handle. + // A separate done flag distinguishes "completed with undefined" + // from "not completed". + globalThis.__workflowDone = true; + globalThis.__workflowResult = result; + }, function(error) { // Preserve display info on the host-side failed object - // (matches the legacy host-visible shape) AND serialize the - // entire thrown value so the host can dehydrate the original + // (matches the legacy host-visible shape) AND keep the RAW + // thrown value so the host can serialize the original // type-identity, cause chain, or non-Error throws verbatim // through the standard error pipeline. globalThis.__workflowError = { message: error && error.message != null ? String(error.message) : String(error), stack: error && error.stack ? error.stack : "", name: error && error.name ? error.name : (error instanceof Error ? "Error" : typeof error), - valueBytes: globalThis[Symbol.for("workflow-serialize")](error), + value: error, }; } ); @@ -1323,6 +1346,7 @@ export async function startQuickJSWorkflow( do { madeProgress = await processEvents( vm, + serde, events, advanceClock, options.encryptionKey @@ -1351,6 +1375,7 @@ export async function startQuickJSWorkflow( // ---- Check result ---- return makeLiveSession( vm, + serde, interruptBudget, advanceClock, options.encryptionKey @@ -1380,11 +1405,12 @@ function makeSettledSession( */ function makeLiveSession( vm: QuickJS, + serde: QuickJSSerde, interruptBudget: InterruptBudget, advanceClock: (ms: number) => void, encryptionKey?: DecryptionKey ): QuickJSWorkflowSession { - const result = checkWorkflowState(vm, { keepAliveOnSuspend: true }); + const result = checkWorkflowState(vm, serde, { keepAliveOnSuspend: true }); let alive = !!result.suspended; const session: QuickJSWorkflowSession = { @@ -1406,6 +1432,7 @@ function makeLiveSession( do { madeProgress = await processEvents( vm, + serde, newEvents, advanceClock, encryptionKey @@ -1417,7 +1444,9 @@ function makeLiveSession( } while (batch > 0); } while (madeProgress && --maxIterations > 0); - const next = checkWorkflowState(vm, { keepAliveOnSuspend: true }); + const next = checkWorkflowState(vm, serde, { + keepAliveOnSuspend: true, + }); if (!next.suspended) alive = false; session.result = next; return next; @@ -1440,6 +1469,7 @@ function makeLiveSession( async function processEvents( vm: QuickJS, + serde: QuickJSSerde, events: Event[], advanceClock: (ms: number) => void, encryptionKey?: DecryptionKey @@ -1490,11 +1520,11 @@ async function processEvents( prefix: new TextDecoder().decode(decryptedOutput.subarray(0, 4)), byteLength: decryptedOutput.byteLength, }); - const bytesHandle = vm.newUint8Array(decryptedOutput); - vm.setProp(vm.global, '__tmp_result', bytesHandle); - bytesHandle.dispose(); + const valueHandle = serde.deserialize(decryptedOutput); + vm.setProp(vm.global, '__tmp_result', valueHandle); + valueHandle.dispose(); vm.evalCode( - `globalThis.__resolvers[${cidJs}].resolve(globalThis[Symbol.for("workflow-deserialize")](globalThis.__tmp_result));` + + `globalThis.__resolvers[${cidJs}].resolve(globalThis.__tmp_result);` + `delete globalThis.__resolvers[${cidJs}];` + `delete globalThis.__tmp_result;` ).dispose(); @@ -1532,11 +1562,13 @@ async function processEvents( rawOutput, encryptionKey ); - const bytesHandle = vm.newUint8Array(decryptedOutput); - vm.setProp(vm.global, '__tmp_buf', bytesHandle); - bytesHandle.dispose(); + // Host serde: deserialize into a VM value NOW (same path as + // the resolver branch above) and buffer the value itself. + const valueHandle = serde.deserialize(decryptedOutput); + vm.setProp(vm.global, '__tmp_buf', valueHandle); + valueHandle.dispose(); vm.evalCode( - `globalThis.__terminalBuffer[${cidJs}] = { kind: "resolve_bytes", bytes: globalThis.__tmp_buf };` + + `globalThis.__terminalBuffer[${cidJs}] = { kind: "resolve_value", value: globalThis.__tmp_buf };` + `delete globalThis.__tmp_buf;` ).dispose(); } else { @@ -1564,13 +1596,12 @@ async function processEvents( // (TypeError, FatalError with original cause chain, etc.) with // the original message and stack preserved. const decrypted = await prepareBytesForVM(errorData, encryptionKey); - const bytesHandle = vm.newUint8Array(decrypted); - vm.setProp(vm.global, '__tmp_error', bytesHandle); - bytesHandle.dispose(); + const errorHandle = serde.deserialize(decrypted); + vm.setProp(vm.global, '__tmp_error', errorHandle); + errorHandle.dispose(); vm.evalCode( `(function(){` + - `var e=globalThis[Symbol.for("workflow-deserialize")](globalThis.__tmp_error);` + - `globalThis.__resolvers[${cidJs}].reject(e);` + + `globalThis.__resolvers[${cidJs}].reject(globalThis.__tmp_error);` + `delete globalThis.__resolvers[${cidJs}];` + `delete globalThis.__tmp_error;` + `})()` @@ -1614,11 +1645,13 @@ async function processEvents( const errorData = eventData?.error; if (errorData instanceof Uint8Array) { const decrypted = await prepareBytesForVM(errorData, encryptionKey); - const bytesHandle = vm.newUint8Array(decrypted); - vm.setProp(vm.global, '__tmp_buf', bytesHandle); - bytesHandle.dispose(); + // Host serde: deserialize into the VM error value NOW (same + // path as the resolver branch above) and buffer it. + const errorHandle = serde.deserialize(decrypted); + vm.setProp(vm.global, '__tmp_buf', errorHandle); + errorHandle.dispose(); vm.evalCode( - `globalThis.__terminalBuffer[${cidJs}] = { kind: "reject_bytes", bytes: globalThis.__tmp_buf };` + + `globalThis.__terminalBuffer[${cidJs}] = { kind: "reject_value", value: globalThis.__tmp_buf };` + `delete globalThis.__tmp_buf;` ).dispose(); } else { @@ -1780,12 +1813,12 @@ async function processEvents( rawAbortPayload, encryptionKey ); - const bytesHandle = vm.newUint8Array(decrypted); - vm.setProp(vm.global, '__tmp_abort', bytesHandle); - bytesHandle.dispose(); + const payloadHandle = serde.deserialize(decrypted); + vm.setProp(vm.global, '__tmp_abort', payloadHandle); + payloadHandle.dispose(); vm.evalCode( `(function(){` + - `var p=globalThis[Symbol.for("workflow-deserialize")](globalThis.__tmp_abort);` + + `var p=globalThis.__tmp_abort;` + `delete globalThis.__tmp_abort;` + `globalThis.__abortSignals[${cidJs}]._setAborted(p&&typeof p==="object"?p.reason:undefined);` + `})()` @@ -1844,11 +1877,11 @@ async function processEvents( rawPayload, encryptionKey ); - const bytesHandle = vm.newUint8Array(decryptedPayload); - vm.setProp(vm.global, '__tmp_result', bytesHandle); - bytesHandle.dispose(); + const payloadHandle = serde.deserialize(decryptedPayload); + vm.setProp(vm.global, '__tmp_result', payloadHandle); + payloadHandle.dispose(); vm.evalCode( - `globalThis.__resolvers[${cidJs}].resolve(globalThis[Symbol.for("workflow-deserialize")](globalThis.__tmp_result));` + + `globalThis.__resolvers[${cidJs}].resolve(globalThis.__tmp_result);` + `delete globalThis.__resolvers[${cidJs}];` + `delete globalThis.__tmp_result;` ).dispose(); @@ -1895,17 +1928,16 @@ async function processEvents( rawPayload, encryptionKey ); - const bytesHandle = vm.newUint8Array(decryptedPayload); - vm.setProp(vm.global, '__tmp_result', bytesHandle); - bytesHandle.dispose(); + const payloadHandle = serde.deserialize(decryptedPayload); + vm.setProp(vm.global, '__tmp_result', payloadHandle); + payloadHandle.dispose(); // NOTE: replacement is a function so `$`-sequences in the // substituted JS never get interpreted as String.replace // special replacement patterns. vm.evalCode( bufferAndTrack.replace( '%PAYLOAD%', - () => - 'globalThis[Symbol.for("workflow-deserialize")](globalThis.__tmp_result)' + () => 'globalThis.__tmp_result' ) + 'delete globalThis.__tmp_result;' ).dispose(); } else { @@ -2061,8 +2093,93 @@ function markCreated(vm: QuickJS, cidJs: string, opType?: string): void { * attributes/hooks/steps/waits) and pending abort recordings are surfaced * for the entrypoint to flush. */ -function collectDrainOperations(vm: QuickJS): PendingOperation[] { - using h = vm.evalCode(`(function(){ +/** + * Per-VM cache of serialized pending-op field bytes, keyed + * `correlationId:field`. A step's raw input is immutable once pushed, so + * its bytes are computed once even though the op is re-collected on every + * suspension it stays pending through. + */ +const pendingByteCache = new WeakMap>(); + +function ensurePendingByteCache(vm: QuickJS): Map { + let cache = pendingByteCache.get(vm); + if (!cache) { + cache = new Map(); + pendingByteCache.set(vm, cache); + } + return cache; +} + +/** + * The pending-op fields that hold RAW guest values (the bootstrap no longer + * serializes them in the VM). Collection projects them out of the dumped + * plain metadata and serializes each through a handle with the host serde. + */ +const RAW_PENDING_FIELDS = ['input', 'metadata', 'abortPayload'] as const; + +/** + * Dump a filtered view of `globalThis.__pending` to host PendingOperation + * objects, serializing the raw-value fields host-side. `filterExpr` is a + * guest expression that evaluates to the array of ops to collect. + */ +function dumpPendingOps( + vm: QuickJS, + serde: QuickJSSerde, + filterExpr: string, + byteCache?: Map +): PendingOperation[] { + using projected = vm.evalCode(`(function(){ + var ops = ${filterExpr}; + globalThis.__rawFields = []; + return ops.map(function(p){ + var q = {}; + for (var k in p) { + if (k === 'input' || k === 'metadata' || k === 'abortPayload') continue; + q[k] = p[k]; + } + var raw = {}; + ['input', 'metadata', 'abortPayload'].forEach(function(f){ + if (p[f] !== undefined) { + raw[f] = globalThis.__rawFields.length; + globalThis.__rawFields.push(p[f]); + } + }); + q.__rawIndices = raw; + return q; + }); + })()`); + const plainOps = vm.dump(projected) as (PendingOperation & { + __rawIndices?: Record; + })[]; + using rawFields = vm.evalCode('globalThis.__rawFields'); + for (const op of plainOps) { + const rawIndices = op.__rawIndices ?? {}; + delete op.__rawIndices; + for (const field of RAW_PENDING_FIELDS) { + const index = rawIndices[field]; + if (index === undefined) continue; + const cacheKey = `${op.correlationId}:${field}`; + let bytes = byteCache?.get(cacheKey); + if (!bytes) { + using valueHandle = rawFields.getProp(String(index)); + bytes = serde.serialize(valueHandle); + byteCache?.set(cacheKey, bytes); + } + (op as unknown as Record)[field] = bytes; + } + } + vm.evalCode('delete globalThis.__rawFields').dispose(); + return plainOps; +} + +function collectDrainOperations( + vm: QuickJS, + serde: QuickJSSerde +): PendingOperation[] { + return dumpPendingOps( + vm, + serde, + `(function(){ var toDispose = []; globalThis.__pending.forEach(function(p){ if (p.type === "hook" && p.isSystem && !p.abortRequested && !p.disposed) { @@ -2086,20 +2203,24 @@ function collectDrainOperations(vm: QuickJS): PendingOperation[] { if (p.type === "hook" && p.disposed) return false; return true; }); - })()`); - return vm.dump(h) as PendingOperation[]; + })()` + ); } function checkWorkflowState( vm: QuickJS, + serde: QuickJSSerde, opts: { keepAliveOnSuspend?: boolean } = {} ): QuickJSRuntimeResult { - // Check completed — __workflowResult is a format-prefixed Uint8Array + // Check completed — __workflowResult holds the RAW return value (with a + // separate done flag so `undefined` results are distinguishable); the + // host serializes it through a handle. { - using h = vm.evalCode('globalThis.__workflowResult'); - if (!h.isUndefined) { - const resultBytes = h.toUint8Array(); - const drainOperations = collectDrainOperations(vm); + using done = vm.evalCode('globalThis.__workflowDone === true'); + if (done.toBoolean()) { + using h = vm.evalCode('globalThis.__workflowResult'); + const resultBytes = serde.serialize(h); + const drainOperations = collectDrainOperations(vm, serde); vm.dispose(); return { completed: { @@ -2114,14 +2235,39 @@ function checkWorkflowState( { using h = vm.evalCode('globalThis.__workflowError'); if (!h.isUndefined) { - const errorObj = vm.dump(h) as - | { - message: string; - stack?: string; - name?: string; - valueBytes?: Uint8Array; - } - | string; + // The display fields are plain strings; the thrown value itself is + // RAW and serialized host-side through a handle. + const errorObj = h.isString + ? (h.toString() as string) + : (() => { + using plain = vm.evalCode( + '(function(e){return {message: e.message, stack: e.stack, name: e.name};})(globalThis.__workflowError)' + ); + return vm.dump(plain) as { + message: string; + stack?: string; + name?: string; + }; + })(); + let valueBytes: Uint8Array | undefined; + if (!h.isString) { + using rawValue = h.getProp('value'); + try { + valueBytes = serde.serialize(rawValue); + } catch (serializeErr) { + // A thrown value the codec cannot serialize must not mask the + // workflow failure itself — fall back to the display fields. + runtimeLogger.warn( + 'QuickJS runtime: failed to serialize thrown workflow error', + { + message: + serializeErr instanceof Error + ? serializeErr.message + : String(serializeErr), + } + ); + } + } const failed = typeof errorObj === 'string' ? { message: errorObj } @@ -2129,14 +2275,14 @@ function checkWorkflowState( message: errorObj.message, stack: errorObj.stack || undefined, name: errorObj.name || undefined, - valueBytes: errorObj.valueBytes, + valueBytes, }; runtimeLogger.error('QuickJS runtime: workflow failed in VM', { errorMessage: failed.message, errorName: failed.name, errorStack: failed.stack, }); - const drainOperations = collectDrainOperations(vm); + const drainOperations = collectDrainOperations(vm, serde); vm.dispose(); return { failed: { @@ -2155,13 +2301,15 @@ function checkWorkflowState( 'Object.keys(globalThis.__resolvers).length > 0 || globalThis.__pending.some(function(p){return!p.hasCreatedEvent;})' ); if (vm.dump(h)) { - using pendingH = vm.evalCode( - // Ops with an active resolver or without a created event are - // pending; abort-requested hooks are also surfaced (even when - // already created and unawaited) so the host records the abort. - `globalThis.__pending.filter(function(p){return!!globalThis.__resolvers[p.correlationId] || !p.hasCreatedEvent || p.abortRequested;})` + // Ops with an active resolver or without a created event are + // pending; abort-requested hooks are also surfaced (even when + // already created and unawaited) so the host records the abort. + const pendingOps = dumpPendingOps( + vm, + serde, + `globalThis.__pending.filter(function(p){return!!globalThis.__resolvers[p.correlationId] || !p.hasCreatedEvent || p.abortRequested;})`, + ensurePendingByteCache(vm) ); - const pendingOps = vm.dump(pendingH) as PendingOperation[]; if (!opts.keepAliveOnSuspend) vm.dispose(); return { diff --git a/packages/core/src/runtime/quickjs-serde.test.ts b/packages/core/src/runtime/quickjs-serde.test.ts new file mode 100644 index 0000000000..95b920610f --- /dev/null +++ b/packages/core/src/runtime/quickjs-serde.test.ts @@ -0,0 +1,545 @@ +/** + * Wire-format parity tests for the host-side QuickJS serde. + * + * Every case round-trips a value three ways and cross-checks against the + * host reference codec (`serialization/workflow-vm.ts` — the exact codec + * the retired in-VM serde bundle was built from): + * + * 1. guest value ──host serde serialize──▶ bytes, byte-compared with the + * reference codec serializing the equivalent host value; + * 2. reference-codec bytes ──host serde deserialize──▶ guest value, + * verified from inside the VM; + * 3. host serde bytes ──host serde deserialize──▶ guest value (full + * round trip through the new implementation only). + * + * Event logs persist across SDK versions, so these equivalences are what + * keeps old runs replayable by the new runtime (and runs started by the + * new runtime readable by node-engine steps and observability). + */ + +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { QuickJS } from 'quickjs-wasi'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + deserialize as referenceDeserialize, + serialize as referenceSerialize, +} from '../serialization/workflow-vm.js'; +import { createQuickJSSerde, type QuickJSSerde } from './quickjs-serde.js'; + +const require = createRequire(import.meta.url); + +let vm: QuickJS; +let serde: QuickJSSerde; + +beforeAll(async () => { + const wasm = fs.readFileSync(require.resolve('quickjs-wasi/quickjs.wasm')); + vm = await QuickJS.create({ wasm }); + serde = createQuickJSSerde(vm); +}); + +afterAll(() => { + serde.dispose(); + vm.dispose(); +}); + +/** Serialize the guest value produced by evaluating `expr` in the VM. */ +function serializeGuest(expr: string): Uint8Array { + const handle = vm.evalCode(`(${expr})`); + try { + return serde.serialize(handle); + } finally { + handle.dispose(); + } +} + +/** Run `checkFnSource` (guest fn of one arg) against a deserialized value. */ +function checkInGuest(bytes: Uint8Array, checkFnSource: string): unknown { + const value = serde.deserialize(bytes); + const checker = vm.evalCode(`(${checkFnSource})`); + try { + const result = vm.callFunction(checker, vm.undefined, value); + const dumped = vm.dump(result); + result.dispose(); + return dumped; + } finally { + checker.dispose(); + value.dispose(); + } +} + +const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +describe('wire parity: guest serialize matches the reference codec', () => { + const cases: [name: string, guestExpr: string, hostValue: () => unknown][] = [ + ['undefined', 'undefined', () => undefined], + ['null', 'null', () => null], + ['number', '42.5', () => 42.5], + ['negative zero', '-0', () => -0], + ['NaN', 'NaN', () => Number.NaN], + ['Infinity', 'Infinity', () => Number.POSITIVE_INFINITY], + ['string', '"hello \\u2028 world"', () => 'hello \u2028 world'], + ['boolean', 'true', () => true], + [ + 'bigint', + '123456789012345678901234567890n', + () => 123456789012345678901234567890n, + ], + [ + 'plain object', + '({a: 1, b: "two", c: null})', + () => ({ a: 1, b: 'two', c: null }), + ], + ['nested arrays', '[1, [2, [3, [4]]]]', () => [1, [2, [3, [4]]]]], + [ + 'sparse array', + '(() => { const a = [1]; a[3] = 4; return a; })()', + () => { + const a: unknown[] = [1]; + a[3] = 4; + return a; + }, + ], + ['Date', 'new Date(1700000000000)', () => new Date(1700000000000)], + ['invalid Date', 'new Date(NaN)', () => new Date(Number.NaN)], + ['RegExp', '/ab+c/gi', () => /ab+c/gi], + [ + 'Map', + 'new Map([["k1", 1], ["k2", {nested: true}]])', + () => + new Map([ + ['k1', 1], + ['k2', { nested: true }], + ]), + ], + ['Set', 'new Set([1, "two", null])', () => new Set([1, 'two', null])], + [ + 'Uint8Array', + 'new Uint8Array([1, 2, 3, 255])', + () => new Uint8Array([1, 2, 3, 255]), + ], + ['empty Uint8Array', 'new Uint8Array(0)', () => new Uint8Array(0)], + [ + 'Int32Array', + 'new Int32Array([-1, 2147483647])', + () => new Int32Array([-1, 2147483647]), + ], + [ + 'Float64Array', + 'new Float64Array([1.5, -2.25])', + () => new Float64Array([1.5, -2.25]), + ], + [ + 'BigInt64Array', + 'new BigInt64Array([1n, -2n])', + () => new BigInt64Array([1n, -2n]), + ], + [ + 'ArrayBuffer', + 'new Uint8Array([9, 8, 7]).buffer', + () => new Uint8Array([9, 8, 7]).buffer, + ], + [ + 'subarray view', + 'new Uint8Array([1,2,3,4,5]).subarray(1, 4)', + () => new Uint8Array([1, 2, 3, 4, 5]).subarray(1, 4), + ], + [ + 'Error', + '(() => { const e = new Error("boom"); e.stack = "fake-stack"; return e; })()', + () => { + const e = new Error('boom'); + e.stack = 'fake-stack'; + return e; + }, + ], + [ + 'TypeError', + '(() => { const e = new TypeError("bad type"); e.stack = "ts"; return e; })()', + () => { + const e = new TypeError('bad type'); + e.stack = 'ts'; + return e; + }, + ], + [ + 'Error with cause', + '(() => { const c = new Error("cause"); c.stack = "cs"; const e = new Error("outer", { cause: c }); e.stack = "os"; return e; })()', + () => { + const c = new Error('cause'); + c.stack = 'cs'; + const e = new Error('outer', { cause: c }); + e.stack = 'os'; + return e; + }, + ], + [ + 'custom-named Error', + '(() => { const e = new Error("custom"); e.name = "MyCustomError"; e.stack = "st"; return e; })()', + () => { + const e = new Error('custom'); + e.name = 'MyCustomError'; + e.stack = 'st'; + return e; + }, + ], + [ + 'shared reference', + '(() => { const shared = {x: 1}; return {a: shared, b: shared}; })()', + () => { + const shared = { x: 1 }; + return { a: shared, b: shared }; + }, + ], + [ + 'cycle', + '(() => { const o = {}; o.self = o; return o; })()', + () => { + const o: Record = {}; + o.self = o; + return o; + }, + ], + [ + 'null-prototype object', + 'Object.assign(Object.create(null), {k: "v"})', + () => Object.assign(Object.create(null), { k: 'v' }), + ], + [ + 'boxed primitives', + '[new Number(5), new String("s"), new Boolean(false)]', + () => [new Number(5), new String('s'), new Boolean(false)], + ], + ]; + + for (const [name, guestExpr, hostValue] of cases) { + it(name, () => { + const guestBytes = serializeGuest(guestExpr); + const referenceBytes = referenceSerialize(hostValue()); + expect(text(guestBytes)).toBe(text(referenceBytes)); + }); + } +}); + +describe('wire parity: reference-codec bytes revive correctly in the VM', () => { + it('revives built-ins with working prototypes', () => { + const bytes = referenceSerialize({ + when: new Date(1700000000000), + pattern: /x\d+/g, + entries: new Map([['a', 1]]), + items: new Set(['b']), + bytes: new Uint8Array([1, 2, 3]), + big: 42n, + }); + expect( + checkInGuest( + text(bytes) === '' ? bytes : bytes, + `function (v) { + return { + isDate: v.when instanceof Date, + time: v.when.getTime(), + regExp: v.pattern instanceof RegExp && v.pattern.source === "x\\\\d+" && v.pattern.flags === "g", + mapGet: v.entries instanceof Map && v.entries.get("a") === 1, + setHas: v.items instanceof Set && v.items.has("b"), + bytesOk: v.bytes instanceof Uint8Array && v.bytes.length === 3 && v.bytes[2] === 3, + bigOk: typeof v.big === "bigint" && v.big === 42n, + }; + }` + ) + ).toEqual({ + isDate: true, + time: 1700000000000, + regExp: true, + mapGet: true, + setHas: true, + bytesOk: true, + bigOk: true, + }); + }); + + it('revives Error subclasses with instanceof identity and cause chain', () => { + const cause = new RangeError('too big'); + cause.stack = 'cause-stack'; + const outer = new TypeError('bad', { cause }); + outer.stack = 'outer-stack'; + const bytes = referenceSerialize(outer); + expect( + checkInGuest( + bytes, + `function (e) { + return { + isTypeError: e instanceof TypeError, + message: e.message, + stack: e.stack, + causeIsRangeError: e.cause instanceof RangeError, + causeMessage: e.cause && e.cause.message, + }; + }` + ) + ).toEqual({ + isTypeError: true, + message: 'bad', + stack: 'outer-stack', + causeIsRangeError: true, + causeMessage: 'too big', + }); + }); + + it('revives shared references and cycles with identity intact', () => { + const shared = { tag: 'shared' }; + const cyclic: Record = { a: shared, b: shared }; + cyclic.self = cyclic; + const bytes = referenceSerialize(cyclic); + expect( + checkInGuest( + bytes, + `function (v) { + return { sameRef: v.a === v.b, cycle: v.self === v }; + }` + ) + ).toEqual({ sameRef: true, cycle: true }); + }); +}); + +describe('full round trip through the host serde only', () => { + it('guest → bytes → guest preserves values and identity', () => { + const bytes = serializeGuest( + `(() => { + const shared = new Map([["n", 1]]); + return { + shared1: shared, + shared2: shared, + date: new Date(1700000000000), + list: [1, "two", new Set([3])], + }; + })()` + ); + expect( + checkInGuest( + bytes, + `function (v) { + return { + sameRef: v.shared1 === v.shared2, + mapVal: v.shared1.get("n"), + time: v.date.getTime(), + setHas: v.list[2].has(3), + }; + }` + ) + ).toEqual({ sameRef: true, mapVal: 1, time: 1700000000000, setHas: true }); + }); +}); + +describe('workflow-specific reducers', () => { + it('step function proxies round-trip through StepFunction (with closure vars and bound this)', () => { + // Minimal WORKFLOW_USE_STEP mirroring the runtime bootstrap's proxy shape. + vm.evalCode(` + globalThis[Symbol.for("WORKFLOW_USE_STEP")] = function (stepId, closureVarsFn) { + var fn = function () { return "called:" + stepId; }; + fn.stepId = stepId; + if (closureVarsFn) fn.__closureVarsFn = closureVarsFn; + fn.bind = function (thisArg) { + var partialArgs = Array.prototype.slice.call(arguments, 1); + var bound = Function.prototype.bind.apply(this, [thisArg].concat(partialArgs)); + bound.stepId = stepId; + if (closureVarsFn) bound.__closureVarsFn = closureVarsFn; + bound.__boundThis = thisArg; + if (partialArgs.length > 0) bound.__boundArgs = partialArgs; + return bound; + }; + return fn; + }; + `).dispose(); + + const bytes = serializeGuest( + `globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//file//fn", function () { return { captured: 7 }; })` + ); + // Wire parity with the reference codec's StepFunction reducer. + const hostProxy = Object.assign(() => {}, { + stepId: 'step//file//fn', + __closureVarsFn: () => ({ captured: 7 }), + }); + expect(text(bytes)).toBe(text(referenceSerialize(hostProxy))); + + expect( + checkInGuest( + bytes, + `function (fn) { + return { + stepId: fn.stepId, + captured: fn.__closureVarsFn().captured, + callable: fn() === "called:step//file//fn", + }; + }` + ) + ).toEqual({ stepId: 'step//file//fn', captured: 7, callable: true }); + }); + + it('workflow function references reduce to { workflowId }', () => { + const bytes = serializeGuest( + `Object.assign(function () {}, { workflowId: "workflow//file//wf" })` + ); + const hostRef = Object.assign(() => {}, { + workflowId: 'workflow//file//wf', + }); + expect(text(bytes)).toBe(text(referenceSerialize(hostRef))); + expect(checkInGuest(bytes, `function (f) { return f.workflowId; }`)).toBe( + 'workflow//file//wf' + ); + }); + + it('named stream handles round-trip via symbol-stamped properties', () => { + vm.evalCode(` + if (typeof globalThis.ReadableStream === "undefined") { + globalThis.ReadableStream = function () {}; + } + if (typeof globalThis.WritableStream === "undefined") { + globalThis.WritableStream = function () {}; + } + `).dispose(); + // The stream prototypes were not present at serde creation in this + // test VM, so recreate the serde with them installed. + serde.dispose(); + serde = createQuickJSSerde(vm); + + const bytes = serializeGuest( + `(() => { + const s = Object.create(globalThis.ReadableStream.prototype); + s[Symbol.for("WORKFLOW_STREAM_NAME")] = "stream_123"; + s[Symbol.for("WORKFLOW_STREAM_TYPE")] = "bytes"; + s[Symbol.for("WORKFLOW_STREAM_FRAMING")] = "framed-v1"; + return s; + })()` + ); + expect( + checkInGuest( + bytes, + `function (s) { + return { + name: s[Symbol.for("WORKFLOW_STREAM_NAME")], + type: s[Symbol.for("WORKFLOW_STREAM_TYPE")], + framing: s[Symbol.for("WORKFLOW_STREAM_FRAMING")], + proto: Object.getPrototypeOf(s) === globalThis.ReadableStream.prototype, + }; + }` + ) + ).toEqual({ + name: 'stream_123', + type: 'bytes', + framing: 'framed-v1', + proto: true, + }); + }); + + it('class instances with WORKFLOW_SERIALIZE round-trip through the registry', () => { + vm.evalCode(` + (function () { + var registry = globalThis[Symbol.for("workflow-class-registry")]; + if (!registry) { + registry = new Map(); + globalThis[Symbol.for("workflow-class-registry")] = registry; + } + function Point(x, y) { this.x = x; this.y = y; } + Point.classId = "class//test//Point"; + Point[Symbol.for("workflow-serialize")] = function (p) { return [p.x, p.y]; }; + Point[Symbol.for("workflow-deserialize")] = function (data) { return new Point(data[0], data[1]); }; + registry.set("class//test//Point", Point); + globalThis.__TestPoint = Point; + })(); + `).dispose(); + + const bytes = serializeGuest(`new globalThis.__TestPoint(3, 4)`); + expect(text(bytes)).toContain('"Instance"'); + expect(text(bytes)).toContain('class//test//Point'); + expect( + checkInGuest( + bytes, + `function (p) { + return { x: p.x, y: p.y, isPoint: p instanceof globalThis.__TestPoint }; + }` + ) + ).toEqual({ x: 3, y: 4, isPoint: true }); + }); +}); + +describe('side-effect freedom', () => { + it('serializing does not execute patched prototype methods', () => { + vm.evalCode(` + globalThis.__spyCalls = 0; + const originalToISOString = Date.prototype.toISOString; + Date.prototype.toISOString = function () { globalThis.__spyCalls++; return originalToISOString.call(this); }; + const originalGetTime = Date.prototype.getTime; + Date.prototype.getTime = function () { globalThis.__spyCalls++; return originalGetTime.call(this); }; + const originalForEach = Map.prototype.forEach; + Map.prototype.forEach = function () { globalThis.__spyCalls++; return originalForEach.apply(this, arguments); }; + Map.prototype[Symbol.iterator] = function () { globalThis.__spyCalls++; throw new Error("iterator should not run"); }; + `).dispose(); + + const bytes = serializeGuest( + `({ when: new Date(1700000000000), entries: new Map([["k", 1]]) })` + ); + const spyCalls = vm + .evalCode('globalThis.__spyCalls') + .consume((h) => h.toNumber()); + expect(spyCalls).toBe(0); + // Output is still correct — captured intrinsics did the work. + expect(text(bytes)).toBe( + text( + referenceSerialize({ + when: new Date(1700000000000), + entries: new Map([['k', 1]]), + }) + ) + ); + + // Restore for other tests. + vm.evalCode(` + delete Map.prototype[Symbol.iterator]; + `).dispose(); + }); + + it('a Symbol.toStringTag spoof does not reclassify a plain object', () => { + // Classification is by engine brand (classId), so an object CLAIMING to + // be a Date serializes as the plain object it actually is. The + // unhardened reference codec crashes on this input (devalue's default + // tagOf trusts Object.prototype.toString and routes it to the Date + // extractor) — same strictly-better outcome as the node:vm hardened + // codec. + expect(() => + referenceSerialize({ [Symbol.toStringTag]: 'Date', value: 1 }) + ).toThrow(); + const bytes = serializeGuest( + `(() => { + const o = { value: 1 }; + Object.defineProperty(o, Symbol.toStringTag, { + value: "Date", + enumerable: false, + }); + return o; + })()` + ); + expect(text(bytes)).toBe(text(referenceSerialize({ value: 1 }))); + }); +}); + +describe('reducer/reviver exhaustiveness vs the shared value-space codec', () => { + // A reducer/reviver added to codec-devalue-vm's workflow mode but not to + // the handle-space serde would silently round-trip values of that type + // as plain objects — this pins the two key sets to each other so the + // next addition fails loudly here instead. + it('reducer key sets match exactly (order included — first match wins)', async () => { + const { getWorkflowModeReducerKeys } = await import( + '../serialization/codec-devalue-vm.js' + ); + expect([...serde.reducerKeys]).toEqual(getWorkflowModeReducerKeys()); + }); + + it('reviver key sets match exactly', async () => { + const { getWorkflowModeReviverKeys } = await import( + '../serialization/codec-devalue-vm.js' + ); + expect([...serde.reviverKeys].sort()).toEqual( + getWorkflowModeReviverKeys().sort() + ); + }); +}); diff --git a/packages/core/src/runtime/quickjs-serde.ts b/packages/core/src/runtime/quickjs-serde.ts new file mode 100644 index 0000000000..ac3e336ed8 --- /dev/null +++ b/packages/core/src/runtime/quickjs-serde.ts @@ -0,0 +1,1996 @@ +/** + * Host-side serialization for the QuickJS engine. + * + * Implements the workflow wire codec (format-prefixed devalue, identical to + * `codec-devalue-vm.ts` / the node:vm engine's workflow-mode codec) as + * host code operating on `JSValueHandle`s, using devalue 5.9's pluggable + * operations (quickjs-wasi's host-side introspection primitives underneath). + * The serde bundle previously evaluated inside the VM is gone: guest values + * are read and built through handles, so no serializer code lives in — or + * can be tampered with from — the guest realm. + * + * Side-effect discipline mirrors the node:vm engine's hardened codec + * (serialization/hardened.ts): + * + * - classification is by engine brand (`classId` against boot-captured + * samples, `isError`, `isProxy`), never `instanceof` or + * `Symbol.toStringTag`; + * - extraction goes through intrinsics captured at boot (before any user + * code runs) invoked with explicit receivers, or through own-property + * descriptor reads — patched prototypes and inherited accessors never + * run; + * - the only guest code serialization can execute is the same code the + * previous in-VM codec executed by contract: a class's static + * `WORKFLOW_SERIALIZE` method, a step proxy's `__closureVarsFn`, and + * `WORKFLOW_USE_STEP` / `WORKFLOW_DESERIALIZE` on revival. + * + * Wire-format parity with the previous in-VM codec is REQUIRED and covered + * by tests: event logs written by either codec must be readable by the + * other (steps serialized by the node runtime feed VM revival and vice + * versa). + * + * Hybrid value space: reducers return host shapes (plain objects, strings, + * numbers) whose leaves may be guest handles — exactly how the node:vm + * codec mixes host shapes with sandbox-realm leaves. Every stringify + * operation therefore dispatches on `JSValueHandle` and falls back to + * devalue's default host operations for host values. Parse operations + * always build guest values, so the parse side is handle-only. + */ + +import { + defaultStringifyOperations, + filterArrayIndices, + type ParseOperations, + parse, + type StringifyOperations, + stringify, +} from 'devalue'; +import { JSValueHandle, type QuickJS } from 'quickjs-wasi'; +import { SerializationFormat } from '../serialization/types.js'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const FORMAT_PREFIX_LENGTH = 4; + +// ---- base64 (host-side; wire-compatible with the old in-VM btoa path) ---- + +function bytesToBase64(bytes: Uint8Array): string { + if (bytes.length === 0) return '.'; + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString( + 'base64' + ); +} + +function base64ToBytes(value: string): Uint8Array { + if (value === '.') return new Uint8Array(0); + return new Uint8Array(Buffer.from(value, 'base64')); +} + +// ---- boot-time captures ---- + +/** devalue tags decided by engine class id, sampled at boot. */ +const BRANDED_SAMPLES = `({ + Number: new Number(0), + String: new String(''), + Boolean: new Boolean(false), + BigInt: Object(0n), + Date: new Date(0), + RegExp: /x/, + Array: [], + Set: new Set(), + Map: new Map(), + ArrayBuffer: new ArrayBuffer(0), + DataView: new DataView(new ArrayBuffer(0)), + Int8Array: new Int8Array(0), + Uint8Array: new Uint8Array(0), + Uint8ClampedArray: new Uint8ClampedArray(0), + Int16Array: new Int16Array(0), + Uint16Array: new Uint16Array(0), + Int32Array: new Int32Array(0), + Uint32Array: new Uint32Array(0), + Float32Array: new Float32Array(0), + Float64Array: new Float64Array(0), + BigInt64Array: new BigInt64Array(0), + BigUint64Array: new BigUint64Array(0), +})`; + +/** + * Intrinsics needed for reading and building, captured from the guest realm + * at serde creation (which the runtime does right after evaluating the + * bootstrap, before the workflow bundle). Held only on the host: later + * patching inside the VM cannot influence serialization. + * + * Globals installed by the extensions/bootstrap (Headers, Request, + * Response, ReadableStream, WritableStream, URL, URLSearchParams, + * DOMException, __WorkflowAbortSignal) are captured defensively — absent + * ones yield `undefined` and their reducers simply never match, exactly + * like the old in-VM reducers' `globalThis.X` probes. + */ +const CAPTURE_INTRINSICS = `(() => { + const descriptor = (object, key) => + Object.getOwnPropertyDescriptor(object, key); + const getter = (object, key) => { + const d = descriptor(object, key); + return d && d.get; + }; + const TypedArray = Object.getPrototypeOf(Int8Array.prototype); + const maybeProto = (Cls) => (Cls ? Cls.prototype : undefined); + const g = globalThis; + + return { + // --- reading (stringify) --- + dateGetTime: Date.prototype.getTime, + dateToISOString: Date.prototype.toISOString, + regExpSource: getter(RegExp.prototype, 'source'), + regExpFlags: getter(RegExp.prototype, 'flags'), + numberValueOf: Number.prototype.valueOf, + stringValueOf: String.prototype.valueOf, + booleanValueOf: Boolean.prototype.valueOf, + bigIntValueOf: BigInt.prototype.valueOf, + bigIntToString: BigInt.prototype.toString, + setForEach: Set.prototype.forEach, + mapForEach: Map.prototype.forEach, + headersForEach: g.Headers ? g.Headers.prototype.forEach : undefined, + urlHref: g.URL ? getter(g.URL.prototype, 'href') : undefined, + urlSearchParamsToString: g.URLSearchParams + ? g.URLSearchParams.prototype.toString + : undefined, + urlSearchParamsSize: g.URLSearchParams + ? getter(g.URLSearchParams.prototype, 'size') + : undefined, + viewBuffer: getter(TypedArray, 'buffer'), + viewByteOffset: getter(TypedArray, 'byteOffset'), + viewByteLength: getter(TypedArray, 'byteLength'), + viewLength: getter(TypedArray, 'length'), + dataViewBuffer: getter(DataView.prototype, 'buffer'), + dataViewByteOffset: getter(DataView.prototype, 'byteOffset'), + dataViewByteLength: getter(DataView.prototype, 'byteLength'), + arrayBufferByteLength: getter(ArrayBuffer.prototype, 'byteLength'), + objectPrototype: Object.prototype, + errorPrototype: Error.prototype, + domExceptionPrototype: maybeProto(g.DOMException), + headersPrototype: maybeProto(g.Headers), + requestPrototype: maybeProto(g.Request), + responsePrototype: maybeProto(g.Response), + readableStreamPrototype: maybeProto(g.ReadableStream), + writableStreamPrototype: maybeProto(g.WritableStream), + urlPrototype: maybeProto(g.URL), + urlSearchParamsPrototype: maybeProto(g.URLSearchParams), + + // --- building (parse) --- + Date, RegExp, Set, Map, Array, Object, DataView, + Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, + Int32Array, Uint32Array, Float32Array, Float64Array, + BigInt64Array, BigUint64Array, + Error, + AggregateError: g.AggregateError, + EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError, + DOMException: g.DOMException, + Headers: g.Headers, + Request: g.Request, + Response: g.Response, + ReadableStream: g.ReadableStream, + WritableStream: g.WritableStream, + URL: g.URL, + URLSearchParams: g.URLSearchParams, + setAdd: Set.prototype.add, + mapSet: Map.prototype.set, + objectCreate: Object.create, + defineProperty: Object.defineProperty, + functionBind: Function.prototype.bind, + makeSparseArray: (length) => { + const array = []; + array[4294967294] = undefined; + delete array[4294967294]; + array.length = length; + return array; + }, + // Builds the closure-vars thunk a revived step proxy carries. Must be a + // guest closure (the proxy stores and later calls it from guest code). + makeThunk: (vars) => () => vars, + }; +})()`; + +/** + * Well-known symbols the reducers/revivers read or stamp, captured as guest + * symbol handles so descriptor reads/writes can be keyed on them. + */ +const SYMBOL_NAMES = [ + 'WORKFLOW_ABORT_STREAM_NAME', + 'WORKFLOW_ABORT_HOOK_TOKEN', + 'BODY_INIT', + 'WORKFLOW_STREAM_NAME', + 'WORKFLOW_STREAM_TYPE', + 'WORKFLOW_STREAM_FRAMING', + 'WORKFLOW_STREAM_SERVER_RUN_ID', + 'WORKFLOW_STREAM_SERVER_DEPLOYMENT_ID', + 'WEBHOOK_RESPONSE_WRITABLE', + 'WORKFLOW_USE_STEP', + 'workflow-serialize', // @workflow/serde WORKFLOW_SERIALIZE + 'workflow-deserialize', // @workflow/serde WORKFLOW_DESERIALIZE + 'workflow-class-registry', + '@workflow/errors//FatalError', + '@workflow/errors//HookConflictError', + '@workflow/errors//RetryableError', + '@workflow/errors//RuntimeDecryptionError', +] as const; +type SymbolName = (typeof SYMBOL_NAMES)[number]; + +export interface QuickJSSerde { + /** Serialize a guest value handle to format-prefixed wire bytes. */ + serialize(value: JSValueHandle): Uint8Array; + /** Build a guest value in the VM from format-prefixed wire bytes. */ + deserialize(data: Uint8Array): JSValueHandle; + /** + * The reducer names this codec applies, in registration order. Exposed + * so tests can assert exhaustiveness against the shared value-space + * codec (codec-devalue-vm) — a reducer added there but not here would + * otherwise silently round-trip values as plain objects. + */ + reducerKeys: readonly string[]; + /** Reviver names, for the same exhaustiveness check. */ + reviverKeys: readonly string[]; + dispose(): void; +} + +/** + * Create the host-side serde for a VM. Must be called after the runtime + * bootstrap has been evaluated (so bootstrap-installed globals are + * capturable) and before the workflow bundle runs. + */ +export function createQuickJSSerde(vm: QuickJS): QuickJSSerde { + const disposables: JSValueHandle[] = []; + const keep = (handle: JSValueHandle): JSValueHandle => { + disposables.push(handle); + return handle; + }; + const isHandle = (value: unknown): value is JSValueHandle => + value instanceof JSValueHandle; + + // --- boot capture --- + + const tagByClassId = new Map(); + { + const samples = vm.evalCode(BRANDED_SAMPLES); + try { + for (const tag of samples.getOwnPropertyNames()) { + const sample = samples.getProp(tag); + tagByClassId.set(sample.classId, tag); + sample.dispose(); + } + } finally { + samples.dispose(); + } + } + + const intrinsics = keep(vm.evalCode(CAPTURE_INTRINSICS)); + const at = (name: string): JSValueHandle => keep(intrinsics.getProp(name)); + /** Absent captures (extension/bootstrap global not installed). */ + const optional = (name: string): JSValueHandle | undefined => { + const handle = at(name); + return handle.isUndefined ? undefined : handle; + }; + + const i = { + dateGetTime: at('dateGetTime'), + dateToISOString: at('dateToISOString'), + regExpSource: at('regExpSource'), + regExpFlags: at('regExpFlags'), + numberValueOf: at('numberValueOf'), + stringValueOf: at('stringValueOf'), + booleanValueOf: at('booleanValueOf'), + bigIntValueOf: at('bigIntValueOf'), + bigIntToString: at('bigIntToString'), + setForEach: at('setForEach'), + mapForEach: at('mapForEach'), + headersForEach: optional('headersForEach'), + urlHref: optional('urlHref'), + urlSearchParamsToString: optional('urlSearchParamsToString'), + urlSearchParamsSize: optional('urlSearchParamsSize'), + viewBuffer: at('viewBuffer'), + viewByteOffset: at('viewByteOffset'), + viewByteLength: at('viewByteLength'), + viewLength: at('viewLength'), + dataViewBuffer: at('dataViewBuffer'), + dataViewByteOffset: at('dataViewByteOffset'), + dataViewByteLength: at('dataViewByteLength'), + arrayBufferByteLength: at('arrayBufferByteLength'), + objectPrototype: at('objectPrototype'), + errorPrototype: at('errorPrototype'), + domExceptionPrototype: optional('domExceptionPrototype'), + headersPrototype: optional('headersPrototype'), + requestPrototype: optional('requestPrototype'), + responsePrototype: optional('responsePrototype'), + readableStreamPrototype: optional('readableStreamPrototype'), + writableStreamPrototype: optional('writableStreamPrototype'), + urlPrototype: optional('urlPrototype'), + urlSearchParamsPrototype: optional('urlSearchParamsPrototype'), + Date: at('Date'), + RegExp: at('RegExp'), + Set: at('Set'), + Map: at('Map'), + Array: at('Array'), + Object: at('Object'), + Error: at('Error'), + AggregateError: optional('AggregateError'), + DOMException: optional('DOMException'), + Headers: optional('Headers'), + ReadableStream: optional('ReadableStream'), + WritableStream: optional('WritableStream'), + URL: optional('URL'), + URLSearchParams: optional('URLSearchParams'), + setAdd: at('setAdd'), + mapSet: at('mapSet'), + objectCreate: at('objectCreate'), + defineProperty: at('defineProperty'), + functionBind: at('functionBind'), + makeSparseArray: at('makeSparseArray'), + makeThunk: at('makeThunk'), + }; + + const errorConstructors = new Map(); + for (const name of [ + 'EvalError', + 'RangeError', + 'ReferenceError', + 'SyntaxError', + 'TypeError', + 'URIError', + ]) { + errorConstructors.set(name, at(name)); + } + + const typedArrayConstructors = new Map(); + for (const name of [ + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', + 'DataView', + ]) { + typedArrayConstructors.set(name, at(name)); + } + + const symbols = new Map(); + for (const name of SYMBOL_NAMES) { + symbols.set(name, keep(vm.evalCode(`Symbol.for(${JSON.stringify(name)})`))); + } + const sym = (name: SymbolName): JSValueHandle => { + const handle = symbols.get(name); + if (!handle) throw new Error(`unknown captured symbol: ${name}`); + return handle; + }; + + // --- handle helpers --- + + const call = ( + fn: JSValueHandle, + thisValue: JSValueHandle, + ...args: JSValueHandle[] + ): JSValueHandle => vm.callFunction(fn, thisValue, ...args); + + const invoke = (fn: JSValueHandle, ...args: JSValueHandle[]): JSValueHandle => + vm.callFunction(fn, vm.undefined, ...args); + + /** + * Guest own-property check through the handle's introspection method. + * MUST NOT be replaced with `Object.hasOwn`, which would interrogate the + * host JSValueHandle wrapper object (always false) instead of the guest + * value — biome's noPrototypeBuiltins auto-fix does exactly that, which + * is why this is centralized here with the suppression. + */ + const guestHasOwn = (handle: JSValueHandle, key: string): boolean => + // biome-ignore lint/suspicious/noPrototypeBuiltins: JSValueHandle.hasOwnProperty is quickjs-wasi's guest-side introspection API, not Object.prototype.hasOwnProperty + handle.hasOwnProperty(key); + + /** + * Own data-property read. Returns undefined for absent properties AND for + * accessor properties — an inherited or own getter is never invoked + * (matching the hardened node:vm codec's descriptor-based reads). + */ + const own = ( + target: JSValueHandle, + key: string | JSValueHandle + ): JSValueHandle | undefined => { + const descriptor = target.getOwnPropertyDescriptor(key as string); + if (!descriptor) return undefined; + descriptor.get?.dispose(); + descriptor.set?.dispose(); + return descriptor.value; + }; + + /** + * Data-property read following the prototype chain (the safe analogue of + * `value.name` on an Error instance, where `name`/`message` live on the + * prototype). Accessors anywhere on the chain are skipped, not invoked. + */ + const chained = ( + target: JSValueHandle, + key: string | JSValueHandle + ): JSValueHandle | undefined => { + let current: JSValueHandle | undefined; + let owned = false; // whether `current` is ours to dispose + let cursor = target; + for (let depth = 0; depth < 32; depth++) { + const found = own(cursor, key); + if (found) { + if (owned) (cursor as JSValueHandle).dispose(); + return found; + } + const proto = cursor.getPrototypeOf(); + if (owned) (cursor as JSValueHandle).dispose(); + if (proto.isNull || proto.isUndefined) { + proto.dispose(); + return undefined; + } + cursor = proto; + owned = true; + current = proto; + } + if (owned && current) current.dispose(); + return undefined; + }; + + /** Host string from a data property (own-or-chain), or undefined. */ + const chainedString = ( + target: JSValueHandle, + key: string | JSValueHandle + ): string | undefined => { + const handle = chained(target, key); + if (!handle) return undefined; + const result = handle.isString ? handle.toString() : undefined; + handle.dispose(); + return result; + }; + + const ownString = ( + target: JSValueHandle, + key: string | JSValueHandle + ): string | undefined => { + const handle = own(target, key); + if (!handle) return undefined; + const result = handle.isString ? handle.toString() : undefined; + handle.dispose(); + return result; + }; + + /** + * Whether `handle` has `prototypeHandle` anywhere on its prototype chain — + * the trap-free analogue of `instanceof` (which would fire + * `Symbol.hasInstance`). + */ + const hasPrototype = ( + handle: JSValueHandle, + prototypeHandle: JSValueHandle | undefined + ): boolean => { + if (!prototypeHandle) return false; + let cursor = handle.getPrototypeOf(); + for (let depth = 0; depth < 32; depth++) { + if (cursor.isNull || cursor.isUndefined) { + cursor.dispose(); + return false; + } + if (cursor.identity === prototypeHandle.identity) { + cursor.dispose(); + return true; + } + const next = cursor.getPrototypeOf(); + cursor.dispose(); + cursor = next; + } + cursor.dispose(); + return false; + }; + + /** `Object.defineProperty` write, immune to inherited setters. */ + const define = ( + target: JSValueHandle, + key: string | JSValueHandle, + value: JSValueHandle + ): void => { + const descriptor = vm.newObject(); + try { + descriptor.setProp('value', value); + descriptor.setProp('writable', vm.true); + descriptor.setProp('enumerable', vm.true); + descriptor.setProp('configurable', vm.true); + if (typeof key === 'string') { + const keyHandle = vm.newString(key); + try { + call( + i.defineProperty, + vm.undefined, + target, + keyHandle, + descriptor + ).dispose(); + } finally { + keyHandle.dispose(); + } + } else { + call(i.defineProperty, vm.undefined, target, key, descriptor).dispose(); + } + } finally { + descriptor.dispose(); + } + }; + + const newGuestString = (value: string): JSValueHandle => vm.newString(value); + + /** Collect Set values / Map entries via captured forEach. */ + const collect = ( + forEach: JSValueHandle, + collection: JSValueHandle, + arity: 1 | 2 + ): unknown[] => { + const collected: unknown[] = []; + const visitor = vm.newEphemeralFunction( + (value: JSValueHandle, key: JSValueHandle) => { + collected.push(arity === 1 ? value.dup() : [key.dup(), value.dup()]); + return vm.undefined; + } + ); + try { + call(forEach, collection, visitor).dispose(); + } finally { + visitor.dispose(); + } + return collected; + }; + + /** Copy a typed array / DataView's viewed bytes to the host. */ + const viewBytes = (handle: JSValueHandle): Uint8Array => { + const isDataView = handle.isDataView; + const buffer = call(isDataView ? i.dataViewBuffer : i.viewBuffer, handle); + try { + const byteOffset = call( + isDataView ? i.dataViewByteOffset : i.viewByteOffset, + handle + ).consume((h) => h.toNumber()); + const byteLength = call( + isDataView ? i.dataViewByteLength : i.viewByteLength, + handle + ).consume((h) => h.toNumber()); + const bytes = new Uint8Array(buffer.toArrayBuffer()); + return bytes.subarray(byteOffset, byteOffset + byteLength); + } finally { + buffer.dispose(); + } + }; + + const tagOfHandle = (handle: JSValueHandle): string => { + if (handle.isProxy) return 'Object'; + return tagByClassId.get(handle.classId) ?? 'Object'; + }; + + // --- identity --- + + const identities = new Map(); + const identityOf = (pointer: number): object => { + let identity = identities.get(pointer); + if (!identity) { + identity = { pointer }; + identities.set(pointer, identity); + } + return identity; + }; + + const primitiveOf = (handle: JSValueHandle): unknown => { + if (handle.isUndefined) return undefined; + if (handle.isNull) return null; + if (handle.isBool) return handle.toBoolean(); + if (handle.isNumber) return handle.toNumber(); + if (handle.isBigInt) return guestBigInt(handle); + return handle.toString(); + }; + + /** + * Extract a guest bigint via the captured `BigInt.prototype.toString` — + * `handle.toBigInt()` truncates to 64 bits. + */ + const guestBigInt = (handle: JSValueHandle): bigint => + BigInt(call(i.bigIntToString, handle).consume((h) => h.toString())); + + // --- hybrid stringify operations --- + // Handles take the introspection path; host values (reducer outputs) + // fall back to devalue's defaults. + + const d = defaultStringifyOperations; + + const stringifyOperations: StringifyOperations = { + identify: (value) => { + if (!isHandle(value)) return d.identify(value); + const pointer = value.identity; + if (pointer === 0 || value.isString) return primitiveOf(value); + return identityOf(pointer); + }, + typeOf: (value) => { + if (!isHandle(value)) return d.typeOf(value); + if (value.isNull) return 'null'; + return value.typeof as ReturnType; + }, + toPrimitive: (value) => (isHandle(value) ? primitiveOf(value) : value), + tagOf: (value) => (isHandle(value) ? tagOfHandle(value) : d.tagOf(value)), + isThenable: (value) => + isHandle(value) ? value.isPromise : d.isThenable(value), + toPromise: async (value) => { + if (!isHandle(value)) return d.toPromise(value); + const settled = await vm.resolvePromise(value); + if ('error' in settled) throw settled.error; + return settled.value; + }, + unbox: (value) => { + if (!isHandle(value)) return d.unbox(value); + switch (tagOfHandle(value)) { + case 'Number': + return call(i.numberValueOf, value); + case 'String': + return call(i.stringValueOf, value); + case 'Boolean': + return call(i.booleanValueOf, value); + default: + return call(i.bigIntValueOf, value); + } + }, + toISOString: (value) => { + if (!isHandle(value)) return d.toISOString(value); + if ( + Number.isNaN(call(i.dateGetTime, value).consume((h) => h.toNumber())) + ) { + return ''; + } + return call(i.dateToISOString, value).consume((h) => h.toString()); + }, + toStringValue: (value) => { + if (!isHandle(value)) return d.toStringValue(value); + // URL / URLSearchParams are matched by workflow reducers before + // devalue's native handling, and Temporal doesn't exist in the VM, so + // this is unreachable in practice. Refuse rather than invoke guest + // `toString`. + throw new Error( + `no captured string conversion for ${tagOfHandle(value)} in the VM` + ); + }, + regExpInfo: (value) => { + if (!isHandle(value)) return d.regExpInfo(value); + return { + source: call(i.regExpSource, value).consume((h) => h.toString()), + flags: call(i.regExpFlags, value).consume((h) => h.toString()), + }; + }, + valuesOf: (value) => + isHandle(value) ? collect(i.setForEach, value, 1) : d.valuesOf(value), + entriesOf: (value) => + isHandle(value) + ? (collect(i.mapForEach, value, 2) as Iterable<[unknown, unknown]>) + : d.entriesOf(value), + viewInfo: (value) => { + if (!isHandle(value)) return d.viewInfo(value); + const isDataView = value.isDataView; + const buffer = call(isDataView ? i.dataViewBuffer : i.viewBuffer, value); + const info = { + buffer, + byteOffset: call( + isDataView ? i.dataViewByteOffset : i.viewByteOffset, + value + ).consume((h) => h.toNumber()), + byteLength: call( + isDataView ? i.dataViewByteLength : i.viewByteLength, + value + ).consume((h) => h.toNumber()), + bufferByteLength: call(i.arrayBufferByteLength, buffer).consume((h) => + h.toNumber() + ), + length: 0, + }; + if (!isDataView) { + info.length = call(i.viewLength, value).consume((h) => h.toNumber()); + } + return info; + }, + toArrayBuffer: (value) => + isHandle(value) ? value.toArrayBuffer() : d.toArrayBuffer(value), + lengthOf: (value) => { + if (!isHandle(value)) return d.lengthOf(value); + const length = own(value, 'length'); + return length?.consume((h) => h.toNumber()) ?? 0; + }, + hasOwn: (value, key) => + isHandle(value) ? guestHasOwn(value, String(key)) : d.hasOwn(value, key), + indicesOf: (value) => + isHandle(value) ? filterArrayIndices(value.keys()) : d.indicesOf(value), + shapeOf: (value) => { + if (!isHandle(value)) return d.shapeOf(value); + if (value.isProxy) return { kind: 'not-plain' as const }; + const prototype = value.getPrototypeOf(); + try { + const isPlain = + prototype.isNull || prototype.identity === i.objectPrototype.identity; + if (!isPlain) return { kind: 'not-plain' as const }; + const keys: string[] = []; + for (const key of value.getOwnPropertyKeys()) { + if (typeof key !== 'string') { + const enumerable = + value.getOwnPropertyDescriptor(key)?.enumerable ?? false; + key.dispose(); + if (enumerable) return { kind: 'symbol-keys' as const }; + continue; + } + if (value.propertyIsEnumerable(key)) keys.push(key); + } + return { + kind: prototype.isNull ? ('null-proto' as const) : ('plain' as const), + keys, + }; + } finally { + prototype.dispose(); + } + }, + get: (value, key) => { + if (!isHandle(value)) return d.get(value, key); + const descriptor = value.getOwnPropertyDescriptor(String(key)); + if (!descriptor) return undefined; + if (descriptor.get) { + // Parity with the hardened node:vm codec: getters are invoked (full + // compatibility with values whose shape depends on accessors), the + // difference from `[[Get]]` being that this is an explicit, single + // invocation of the accessor the descriptor names. + const result = call(descriptor.get, value); + descriptor.get.dispose(); + descriptor.set?.dispose(); + return result; + } + descriptor.set?.dispose(); + return descriptor.value; + }, + }; + + // --- workflow reducers (handle space) --- + + /** Host shape for error-family reduction; leaves may be handles. */ + const reduceErrorShape = ( + value: JSValueHandle + ): { message: string; stack?: string; cause?: unknown } => { + const shape: { message: string; stack?: string; cause?: unknown } = { + message: chainedString(value, 'message') ?? '', + }; + const stack = chainedString(value, 'stack'); + if (stack !== undefined) shape.stack = stack; + if (guestHasOwn(value, 'cause')) { + shape.cause = own(value, 'cause') ?? undefined; + } + return shape; + }; + + const namedErrorSubclassReducer = + (subclassName: string) => (value: unknown) => { + if (!isHandle(value) || !value.isError) return false; + if (chainedString(value, 'name') !== subclassName) return false; + return reduceErrorShape(value); + }; + + /** Own symbol-keyed data read returning a host string, or undefined. */ + const ownSymbolString = ( + value: JSValueHandle, + name: SymbolName + ): string | undefined => { + const handle = own(value, sym(name)); + if (!handle) return undefined; + const result = handle.isString ? handle.toString() : undefined; + handle.dispose(); + return result; + }; + + const reduceAbort = (value: JSValueHandle): unknown => { + // streamName/hookToken live on the signal (or the controller's signal). + const signal = own(value, 'signal'); + const holder = signal ?? value; + const streamName = + ownSymbolString(value, 'WORKFLOW_ABORT_STREAM_NAME') ?? + ownSymbolString(holder, 'WORKFLOW_ABORT_STREAM_NAME'); + const hookToken = + ownSymbolString(value, 'WORKFLOW_ABORT_HOOK_TOKEN') ?? + ownSymbolString(holder, 'WORKFLOW_ABORT_HOOK_TOKEN'); + if (!streamName) { + signal?.dispose(); + throw new Error('AbortController/AbortSignal stream name is not set'); + } + const aborted = + own(holder, 'aborted')?.consume((h) => h.toBoolean()) ?? false; + const reason = aborted ? own(holder, 'reason') : undefined; + if (signal && holder !== value) signal.dispose(); + return { + streamName, + hookToken, + aborted, + reason: aborted ? reason : undefined, + }; + }; + + const reducers: Record any> = { + // Order is wire-significant (first match wins) and mirrors + // codec-devalue-vm.ts getReducersForMode('workflow') exactly. + AbortController: (value) => { + if (!isHandle(value) || value.typeof !== 'object' || value.isNull) { + return false; + } + if (!guestHasOwn(value, 'signal')) return false; + const hasStamp = + ownSymbolString(value, 'WORKFLOW_ABORT_STREAM_NAME') !== undefined || + own(value, 'signal')?.consume( + (signal) => + ownSymbolString(signal, 'WORKFLOW_ABORT_STREAM_NAME') !== undefined + ); + if (!hasStamp) return false; + return reduceAbort(value); + }, + AbortSignal: (value) => { + if (!isHandle(value) || value.typeof !== 'object' || value.isNull) { + return false; + } + if (ownSymbolString(value, 'WORKFLOW_ABORT_STREAM_NAME') === undefined) { + return false; + } + return reduceAbort(value); + }, + Class: (value) => { + if (!isHandle(value) || value.typeof !== 'function') return false; + const classId = ownString(value, 'classId'); + if (classId === undefined) return false; + return { classId }; + }, + Instance: (value) => { + if (!isHandle(value) || value.typeof !== 'object' || value.isNull) { + return false; + } + const cls = chained(value, 'constructor'); + if (!cls || cls.typeof !== 'function') { + cls?.dispose(); + return false; + } + try { + const serializeMethod = chained(cls, sym('workflow-serialize')); + if (!serializeMethod || serializeMethod.typeof !== 'function') { + serializeMethod?.dispose(); + return false; + } + try { + const classId = chainedString(cls, 'classId'); + if (classId === undefined) { + const name = chainedString(cls, 'name') ?? ''; + throw new Error( + `Class "${name}" with Symbol(workflow-serialize) must have a static "classId" property.` + ); + } + // Guest code by contract: the class's own serializer runs, exactly + // as it did under the in-VM codec. + const data = call(serializeMethod, cls, value); + return { classId, data }; + } finally { + serializeMethod.dispose(); + } + } finally { + cls.dispose(); + } + }, + StepFunction: (value) => { + if (!isHandle(value) || value.typeof !== 'function') return false; + const stepId = ownString(value, 'stepId'); + if (stepId === undefined) return false; + const payload: { + stepId: string; + closureVars?: unknown; + boundThis?: unknown; + boundArgs?: unknown; + } = { stepId }; + const closureVarsFn = own(value, '__closureVarsFn'); + if (closureVarsFn) { + if (closureVarsFn.typeof === 'function') { + // Guest code by contract (same as the in-VM codec). + const closureVars = call(closureVarsFn, vm.undefined); + if (!closureVars.isUndefined) payload.closureVars = closureVars; + else closureVars.dispose(); + } + closureVarsFn.dispose(); + } + if (guestHasOwn(value, '__boundThis')) { + payload.boundThis = own(value, '__boundThis'); + } + const boundArgs = own(value, '__boundArgs'); + if (boundArgs) { + const length = + own(boundArgs, 'length')?.consume((h) => h.toNumber()) ?? 0; + if (boundArgs.isArray && length > 0) payload.boundArgs = boundArgs; + else boundArgs.dispose(); + } + return payload; + }, + ArrayBuffer: (value) => { + if (!isHandle(value) || tagOfHandle(value) !== 'ArrayBuffer') { + return false; + } + return bytesToBase64(new Uint8Array(value.toArrayBuffer())); + }, + BigInt: (value) => { + if (!isHandle(value) || !value.isBigInt) return false; + return guestBigInt(value).toString(); + }, + BigInt64Array: (value) => + isHandle(value) && tagOfHandle(value) === 'BigInt64Array' + ? bytesToBase64(viewBytes(value)) + : false, + BigUint64Array: (value) => + isHandle(value) && tagOfHandle(value) === 'BigUint64Array' + ? bytesToBase64(viewBytes(value)) + : false, + Date: (value) => { + if (!isHandle(value) || tagOfHandle(value) !== 'Date') return false; + const time = call(i.dateGetTime, value).consume((h) => h.toNumber()); + if (Number.isNaN(time)) return '.'; + return call(i.dateToISOString, value).consume((h) => h.toString()); + }, + DOMException: (value) => { + if (!isHandle(value) || !isHandle(value)) return false; + if ( + !i.domExceptionPrototype || + !hasPrototype(value, i.domExceptionPrototype) + ) { + return false; + } + const shape = reduceErrorShape(value) as Record; + return { + message: shape.message, + name: chainedString(value, 'name'), + stack: shape.stack, + ...(Object.hasOwn(shape, 'cause') ? { cause: shape.cause } : {}), + }; + }, + AggregateError: (value) => { + if (!isHandle(value) || !value.isError) return false; + if (chainedString(value, 'name') !== 'AggregateError') return false; + const shape = reduceErrorShape(value) as Record; + return { + message: shape.message, + stack: shape.stack, + errors: own(value, 'errors'), + ...(Object.hasOwn(shape, 'cause') ? { cause: shape.cause } : {}), + }; + }, + EvalError: namedErrorSubclassReducer('EvalError'), + FatalError: namedErrorSubclassReducer('FatalError'), + HookConflictError: (value) => { + if (!isHandle(value) || !value.isError) return false; + if (chainedString(value, 'name') !== 'HookConflictError') return false; + const shape = reduceErrorShape(value) as Record; + const reduced: Record = { + message: shape.message, + stack: shape.stack, + token: own(value, 'token'), + }; + const conflictingRunId = own(value, 'conflictingRunId'); + if (conflictingRunId && !conflictingRunId.isUndefined) { + reduced.conflictingRunId = conflictingRunId; + } else { + conflictingRunId?.dispose(); + } + if (Object.hasOwn(shape, 'cause')) reduced.cause = shape.cause; + return reduced; + }, + RangeError: namedErrorSubclassReducer('RangeError'), + ReferenceError: namedErrorSubclassReducer('ReferenceError'), + RetryableError: (value) => { + if (!isHandle(value) || !value.isError) return false; + if (chainedString(value, 'name') !== 'RetryableError') return false; + const shape = reduceErrorShape(value) as Record; + // retryAfter is a guest Date (or string/number); normalize to an epoch + // timestamp exactly like the in-VM reducer. + let retryAfter = Date.now() + 1000; + const raw = own(value, 'retryAfter'); + if (raw) { + if (tagOfHandle(raw) === 'Date') { + const t = call(i.dateGetTime, raw).consume((h) => h.toNumber()); + if (!Number.isNaN(t)) retryAfter = t; + } else if (raw.isString || raw.isNumber) { + const t = new Date( + raw.isString ? raw.toString() : raw.toNumber() + ).getTime(); + if (!Number.isNaN(t)) retryAfter = t; + } + raw.dispose(); + } + const reduced: Record = { + message: shape.message, + stack: shape.stack, + retryAfter, + }; + if (Object.hasOwn(shape, 'cause')) reduced.cause = shape.cause; + return reduced; + }, + RuntimeDecryptionError: (value) => { + if (!isHandle(value) || !value.isError) return false; + if (chainedString(value, 'name') !== 'RuntimeDecryptionError') { + return false; + } + const shape = reduceErrorShape(value) as Record; + const reduced: Record = { + message: shape.message, + stack: shape.stack, + }; + const context = own(value, 'context'); + if (context && !context.isUndefined) reduced.context = context; + else context?.dispose(); + if (Object.hasOwn(shape, 'cause')) reduced.cause = shape.cause; + return reduced; + }, + SyntaxError: namedErrorSubclassReducer('SyntaxError'), + TypeError: namedErrorSubclassReducer('TypeError'), + URIError: namedErrorSubclassReducer('URIError'), + Error: (value) => { + if (!isHandle(value) || !value.isError) return false; + const shape = reduceErrorShape(value) as Record; + return { + name: chainedString(value, 'name') ?? 'Error', + message: shape.message, + stack: shape.stack, + ...(Object.hasOwn(shape, 'cause') ? { cause: shape.cause } : {}), + }; + }, + Float32Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Float32Array' + ? bytesToBase64(viewBytes(value)) + : false, + Float64Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Float64Array' + ? bytesToBase64(viewBytes(value)) + : false, + Int8Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Int8Array' + ? bytesToBase64(viewBytes(value)) + : false, + Int16Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Int16Array' + ? bytesToBase64(viewBytes(value)) + : false, + Int32Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Int32Array' + ? bytesToBase64(viewBytes(value)) + : false, + Map: (value) => + isHandle(value) && tagOfHandle(value) === 'Map' + ? collect(i.mapForEach, value, 2) + : false, + RegExp: (value) => { + if (!isHandle(value) || tagOfHandle(value) !== 'RegExp') return false; + return { + source: call(i.regExpSource, value).consume((h) => h.toString()), + flags: call(i.regExpFlags, value).consume((h) => h.toString()), + }; + }, + Headers: (value) => { + if ( + !isHandle(value) || + !i.headersPrototype || + !hasPrototype(value, i.headersPrototype) || + !i.headersForEach + ) { + return false; + } + // Headers.forEach yields (value, key); normalize to [key, value] + // pairs of host strings, matching Array.from(headers). + const entries: [string, string][] = []; + const visitor = vm.newEphemeralFunction( + (headerValue: JSValueHandle, headerKey: JSValueHandle) => { + entries.push([headerKey.toString(), headerValue.toString()]); + return vm.undefined; + } + ); + try { + call(i.headersForEach, value, visitor).dispose(); + } finally { + visitor.dispose(); + } + return entries; + }, + Request: (value) => { + if (!isHandle(value) || value.typeof !== 'object' || value.isNull) { + return false; + } + const isRequest = + (i.requestPrototype && hasPrototype(value, i.requestPrototype)) || + own(value, 'json')?.consume((h) => h.typeof === 'function'); + if (!isRequest) return false; + const method = own(value, 'method'); + if (!method || !method.isString) { + method?.dispose(); + return false; + } + const data: Record = { + method, + url: own(value, 'url'), + headers: own(value, 'headers'), + body: own(value, 'body'), + duplex: own(value, 'duplex'), + }; + const responseWritable = own(value, sym('WEBHOOK_RESPONSE_WRITABLE')); + if (responseWritable && !responseWritable.isUndefined) { + data.responseWritable = responseWritable; + } else { + responseWritable?.dispose(); + } + return data; + }, + Response: (value) => { + if (!isHandle(value) || value.typeof !== 'object' || value.isNull) { + return false; + } + const isResponse = + (i.responsePrototype && hasPrototype(value, i.responsePrototype)) || + chained(value, 'clone')?.consume((h) => h.typeof === 'function'); + if (!isResponse) return false; + const status = own(value, 'status'); + if (!status || !status.isNumber) { + status?.dispose(); + return false; + } + return { + type: own(value, 'type'), + url: own(value, 'url'), + status, + statusText: own(value, 'statusText'), + headers: own(value, 'headers'), + body: own(value, 'body'), + redirected: own(value, 'redirected'), + }; + }, + ReadableStream: (value) => { + if ( + !isHandle(value) || + value.typeof !== 'object' || + value.isNull || + !i.readableStreamPrototype || + !hasPrototype(value, i.readableStreamPrototype) + ) { + return false; + } + const bodyInit = own(value, sym('BODY_INIT')); + if (bodyInit && !bodyInit.isUndefined) { + return { bodyInit }; + } + bodyInit?.dispose(); + const name = ownSymbolString(value, 'WORKFLOW_STREAM_NAME'); + if (name) { + const s: Record = { name }; + const type = ownSymbolString(value, 'WORKFLOW_STREAM_TYPE'); + if (type) s.type = type; + const framing = ownSymbolString(value, 'WORKFLOW_STREAM_FRAMING'); + if (framing) s.framing = framing; + return s; + } + return { name: '__empty' }; + }, + WritableStream: (value) => { + if ( + !isHandle(value) || + value.typeof !== 'object' || + value.isNull || + !i.writableStreamPrototype || + !hasPrototype(value, i.writableStreamPrototype) + ) { + return false; + } + const s: Record = { + name: ownSymbolString(value, 'WORKFLOW_STREAM_NAME') || '__empty', + }; + const runId = ownSymbolString(value, 'WORKFLOW_STREAM_SERVER_RUN_ID'); + if (runId) s.runId = runId; + const deploymentId = ownSymbolString( + value, + 'WORKFLOW_STREAM_SERVER_DEPLOYMENT_ID' + ); + if (deploymentId) s.deploymentId = deploymentId; + return s; + }, + Set: (value) => + isHandle(value) && tagOfHandle(value) === 'Set' + ? collect(i.setForEach, value, 1) + : false, + URL: (value) => { + if ( + !isHandle(value) || + !i.urlPrototype || + !hasPrototype(value, i.urlPrototype) || + !i.urlHref + ) { + return false; + } + return call(i.urlHref, value).consume((h) => h.toString()); + }, + WorkflowFunction: (value) => { + if (!isHandle(value) || value.typeof !== 'function') return false; + const workflowId = ownString(value, 'workflowId'); + if (workflowId === undefined) return false; + return { workflowId }; + }, + URLSearchParams: (value) => { + if ( + !isHandle(value) || + !i.urlSearchParamsPrototype || + !hasPrototype(value, i.urlSearchParamsPrototype) || + !i.urlSearchParamsToString + ) { + return false; + } + const size = i.urlSearchParamsSize + ? call(i.urlSearchParamsSize, value).consume((h) => h.toNumber()) + : Number.NaN; + if (size === 0) return '.'; + return call(i.urlSearchParamsToString, value).consume((h) => + h.toString() + ); + }, + Uint8Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Uint8Array' + ? bytesToBase64(viewBytes(value)) + : false, + Uint8ClampedArray: (value) => + isHandle(value) && tagOfHandle(value) === 'Uint8ClampedArray' + ? bytesToBase64(viewBytes(value)) + : false, + Uint16Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Uint16Array' + ? bytesToBase64(viewBytes(value)) + : false, + Uint32Array: (value) => + isHandle(value) && tagOfHandle(value) === 'Uint32Array' + ? bytesToBase64(viewBytes(value)) + : false, + }; + + // --- parse operations (handle-only: everything is built in the VM) --- + + const parseOperations: ParseOperations = { + fromPrimitive: (value) => + typeof value === 'bigint' ? vm.newBigInt(value) : vm.hostToHandle(value), + fromISOString: (iso) => { + const argument = + iso === '' ? vm.newNumber(Number.NaN) : vm.newString(iso); + try { + return vm.construct(i.Date, argument); + } finally { + argument.dispose(); + } + }, + fromStringValue: (tag, _string) => { + throw new Error(`${tag} cannot be revived in the VM`); + }, + fromArrayBuffer: (buffer) => vm.newArrayBuffer(buffer), + fromRegExpInfo: (source, flags) => { + const sourceHandle = vm.newString(source); + try { + if (!flags) return vm.construct(i.RegExp, sourceHandle); + const flagsHandle = vm.newString(flags); + try { + return vm.construct(i.RegExp, sourceHandle, flagsHandle); + } finally { + flagsHandle.dispose(); + } + } finally { + sourceHandle.dispose(); + } + }, + fromViewInfo: (tag, buffer, byteOffset, length) => { + const Constructor = typedArrayConstructors.get(tag); + if (!Constructor) throw new Error(`${tag} is not available in the VM`); + if (byteOffset === undefined) { + return vm.construct(Constructor, buffer as JSValueHandle); + } + const offsetHandle = vm.newNumber(byteOffset); + const lengthHandle = vm.newNumber(length ?? 0); + try { + return vm.construct( + Constructor, + buffer as JSValueHandle, + offsetHandle, + lengthHandle + ); + } finally { + offsetHandle.dispose(); + lengthHandle.dispose(); + } + }, + box: (value) => vm.construct(i.Object, value as JSValueHandle), + createArray: (length) => { + const lengthHandle = vm.newNumber(length); + try { + return vm.construct(i.Array, lengthHandle); + } finally { + lengthHandle.dispose(); + } + }, + createSparseArray: (length) => { + const lengthHandle = vm.newNumber(length); + try { + return invoke(i.makeSparseArray, lengthHandle); + } finally { + lengthHandle.dispose(); + } + }, + createObject: () => vm.newObject(), + createNullPrototypeObject: () => + call(i.objectCreate, vm.undefined, vm.null), + createSet: () => vm.construct(i.Set), + createMap: () => vm.construct(i.Map), + set: (target, key, value) => + define(target as JSValueHandle, String(key), value as JSValueHandle), + addValue: (set, value) => { + call(i.setAdd, set as JSValueHandle, value as JSValueHandle).dispose(); + }, + addEntry: (map, key, value) => { + call( + i.mapSet, + map as JSValueHandle, + key as JSValueHandle, + value as JSValueHandle + ).dispose(); + }, + }; + + // --- workflow revivers (handle space) --- + + /** Guest lookup of a registered error class on globalThis, by symbol. */ + const registeredErrorClass = ( + name: SymbolName + ): JSValueHandle | undefined => { + const cls = own(vm.global, sym(name)); + if (!cls || cls.typeof !== 'function') { + cls?.dispose(); + return undefined; + } + return cls; + }; + + /** Construct a guest Error via `ctor(message)` + define stack/cause. */ + const buildError = ( + ctor: JSValueHandle, + value: JSValueHandle, + opts: { name?: string; extraCtorArgs?: JSValueHandle[] } = {} + ): JSValueHandle => { + const message = own(value, 'message') ?? vm.undefined; + const error = vm.construct(ctor, message, ...(opts.extraCtorArgs ?? [])); + if (message !== vm.undefined) message.dispose(); + if (opts.name !== undefined) { + const nameHandle = newGuestString(opts.name); + define(error, 'name', nameHandle); + nameHandle.dispose(); + } + const stack = own(value, 'stack'); + if (stack && !stack.isUndefined) define(error, 'stack', stack); + stack?.dispose(); + if (guestHasOwn(value, 'cause')) { + const cause = own(value, 'cause') ?? vm.undefined; + define(error, 'cause', cause); + if (cause !== vm.undefined) cause.dispose(); + } + return error; + }; + + const namedErrorSubclassReviver = + (subclassName: string) => (value: JSValueHandle) => { + const ctor = errorConstructors.get(subclassName); + if (ctor) return buildError(ctor, value); + return buildError(i.Error, value, { name: subclassName }); + }; + + const reviveAbortSignal = (value: JSValueHandle): JSValueHandle => { + const cls = own(vm.global, '__WorkflowAbortSignal'); + if (!cls || cls.typeof !== 'function') { + cls?.dispose(); + throw new Error( + 'WorkflowAbortSignal is not registered in the VM (bootstrap not evaluated)' + ); + } + try { + const streamName = own(value, 'streamName') ?? vm.undefined; + const hookToken = own(value, 'hookToken') ?? vm.undefined; + const signal = vm.construct(cls, streamName, hookToken); + if (streamName !== vm.undefined) streamName.dispose(); + if (hookToken !== vm.undefined) hookToken.dispose(); + const aborted = own(value, 'aborted'); + if (aborted?.toBoolean()) { + const setAborted = chained(signal, '_setAborted'); + if (setAborted) { + const reason = own(value, 'reason') ?? vm.undefined; + call(setAborted, signal, reason).dispose(); + if (reason !== vm.undefined) reason.dispose(); + setAborted.dispose(); + } + } + aborted?.dispose(); + return signal; + } finally { + cls.dispose(); + } + }; + + const revivers: Record any> = { + AbortController: (value: JSValueHandle) => { + const controller = vm.newObject(); + const streamName = own(value, 'streamName') ?? vm.undefined; + define(controller, sym('WORKFLOW_ABORT_STREAM_NAME'), streamName); + if (streamName !== vm.undefined) streamName.dispose(); + const hookToken = own(value, 'hookToken') ?? vm.undefined; + define(controller, sym('WORKFLOW_ABORT_HOOK_TOKEN'), hookToken); + if (hookToken !== vm.undefined) hookToken.dispose(); + const signal = reviveAbortSignal(value); + define(controller, 'signal', signal); + signal.dispose(); + const noop = vm.evalCode('(function(){})'); + define(controller, 'abort', noop); + noop.dispose(); + return controller; + }, + AbortSignal: (value: JSValueHandle) => reviveAbortSignal(value), + Class: (value: JSValueHandle) => { + const classId = ownString(value, 'classId'); + const cls = lookupRegisteredClass(classId); + if (!cls) { + throw new Error( + `Class "${classId}" not found. Make sure the class is registered with registerSerializationClass.` + ); + } + return cls; + }, + Instance: (value: JSValueHandle) => { + const classId = ownString(value, 'classId'); + const cls = lookupRegisteredClass(classId); + if (!cls) { + throw new Error( + `Class "${classId}" not found. Make sure the class is registered with registerSerializationClass.` + ); + } + try { + const deserializeMethod = chained(cls, sym('workflow-deserialize')); + if (!deserializeMethod || deserializeMethod.typeof !== 'function') { + deserializeMethod?.dispose(); + throw new Error( + `Class "${classId}" does not have a static Symbol(workflow-deserialize) method.` + ); + } + try { + const data = own(value, 'data') ?? vm.undefined; + const result = call(deserializeMethod, cls, data); + if (data !== vm.undefined) data.dispose(); + return result; + } finally { + deserializeMethod.dispose(); + } + } finally { + cls.dispose(); + } + }, + StepFunction: (value: JSValueHandle) => { + const useStep = own(vm.global, sym('WORKFLOW_USE_STEP')); + if (!useStep || useStep.typeof !== 'function') { + useStep?.dispose(); + throw new Error( + 'WORKFLOW_USE_STEP not found on global object. Step functions cannot be deserialized outside workflow context.' + ); + } + try { + const stepId = own(value, 'stepId') ?? vm.undefined; + const closureVars = own(value, 'closureVars'); + let proxy: JSValueHandle; + if (closureVars && !closureVars.isUndefined) { + const thunk = invoke(i.makeThunk, closureVars); + proxy = invoke(useStep, stepId, thunk); + thunk.dispose(); + } else { + proxy = invoke(useStep, stepId); + } + closureVars?.dispose(); + if (stepId !== vm.undefined) stepId.dispose(); + if (guestHasOwn(value, 'boundThis')) { + // Re-bind through the proxy's own (overridden) `.bind`, which is + // an own data property stamped by WORKFLOW_USE_STEP. + const bind = own(proxy, 'bind') ?? chained(proxy, 'bind'); + const boundThis = own(value, 'boundThis') ?? vm.undefined; + const boundArgs = own(value, 'boundArgs'); + const args: JSValueHandle[] = [boundThis]; + const argHandles: JSValueHandle[] = []; + if (boundArgs && boundArgs.isArray) { + const length = + own(boundArgs, 'length')?.consume((h) => h.toNumber()) ?? 0; + for (let index = 0; index < length; index++) { + const element = own(boundArgs, String(index)) ?? vm.undefined; + args.push(element); + if (element !== vm.undefined) argHandles.push(element); + } + } + boundArgs?.dispose(); + if (bind) { + const bound = call(bind, proxy, ...args); + bind.dispose(); + proxy.dispose(); + proxy = bound; + } + if (boundThis !== vm.undefined) boundThis.dispose(); + for (const handle of argHandles) handle.dispose(); + } + return proxy; + } finally { + useStep.dispose(); + } + }, + ArrayBuffer: (value: string | JSValueHandle) => + vm.newArrayBuffer( + base64ToBytes(isHandle(value) ? value.toString() : value) + .buffer as ArrayBuffer + ), + BigInt: (value: string | JSValueHandle) => + vm.newBigInt(BigInt(isHandle(value) ? value.toString() : value)), + BigInt64Array: (value: string | JSValueHandle) => + buildTypedArray('BigInt64Array', value), + BigUint64Array: (value: string | JSValueHandle) => + buildTypedArray('BigUint64Array', value), + Date: (value: JSValueHandle | string) => { + // The reducer emits '.' for invalid dates and an ISO string otherwise. + const iso = isHandle(value) ? value.toString() : value; + const argument = + iso === '.' ? vm.newNumber(Number.NaN) : vm.newString(iso); + try { + return vm.construct(i.Date, argument); + } finally { + argument.dispose(); + if (isHandle(value)) value.dispose(); + } + }, + DOMException: (value: JSValueHandle) => { + if (i.DOMException) { + const message = own(value, 'message') ?? vm.undefined; + const name = own(value, 'name') ?? vm.undefined; + const error = vm.construct(i.DOMException, message, name); + if (message !== vm.undefined) message.dispose(); + if (name !== vm.undefined) name.dispose(); + const stack = own(value, 'stack'); + if (stack && !stack.isUndefined) define(error, 'stack', stack); + stack?.dispose(); + if (guestHasOwn(value, 'cause')) { + const cause = own(value, 'cause') ?? vm.undefined; + define(error, 'cause', cause); + if (cause !== vm.undefined) cause.dispose(); + } + return error; + } + return buildError(i.Error, value, { + name: ownString(value, 'name') ?? 'DOMException', + }); + }, + AggregateError: (value: JSValueHandle) => { + const errors = own(value, 'errors'); + const errorsArg = + errors && !errors.isUndefined ? errors : vm.evalCode('([])'); + const message = own(value, 'message') ?? vm.undefined; + const ctor = i.AggregateError ?? i.Error; + const error = + ctor === i.AggregateError + ? vm.construct(ctor, errorsArg, message) + : buildError(i.Error, value, { name: 'AggregateError' }); + if (ctor === i.AggregateError) { + const stack = own(value, 'stack'); + if (stack && !stack.isUndefined) define(error, 'stack', stack); + stack?.dispose(); + if (guestHasOwn(value, 'cause')) { + const cause = own(value, 'cause') ?? vm.undefined; + define(error, 'cause', cause); + if (cause !== vm.undefined) cause.dispose(); + } + } + if (message !== vm.undefined) message.dispose(); + errorsArg.dispose(); + return error; + }, + EvalError: namedErrorSubclassReviver('EvalError'), + FatalError: (value: JSValueHandle) => { + const cls = registeredErrorClass('@workflow/errors//FatalError'); + const error = cls + ? buildError(cls, value) + : buildError(i.Error, value, { name: 'FatalError' }); + cls?.dispose(); + return error; + }, + HookConflictError: (value: JSValueHandle) => { + const cls = registeredErrorClass('@workflow/errors//HookConflictError'); + let error: JSValueHandle; + if (cls) { + // Constructor takes (token, conflictingRunId). + const token = own(value, 'token') ?? vm.undefined; + const conflictingRunId = own(value, 'conflictingRunId') ?? vm.undefined; + error = vm.construct(cls, token, conflictingRunId); + if (token !== vm.undefined) token.dispose(); + if (conflictingRunId !== vm.undefined) conflictingRunId.dispose(); + const stack = own(value, 'stack'); + if (stack && !stack.isUndefined) define(error, 'stack', stack); + stack?.dispose(); + if (guestHasOwn(value, 'cause')) { + const cause = own(value, 'cause') ?? vm.undefined; + define(error, 'cause', cause); + if (cause !== vm.undefined) cause.dispose(); + } + cls.dispose(); + } else { + error = buildError(i.Error, value, { name: 'HookConflictError' }); + const token = own(value, 'token'); + if (token) { + define(error, 'token', token); + token.dispose(); + } + const conflictingRunId = own(value, 'conflictingRunId'); + if (conflictingRunId && !conflictingRunId.isUndefined) { + define(error, 'conflictingRunId', conflictingRunId); + } + conflictingRunId?.dispose(); + } + return error; + }, + RangeError: namedErrorSubclassReviver('RangeError'), + ReferenceError: namedErrorSubclassReviver('ReferenceError'), + RetryableError: (value: JSValueHandle) => { + const retryAfterMs = + own(value, 'retryAfter')?.consume((h) => + h.isNumber ? h.toNumber() : Number.NaN + ) ?? Number.NaN; + const timeHandle = vm.newNumber(retryAfterMs); + const retryAfterDate = vm.construct(i.Date, timeHandle); + timeHandle.dispose(); + const cls = registeredErrorClass('@workflow/errors//RetryableError'); + let error: JSValueHandle; + if (cls) { + // Constructor takes (message, { retryAfter }). + const options = vm.newObject(); + options.setProp('retryAfter', retryAfterDate); + const message = own(value, 'message') ?? vm.undefined; + error = vm.construct(cls, message, options); + if (message !== vm.undefined) message.dispose(); + options.dispose(); + const stack = own(value, 'stack'); + if (stack && !stack.isUndefined) define(error, 'stack', stack); + stack?.dispose(); + if (guestHasOwn(value, 'cause')) { + const cause = own(value, 'cause') ?? vm.undefined; + define(error, 'cause', cause); + if (cause !== vm.undefined) cause.dispose(); + } + cls.dispose(); + } else { + error = buildError(i.Error, value, { name: 'RetryableError' }); + define(error, 'retryAfter', retryAfterDate); + } + retryAfterDate.dispose(); + return error; + }, + RuntimeDecryptionError: (value: JSValueHandle) => { + const cls = registeredErrorClass( + '@workflow/errors//RuntimeDecryptionError' + ); + let error: JSValueHandle; + if (cls) { + // Constructor takes (message, { cause, context }). + const options = vm.newObject(); + const context = own(value, 'context'); + if (context && !context.isUndefined) { + options.setProp('context', context); + } + context?.dispose(); + if (guestHasOwn(value, 'cause')) { + const cause = own(value, 'cause') ?? vm.undefined; + options.setProp('cause', cause); + if (cause !== vm.undefined) cause.dispose(); + } + const message = own(value, 'message') ?? vm.undefined; + error = vm.construct(cls, message, options); + if (message !== vm.undefined) message.dispose(); + options.dispose(); + const stack = own(value, 'stack'); + if (stack && !stack.isUndefined) define(error, 'stack', stack); + stack?.dispose(); + cls.dispose(); + } else { + error = buildError(i.Error, value, { name: 'RuntimeDecryptionError' }); + const context = own(value, 'context'); + if (context && !context.isUndefined) { + define(error, 'context', context); + } + context?.dispose(); + } + return error; + }, + SyntaxError: namedErrorSubclassReviver('SyntaxError'), + TypeError: namedErrorSubclassReviver('TypeError'), + URIError: namedErrorSubclassReviver('URIError'), + Error: (value: JSValueHandle) => + buildError(i.Error, value, { + name: ownString(value, 'name') ?? 'Error', + }), + Float32Array: (value: string | JSValueHandle) => + buildTypedArray('Float32Array', value), + Float64Array: (value: string | JSValueHandle) => + buildTypedArray('Float64Array', value), + Int8Array: (value: string | JSValueHandle) => + buildTypedArray('Int8Array', value), + Int16Array: (value: string | JSValueHandle) => + buildTypedArray('Int16Array', value), + Int32Array: (value: string | JSValueHandle) => + buildTypedArray('Int32Array', value), + Map: (value: JSValueHandle) => { + // value is a guest array of [k, v] arrays. + const map = vm.construct(i.Map); + const length = own(value, 'length')?.consume((h) => h.toNumber()) ?? 0; + for (let index = 0; index < length; index++) { + const entry = own(value, String(index)); + if (!entry) continue; + const key = own(entry, '0') ?? vm.undefined; + const entryValue = own(entry, '1') ?? vm.undefined; + call(i.mapSet, map, key, entryValue).dispose(); + if (key !== vm.undefined) key.dispose(); + if (entryValue !== vm.undefined) entryValue.dispose(); + entry.dispose(); + } + return map; + }, + RegExp: (value: JSValueHandle) => { + const source = own(value, 'source') ?? vm.undefined; + const flags = own(value, 'flags') ?? vm.undefined; + const regexp = vm.construct(i.RegExp, source, flags); + if (source !== vm.undefined) source.dispose(); + if (flags !== vm.undefined) flags.dispose(); + return regexp; + }, + Set: (value: JSValueHandle) => { + const set = vm.construct(i.Set); + const length = own(value, 'length')?.consume((h) => h.toNumber()) ?? 0; + for (let index = 0; index < length; index++) { + const element = own(value, String(index)) ?? vm.undefined; + call(i.setAdd, set, element).dispose(); + if (element !== vm.undefined) element.dispose(); + } + return set; + }, + URL: (value: JSValueHandle | string) => { + if (!i.URL) throw new Error('URL is not available in the VM'); + const href = isHandle(value) ? value : vm.newString(value); + try { + return vm.construct(i.URL, href); + } finally { + if (!isHandle(value)) href.dispose(); + } + }, + WorkflowFunction: (value: JSValueHandle) => { + const workflowId = own(value, 'workflowId') ?? vm.undefined; + const throwerFactory = vm.evalCode( + `(function(workflowId) { + var f = function() { + throw new Error('Workflow functions cannot be called directly. Use start() to invoke them.'); + }; + f.workflowId = workflowId; + return f; + })` + ); + try { + return invoke(throwerFactory, workflowId); + } finally { + throwerFactory.dispose(); + if (workflowId !== vm.undefined) workflowId.dispose(); + } + }, + URLSearchParams: (value: JSValueHandle | string) => { + if (!i.URLSearchParams) { + throw new Error('URLSearchParams is not available in the VM'); + } + const raw = isHandle(value) ? value.toString() : value; + const init = vm.newString(raw === '.' ? '' : raw); + try { + return vm.construct(i.URLSearchParams, init); + } finally { + init.dispose(); + if (isHandle(value)) value.dispose(); + } + }, + Uint8Array: (value: string | JSValueHandle) => + buildTypedArray('Uint8Array', value), + Uint8ClampedArray: (value: string | JSValueHandle) => + buildTypedArray('Uint8ClampedArray', value), + Uint16Array: (value: string | JSValueHandle) => + buildTypedArray('Uint16Array', value), + Uint32Array: (value: string | JSValueHandle) => + buildTypedArray('Uint32Array', value), + Headers: (value: JSValueHandle) => { + if (!i.Headers) throw new Error('Headers is not available in the VM'); + return vm.construct(i.Headers, value); + }, + Request: (value: JSValueHandle) => { + // Mirror the in-VM reviver: mutate the parsed object into a + // Request-alike by attaching the prototype methods directly. + if (i.requestPrototype) { + for (const method of ['json', 'text', 'arrayBuffer']) { + const fn = own(i.requestPrototype, method); + if (fn && fn.typeof === 'function') define(value, method, fn); + fn?.dispose(); + } + } + const responseWritable = own(value, 'responseWritable'); + if (responseWritable && !responseWritable.isUndefined) { + define(value, sym('WEBHOOK_RESPONSE_WRITABLE'), responseWritable); + } + responseWritable?.dispose(); + return value.dup(); + }, + Response: (value: JSValueHandle) => { + if (i.responsePrototype) { + for (const method of [ + 'json', + 'text', + 'arrayBuffer', + 'bytes', + 'clone', + ]) { + const fn = own(i.responsePrototype, method); + if (fn && fn.typeof === 'function') define(value, method, fn); + fn?.dispose(); + } + } + const body = own(value, 'body') ?? vm.undefined; + define(value, '_body', body); + if (body !== vm.undefined) body.dispose(); + const status = own(value, 'status')?.consume((h) => h.toNumber()) ?? 0; + const ok = status >= 200 && status < 300 ? vm.true : vm.false; + define(value, 'ok', ok); + define(value, 'bodyUsed', vm.false); + return value.dup(); + }, + ReadableStream: (value: JSValueHandle) => { + const prototype = i.readableStreamPrototype ?? vm.null; + const stream = call(i.objectCreate, vm.undefined, prototype); + const bodyInit = own(value, 'bodyInit'); + if (bodyInit && !bodyInit.isUndefined) { + define(stream, sym('BODY_INIT'), bodyInit); + bodyInit.dispose(); + return stream; + } + bodyInit?.dispose(); + const name = own(value, 'name'); + if (name && !name.isUndefined) { + define(stream, sym('WORKFLOW_STREAM_NAME'), name); + const type = own(value, 'type'); + if (type && !type.isUndefined) { + define(stream, sym('WORKFLOW_STREAM_TYPE'), type); + } + type?.dispose(); + const framing = own(value, 'framing'); + if (framing && !framing.isUndefined) { + define(stream, sym('WORKFLOW_STREAM_FRAMING'), framing); + } + framing?.dispose(); + } + name?.dispose(); + return stream; + }, + WritableStream: (value: JSValueHandle) => { + const prototype = i.writableStreamPrototype ?? vm.null; + const stream = call(i.objectCreate, vm.undefined, prototype); + const name = own(value, 'name'); + if (name && !name.isUndefined) { + define(stream, sym('WORKFLOW_STREAM_NAME'), name); + } + name?.dispose(); + const runId = own(value, 'runId'); + if (runId?.isString) { + define(stream, sym('WORKFLOW_STREAM_SERVER_RUN_ID'), runId); + } + runId?.dispose(); + const deploymentId = own(value, 'deploymentId'); + if (deploymentId?.isString) { + define( + stream, + sym('WORKFLOW_STREAM_SERVER_DEPLOYMENT_ID'), + deploymentId + ); + } + deploymentId?.dispose(); + return stream; + }, + }; + + function buildTypedArray( + tag: string, + base64: string | JSValueHandle + ): JSValueHandle { + const Constructor = typedArrayConstructors.get(tag); + if (!Constructor) throw new Error(`${tag} is not available in the VM`); + // Parse operations build guest values, so a reduced base64 payload + // arrives as a guest string handle. + const raw = isHandle(base64) ? base64.toString() : base64; + const bytes = base64ToBytes(raw); + const buffer = vm.newArrayBuffer( + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength + ) as ArrayBuffer + ); + try { + return vm.construct(Constructor, buffer); + } finally { + buffer.dispose(); + } + } + + function lookupRegisteredClass( + classId: string | undefined + ): JSValueHandle | undefined { + if (classId === undefined) return undefined; + const registry = own(vm.global, sym('workflow-class-registry')); + if (!registry || registry.isUndefined) { + registry?.dispose(); + return undefined; + } + try { + const getMethod = chained(registry, 'get'); + if (!getMethod) return undefined; + try { + const key = vm.newString(classId); + const cls = call(getMethod, registry, key); + key.dispose(); + if (cls.isUndefined || cls.typeof !== 'function') { + cls.dispose(); + return undefined; + } + return cls; + } finally { + getMethod.dispose(); + } + } finally { + registry.dispose(); + } + } + + // --- public API --- + + return { + reducerKeys: Object.keys(reducers), + reviverKeys: Object.keys(revivers), + serialize(value: JSValueHandle): Uint8Array { + const payload = encoder.encode( + stringify(value, reducers, { operations: stringifyOperations }) + ); + const prefix = encoder.encode(SerializationFormat.DEVALUE_V1); + const result = new Uint8Array(prefix.length + payload.length); + result.set(prefix, 0); + result.set(payload, prefix.length); + return result; + }, + deserialize(data: Uint8Array): JSValueHandle { + if (data.length < FORMAT_PREFIX_LENGTH) { + throw new Error('Data too short to contain format prefix'); + } + const prefix = decoder.decode(data.subarray(0, FORMAT_PREFIX_LENGTH)); + if (prefix !== SerializationFormat.DEVALUE_V1) { + throw new Error(`Unsupported serialization format: ${prefix}`); + } + const payload = decoder.decode(data.subarray(FORMAT_PREFIX_LENGTH)); + return parse(payload, revivers, { + operations: parseOperations, + }) as JSValueHandle; + }, + dispose() { + for (const handle of disposables.reverse()) handle.dispose(); + disposables.length = 0; + identities.clear(); + }, + }; +} diff --git a/packages/core/src/serialization/codec-devalue-vm.ts b/packages/core/src/serialization/codec-devalue-vm.ts index c44ed61b9a..892ca3fed4 100644 --- a/packages/core/src/serialization/codec-devalue-vm.ts +++ b/packages/core/src/serialization/codec-devalue-vm.ts @@ -143,6 +143,18 @@ function getReviversForMode(mode: SerializationMode): Partial { } } +/** + * The workflow-mode reducer/reviver key sets — exported for the QuickJS + * host serde's exhaustiveness test (quickjs-serde.test.ts), which pins + * that the handle-space codec implements exactly these. + */ +export function getWorkflowModeReducerKeys(): string[] { + return Object.keys(getReducersForMode('workflow')); +} +export function getWorkflowModeReviverKeys(): string[] { + return Object.keys(getReviversForMode('workflow')); +} + export const devalueVmCodec: Codec = { formatPrefix: SerializationFormat.DEVALUE_V1, diff --git a/packages/core/src/serialization/vm-bundle-entry.ts b/packages/core/src/serialization/vm-bundle-entry.ts deleted file mode 100644 index 081a66f40f..0000000000 --- a/packages/core/src/serialization/vm-bundle-entry.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Entry point for the VM serialization bundle. - * - * This file is bundled by esbuild into a self-contained IIFE that - * sets up serialize/deserialize on globalThis. The bundled output - * is evaluated inside the QuickJS VM during bootstrap. - * - * TextEncoder, TextDecoder, and Headers are provided by native C - * extensions in quickjs-wasi, so no polyfills are needed. - */ - -import { monotonicFactory } from 'ulid'; -import { deserialize, serialize } from './workflow-vm.js'; - -// Install on global scope under the public well-known symbols. The -// snapshot runtime's bootstrap (and the various inline-evaluated JS -// strings in `snapshot-runtime.ts`) reach the same functions via -// `globalThis[Symbol.for('workflow-serialize')]` etc. -(globalThis as any)[Symbol.for('workflow-serialize')] = serialize; -(globalThis as any)[Symbol.for('workflow-deserialize')] = deserialize; - -// ULID generator for correlationIds — uses the same monotonicFactory -// as the node:vm engine. Both inputs MUST be set by the host before the -// first ULID is drawn, otherwise the seeded-ULID determinism guarantee -// is silently broken: -// -// * `Math.random` must be replaced with the host's seeded PRNG via -// `vm.newFunction('random', …)` (see `quickjs-runtime.ts`, the -// `Seeded Math.random` block). Two workflow invocations of the same -// run MUST observe an identical random sequence so their -// correlationIds collide and the world's EntityConflictError dedup -// applies. We pass it explicitly to `monotonicFactory` because -// ULID's auto-detect (`detectPRNG`) only knows about -// `crypto.getRandomValues` / `crypto.randomBytes`, neither of which -// exist in QuickJS. The PRNG is deliberately LATE-BOUND (the arrow -// reads `Math.random` at draw time, not at bundle-eval time) so -// that this bundle can be evaluated during static VM initialization -// — before the per-run seeded PRNG is installed — without capturing -// the unseeded built-in. This is also what allows a future VM -// snapshot taken after bundle eval to have its PRNG swapped -// post-restore. -// * `globalThis.__ulidTimestamp` must be a number (typically -// `workflowRun.startedAt`). It's used in place of `Date.now()` so -// the time portion of the ULID is also stable across concurrent -// invocations of the same run. -// -// The timestamp prerequisite is validated below — fail loudly rather -// than fall back to `Date.now()`, which would re-introduce -// non-determinism that replay relies on us NOT having. -const ulid = monotonicFactory(() => Math.random()); -(globalThis as any).__generateUlid = () => { - const t = (globalThis as any).__ulidTimestamp; - if (typeof t !== 'number') { - throw new Error( - '__generateUlid: globalThis.__ulidTimestamp must be a number set by ' + - 'the host before the serde bundle is evaluated. Without it, ULIDs ' + - 'would fall back to Date.now() and concurrent workflow invocations ' + - 'of the same resumption would produce divergent correlationIds.' - ); - } - return ulid(t); -}; diff --git a/packages/core/src/serialization/workflow-vm.ts b/packages/core/src/serialization/workflow-vm.ts index 3aaf5bc730..7ebf7c938f 100644 --- a/packages/core/src/serialization/workflow-vm.ts +++ b/packages/core/src/serialization/workflow-vm.ts @@ -1,8 +1,13 @@ /** - * VM-compatible workflow mode serialization. + * Host-side reference implementation of the QuickJS engine's workflow-mode + * wire codec. * - * This module is designed to be bundled into the QuickJS WASM VM. - * It has NO Node.js dependencies (no Buffer, no node:util). + * The QuickJS engine serializes through handles on the host + * (runtime/quickjs-serde.ts); this module is the value-space equivalent of + * that codec and is used by tests to build wire fixtures and assert + * byte-level parity. It has NO Node.js dependencies (no Buffer, no + * node:util), which is also what made it bundleable into the VM before the + * serde moved host-side. * * Produces and consumes the same wire format as the Node.js workflow.ts — * format-prefixed devalue data ("devl" + devalue.stringify output). diff --git a/packages/core/turbo.json b/packages/core/turbo.json index aa04cd0e81..92e81568fa 100644 --- a/packages/core/turbo.json +++ b/packages/core/turbo.json @@ -6,7 +6,6 @@ "outputs": [ "dist", "src/version.ts", - "src/runtime/vm-serde-bundle.generated.ts", "src/runtime/quickjs-assets.generated.ts" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce61056f5b..272a356de0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -556,8 +556,8 @@ importers: specifier: 5.1.6 version: 5.1.6 quickjs-wasi: - specifier: 3.1.0 - version: 3.1.0 + specifier: 3.3.0 + version: 3.3.0 seedrandom: specifier: 3.0.5 version: 3.0.5 @@ -15054,8 +15054,8 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - quickjs-wasi@3.1.0: - resolution: {integrity: sha512-Vw2g4GhAh/QVgPIoDRgpPMBv9Z+E1LjUGgwrLewjjvTqONtty0GukgE+2IoZU1Z4anNF3uZIWA5EtBa3m0QiWQ==} + quickjs-wasi@3.3.0: + resolution: {integrity: sha512-IYbBhELZIWUHLriW3TA3Fsg/lhFxWHtc4U++VtdoIwDMiatb0xJKskgHVREl1R4xXmnUvzmtuERYHEDuYkqyOA==} radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} @@ -33226,7 +33226,7 @@ snapshots: quick-lru@5.1.1: {} - quickjs-wasi@3.1.0: {} + quickjs-wasi@3.3.0: {} radix-ui@1.4.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: