feat: pluggable operations for stringify - #172
Conversation
Route every introspection stringify performs on the value being
serialized (property reads, prototype method calls, iteration, type
classification) through a StringifyOperations interface, overridable
via a new options argument:
stringify(value, reducers, { operations: { ... } })
Omitted members fall back to defaultOperations (exported), which
preserve existing behavior exactly.
Motivations:
- Side-effect-free serialization: deterministic/sandboxed runtimes can
replace operations that execute user code (getters, proxy traps,
Symbol.toStringTag accessors, patched prototype methods) with
implementations based on captured intrinsics and descriptors
- Foreign-runtime serialization: values living in another JS runtime
(node:vm context, WASM-hosted engine, remote process) can be
serialized through opaque handles, with identify() keying dedup and
cycle detection on the underlying value identity
🦋 Changeset detectedLatest commit: 48c10aa The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
This PR makes stringify/stringifyAsync pluggable by routing all value introspection (property reads, tagging, iteration, etc.) through an overridable StringifyOperations interface, enabling side-effect-free serialization and foreign-runtime/handle-based serialization while preserving default behavior via defaultOperations.
Changes:
- Add a third
optionsargument tostringify/stringifyAsyncto acceptoperationsoverrides merged over defaults. - Introduce
src/operations.jswith the extracted default operation implementations and export them asdefaultOperations. - Add comprehensive tests and documentation for custom operations, including side-effect-free and handle-based scenarios.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/operations.test.js | Adds new test coverage for operation overrides, side-effect-free behavior, and handle-based serialization parity. |
| src/types.d.ts | Defines StringifyOperations and StringifyOptions typings used by the new API surface. |
| src/stringify.js | Refactors serializer internals to route all introspection through ops, supporting override injection. |
| src/operations.js | Introduces the default StringifyOperations implementation backing existing behavior. |
| README.md | Documents the new operations option and provides usage examples for the two primary use cases. |
| index.js | Exports defaultOperations from the package entrypoint. |
| .changeset/pluggable-stringify-operations.md | Declares a minor release for the new operations feature. |
Comments suppressed due to low confidence (1)
src/operations.js:79
default_operationsis exported (asdefaultOperations) and used as the default fast-path when no overrides are passed. If a consumer mutates it, they can inadvertently change serialization behavior process-wide. Freezing the object makes the default behavior robust while still allowing customization via theoperationsoption.
},
get: (value, key) => value[key]
};
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Treat explicitly-undefined operation overrides like omitted members instead of clobbering defaults - Freeze default_operations and the shared objectShape sentinels (NOT_PLAIN, SYMBOL_KEYS) now that they are part of the public API
Applies the runtime changes from sveltejs/devalue#172 (head 2e97724) via pnpm patch, adapted to 5.8.1's merged TypedArray/DataView case so encoded output stays byte-identical. Enables injecting hardened, side-effect-free introspection operations into stringify.
- Vendor devalue 5.8.1 + the pending operations interface (sveltejs/devalue#172) into @workflow/core, replacing the pnpm patch: patchedDependencies don't ship with published packages, so consumers would have silently received a stock devalue that ignores the hardened operations. - Stop capturing URLSearchParams.prototype.size (absent on older Node 18, crashed at module load); derive emptiness from the captured toString output, which is equivalent to size === 0. - Read all AbortController/AbortSignal serialization fields passively via captured native accessors (signal/aborted/reason), so native holders don't taint retention and patched accessors do.
elliott-with-the-longest-name-on-github
left a comment
There was a problem hiding this comment.
Couple of nits, otherwise looks good. I definitely want to bikeshed the naming of a few of these with Rich before we merge but I don't really have any qualms with the design!
- Build the merged operations object from the default keys, so nullish members coalesce to defaults and inherited members (e.g. from a class instance) are picked up - Rename resolveThenable to toPromise and drop the redundant Promise.resolve() wrapper at the call site - Rename hasOwnIndex to hasOwn, matching Object.hasOwn's contract - Document that identify() keys are compared against every value in the payload, including primitives, so derived keys must be unforgeable - Export StringifyOperations and StringifyOptions from the package - Drop a comment the typeOf signature already conveys, and note where the host-primitive boundary is
Add a tripwire suite: every object in the payload is wrapped in a Proxy whose every trap records a violation and throws, while a complete operations implementation unwraps through a WeakMap side-channel. Any direct touch in stringify.js (e.g. a stray thing.foo added in a future case) trips instantly with the trap name and key, making the core claim of custom operations — the algorithm never touches the value directly — an executable regression test rather than an implied property of output parity. Covers sync paths (POJOs, null-proto, dense/sparse arrays in both HOLE and SPARSE encodings, Date/RegExp/URL, Map/Set with object keys, boxed primitives, typed arrays/subarrays/DataView/ArrayBuffer, cycles, dedup), the reducer path, and async — where the only tolerated read is the engine-level .then probe from the promise resolution procedure, which is absorbed by the proxy (counted and asserted exactly) and cannot reach the underlying value. A negative control asserts the tripwire fires under default operations, so the suite cannot pass vacuously.
Fulfilling with an inert (non-proxy) handle would avoid the probe, but an inert handle cannot observe reads — and the handle is exactly the object a stray direct touch in the async path would land on. Verified by fault injection: with a plain-object handle, an injected value?.constructor in stringifyAsync's resolution callback goes undetected; with the tripwire proxy it is caught as get (constructor).
|
The naming issue has been driving me crazy; here's where I've gotten so far: Proposal: consistent naming for
|
| Family | Rule | Members |
|---|---|---|
isXxx / hasXxx |
predicates returning booleans | isThenable, hasOwn |
toXxx |
conversions; whole result crosses to host JS | toPrimitive, toISOString, toStringValue, toArrayBuffer, toPromise |
xxxOf |
host data about the value, or its constituents | typeOf, tagOf, lengthOf, indicesOf, shapeOf, valuesOf, entriesOf |
xxxInfo |
multi-field descriptors (host + constituents) | viewInfo, regExpInfo |
| bare verbs | value-space accessors (results re-serialized) | get, unbox, identify |
The scheme makes the host/value-space boundary legible from the name: toXxx results go straight into the output string, while bare-verb results (and the constituents of valuesOf/entriesOf/viewInfo.buffer) may be foreign values/handles that are serialized recursively.
Renames
| Before | After | Rationale |
|---|---|---|
primitive |
toPrimitive |
conversion to host; mirrors Symbol.toPrimitive |
dateISO |
toISOString |
conversion to host; mirrors Date.prototype.toISOString |
arrayBuffer |
toArrayBuffer |
conversion to host (foreign impls copy bytes into a host buffer) |
tag |
tagOf |
host data about the value; pairs with typeOf |
objectShape |
shapeOf |
host data about the value |
arrayLength |
lengthOf |
host data about the value |
arrayIndices |
indicesOf |
host data about the value |
setValues |
valuesOf |
constituents of the value; reads naturally: valuesOf(set) |
mapEntries |
entriesOf |
constituents of the value; entriesOf(map) |
regExp |
regExpInfo |
multi-field descriptor; pairs with viewInfo |
Unchanged (already fit the scheme)
| Name | Family |
|---|---|
isThenable, hasOwn |
predicates |
toPromise, toStringValue |
conversions |
typeOf, viewInfo |
queries / descriptors |
get, unbox, identify |
value-space accessors |
Note: toStringValue and unbox keep their slightly awkward names deliberately — the natural names toString and valueOf would shadow Object.prototype methods on the operations object. This is now called out in the interface docs so nobody "fixes" them later.
Parameter names
Where an operation only ever receives a single brand, the parameter is named for it instead of the generic value:
| Operation | Parameter |
|---|---|
toPromise |
thenable |
unbox |
boxed |
toISOString |
date |
regExpInfo |
regexp |
valuesOf |
set |
entriesOf |
map |
viewInfo |
view |
toArrayBuffer |
buffer |
lengthOf, indicesOf |
array |
value is kept only for operations that accept several kinds (typeOf, identify, get, hasOwn, toStringValue, shapeOf, toPrimitive, tagOf, isThenable).
Scope of the change
Purely mechanical: definitions (src/operations.js, src/types.d.ts), call sites (src/stringify.js), both test suites, and the README examples. No behavior changes; all 768 tests pass and the generated type declarations build cleanly.
|
Love the naming proposal. Ship it |
elliott-with-the-longest-name-on-github
left a comment
There was a problem hiding this comment.
🔥
5b53532
into
sveltejs:main
Both devalue PRs (sveltejs/devalue#172, #173) have landed; the vendored tarball is now built from devalue main (07d6a38) and the operations implementation uses the final hook names: - stringify: toPrimitive, tagOf, toISOString, regExpInfo, valuesOf, entriesOf, toArrayBuffer, lengthOf, indicesOf, shapeOf - parse: fromPrimitive (absorbs the former bigint hook — host bigints arrive pre-converted), fromISOString, fromStringValue (absorbs url/urlSearchParams/temporal), fromArrayBuffer, fromRegExpInfo, fromViewInfo, set (absorbs setIndex/setProperty), addValue, addEntry The ops objects are now typed as the complete StringifyOperations / ParseOperations interfaces rather than Partial: the POC implements every member of both (19 + 16), so if a future devalue change adds a hook this implementation lacks, compilation fails — turning the POC into a standing gap detector for exactly the drift this update fixes.
The pluggable operations API (sveltejs/devalue#172, #173) is now in a published npm release, so the vendored tarball is replaced with a regular semver dependency. The complete-interface typing keeps verifying hook coverage against the published package: 19 stringify + 16 parse hooks, all implemented.
* test: POC for host-side devalue serialization of guest values Adds a proof-of-concept showing that a serialization layer can live entirely on the host and operate on JSValueHandles, rather than being bundled into the guest — matching how it works for node:vm. test/devalue-operations.ts implements both halves of devalue's pluggable operations over handles: - stringify: engine brand checks for classification (via a classId->tag map built at boot), boot-captured intrinsics for extraction, and descriptor reads instead of [[Get]] - parse: values built inside the VM through boot-captured constructors, returning a handle the guest can use directly test/devalue-round-trip.test.ts round-trips guest values through stringify + parse, asserting wire-format parity with host devalue and comparing the revived value to the original from inside the VM. Closes three host-API gaps the POC surfaced: - handle.identity: qjs_get_value_ptr was only reachable internally, but any dedup/cycle detection across handles needs it - handle.toBoolean(): the primitive extraction set was missing booleans (even getOwnPropertyDescriptor reached into the raw exports for it) - vm.construct(): JS_CallConstructor was unbound, so the host could not invoke "new" on a captured constructor * feat: handle lifetime and host callback APIs Closes the remaining gaps surfaced by the host-side serialization POC. - vm.withScope(fn): batch handle disposal, with scope.escape() for the values that should outlive it. Scopes nest, and escape() transfers to the enclosing scope. Applied in the POC's viewInfo/dateISO/regExp operations, which create several intermediates each. - vm.newEphemeralFunction(fn): host callbacks registered by newFunction() live for the lifetime of the VM by design, since names must be re-registrable after a snapshot is restored. That makes newFunction() unusable for callbacks created in a loop: the name collides and the registration leaks. Ephemeral functions get a generated name and unregister when their handle is disposed. The POC's Set/Map visitor now uses this instead of a hand-rolled single-collector workaround. - vm.unregisterHostCallback(name): the missing counterpart to registerHostCallback(), so a named callback can be removed and its name reused. - handle.disposed: makes disposal observable, which withScope() needs to be testable. Note that handle methods still do not guard against use after disposal; documented in the README rather than fixed here. - resolvePromise() and the newPromise() settled hook now subscribe via a captured Promise.prototype.then rather than reading `.then` off the value, which would run guest code for a proxy or a shadowed accessor. Also documents that handle.toString() executes guest code for non-strings, and drops the POC's fake-object workaround for arrayIndices now that devalue exports filterArrayIndices. * test: update to the merged devalue operations API Both devalue PRs (sveltejs/devalue#172, #173) have landed; the vendored tarball is now built from devalue main (07d6a38) and the operations implementation uses the final hook names: - stringify: toPrimitive, tagOf, toISOString, regExpInfo, valuesOf, entriesOf, toArrayBuffer, lengthOf, indicesOf, shapeOf - parse: fromPrimitive (absorbs the former bigint hook — host bigints arrive pre-converted), fromISOString, fromStringValue (absorbs url/urlSearchParams/temporal), fromArrayBuffer, fromRegExpInfo, fromViewInfo, set (absorbs setIndex/setProperty), addValue, addEntry The ops objects are now typed as the complete StringifyOperations / ParseOperations interfaces rather than Partial: the POC implements every member of both (19 + 16), so if a future devalue change adds a hook this implementation lacks, compilation fails — turning the POC into a standing gap detector for exactly the drift this update fixes. * chore: one changeset per API addition Splits the two bundled changesets so each new API (handle.identity, handle.toBoolean, handle.disposed, vm.construct, vm.withScope, vm.newEphemeralFunction, vm.unregisterHostCallback) is documented individually, and the resolvePromise captured-then hardening is a separate patch changeset since it is a behavior fix, not an API addition. * test: use published devalue 5.9.0 The pluggable operations API (sveltejs/devalue#172, #173) is now in a published npm release, so the vendored tarball is replaced with a regular semver dependency. The complete-interface typing keeps verifying hook coverage against the published package: 19 stringify + 16 parse hooks, all implemented.
Summary
Adds an optional third argument to
stringify/stringifyAsync:Every introspection the serializer performs on the value — property reads, prototype method calls, iteration, type classification — now routes through a
StringifyOperationsinterface. Omitted members fall back todefaultOperations(newly exported), which are the current behavior extracted verbatim, so the default path is unchanged.Motivation
Two use cases, both real (we're building on them for Vercel's Workflow SDK, which uses devalue at the core of its durable-execution serialization):
Side-effect-free serialization. Today, serializing a value can execute user code in ways that are surprising and, for deterministic-replay runtimes, corrupting:
thing[key]fires getters and proxy trapsObject.prototype.toString(type classification) readsSymbol.toStringTag, which can be a getter — user code runs and the brand is spoofablething.toISOString(),thing.valueOf(),Map.prototype[Symbol.iterator],.source/.flags/.buffer/.byteLengthetc. all dispatch through patchable prototypestypeof thing.thentriggersthengettersWith this change, a caller can override exactly the risky operations with implementations based on captured intrinsics, internal-slot brand checks, and property descriptors — making
stringifyprovably side-effect free for their values.Foreign-runtime serialization. The algorithm never touches the value directly anymore, so "value" can be an opaque handle to something in another JS runtime (a
node:vmcontext, a WASM-hosted engine, a remote process). The newidentifyoperation keys deduplication/cycle detection on the underlying value's identity rather than the handle's, so two distinct handles to one object still serialize as one reference.Design notes
{ operations }) rather than a positional bag, leaving room for future optionsdateISO,regExp,mapEntries,viewInfo, …) so overrides are small; the plain-object path is a single coarseobjectShapecall (classification + keys in one crossing, which matters for WASM-boundary implementations)unevalandparseare deliberately out of scopeTests
16 new tests in
test/operations.test.js(760 total passing):Date.prototype.toISOString,Map/Set.prototype[Symbol.iterator],Symbol.toStringTaggetter, object getters,.thengetter — asserting zero invocations under overridden opsHandlewrapper + full ops): output parity with plainstringifyacross primitives, POJOs, null-proto objects, sparse arrays, Map/Set, Date/RegExp/URL/Temporal, typed-array subarrays, DataView, cycles, dedup-through-distinct-handles, reducers, and async thenablesPerformance
Existing benchmark suite (typed arrays — covers the hottest refactored paths), before/after on the same machine:
No regression (differences within noise). Call sites remain monomorphic — when no
operationsare passed, the shareddefault_operationsobject is used directly.