diff --git a/.changeset/quickjs-host-serde.md b/.changeset/quickjs-host-serde.md new file mode 100644 index 0000000000..8039eb957e --- /dev/null +++ b/.changeset/quickjs-host-serde.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +QuickJS engine: move serialization out of the VM onto the host, replacing the in-VM serde bundle with side-effect-free handle introspection (same wire format, 2–100× faster). 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..17ca9364e9 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.1", "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 b885142be3..4bfda57405 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 ---- @@ -259,17 +262,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"; @@ -425,13 +428,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, @@ -642,16 +646,15 @@ globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")] = function(options) { } } - // 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, tokenRetentionUntil: tokenRetentionUntil, isWebhook: !!options.isWebhook, - metadata: options.metadata ? globalThis[Symbol.for("workflow-serialize")](options.metadata) : undefined, + metadata: options.metadata, hasCreatedEvent: false, }; globalThis.__pending.push(pendingOp); @@ -852,8 +855,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 @@ -884,17 +887,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; } } @@ -967,9 +970,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 @@ -1062,9 +1066,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(); @@ -1158,6 +1159,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 @@ -1191,18 +1197,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 @@ -1255,7 +1270,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) { @@ -1314,24 +1331,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, }; } ); @@ -1352,6 +1375,7 @@ export async function startQuickJSWorkflow( do { madeProgress = await processEvents( vm, + serde, events, advanceClock, options.encryptionKey @@ -1380,6 +1404,7 @@ export async function startQuickJSWorkflow( // ---- Check result ---- return makeLiveSession( vm, + serde, interruptBudget, advanceClock, options.encryptionKey @@ -1409,11 +1434,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 = { @@ -1435,6 +1461,7 @@ function makeLiveSession( do { madeProgress = await processEvents( vm, + serde, newEvents, advanceClock, encryptionKey @@ -1446,7 +1473,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; @@ -1469,6 +1498,7 @@ function makeLiveSession( async function processEvents( vm: QuickJS, + serde: QuickJSSerde, events: Event[], advanceClock: (ms: number) => void, encryptionKey?: DecryptionKey @@ -1519,11 +1549,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(); @@ -1561,11 +1591,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 { @@ -1593,13 +1625,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;` + `})()` @@ -1643,11 +1674,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 { @@ -1809,12 +1842,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);` + `})()` @@ -1873,11 +1906,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(); @@ -1924,17 +1957,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 { @@ -2090,8 +2122,123 @@ 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 = []; + // Settled ops — created, resolver-less, no abort in flight — are + // never collected again by either the suspension or the drain + // filter, so their cached bytes are dead weight; surface their cids + // so the host can evict them (see the byte-cache eviction below). + var settled = []; + globalThis.__pending.forEach(function(p){ + if (p.hasCreatedEvent && !globalThis.__resolvers[p.correlationId] && !p.abortRequested) { + settled.push(p.correlationId); + } + }); + return { settled: settled, ops: 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 dumped = vm.dump(projected) as { + settled: string[]; + ops: (PendingOperation & { + __rawIndices?: Record; + })[]; + }; + const plainOps = dumped.ops; + // Byte-cache eviction: entries for settled ops can never be read again + // (neither collection filter matches a settled op), so dropping them + // bounds the cache by the LIVE pending set instead of growing + // monotonically for the VM's lifetime — which matters for the inline + // loop's long-lived sessions and snapshot-restored VMs. + if (byteCache && dumped.settled.length > 0) { + for (const cid of dumped.settled) { + for (const field of RAW_PENDING_FIELDS) { + byteCache.delete(`${cid}:${field}`); + } + } + } + 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[] { + // Share the per-VM byte cache with the suspension path: an op that was + // serialized during a suspension pass must reuse those exact bytes at + // terminal drain — re-serializing can invoke getters again and produce + // a DIFFERENT byte sequence for what the event log treats as one value. + return dumpPendingOps( + vm, + serde, + `(function(){ var toDispose = []; globalThis.__pending.forEach(function(p){ if (p.type === "hook" && p.isSystem && !p.abortRequested && !p.disposed) { @@ -2115,20 +2262,25 @@ function collectDrainOperations(vm: QuickJS): PendingOperation[] { if (p.type === "hook" && p.disposed) return false; return true; }); - })()`); - return vm.dump(h) as PendingOperation[]; + })()`, + ensurePendingByteCache(vm) + ); } 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: { @@ -2143,14 +2295,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 } @@ -2158,14 +2335,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: { @@ -2184,13 +2361,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..2ce2164a8c --- /dev/null +++ b/packages/core/src/runtime/quickjs-serde.test.ts @@ -0,0 +1,630 @@ +/** + * 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('NUL (U+0000) safety across the WASM boundary', () => { + // `JS_ToCString` is NUL-terminated: naive extraction truncates guest + // strings at the first U+0000 and mangles NUL-bearing property keys + // (truncated keys either drop — the truncated name fails the + // enumerability probe — or collide with a sibling key). These pin the + // guestString length-check fallback and the shapeOf/get/hasOwn + // handle-keyed paths. Regression: nullByteWorkflow failing on every + // quickjs e2e leg. + + it('round-trips NUL-bearing string values (guest → bytes → guest)', () => { + const bytes = serializeGuest(`(() => ({ + middle: "ab\u0000cd", + leading: "\u0000x", + trailing: "x\u0000", + only: "\u0000", + multi: "a\u0000b\u0000c", + }))()`); + expect( + checkInGuest( + bytes, + `function (v) { + return [ + v.middle === "ab\u0000cd", + v.leading === "\u0000x", + v.trailing === "x\u0000", + v.only === "\u0000", + v.multi === "a\u0000b\u0000c", + ].every(Boolean); + }` + ) + ).toBe(true); + }); + + it('matches the reference codec byte-for-byte on NUL strings', () => { + const guestBytes = serializeGuest(`("ab\u0000cd")`); + const referenceBytes = referenceSerialize('abcd'); + expect(Buffer.from(guestBytes).toString('utf8')).toBe( + Buffer.from(referenceBytes).toString('utf8') + ); + }); + + it('round-trips NUL-bearing object keys, including the collision shape', () => { + // "a\u0000b" truncates to "a" — with a REAL sibling "a" present the + // truncated key collides instead of dropping, which is the harder + // detection case for the enumeration fast path. + const bytes = serializeGuest(`(() => ({ + "a\u0000b": "nul-key-value", + a: "plain-key-value", + normal: 1, + }))()`); + expect( + checkInGuest( + bytes, + `function (v) { + return { + nulKey: v["a\u0000b"], + plain: v.a, + normal: v.normal, + keyCount: Object.keys(v).length, + }; + }` + ) + ).toEqual({ + nulKey: 'nul-key-value', + plain: 'plain-key-value', + normal: 1, + keyCount: 3, + }); + }); + + it('round-trips a NUL key that would otherwise silently drop', () => { + const bytes = serializeGuest(`(() => ({ "k\u0000": 42 }))()`); + expect(checkInGuest(bytes, `function (v) { return v["k\u0000"]; }`)).toBe( + 42 + ); + }); + + it('deserializes reference-codec NUL keys into the guest correctly', () => { + const referenceBytes = referenceSerialize({ 'xy': 'v' }); + expect( + checkInGuest(referenceBytes, `function (v) { return v["x\u0000y"]; }`) + ).toBe('v'); + }); +}); + +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..6d1a51a54c --- /dev/null +++ b/packages/core/src/runtime/quickjs-serde.ts @@ -0,0 +1,2118 @@ +/** + * 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, + jsonStringify: JSON.stringify, + objectKeys: Object.keys, + dateNow: Date.now, + hasOwnCall: (o, k) => Object.prototype.hasOwnProperty.call(o, k), + 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'), + jsonStringify: at('jsonStringify'), + objectKeys: at('objectKeys'), + dateNow: at('dateNow'), + hasOwnCall: at('hasOwnCall'), + 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); + + /** + * NUL-safe host string from a guest string handle. `handle.toString()` + * routes through `JS_ToCString`, which is NUL-terminated — a guest + * string containing U+0000 arrives silently truncated. Detection is + * cheap: `handle.length` is the true guest length (UTF-16 code units), + * so a mismatch against the extracted string's length means data was + * lost. The recovery path escapes the string INSIDE the VM with the + * captured `JSON.stringify` (the escaped form is NUL-free by + * construction, so its own extraction cannot truncate) and parses it + * host-side. NUL-free strings — the overwhelming majority — pay only + * the `.length` read. + */ + const guestString = (handle: JSValueHandle): string => { + const fast = handle.toString(); + if (fast.length === handle.length) return fast; + return JSON.parse( + invoke(i.jsonStringify, handle).consume((h) => h.toString()) + ) as string; + }; + + /** + * 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 ? guestString(handle) : 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 ? guestString(handle) : 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) => { + // dup(): the visitor's own arg handles are borrowed (C-owned, + // scope-exempt as of quickjs-wasi 3.3.1); the dups are owned + // references, scope-tracked and swept at the pass boundary. + 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 guestString(handle); + }; + + /** + * 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(guestString), + flags: call(i.regExpFlags, value).consume(guestString), + }; + }, + 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) => { + if (!isHandle(value)) return d.hasOwn(value, key); + if (typeof key === 'string' && key.includes('\u0000')) { + // C-string key APIs truncate at NUL — check inside the guest. + const keyHandle = vm.newString(key); + try { + return invoke(i.hasOwnCall, value, keyHandle).consume((h) => + h.toBoolean() + ); + } finally { + keyHandle.dispose(); + } + } + return guestHasOwn(value, String(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); + } + // NUL-key guard: the enumeration above yields HOST strings that + // crossed `JS_ToCString`, so a key containing U+0000 arrives + // truncated — either dropping it (the truncated name fails the + // propertyIsEnumerable probe) or colliding with a sibling key. A + // single guest `Object.keys` call exposes both shapes cheaply: + // a count mismatch or a duplicate in the fast list means at + // least one key was mangled, and the guest array (whose entries + // are handles) is then re-extracted NUL-safely via guestString. + const guestKeys = invoke(i.objectKeys, value); + try { + const guestCount = guestKeys.length; + if ( + keys.length !== guestCount || + new Set(keys).size !== keys.length + ) { + keys.length = 0; + for (let idx = 0; idx < guestCount; idx++) { + const keyHandle = guestKeys.getProp(String(idx)); + keys.push(guestString(keyHandle)); + keyHandle.dispose(); + } + } + } finally { + guestKeys.dispose(); + } + 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); + // A NUL-bearing key cannot be looked up through the C-string APIs + // (the lookup name would truncate to the wrong key). Route it + // through a guest string handle instead — `vm.newString` is + // length-aware, and handle-keyed getProp performs a plain [[Get]] + // (getters are invoked, matching the descriptor path's explicit + // accessor invocation below). + if (typeof key === 'string' && key.includes('\u0000')) { + const keyHandle = vm.newString(key); + try { + return vm.getProp(value, keyHandle); + } finally { + keyHandle.dispose(); + } + } + 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 ? guestString(handle) : 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. The absent/invalid + // fallback reads the GUEST clock (the deterministic replay clock at + // the WASI layer), not the host wall clock: the in-VM reducer's + // `Date.now()` was replay-stable by construction, and a host-side + // `Date.now()` here would write different bytes on every replay of + // the same value. + let retryAfter = invoke(i.dateNow).consume((h) => h.toNumber()) + 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(guestString), + flags: call(i.regExpFlags, value).consume(guestString), + }; + }, + 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([guestString(headerKey), guestString(headerValue)]); + 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(guestString); + }, + 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 { + // Handle scope: reducers and the hybrid operations mint one handle + // per visited value node (descriptor reads, dup()s, intrinsic call + // results) and nothing disposes them individually — without the + // scope each serialize leaks ~one handle per node for the VM's + // lifetime, which compounds across an inline-loop session's whole + // batch. Requires quickjs-wasi >= 3.3.1: earlier versions also + // scope-tracked the handles the host-callback trampoline wraps + // around C-owned argv pointers, and disposing those (Map/Set/ + // Headers forEach visitors run mid-pass) corrupted the guest heap; + // 3.3.1 marks them borrowed and scope-exempt. The input handle is + // caller-owned (created before the scope) and the output is host + // bytes, so nothing escapes. + // + // `identities` must be cleared per pass ONCE handles are bulk-freed: + // it keys object identity on the raw guest pointer, and freeing + // handles lets QuickJS reuse pointers — a stale entry from an + // earlier pass could then alias a different object and corrupt the + // dedup/cycle detection. Identity only needs stability within one + // stringify pass. + try { + const payload = vm.withScope(() => + 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; + } finally { + identities.clear(); + } + }, + 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)); + // Handle scope, mirroring serialize(): revivers mint intermediate + // handles (children already attached to their parents, intrinsic + // call results) that are safe to free once the graph is built — + // guest values are refcounted, so parents keep their children + // alive. Only the root escapes to the caller. + return vm.withScope((scope) => + scope.escape( + 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/runtime/vm-serde-bundle.generated.ts b/packages/core/src/runtime/vm-serde-bundle.generated.ts new file mode 100644 index 0000000000..cf5de78356 --- /dev/null +++ b/packages/core/src/runtime/vm-serde-bundle.generated.ts @@ -0,0 +1,15 @@ +/** + * 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: 25.6 KB minified + */ +export const VM_SERDE_BUNDLE: string = `"use strict";(()=>{var D="0123456789ABCDEFGHJKMNPQRSTVWXYZ";var w;(function(r){r.Base32IncorrectEncoding="B32_ENC_INVALID",r.DecodeTimeInvalidCharacter="DEC_TIME_CHAR",r.DecodeTimeValueMalformed="DEC_TIME_MALFORMED",r.EncodeTimeNegative="ENC_TIME_NEG",r.EncodeTimeSizeExceeded="ENC_TIME_SIZE_EXCEED",r.EncodeTimeValueMalformed="ENC_TIME_MALFORMED",r.PRNGDetectFailure="PRNG_DETECT",r.ULIDInvalid="ULID_INVALID",r.Unexpected="UNEXPECTED",r.UUIDInvalid="UUID_INVALID"})(w||(w={}));var O=class extends Error{constructor(e,t){super(\`\${t} (\${e})\`),this.name="ULIDError",this.code=e}};function Fr(r){let e=Math.floor(r()*32)%32;return D.charAt(e)}function sr(r,e,t){return e>r.length-1?r:r.substr(0,e)+t+r.substr(e+1)}function Wr(r){let e,t=r.length,n,o,s=r,p=31;for(;!e&&t-->=0;){if(n=s[t],o=D.indexOf(n),o===-1)throw new O(w.Base32IncorrectEncoding,"Incorrectly encoded string");if(o===p){s=sr(s,t,D[0]);continue}e=sr(s,t,D[o+1])}if(typeof e=="string")return e;throw new O(w.Base32IncorrectEncoding,"Failed incrementing string")}function Cr(r){let e=Pr(),t=e&&(e.crypto||e.msCrypto)||null;if(typeof t?.getRandomValues=="function")return()=>{let n=new Uint8Array(1);return t.getRandomValues(n),n[0]/255};if(typeof t?.randomBytes=="function")return()=>t.randomBytes(1).readUInt8()/255;throw new O(w.PRNGDetectFailure,"Failed to find a reliable PRNG")}function Pr(){return jr()?self:typeof window<"u"?window:typeof global<"u"?global:typeof globalThis<"u"?globalThis:null}function Br(r,e){let t="";for(;r>0;r--)t=Fr(e)+t;return t}function ar(r,e=10){if(isNaN(r))throw new O(w.EncodeTimeValueMalformed,\`Time must be a number: \${r}\`);if(r>0xffffffffffff)throw new O(w.EncodeTimeSizeExceeded,\`Cannot encode a time larger than \${0xffffffffffff}: \${r}\`);if(r<0)throw new O(w.EncodeTimeNegative,\`Time must be positive: \${r}\`);if(Number.isInteger(r)===!1)throw new O(w.EncodeTimeValueMalformed,\`Time must be an integer: \${r}\`);let t,n="";for(let o=e;o>0;o--)t=r%32,n=D.charAt(t)+n,r=(r-t)/32;return n}function jr(){return typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope}function ir(r){let e=r||Cr(),t=0,n;return function(s){let p=!s||isNaN(s)?Date.now():s;if(p<=t){let c=n=Wr(n);return ar(t,10)+c}t=p;let g=n=Br(16,e);return ar(p,10)+g}}var S=class extends Error{constructor(e,t,n,o){super(e),this.name="DevalueError",this.path=t.join(""),this.value=n,this.root=o}};var Kr=Object.getOwnPropertyNames(Object.prototype).sort().join("\\0");function fr(r){let e=Object.getPrototypeOf(r);return e===Object.prototype||e===null||Object.getPrototypeOf(e)===null||Object.getOwnPropertyNames(e).sort().join("\\0")===Kr}function yr(r){return Object.prototype.toString.call(r).slice(8,-1)}function Vr(r){switch(r){case'"':return'\\\\"';case"<":return"\\\\u003C";case"\\\\":return"\\\\\\\\";case\` +\`:return"\\\\n";case"\\r":return"\\\\r";case" ":return"\\\\t";case"\\b":return"\\\\b";case"\\f":return"\\\\f";case"\\u2028":return"\\\\u2028";case"\\u2029":return"\\\\u2029";default:return r<" "?\`\\\\u\${r.charCodeAt(0).toString(16).padStart(4,"0")}\`:""}}function I(r){let e="",t=0,n=r.length;for(let o=0;oObject.getOwnPropertyDescriptor(r,e).enumerable)}var zr=/^[a-zA-Z_$][a-zA-Z_$0-9]*$/;function Z(r){return zr.test(r)?"."+r:"["+JSON.stringify(r)+"]"}function Y(r){return!(!Number.isInteger(r)||r<0||r>4294967294)}function ur(r){return!(!Number.isInteger(r)||r<0||r>4294967295)}function $r(r){if(r.length===0||r.length>1&&r.charCodeAt(0)===48)return!1;for(let e=0;e57)return!1}return Y(+r)}function Zr(r){for(var e=r.length-1;e>=0&&!$r(r[e]);e--);return e+1}function pr(r){let e=Object.keys(r);return e.length=Zr(e),e}function Yr(r){return new Uint8Array(r).toBase64()}function Gr(r){return Uint8Array.fromBase64(r).buffer}function Hr(r){return Buffer.from(r).toString("base64")}function Xr(r){return Uint8Array.from(Buffer.from(r,"base64")).buffer}function qr(r){let e=new Uint8Array(r),t="",n=32768;for(let o=0;or,typeOf:r=>r===null?"null":typeof r,toPrimitive:r=>r,tagOf:r=>yr(r),isThenable:r=>typeof r.then=="function",toPromise:r=>Promise.resolve(r),unbox:r=>r.valueOf(),toISOString:r=>isNaN(r.getDate())?"":r.toISOString(),toStringValue:r=>r.toString(),regExpInfo:r=>({source:r.source,flags:r.flags}),valuesOf:r=>r,entriesOf:r=>r,viewInfo:r=>({buffer:r.buffer,byteOffset:r.byteOffset,byteLength:r.byteLength,length:r.length,bufferByteLength:r.buffer.byteLength}),toArrayBuffer:r=>r,lengthOf:r=>r.length,hasOwn:(r,e)=>Object.hasOwn(r,e),indicesOf:r=>pr(r),shapeOf:r=>fr(r)?lr(r).length>0?vr:{kind:Object.getPrototypeOf(r)===null?"null-proto":"plain",keys:Object.keys(r)}:Qr,get:(r,e)=>r[e]},Er=Object.freeze(re),ee={fromPrimitive:r=>r,fromISOString:r=>new Date(r),fromStringValue:(r,e)=>r==="URL"?new URL(e):r==="URLSearchParams"?new URLSearchParams(e):Temporal[r.slice(9)].from(e),fromArrayBuffer:r=>r,fromRegExpInfo:(r,e)=>new RegExp(r,e),fromViewInfo:(r,e,t,n)=>{let o=globalThis[r];return t!==void 0?new o(e,t,n):new o(e)},box:r=>Object(r),createArray:r=>new Array(r),createSparseArray:r=>{let e=[];return e[4294967294]=void 0,delete e[4294967294],e.length=r,e},createObject:()=>({}),createNullPrototypeObject:()=>Object.create(null),createSet:()=>new Set,createMap:()=>new Map,set:(r,e,t)=>{r[e]=t},addValue:(r,e)=>{r.add(e)},addEntry:(r,e,t)=>{r.set(e,t)}},_r=Object.freeze(ee);function G(r,e,t){return C(JSON.parse(r),e,t)}function C(r,e,t){let n=W(_r,t?.operations);if(typeof r=="number")return g(r,!0);if(!Array.isArray(r)||r.length===0)throw new Error("Invalid input");let o=r,s=Array(o.length),p=null;function g(c,V=!1){if(c===-1)return n.fromPrimitive(void 0);if(c===-3)return n.fromPrimitive(NaN);if(c===-4)return n.fromPrimitive(1/0);if(c===-5)return n.fromPrimitive(-1/0);if(c===-6)return n.fromPrimitive(-0);if(V||typeof c!="number")throw new Error("Invalid input");if(c in s)return s[c];let i=o[c];if(!i||typeof i!="object")s[c]=n.fromPrimitive(i);else if(Array.isArray(i))if(typeof i[0]=="string"){let d=i[0],a=e&&Object.hasOwn(e,d)?e[d]:void 0;if(a){let y=i[1];if(typeof y!="number"&&(y=o.push(i[1])-1),Object.hasOwn(s,y))return s[c]=a(s[y]);if(p??(p=new Set),p.has(y))throw new Error("Invalid circular reference");return p.add(y),s[c]=a(g(y)),p.delete(y),s[c]}switch(d){case"Date":s[c]=n.fromISOString(i[1]);break;case"Set":let y=n.createSet();s[c]=y;for(let l=1;l=d)throw new Error("Invalid input");n.set(a,E,g(i[y+1]))}}else{let d=n.createArray(i.length);s[c]=d;for(let a=0;a{let k=i(T,y);k<0&&(s[y]=k)})}else{let T=o.tagOf(a);switch(T){case"Number":case"String":case"Boolean":case"BigInt":f=\`["Object",\${i(o.unbox(a))}]\`;break;case"Date":f=\`["Date","\${o.toISOString(a)}"]\`;break;case"URL":f=\`["URL",\${I(o.toStringValue(a))}]\`;break;case"URLSearchParams":f=\`["URLSearchParams",\${I(o.toStringValue(a))}]\`;break;case"RegExp":let{source:k,flags:M}=o.regExpInfo(a);f=M?\`["RegExp",\${I(k)},"\${M}"]\`:\`["RegExp",\${I(k)}]\`;break;case"Array":{let u=!1,b=o.lengthOf(a);f="[";for(let m=0;m0&&(f+=","),o.hasOwn(a,m))c.push(\`[\${m}]\`),f+=i(o.get(a,m)),c.pop();else if(u)f+=-2;else{let x=o.indicesOf(a),nr=x.length,or=String(b).length,Dr=(b-nr)*3,Ur=4+or+nr*(or+1);if(Dr>Ur){f="["+-7+","+b;for(let z=0;z{if(typeof r!="function")return!1;let e=r.classId;return typeof e!="string"?!1:{classId:e}},Instance:r=>{if(r===null||typeof r!="object")return!1;let e=r.constructor;if(!e||typeof e!="function")return!1;let t=e[X];if(typeof t!="function")return!1;let n=e.classId;if(typeof n!="string")throw new Error(\`Class "\${e.name}" with \${String(X)} must have a static "classId" property.\`);let o=t.call(e,r);return{classId:n,data:o}}}}function B(r=globalThis){return{Class:e=>{let t=e.classId,n=Q(t,r);if(!n)throw new Error(\`Class "\${t}" not found. Make sure the class is registered with registerSerializationClass.\`);return n},Instance:e=>{let t=e.classId,n=e.data,o=Q(t,r);if(!o)throw new Error(\`Class "\${t}" not found. Make sure the class is registered with registerSerializationClass.\`);let s=o[q];if(typeof s!="function")throw new Error(\`Class "\${t}" does not have a static \${String(q)} method.\`);return s.call(o,n)}}}function Or(r,e,t){if(t===0)return".";let n=new Uint8Array(r,e,t),o="";for(let s=0;s{if(!(e instanceof Error)||e.name!==r)return!1;let t={message:e.message,stack:e.stack};return"cause"in e&&(t.cause=e.cause),t}}function N(r){return e=>{let t=globalThis[r],n;return typeof t=="function"?n=new t(e.message):(n=new Error(e.message),n.name=r),e.stack!==void 0&&(n.stack=e.stack),"cause"in e&&(n.cause=e.cause),n}}function j(){return{ArrayBuffer:r=>r instanceof ArrayBuffer&&Or(r,0,r.byteLength),BigInt:r=>typeof r=="bigint"&&r.toString(),BigInt64Array:r=>r instanceof BigInt64Array&&R(r),BigUint64Array:r=>r instanceof BigUint64Array&&R(r),Date:r=>r instanceof Date?!Number.isNaN(r.getDate())?r.toISOString():".":!1,DOMException:r=>{if(!(r instanceof DOMException))return!1;let e={message:r.message,name:r.name,stack:r.stack};return"cause"in r&&(e.cause=r.cause),e},AggregateError:r=>{if(!(r instanceof Error)||r.name!=="AggregateError")return!1;let e={message:r.message,stack:r.stack,errors:r.errors};return"cause"in r&&(e.cause=r.cause),e},EvalError:h("EvalError"),FatalError:h("FatalError"),HookConflictError:r=>{if(!(r instanceof Error)||r.name!=="HookConflictError")return!1;let e={message:r.message,stack:r.stack,token:r.token};return r.conflictingRunId!==void 0&&(e.conflictingRunId=r.conflictingRunId),"cause"in r&&(e.cause=r.cause),e},RangeError:h("RangeError"),ReferenceError:h("ReferenceError"),RetryableError:r=>{if(!(r instanceof Error)||r.name!=="RetryableError")return!1;let e=r.retryAfter,t;if(e&&typeof e=="object"&&typeof e.getTime=="function"){let o=e.getTime();t=Number.isNaN(o)?Date.now()+1e3:o}else if(typeof e=="string"||typeof e=="number"){let o=new Date(e).getTime();t=Number.isNaN(o)?Date.now()+1e3:o}else t=Date.now()+1e3;let n={message:r.message,stack:r.stack,retryAfter:t};return"cause"in r&&(n.cause=r.cause),n},RuntimeDecryptionError:r=>{if(!(r instanceof Error)||r.name!=="RuntimeDecryptionError")return!1;let e={message:r.message,stack:r.stack},t=r.context;return t!==void 0&&(e.context=t),"cause"in r&&(e.cause=r.cause),e},SyntaxError:h("SyntaxError"),TypeError:h("TypeError"),URIError:h("URIError"),Error:r=>{if(!(r instanceof Error))return!1;let e={name:r.name,message:r.message,stack:r.stack};return"cause"in r&&(e.cause=r.cause),e},Float32Array:r=>r instanceof Float32Array&&R(r),Float64Array:r=>r instanceof Float64Array&&R(r),Int8Array:r=>r instanceof Int8Array&&R(r),Int16Array:r=>r instanceof Int16Array&&R(r),Int32Array:r=>r instanceof Int32Array&&R(r),Map:r=>r instanceof Map&&Array.from(r),RegExp:r=>r instanceof RegExp&&{source:r.source,flags:r.flags},Headers:r=>{let e=globalThis.Headers;return!e||!(r instanceof e)?!1:Array.from(r)},Request:r=>{let e=globalThis.Request;if(!e||!(r instanceof e)&&typeof r?.json!="function"||typeof r?.method!="string")return!1;let t={method:r.method,url:r.url,headers:r.headers,body:r.body,duplex:r.duplex},n=r[Symbol.for("WEBHOOK_RESPONSE_WRITABLE")];return n&&(t.responseWritable=n),t},Response:r=>{let e=globalThis.Response;return!e||!(r instanceof e)&&typeof r?.clone!="function"||typeof r?.status!="number"?!1:{type:r.type,url:r.url,status:r.status,statusText:r.statusText,headers:r.headers,body:r.body,redirected:r.redirected}},ReadableStream:(r=>{if(r==null)return!1;let e=globalThis.ReadableStream;if(!e||!(r instanceof e||Object.getPrototypeOf(r)===e.prototype))return!1;let t=r[Symbol.for("BODY_INIT")];if(t!==void 0)return{bodyInit:t};let n=r[Symbol.for("WORKFLOW_STREAM_NAME")];if(n){let o={name:n},s=r[Symbol.for("WORKFLOW_STREAM_TYPE")];s&&(o.type=s);let p=r[Symbol.for("WORKFLOW_STREAM_FRAMING")];return p&&(o.framing=p),o}return{name:"__empty"}}),WritableStream:(r=>{if(r==null)return!1;let e=globalThis.WritableStream;if(!e||!(r instanceof e||Object.getPrototypeOf(r)===e.prototype))return!1;let n={name:r[Symbol.for("WORKFLOW_STREAM_NAME")]||"__empty"},o=r[Symbol.for("WORKFLOW_STREAM_SERVER_RUN_ID")];typeof o=="string"&&(n.runId=o);let s=r[Symbol.for("WORKFLOW_STREAM_SERVER_DEPLOYMENT_ID")];return typeof s=="string"&&(n.deploymentId=s),n}),Set:r=>r instanceof Set&&Array.from(r),URL:r=>r instanceof URL&&r.href,WorkflowFunction:r=>{if(typeof r!="function")return!1;let e=r.workflowId;return typeof e!="string"?!1:{workflowId:e}},URLSearchParams:r=>r instanceof URLSearchParams?r.size===0?".":String(r):!1,Uint8Array:r=>r instanceof Uint8Array&&R(r),Uint8ClampedArray:r=>r instanceof Uint8ClampedArray&&R(r),Uint16Array:r=>r instanceof Uint16Array&&R(r),Uint32Array:r=>r instanceof Uint32Array&&R(r)}}function K(){return{ArrayBuffer:r=>_(r),BigInt:r=>BigInt(r),BigInt64Array:r=>new BigInt64Array(_(r)),BigUint64Array:r=>new BigUint64Array(_(r)),Date:r=>new Date(r),DOMException:r=>{let e=new DOMException(r.message,r.name);return r.stack!==void 0&&(e.stack=r.stack),"cause"in r&&(e.cause=r.cause),e},AggregateError:r=>{let e=new AggregateError(r.errors??[],r.message);return r.stack!==void 0&&(e.stack=r.stack),"cause"in r&&(e.cause=r.cause),e},EvalError:N("EvalError"),FatalError:r=>{let e=globalThis[Symbol.for("@workflow/errors//FatalError")],t;return typeof e=="function"?t=new e(r.message):(t=new Error(r.message),t.name="FatalError"),r.stack!==void 0&&(t.stack=r.stack),"cause"in r&&(t.cause=r.cause),t},HookConflictError:r=>{let e=globalThis[Symbol.for("@workflow/errors//HookConflictError")],t;return typeof e=="function"?t=new e(r.token,r.conflictingRunId):(t=new Error(r.message),t.name="HookConflictError",t.token=r.token,r.conflictingRunId!==void 0&&(t.conflictingRunId=r.conflictingRunId)),r.stack!==void 0&&(t.stack=r.stack),"cause"in r&&(t.cause=r.cause),t},RangeError:N("RangeError"),ReferenceError:N("ReferenceError"),RetryableError:r=>{let e=globalThis[Symbol.for("@workflow/errors//RetryableError")],t=new Date(r.retryAfter),n;return typeof e=="function"?n=new e(r.message,{retryAfter:t}):(n=new Error(r.message),n.name="RetryableError",n.retryAfter=t),r.stack!==void 0&&(n.stack=r.stack),"cause"in r&&(n.cause=r.cause),n},RuntimeDecryptionError:r=>{let e=globalThis[Symbol.for("@workflow/errors//RuntimeDecryptionError")],t;if(typeof e=="function"){let n={};"cause"in r&&(n.cause=r.cause),r.context!==void 0&&(n.context=r.context),t=new e(r.message,n)}else t=new Error(r.message),t.name="RuntimeDecryptionError",r.context!==void 0&&(t.context=r.context),"cause"in r&&(t.cause=r.cause);return r.stack!==void 0&&(t.stack=r.stack),t},SyntaxError:N("SyntaxError"),TypeError:N("TypeError"),URIError:N("URIError"),Error:r=>{let e=new Error(r.message);return e.name=r.name,r.stack!==void 0&&(e.stack=r.stack),"cause"in r&&(e.cause=r.cause),e},Float32Array:r=>new Float32Array(_(r)),Float64Array:r=>new Float64Array(_(r)),Int8Array:r=>new Int8Array(_(r)),Int16Array:r=>new Int16Array(_(r)),Int32Array:r=>new Int32Array(_(r)),Map:r=>new Map(r),RegExp:r=>new RegExp(r.source,r.flags),Set:r=>new Set(r),URL:r=>new URL(r),WorkflowFunction:r=>Object.assign(()=>{throw new Error("Workflow functions cannot be called directly. Use start() to invoke them.")},{workflowId:r.workflowId}),URLSearchParams:r=>new URLSearchParams(r==="."?"":r),Uint8Array:r=>new Uint8Array(_(r)),Uint8ClampedArray:r=>new Uint8ClampedArray(_(r)),Uint16Array:r=>new Uint16Array(_(r)),Uint32Array:r=>new Uint32Array(_(r)),Headers:r=>new globalThis.Headers(r),Request:r=>{let e=globalThis.Request;return e&&(r.json=e.prototype.json,r.text=e.prototype.text,r.arrayBuffer=e.prototype.arrayBuffer),r.responseWritable&&(r[Symbol.for("WEBHOOK_RESPONSE_WRITABLE")]=r.responseWritable),r},Response:r=>{let e=globalThis.Response;return e&&(r.json=e.prototype.json,r.text=e.prototype.text,r.arrayBuffer=e.prototype.arrayBuffer,e.prototype.bytes&&(r.bytes=e.prototype.bytes),e.prototype.clone&&(r.clone=e.prototype.clone)),r._body=r.body,r.ok=r.status>=200&&r.status<300,r.bodyUsed=!1,r},ReadableStream:r=>{let e=globalThis.ReadableStream,t=Object.create(e?e.prototype:{});return r&&"bodyInit"in r?t[Symbol.for("BODY_INIT")]=r.bodyInit:r&&"name"in r&&(t[Symbol.for("WORKFLOW_STREAM_NAME")]=r.name,r.type&&(t[Symbol.for("WORKFLOW_STREAM_TYPE")]=r.type),r.framing&&(t[Symbol.for("WORKFLOW_STREAM_FRAMING")]=r.framing)),t},WritableStream:r=>{let e=globalThis.WritableStream,t=Object.create(e?e.prototype:{});return r&&"name"in r&&(t[Symbol.for("WORKFLOW_STREAM_NAME")]=r.name),r&&typeof r.runId=="string"&&(t[Symbol.for("WORKFLOW_STREAM_SERVER_RUN_ID")]=r.runId),r&&typeof r.deploymentId=="string"&&(t[Symbol.for("WORKFLOW_STREAM_SERVER_DEPLOYMENT_ID")]=r.deploymentId),t}}}function Ir(){return{StepFunction:r=>{if(typeof r!="function")return!1;let e=r.stepId;if(typeof e!="string")return!1;let t=r.__closureVarsFn,n=t&&typeof t=="function"?t():void 0,o="__boundThis"in r,s=o?r.__boundThis:void 0,p=r.__boundArgs,g={stepId:e};return n!==void 0&&(g.closureVars=n),o&&(g.boundThis=s),Array.isArray(p)&&p.length>0&&(g.boundArgs=p),g}}}function Tr(r=globalThis){let e=r[Symbol.for("WORKFLOW_USE_STEP")];return{StepFunction:t=>{let n=t.stepId,o=t.closureVars;if(!e)throw new Error("WORKFLOW_USE_STEP not found on global object. Step functions cannot be deserialized outside workflow context.");let s=o?e(n,()=>o):e(n);if("boundThis"in t){let p=Array.isArray(t.boundArgs)?t.boundArgs:[];return s.bind(t.boundThis,...p)}return s}}}function kr(r){return r.length===4&&/^[a-z0-9]{4}$/.test(r)}var U={DEVALUE_V1:"devl",ENCRYPTED:"encr",SEALED:"encp",GZIP:"gzip",ZSTD:"zstd"};var ce=new TextEncoder,fe=new TextDecoder,L=Symbol.for("WORKFLOW_ABORT_STREAM_NAME"),v=Symbol.for("WORKFLOW_ABORT_HOOK_TOKEN");function hr(r,e){let t=e[L]??e.signal?.[L],n=e[v]??e.signal?.[v];if(!t)throw new Error("AbortController/AbortSignal stream name is not set");return{streamName:t,hookToken:n,aborted:r.aborted,reason:r.aborted?r.reason:void 0}}function Nr(r){let e=globalThis.__WorkflowAbortSignal;if(typeof e!="function")throw new Error("WorkflowAbortSignal is not registered in the VM (bootstrap not evaluated)");let t=new e(r.streamName,r.hookToken);return r.aborted&&t._setAborted(r.reason),t}function ye(){return{AbortController:r=>!r||typeof r!="object"||!r.signal||(r[L]??r.signal?.[L])===void 0?!1:hr(r.signal,r),AbortSignal:r=>!r||typeof r!="object"||r[L]===void 0?!1:hr(r,r)}}function le(){return{AbortController:r=>({[L]:r.streamName,[v]:r.hookToken,signal:Nr(r),abort:()=>{}}),AbortSignal:r=>Nr(r)}}function ue(r){switch(r){case"workflow":return{...ye(),...P(),...Ir(),...j()};case"step":return{...P(),...j()};case"client":return{...P(),...j()}}}function Lr(r){switch(r){case"workflow":return{...le(),...B(),...Tr(),...K()};case"step":return{...B(),...K()};case"client":return{...B(),...K(),StepFunction:()=>{throw new Error("Step functions cannot be deserialized in client context.")}}}}var F={formatPrefix:U.DEVALUE_V1,serialize(r,e){let t=ue(e),n=H(r,t);return ce.encode(n)},deserialize(r,e){let t=Lr(e),n=fe.decode(r);return G(n,t)},deserializeLegacy(r,e){let t=Lr(e);return C(r,t)}};var rr=4,er,tr;function pe(){return er||(er=new globalThis.TextEncoder),er}function ge(){return tr||(tr=new globalThis.TextDecoder),tr}function Mr(r){let e=F.serialize(r,"workflow"),t=pe().encode(U.DEVALUE_V1),n=new Uint8Array(t.length+e.length);return n.set(t,0),n.set(e,t.length),n}function xr(r){if(!(r instanceof Uint8Array)){if(F.deserializeLegacy)return F.deserializeLegacy(r,"workflow");throw new Error("Cannot deserialize non-binary data without legacy support")}if(r.lengthMath.random());globalThis.__generateUlid=()=>{let r=globalThis.__ulidTimestamp;if(typeof r!="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 de(r)};})(); +`; 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 c95266853c..007efb396f 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.1 + version: 3.3.1 seedrandom: specifier: 3.0.5 version: 3.0.5 @@ -15066,8 +15066,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.1: + resolution: {integrity: sha512-03RhBUA6hNX4274oa10pWpkXsbOhgqTvCoFPm8Dy3E7jcp+obNHVQGQZXghcNOutJaiOWkyezwdgbANHD1/Gxw==} radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} @@ -33244,7 +33244,7 @@ snapshots: quick-lru@5.1.1: {} - quickjs-wasi@3.1.0: {} + quickjs-wasi@3.3.1: {} 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: