Skip to content

yaml-splice WASM/TypeScript binding structure

Status: open/proposed

  • The pure yaml-splice crate (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-identical edit_yaml semantics from one implementation, not a second.
  • A thin packages/rust/yaml-splice-wasm cdylib 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.

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.

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}).

@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 FieldEdit discriminated union (Set/Remove/Rename), EditOutcome, and a typed EditError — rather than exposing the raw bindgen any;
  • 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.

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.

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.

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.

  • 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 KB wasm) 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-parser under 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-bindgen change is author discipline, backstopped by the corpus; cross-OS byte-reproducibility is deliberately not a CI gate.
  • Publishing @sksizer/yaml-splice to 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-parser with the lossless CST — that is M-C6XN-lossless-cst-core.

← Back to Decisions