yaml-splice WASM/TypeScript binding structure
Status: open/proposed
Summary
Section titled “Summary”- The pure
yaml-splicecrate (D-7N7I-pure-functional-yaml-editing) is Rust; its first real callers on the TypeScript side are the SDLC frontmatter writers that today reserialize whole blocks. Expose the crate to TypeScript over WebAssembly so those callers get byte-identicaledit_yamlsemantics from one implementation, not a second. - A thin
packages/rust/yaml-splice-wasmcdylib owns the wasm-bindgen surface;packages/ts/yaml-splice(@sksizer/yaml-splice) ships the built artifact plus a hand-written typed façade. - The conformance corpus becomes language-neutral JSON that both the Rust crate and the TS package run — byte-parity is the contract, so the implementation (parser included) can change under either binding without breaking callers.
Context
Section titled “Context”D-7N7I-pure-functional-yaml-editing made the editor pure by design — no filesystem,
clock, locale, threads, or global state — precisely so it could run anywhere. A spike
confirmed the payoff: yaml-splice compiles to wasm32-unknown-unknown unchanged, its
three dependencies (saphyr-parser, serde_json, thiserror) are all wasm-compatible,
and a ~50-line wasm-bindgen wrapper round-trips real frontmatter — a value change that
preserves quoting byte-for-byte, and the semantic no-op that returns byte-identical input.
Difficulty was low: no async-init pain, no marshalling pain, ~271 KB optimized wasm
(~90–110 KB gzipped).
The question this decision settles is not whether — the spike answered that — but how it lives in the tree: where the wasm wrapper sits, what the TS surface is, how it builds in CI, and how byte-parity with the Rust crate is guaranteed.
Decision
Section titled “Decision”1. WASM binding, not a second implementation
Section titled “1. WASM binding, not a second implementation”The TS surface is a projection of the one Rust implementation, reached over wasm. A hand-written TypeScript reimplementation of the D-7N7I contract would be a second copy to hold byte-identical to the first — the exact drift D-7N7I exists to prevent. The corpus would police it, but maintaining two implementations to pass one corpus is cost without benefit while a wasm binding is this cheap.
2. Wrapper crate — packages/rust/yaml-splice-wasm
Section titled “2. Wrapper crate — packages/rust/yaml-splice-wasm”A separate cdylib crate with a workspace dependency on yaml-splice, not a wasm
feature on the core crate. This keeps the core crate’s three-dependency purity intact — no
wasm-bindgen in the library every Rust caller compiles. The wrapper owns exactly the
boundary concerns: the wasm-bindgen exports, the JSON-string edit boundary (edits
cross as a JSON string and route through the crate’s existing From<&serde_json::Value>
value bridge — zero extra deps), and the EditError → thrown-Error mapping (.name =
variant, .span = {start,end}).
3. TS package — packages/ts/yaml-splice
Section titled “3. TS package — packages/ts/yaml-splice”@sksizer/yaml-splice, consumed in-repo via workspace:*. It ships the generated
wasm + glue and a hand-written typed index.ts façade that:
- types the public surface — a
FieldEditdiscriminated union (Set/Remove/Rename),EditOutcome, and a typedEditError— rather than exposing the raw bindgenany; - reads
{ bytes, changed }into a plain object and frees the wasm handle so callers never see the bindgen class (which holds a pointer into linear memory); JSON.stringifys the edits internally.
Default entry is the nodejs target (synchronous load — import and call; the target
for Node/Electron/Tauri callers, which is what the SDLC writers are). A web/bundler
entry (one await init() at bootstrap) is added when a browser consumer appears.
4. Build model — committed artifact + conformance gate
Section titled “4. Build model — committed artifact + conformance gate”The built .wasm + glue is committed into packages/ts/yaml-splice so consumers need
no wasm toolchain and no build step. Regenerating it (build.sh) drives the pinned
cargo → wasm-bindgen → wasm-opt pipeline; the wasm-bindgen crate version and CLI
version are pinned together (a mismatch silently produces wrong glue).
The CI gate is behavioral, not a byte-diff rebuild. A wasm module runs identically on
every platform, but cross-OS builds are not byte-reproducible — the host toolchain stamps
differing metadata even at pinned rustc / wasm-bindgen / binaryen versions — so a “rebuild
on Linux and git diff a macOS-committed artifact” gate goes red for a metadata difference
that changes no behavior. Instead the gate runs the shared §8 corpus against the
committed artifact (exactly what consumers get) and confirms the wrapper source still
compiles to wasm32. Byte reproducibility is a same-OS property the author relies on when
regenerating; behavioral conformance is what CI enforces.
5. Conformance is the contract, shared across bindings
Section titled “5. Conformance is the contract, shared across bindings”The 37-case corpus (D-7N7I §8) moves to language-neutral JSON fixtures
({ source, edits, expected_bytes | expected_error }). The Rust crate’s tests and the TS
package’s tests both load and run them. Byte-parity across every binding is the guarantee,
and it is also what lets the parser — saphyr-parser today, the M-C6XN-lossless-cst-core
lossless CST later — change under either side without a caller noticing.
The spike made the choice easy: the boundary is clean (bytes in, JSON edits in, bytes +
changed out), there is no state to shim, and one implementation stays authoritative. The
committed-artifact-plus-drift-gate pattern is already how this repo keeps generated Rust ↔
TS surfaces honest; reusing it means no new CI concept. The shared corpus is the insurance
that keeps a future parser swap — or a future TS reimplementation, if one is ever justified
— provably byte-identical.
Options considered
Section titled “Options considered”TypeScript reimplementation of the D-7N7I contract
Section titled “TypeScript reimplementation of the D-7N7I contract”Rejected for V1: two implementations to hold byte-identical is the drift D-7N7I fights, for no gain while wasm is this cheap. Not forbidden forever — the shared corpus would police one — but not now.
A wasm feature on the core crate
Section titled “A wasm feature on the core crate”Rejected: it drags wasm-bindgen into the crate every Rust caller builds. A separate
wrapper crate keeps the core’s minimal-dependency purity, which D-7N7I treats as load-bearing.
Build the wasm at CI / install time (no committed artifact)
Section titled “Build the wasm at CI / install time (no committed artifact)”Rejected as the default: every consumer build would then need the pinned wasm-bindgen CLI
and the wasm32 target. Committing the artifact and guarding it with a drift gate confines
the toolchain requirement to one CI job and keeps consumers zero-setup.
serde-wasm-bindgen for the edit boundary
Section titled “serde-wasm-bindgen for the edit boundary”Rejected: the JSON-string boundary works with zero extra dependencies through the crate’s
existing serde_json value bridge. serde-wasm-bindgen would let edits cross as live JS
objects but needs serde derives on FieldEdit (or a mirror type) — more coupling for
marginal ergonomic gain.
Consequences
Section titled “Consequences”- TypeScript callers get in-language, byte-identical field editing; M-2VYE-yaml-splice-extraction-and-writer-migrations’s whole-block writer migrations are unblocked.
- A binary (
~335 KBwasm) lives in the tree, guarded by the conformance gate — a deliberate, reviewed artifact, not incidental. - The parser stays swappable behind the shared corpus; the lossless-CST milestone can
replace
saphyr-parserunder both bindings later. - The committed artifact is guarded behaviorally — it must pass the §8 corpus and the
source must compile. Regenerating it on a source or
wasm-bindgenchange is author discipline, backstopped by the corpus; cross-OS byte-reproducibility is deliberately not a CI gate.
Out of scope
Section titled “Out of scope”- Publishing
@sksizer/yaml-spliceto npm — the tier-2 derived-library promotion, later, once ≥2 independent consumers exist. - A production browser/bundler build beyond a stub entry — added when a browser consumer arrives.
- Replacing
saphyr-parserwith the lossless CST — that is M-C6XN-lossless-cst-core.