Skip to content

feat: pluggable operations for parse - #173

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

feat: pluggable operations for parse#173
elliott-with-the-longest-name-on-github merged 3 commits into
sveltejs:mainfrom
TooTallNate:pluggable-parse-operations

Conversation

@TooTallNate

Copy link
Copy Markdown
Contributor

Stacked on #172 — the diff shown includes that PR's commits until it lands. Review #172 first; the parse-specific changes are in the last commit (83f17f4).

Summary

The inverse of #172: where stringify needs pluggable introspection, parse needs pluggable construction.

parse(serialized, revivers, { operations: { /* Partial<ParseOperations> */ } })
unflatten(parsed, revivers, { operations: { ... } })

Every value parse/unflatten creates while reviving — built-in instances (Date, RegExp, URL, Temporal.*, typed arrays, boxed primitives, BigInt), containers (Map, Set, arrays, objects, null-prototype objects), and the mutations that populate them — now routes through a ParseOperations interface. Omitted members fall back to defaultParseOperations, which is the current behavior extracted verbatim, so the default path is unchanged.

Motivation

  1. Cross-realm revival. Revived values are currently built from the intrinsics of whichever realm devalue runs in, so a value revived on the host and handed to a node:vm sandbox fails every instanceof check inside it. Overriding the constructors fixes that (covered by a test that asserts host instanceof fails while the sandbox's own checks pass).

  2. Foreign-runtime revival. parse never inspects the values it creates — it only feeds them back into other operations — so implementations can build values inside another runtime (WASM-hosted engine, remote process) and return opaque handles. Combined with feat: pluggable operations for stringify #172 this closes the loop: a value can be serialized out of a foreign runtime and revived back into it without either side crossing the boundary as raw data.

Design notes

  • Create-then-populate: containers are created empty and populated via setAdd/mapSet/setProperty/setIndex. This mirrors the existing algorithm and is what keeps cyclic values revivable (the empty container is cached before its contents are built) — documented on the interface so implementors don't "optimize" it away.
  • Two array creators. createArray(length) takes a payload-bounded length; createSparseArray(length) takes an untrusted length and is contractually required not to allocate proportionally to it. This keeps the existing sparse-array DoS mitigation (V8 dictionary-elements trick) intact and, more importantly, makes the requirement explicit for anyone writing their own implementation rather than leaving it as an undocumented invariant of the call site.
    • The default implementation now sets length upfront instead of truncating at the end. I verified with %HasDictionaryElements across lengths from 10 to 5e7 that this is equivalent — both orderings stay in dictionary mode, while naive new Array(len) drops out below ~5e7 (the actual DoS vector).
  • No introspection ops. All the payload validation stays host-side on the parsed JSON, so nothing in parse needs to read a revived value — the interface is purely constructive.
  • Renames defaultOperationsdefaultStringifyOperations for symmetry with defaultParseOperations. feat: pluggable operations for stringify #172 is unreleased so this is free; happy to revert if you'd rather keep the shorter name.
  • uneval remains out of scope.

Tests

20 new tests in test/parse-operations.test.js (779 total passing; test/operations.test.js renamed to test/stringify-operations.test.js):

  • plumbing: partial merging, explicitly-undefined fallback, frozen defaults, unflatten parity, reviver composition
  • sparse arrays: tiny payload declaring a 50M length revives with the right length/keys without allocating; override receives the declared length
  • cross-realm (node:vm): Date/RegExp/Set/Map/array/null-proto/typed-array construction from a sandbox's intrinsics, asserted from inside the sandbox; cycles link correctly across the boundary
  • handle model: full ParseOperations implementation over an opaque wrapper, round-trip parity against plain parse for every supported type, shared references, cycles, revivers, plus one test that round-trips through both operation sets

Performance

baseline this PR
parse: small 283.09 ms 281.17 ms
parse: medium 33.78 ms 34.25 ms
parse: large 33.71 ms 32.67 ms

Within noise. publint clean.

Copilot AI review requested due to automatic review settings July 27, 2026 22:56
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2f71aaf

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@TooTallNate

Copy link
Copy Markdown
Contributor Author

Pushed 1998fda adding filterArrayIndices, from building a real consumer of these operations (a host-side serializer for values living in a QuickJS-in-WASM VM: vercel-labs/quickjs-wasi#26).

Every other operation implemented cleanly against handles. arrayIndices was the one that didn't, because its contract takes the value while the logic a foreign-runtime implementation needs is over keys it already has — and that logic isn't trivial (leading-zero rejection, the 2^32 - 1 bound, and the trailing-non-index trim that the sparse-array heuristic depends on). Reimplementing it invites subtle divergence, so instead the implementation ended up doing this:

arrayIndices: (handle) =>
  defaultStringifyOperations.arrayIndices(
    Object.fromEntries(handle.keys().map((key) => [key, 0]))   // throwaway host object
  ),

Materializing a fake object just to hand it back to the default is obviously not the intended usage. With the helper exported it becomes:

arrayIndices: (handle) => filterArrayIndices(handle.keys())

The change refactors valid_array_indices into a shared cut-point calculation, so the default operation is unchanged (still mutates its own fresh Object.keys() array) while the public helper returns a new array and leaves its input alone. arrayIndices's JSDoc now points implementors at it, and there are unit tests for the filtering rules and non-mutation.

Happy to drop this if you'd rather keep the surface minimal — but some export is probably warranted, since the alternative is every foreign-runtime implementation reimplementing a heuristic-critical helper.

@TooTallNate
TooTallNate force-pushed the pluggable-parse-operations branch from 1998fda to c20c8a4 Compare July 29, 2026 16:48
@TooTallNate

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #172 has landed — and reworked to match the decisions made in the squash:

  • Adopted the merged hook naming throughout (toPrimitive, tagOf, toISOString, indicesOf, shapeOf, …) and dropped both of my cosmetic renames: defaultOperations stays as merged (no more defaultStringifyOperations), and test/operations.test.js keeps its name (parse tests live in a new test/parse-operations.test.js alongside it).
  • Mirrored the naming style on the parse side where a direct stringify counterpart exists: fromPrimitive / fromISOString / fromArrayBuffer are the inverses of toPrimitive / toISOString / toArrayBuffer, with cross-references in the JSDoc. The constructive operations keep create*/set*/mapSet names since they have no extraction counterpart — but consider this set an invitation for the same bikeshedding pass feat: pluggable operations for stringify #172 got.
  • The merge-override logic that feat: pluggable operations for stringify #172 landed inline is extracted to a shared merge_operations (identical semantics — same default-keys iteration and nullish coalescing) so parse doesn't duplicate it.
  • filterArrayIndices's docs/tests now reference indicesOf.

790 tests passing (766 from main including the tripwire suite, plus 19 parse-operations tests and 5 helper tests), dts build and publint clean.

Route every value construction parse/unflatten performs while reviving
(built-in instances, containers, property assignment) through a
ParseOperations interface, overridable via a new options argument:

  parse(serialized, revivers, { operations: { ... } })

Omitted members fall back to defaultParseOperations (exported), which
preserve existing behavior exactly.

This is the inverse of the stringify operations: where stringify needs
pluggable introspection, parse needs pluggable construction.

Motivations:

- Cross-realm revival: build values from the intrinsics of another realm
  (e.g. a node:vm context) so they satisfy instanceof checks there
- Foreign-runtime revival: build values inside another JS runtime (a
  WASM-hosted engine, a remote process) through opaque handles

Containers are created empty then populated (createMap/mapSet,
createObject/setProperty, ...), which is what keeps cyclic values
revivable.

Also renames defaultOperations to defaultStringifyOperations for
symmetry with defaultParseOperations, and extracts the shared
override-merging helper.
The arrayIndices operation encodes the sparse-array heuristic, so a
custom implementation either reimplements the filtering or contorts to
reuse the default. Foreign-runtime implementations typically already
have the keys, so expose the filtering half directly.
@TooTallNate
TooTallNate force-pushed the pluggable-parse-operations branch from c20c8a4 to 163b482 Compare July 29, 2026 17:01
@TooTallNate

Copy link
Copy Markdown
Contributor Author

Reworked the ParseOperations naming to follow the scheme from the #172 naming proposal, with the host/value-space boundary running the other way. Applying it rigorously turned out to shrink the interface (20 → 16 members), because the scheme exposed three places where parse had several ops for what stringify handles with one:

Family Rule Members
fromXxx conversions; whole input is host data, result crosses into value space (inverse of toXxx) fromPrimitive, fromISOString, fromStringValue, fromArrayBuffer
fromXxxInfo construction from a multi-field descriptor (inverse of xxxInfo) fromRegExpInfo, fromViewInfo
createXxx empty value-space containers, populated by the mutators (ordering is what makes cycles revivable) createArray, createSparseArray, createObject, createNullPrototypeObject, createSet, createMap
bare verbs value-space operations; operands and results stay in value space box, set, addValue, addEntry

Every extraction op now has a legible inverse:

stringify parse
toPrimitive(value) (incl. bigint) fromPrimitive(value)absorbs the former bigint op: the host-side BigInt(text) conversion happens in parse.js, and the op receives a host bigint primitive, exactly mirroring toPrimitive's domain
toISOString(date) fromISOString(iso)
toStringValue(value) (URL / URLSearchParams / Temporal.*, one op) fromStringValue(tag, text)absorbs url / urlSearchParams / temporal; same tag set, member-for-member with its inverse
toArrayBuffer(buffer) fromArrayBuffer(buffer)
regExpInfo(regexp) fromRegExpInfo(source, flags)
viewInfo(view) fromViewInfo(type, buffer, byteOffset, length)
unbox(boxed) box(value)
get(value, key) (arrays and objects) set(target, key, value)absorbs setIndex / setProperty, matching get's dual role
valuesOf(set) addValue(set, value)
entriesOf(map) addEntry(map, key, value)

The families are documented in the ParseOperations JSDoc the same way the interface docs call out toStringValue/unbox's deliberate awkwardness. 790 tests passing, dts + publint clean.

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.

Just some notes for myself, going to implement a few little changes and then we should be good to go

Comment thread src/types.d.ts
Comment thread index.js Outdated
@elliott-with-the-longest-name-on-github
elliott-with-the-longest-name-on-github merged commit 07d6a38 into sveltejs:main Jul 29, 2026
5 checks passed
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.

3 participants