Skip to content

feat: pluggable operations for stringify - #172

Merged
elliott-with-the-longest-name-on-github merged 8 commits into
sveltejs:mainfrom
TooTallNate:pluggable-stringify-operations
Jul 29, 2026
Merged

feat: pluggable operations for stringify#172
elliott-with-the-longest-name-on-github merged 8 commits into
sveltejs:mainfrom
TooTallNate:pluggable-stringify-operations

Conversation

@TooTallNate

Copy link
Copy Markdown
Contributor

Summary

Adds an optional third argument to stringify/stringifyAsync:

stringify(value, reducers, { operations: { /* Partial<StringifyOperations> */ } })

Every introspection the serializer performs on the value — property reads, prototype method calls, iteration, type classification — now routes through a StringifyOperations interface. Omitted members fall back to defaultOperations (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):

  1. 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 traps
    • Object.prototype.toString (type classification) reads Symbol.toStringTag, which can be a getter — user code runs and the brand is spoofable
    • thing.toISOString(), thing.valueOf(), Map.prototype[Symbol.iterator], .source/.flags/.buffer/.byteLength etc. all dispatch through patchable prototypes
    • typeof thing.then triggers then getters

    With this change, a caller can override exactly the risky operations with implementations based on captured intrinsics, internal-slot brand checks, and property descriptors — making stringify provably side-effect free for their values.

  2. 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:vm context, a WASM-hosted engine, a remote process). The new identify operation 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

  • API shape: an options object ({ operations }) rather than a positional bag, leaving room for future options
  • Granularity: mostly one operation per brand (dateISO, regExp, mapEntries, viewInfo, …) so overrides are small; the plain-object path is a single coarse objectShape call (classification + keys in one crossing, which matters for WASM-boundary implementations)
  • Reducers are untouched — they receive the raw value/handle exactly as before, and their return values are serialized through the same operations
  • uneval and parse are deliberately out of scope

Tests

16 new tests in test/operations.test.js (760 total passing):

  • default-path equivalence and partial-override merging
  • side-effect-free suite: spy-patched Date.prototype.toISOString, Map/Set.prototype[Symbol.iterator], Symbol.toStringTag getter, object getters, .then getter — asserting zero invocations under overridden ops
  • a complete handle-model implementation (Handle wrapper + full ops): output parity with plain stringify across 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 thenables

Performance

Existing benchmark suite (typed arrays — covers the hottest refactored paths), before/after on the same machine:

baseline (ms) this PR (ms)
stringify: small 192.09 187.47
stringify: medium 12.03 9.68
stringify: large 14.58 9.98
suite total 569.29 546.09

No regression (differences within noise). Call sites remain monomorphic — when no operations are passed, the shared default_operations object is used directly.

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
Copilot AI review requested due to automatic review settings July 23, 2026 18:01
@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48c10aa

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
devalue Minor

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 options argument to stringify/stringifyAsync to accept operations overrides merged over defaults.
  • Introduce src/operations.js with the extracted default operation implementations and export them as defaultOperations.
  • 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_operations is exported (as defaultOperations) 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 the operations option.
	},

	get: (value, key) => value[key]
};

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/stringify.js Outdated
Comment thread src/operations.js Outdated
- 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
NathanColosimo added a commit to vercel/workflow that referenced this pull request Jul 23, 2026
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.
NathanColosimo added a commit to vercel/workflow that referenced this pull request Jul 24, 2026
- 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Comment thread src/stringify.js
Comment thread src/stringify.js
Comment thread src/stringify.js Outdated
Comment thread src/stringify.js Outdated
Comment thread src/types.d.ts
Comment thread src/types.d.ts Outdated
Comment thread src/types.d.ts
Comment thread src/types.d.ts Outdated
Comment thread src/types.d.ts
- 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).
@elliott-with-the-longest-name-on-github

Copy link
Copy Markdown
Contributor

The naming issue has been driving me crazy; here's where I've gotten so far:

Proposal: consistent naming for StringifyOperations

The operations interface currently mixes four naming conventions — bare nouns (primitive, regExp, viewInfo), to-prefixed conversions (toPromise, toStringValue), verbs (identify, unbox, get), and operator/predicate style (typeOf, tag, isThenable, hasOwn). Since this API is unreleased, now is the only cheap moment to make it consistent.

Naming scheme

Members are named by what they do with the value:

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.

@Rich-Harris

Copy link
Copy Markdown
Member

Love the naming proposal. Ship it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

@elliott-with-the-longest-name-on-github
elliott-with-the-longest-name-on-github merged commit 5b53532 into sveltejs:main Jul 29, 2026
5 checks passed
TooTallNate added a commit to vercel-labs/quickjs-wasi that referenced this pull request Jul 30, 2026
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.
TooTallNate added a commit to vercel-labs/quickjs-wasi that referenced this pull request Jul 31, 2026
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.
TooTallNate added a commit to vercel-labs/quickjs-wasi that referenced this pull request Jul 31, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants