noyalib-wasm
wasm-bindgen wrapper around noyalib —
pure-Rust YAML 1.2, zero unsafe, ~338 KB after
LTO. Runs in browsers, Node, Cloudflare Workers, Deno, and
any other WASM-capable host.
Contents
- Install — npm, build from source
- Requirements — toolchain floor, platforms, the core pin
- Quick Start — parse, edit, validate
- Why this approach? — vs
js-yaml - Surface — exported APIs
- Bundle size — what you ship to users
- Targets — every wasm-pack flavour
- Provenance — npm + cosign
- Examples — Node + browser demos
- When not to use noyalib-wasm
- Documentation
- License
Install
npm install @sebastienrousseau/noyalib-wasm
# or
pnpm add @sebastienrousseau/noyalib-wasm
# or
yarn add @sebastienrousseau/noyalib-wasm
Or build from source against any wasm-pack target:
git clone https://github.com/sebastienrousseau/noyalib-wasm
cd noyalib-wasm
wasm-pack build --release --target bundler
Split from the monorepo since v0.0.12. Prior versions shipped from
sebastienrousseau/noyalib/crates/noyalib-wasm/. From v0.0.12 onwardnoyalib-wasmlives here as its own crate, released in strict lockstep with the parentnoyalibat the same version. See ADR-0005 for the rationale and rollback recipe.
Requirements
- Rust 1.89.0 or newer to build from source:
rust-versionin the manifest, enforced by themsrv-coreCI job on every push. - Any tier-1 platform. CI runs the tests on Linux, macOS, and Windows with the stable, beta, and nightly toolchains; stable is the gate, beta and nightly are early warning.
- The matching core. This crate pins
noyalibat the identical=0.0.Xand releases in lockstep with it; Cargo resolves that pin for you. - For the bundle:
wasm-packand a JavaScript runtime that loads WebAssembly (browsers, Node.js 18+, Deno, Bun, Cloudflare Workers).cargo testneeds neither; the pure-Rust core is tested natively.
Quick Start
import init, {
parse,
stringify,
validateJson,
getPath,
merge,
WasmDocument,
} from "@sebastienrousseau/noyalib-wasm";
await init(); // load the WASM blob
// Plain parse / stringify — like js-yaml. Mappings come back as
// plain JS Objects (not `Map`), so dotted property access works.
const obj = parse("host: api.example.com\nport: 8080\n");
console.log(obj.host); // "api.example.com"
const yaml = stringify(obj);
// Indexed read without going through `parse`.
const port = getPath("host: api.example.com\nport: 8080\n", "port"); // 8080
// JSON-compatible YAML 1.2 schema check.
validateJson("a: 1\nb: [2, 3]\n"); // true
// Lossless CST edit — comments + indentation preserved.
const doc = new WasmDocument(source);
doc.set("server.port", "9090");
fs.writeFileSync("config.yaml", doc.toString());
Why this approach?
js-yaml is the de-facto
JS YAML parser, and it’s good — but it makes two tradeoffs that
hurt for editor and tooling workloads:
-
js-yamldiscards comments by spec. It implements the YAML data model, which excludes comments. Round-tripping a document throughparse→dumpstrips every#line. noyalib’sDocumentAPI runs through a lossless CST that reproduces the source byte-for-byte; only the surgically touched span changes on aset. -
js-yamlfollows YAML 1.1 by default. That’s the “Norway problem”:country: NOparses ascountry: false, silently rewriting the country code. noyalib defaults to YAML 1.2 strict semantics; onlytrue/falseare booleans.
Custom YAML tags
parse(yaml) surfaces YAML tags as plain JS object keys:
!Color '#ff8800' deserialises into { "!Color": "#ff8800" }.
This matches the serde-bridge convention every other
serde-wasm-bindgen consumer uses (the Value::Tagged variant
serialises as a single-entry map for cross-format interop).
Round-tripping via stringify does not restore the
YAML-tag prefix — the JS object’s tag-as-key shape becomes a
quoted mapping key in the emitted YAML.
For editor / tooling workloads where the YAML-tag wire form
must survive a parse → emit cycle, use the WasmDocument
class instead. Its set / setValue are surgical edits
through the CST, so untouched tag prefixes round-trip
verbatim:
const doc = new WasmDocument("color: !Color '#ff8800'\n");
doc.set("color", "!Color '#00aaff'"); // tag survives
console.log(doc.toString()); // "color: !Color '#00aaff'\n"
Other differences worth knowing about:
- JSON Schema 2020-12 validation built in. Same engine as
the
noyavalidateCLI ships. - Pure-Rust, zero
unsafe. Every byte of the parser, scanner, formatter, and CST is checked at compile time by the workspace#![forbid(unsafe_code)]lint. - ~338 KB bundle. That’s roughly the same size as
js-yamlminified + gzipped, with the lossless-CST surface and YAML 1.2 semantics baked in.
Surface
All exports are camelCase, matching JS conventions.
Free functions
| Export | What it does |
|---|---|
parse(yaml: string): any | Parse a YAML document into a JS value. Mappings become plain Objects; sequences become Arrays; scalars become numbers / strings / booleans / null. Mirrors js-yaml’s load. |
stringify(value: any): string | Serialise a JS value back to YAML. |
validateJson(yaml: string): boolean | Validate that the document conforms to the YAML 1.2 JSON-compatible schema (only types JSON allows: null / bool / number / string / array / object). Returns true / false; structural JSON Schema 2020-12 validation is on the noyavalidate CLI roadmap. |
getPath(yaml: string, path: string): any | Indexed read without going through parse. Dotted paths ("server.host"); returns null if missing. |
merge(base: string, override: string): string | Deep-merge two YAML documents. Delegates to noyalib::Value::merge. |
WasmDocument class — lossless CST
Construct with new WasmDocument(yaml). Every method preserves
comments and formatting around untouched spans byte-faithfully.
| Method | What it does |
|---|---|
toString(): string | Re-emit. Byte-identical to the parsed source if no edits were made. |
get(path: string): any | Parsed value at a dotted path. Returns null if missing. |
getSource(path: string): string | null | Raw source fragment at a dotted path (no re-quoting / canonicalisation). |
set(path: string, fragment: string): void | Surgically rewrite a value at a dotted path. The fragment is a YAML-shaped string ("9090", "[1,2,3]", …). |
setValue(path: string, value: any): void | Same as set but accepts a JS value instead of a YAML fragment. |
spanAt(path: string): { start: number, end: number } | null | Byte range of the value at a dotted path. |
commentsAt(path: string): { before: string[], inline: string | null } | Comments associated with the node at a path. |
replaceSpan(start: number, end: number, replacement: string): void | Primitive byte replacement. |
Every function is async only via init() — once the WASM
blob is loaded, individual calls are synchronous.
Bundle size
| Build | Size (raw) | Size (gzip) |
|---|---|---|
Default (wasm-pack build --release --target bundler) | ~338 KB | ~140 KB |
--features wasm-opt (post-build pass) | ~280 KB | ~115 KB |
Tree-shaking-friendly — the Document API and the plain
parse / stringify API are independent modules; bundlers
drop whichever your code does not import.
For comparison: js-yaml 4.x lands around ~50 KB minified +
~12 KB gzipped, but does not provide lossless-CST or schema
validation.
Targets
wasm-pack build supports every target wasm-bindgen does:
wasm-pack build --target bundler # webpack, rollup, esbuild
wasm-pack build --target web # native ES module via <script type="module">
wasm-pack build --target nodejs # commonjs Node import
wasm-pack build --target deno # Deno-native module
wasm-pack build --target no-modules # plain global, no module loader
Cloudflare Workers and edge runtimes generally consume the
bundler target via their packaging step.
Provenance
Every release on npm carries an npm provenance attestation linking the published bundle to the GitHub Actions run that produced it. Verify via:
npm view @sebastienrousseau/noyalib-wasm provenance
GitHub Releases ship the crate archive and a CycloneDX SBOM,
each with a sigstore bundle and checksums. A standalone signed
.wasm artefact is not attached to releases; the npm package
(with its provenance attestation, above) is the distribution
channel for the compiled bundle. Verify a release artefact:
cosign verify-blob \
--certificate-identity-regexp '^https://github\.com/sebastienrousseau/noyalib-wasm/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--bundle <artefact>.bundle \
<artefact>
Full cookbook: pkg/VERIFY.md.
Examples
Browser + Node demos under
examples/:
| Path | Target | What it shows |
|---|---|---|
node-stringify.js | Node | parse + stringify round-trip. |
cst-edit.js | Node | Lossless CST edit; comments + whitespace preserved. |
paths-and-spans.js | Node | getPath, merge, getSource, spanAt, replaceSpan, setValue, commentsAt. |
json-compat.js | Node | validateJson — refuse YAML that JSON cannot represent (NaN / Infinity). |
browser/index.html | Browser | Live in-page YAML editor with a parsed-JSON pane. |
benches/bench.js | Node | Throughput measured across the real JS↔wasm boundary. |
The wasm build ships no JSON Schema engine.
validateJsononly checks JSON round-trip safety. For JSON Schema 2020-12 usenoyavalidate --schema, or thenoyalibcrate’svalidate-schemafeature.
# Node:
wasm-pack build --release --target nodejs crates/noyalib-wasm
node crates/noyalib-wasm/examples/cst-edit.js
node crates/noyalib-wasm/examples/json-compat.js
# Browser:
wasm-pack build --release --target web crates/noyalib-wasm
cd crates/noyalib-wasm/examples/browser
python3 -m http.server # or any static-file server
When not to use noyalib-wasm
- You only ever consume YAML in Node and don’t care about
comment-preserving edits or YAML 1.2 strictness.
js-yamlis smaller (~50 KB minified) and the de-facto standard; reach for it first. - You need a streaming parser for multi-GB documents. The WASM bindings always read the full document into memory. For TB-scale streaming workloads, drive the noyalib library directly from a Rust process and pipe results out.
Compatibility
MSRV: Rust 1.89.0 stable, the floor shared across the noyalib family and the
maintainer’s other Rust crates (noyalib ADR-0014). No current
dependency requires it. The whole lockstep set, including the core
noyalib library, shares this floor. CI verifies the floor on every PR via
the Per-crate MSRV workflow job. The bump policy lives in
docs/POLICIES.md.
Tier-1 WASM targets (CI-verified each PR via
wasm-pack test --node): wasm32-unknown-unknown produced
under every wasm-pack mode — bundler (Webpack, Rollup,
esbuild, Vite), web (native ES module), nodejs (CJS),
deno, no-modules. Cloudflare Workers, Deno, and Bun
consume the bundler target.
Documentation
The four entry points, identical across every repo in the family:
-
User Manual — this crate’s rendered book: its guides, architecture, and release notes; the family manual for the core library is at https://sebastienrousseau.github.io/noyalib/manual/
-
API reference — rustdoc on docs.rs
-
Developer docs — this repo’s dev entry point, pointing at the family guide
-
Ecosystem map — the six crates, the lockstep model, the scorecard
-
Engineering policies (MSRV, SemVer, security, performance, concurrency, platform support, feature flags):
docs/POLICIES.md -
Security policy:
SECURITY.md -
JS API reference:
docs/js-api.md -
Bundling (Vite, Webpack, Next.js, Cloudflare Workers, Deno, Bun):
docs/bundling.md -
npm package: https://www.npmjs.com/package/@sebastienrousseau/noyalib-wasm
-
API reference (rustdoc): https://docs.rs/noyalib-wasm
-
Workspace README: https://github.com/sebastienrousseau/noyalib#readme
Conformance
Every push runs the official yaml-test-suite
through this crate’s own JSON model, from the same vendored suite and the same
core commit as the noyalib core: 382 of 382 single-document cases (the 24
multi-document cases are outside parse, which is single-document). The core
itself passes 406 of 406 with no skip list. A two-document configuration that
uses most of YAML at once (tests/fixtures/ultra-complex/) is also parsed to
exactly its expected JSON, natively and in Node. Details and the family table:
noyalib.com/conformance.
License
Dual-licensed under Apache 2.0 or MIT, at your option.