oxml-wasm 0.0.9

WebAssembly bindings for oxml: XML parsing and XPath in the browser
Documentation

Contents

Getting started

The oxml ecosystem

Reference

Practical


Why this is possible at all

libxml-based crates cannot target WebAssembly without a libxml2 toolchain, which is most of the reason a browser-side XPath has not been practical in Rust. oxml is pure Rust with no C dependency and no unsafe, so it compiles to wasm32-unknown-unknown the same way any other crate does.

That is the whole argument for this package. Everything else follows.

Install

npm install oxml-wasm

Or build it yourself:

wasm-pack build --target web      # for a browser
wasm-pack build --target nodejs   # for Node
wasm-pack build --target bundler  # for webpack, Vite, Rollup

Quick Start

import init, { parse, is_well_formed } from "oxml-wasm";

await init();

const doc = parse(`
  <catalogue>
    <book lang="en"><title>Dune</title><price>9.99</price></book>
    <book lang="fr"><title>Germinal</title><price>7.50</price></book>
  </catalogue>
`);

doc.rootName();                       // "catalogue"
doc.size;                              // node count (a property)
doc.queryText("//title");             // ["Dune", "Germinal"]
doc.queryCount("//book");             // 2
doc.queryValue("sum(//price)");       // "17.49"
doc.toXml();                          // the document back as XML

isWellFormed("<a>");                 // false

The oxml ecosystem

Crate What it is
oxml The library: parser, tree, XPath 1.0
xmlschema XSD validation
oxml-cli The command line
oxml-wasm This crate — WebAssembly
oxml-mcp Model Context Protocol server
oxml-lsp Language Server Protocol server

All six ship one version number, in steps of 0.0.1.

API

parse(source: string): Document

Parses a document. Throws on malformed input; the message carries the byte offset and what went wrong.

isWellFormed(source: string): boolean

Parses and discards, returning whether it succeeded. Use this when you want a yes-or-no answer and do not need the tree — it avoids handing a Document back across the boundary.

Document

Method Returns
size Number of nodes (a property, not a method)
rootName() The root element's local name, or undefined
queryText(expr, ns?) string[] — the string-value of each matched node
queryValue(expr, ns?) string — the expression's value, converted to a string
queryCount(expr, ns?) number — how many nodes matched
toXml() string — the document as XML

ns is an optional array of "PREFIX=URI" strings — the same spelling oxml-cli takes for --ns, so the two are learned once.

queryText is for node-sets. queryValue is for anything — count(//x), sum(//price), string(//title), a boolean — and applies XPath's own conversion rules.

queryText on an expression that is not a node-set does not throw: it returns a one-element array holding the converted value, so a caller iterating the result still gets something. queryCount on the same expression returns 0, because nothing was matched.

The three are separate rather than one method returning a tagged union, because crossing the WebAssembly boundary costs a serialisation and a union costs one on every call regardless of what the caller wanted.

Method names are camelCase in JavaScript. wasm-bindgen converts query_text in the Rust source to queryText in the generated module. If you are reading the Rust code, add the conversion mentally.

Memory and lifetimes

A Document holds memory inside the WebAssembly instance, not on the JavaScript heap. wasm-bindgen cannot collect it for you: the JavaScript garbage collector does not know how large it is and will not run on its account.

Call free() when you are done with a large document:

const doc = parse(largeXml);
try {
  const titles = doc.queryText("//title");
  // …
} finally {
  doc.free();
}

The generated class also implements Symbol.dispose, so on a runtime with explicit resource management the try/finally collapses to:

using doc = parse(largeXml);
const titles = doc.queryText("//title");

For a handful of small documents this does not matter. For a page that parses a document per request, it is the difference between steady memory and a slow leak.

Errors

parse throws a JsError. The message is the library's, which includes the byte offset:

try {
  parse("<a></b>");
} catch (e) {
  console.error(e.message);  // 1:4: at byte 3: </b> closes <a>
}

The message leads with a line and column, counted in characters, then the library's own message with its byte offset.

An invalid XPath expression throws from query_* rather than at parse time, because the expression is compiled when it is used.

Migration

From DOMParser and document.evaluate

Browser API oxml-wasm
new DOMParser().parseFromString(s, "text/xml") parse(s)
checking for <parsererror> in the result parse throws; or isWellFormed(s)
doc.documentElement.tagName doc.rootName()
doc.evaluate(e, doc, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null) doc.queryText(e)
doc.evaluate(e, doc, null, XPathResult.NUMBER_TYPE, null).numberValue doc.queryValue(e)
namespace resolver argument not supported — see the FAQ

The most useful difference is error reporting. DOMParser signals a failure by returning a document containing a <parsererror> element, whose shape differs between browsers; parse throws with a byte offset.

The most awkward difference is that you get strings back, not nodes. There is no DOM here to hand you.

Ecosystem comparison

oxml-wasm DOMParser fast-xml-parser libxmljs
Runs in a browser Yes Yes Yes No
Runs in Node Yes With a shim Yes Yes
XPath 1.0 Yes Yes No Yes
Returns a DOM No Yes No (objects) Yes
Consistent across browsers Yes No Yes n/a
Native code Wasm Browser JavaScript C++ addon
Fetches external entities Never No No Configurable

DOMParser is already there and costs nothing to use. Reach for this when you need behaviour that does not vary by browser, or when the same queries have to run in Node and in a browser and give identical answers.

Capabilities in 0.0.8

  • Full XML 1.0 and 1.1 parsing, namespaces resolved by URI
  • XPath 1.0: ten axes, 25 functions, all four value types
  • Well-formedness checking without building a tree
  • Node counts and root element name
  • No C toolchain, no unsafe, no network access

Not yet: returning nodes rather than strings, mutation, serialisation, XSD validation, streaming, namespace prefix bindings.

Bundle size

Not published here. A figure depends on the wasm-pack target, wasm-opt settings, whether the XPath feature is enabled and what your bundler strips, and quoting one number without all four is misleading.

Measure yours:

wasm-pack build --target web --release
ls -l pkg/*.wasm

Building without XPath, if you only need parsing and well-formedness checks, removes the whole expression engine.

Benchmarks

cargo bench --bench core

The benchmark measures src/core.rs natively, not through WebAssembly. That is deliberate: it isolates the parsing and query work from the JavaScript boundary, so a change in one is not read as a change in the other. What it cannot tell you is the cost of crossing that boundary, which depends on your bundler and engine.

On a 129 KB document:

Operation Time
parse 4.7 ms
isWellFormed 5.1 ms
queryCount("//item") 0.55 ms
queryText("//name") 0.44 ms
100 × queryCount on one parsed document 78 ms

The last row is the point of the API's shape: parsing once and querying a hundred times costs about as much as sixteen parses, so a caller that re-parses per query pays for it. See Memory and lifetimes.

From one run on an Apple Silicon laptop that was not idle. These describe the machine as much as the code; compare runs, not numbers.

Examples

Runnable, and run in CI:

Example What it shows
examples/node/basic.mjs Every method, with assertions
examples/node/errors.mjs Malformed documents and invalid expressions
examples/node/memory.mjs free(), and what happens without it
examples/web/index.html A browser page with no bundler

When not to use oxml-wasm

  • You need a DOM. This returns strings. DOMParser returns nodes you can walk and modify.
  • DOMParser is enough. It is already in the browser and costs no download.
  • You are only reading a small, known document. A WebAssembly module is a lot of machinery for one querySelector-shaped job.
  • Documents are larger than the Wasm heap. The whole tree is built in memory, inside a linear memory that has a ceiling.

FAQ

Why strings instead of nodes?

Because every value crossing the WebAssembly boundary is copied, and a node is only useful with the document it came from. Handing back node handles would mean a call into Wasm for every property access — far more expensive than returning the strings you were going to ask for anyway.

If you need the structure, query for the parts you need.

How do I query a document with namespaces?

Bind the prefix with the query:

doc.queryText("//m:item", ["m=urn:example"]);

The binding belongs to the query, not the document, so the same call works against a document that spells the prefix differently — only the URI has to match. An unbound prefix throws rather than silently matching on the local part.

An unprefixed name test matches only nodes in no namespace, which is XPath 1.0's rule and the same one document.evaluate follows. If your document declares a default namespace, //item matches nothing — bind a prefix to that URI.

namespace-uri() still works and needs no binding, which is useful when you know the URI and not the prefix:

doc.queryText("//*[namespace-uri()='urn:example' and local-name()='item']");

Do I have to call free()?

Not for small documents. For large ones, or for a page that parses repeatedly, yes — see Memory and lifetimes.

Does it work in a Web Worker?

Yes, and it is a good place for it: parsing a large document blocks whatever thread it runs on.

Can it fetch a document over HTTP?

No. There is no network code in the module. Fetch it in JavaScript and pass the string.

Is it safe against XXE?

Yes, structurally. The parser contains no code that opens a file or a socket, so an external entity is never substituted with anything.

How it is reported depends on the library version: oxml 0.0.3 rejects the reference as an unknown entity, and 0.0.4 accepts the declaration and expands it to nothing. Neither reads the file. The example in examples/node/errors.mjs asserts the property rather than the message, so it holds across both.

Why is parse throwing on a document my browser accepts?

Browsers are lenient in places the specification is not. oxml rejects malformed documents rather than guessing — the message says which rule and where. If you believe the document is well-formed and it is rejected, that is a bug worth reporting.

Does it support XPath 2.0?

No. XPath 1.0, which is also what document.evaluate implements.

Which browsers?

Anything with WebAssembly: every current browser. There is no polyfilled fallback.

Development

./scripts/gate.sh

That runs everything CI runs, in the order that fails fastest: format, clippy, tests, rustdoc, the #![forbid(unsafe_code)] check, the wasm build, wasm-pack build and wasm-pack test --node, the generated bindings check, the Node examples, the 95% coverage floor and an MSRV build. It pins the toolchain rather than trusting rust-toolchain.toml, because a RUSTUP_TOOLCHAIN in the environment silently overrides that file and a lint that exists in one release and not another then makes a green local run and a red CI one.

Without wasm-pack installed it says so and fails, rather than skipping the steps quietly — a check that vanishes when a tool is missing reports success over what it did not run.

The individual steps, if you want them one at a time:

cargo test --all-features     # the core logic, natively
cargo bench --bench core
wasm-pack test --node         # the bindings
wasm-pack build --target web
./examples/run-all.sh

The logic lives in src/core.rs and is tested natively, so most of the behaviour is covered without a WebAssembly toolchain. src/lib.rs is the thin #[wasm_bindgen] layer over it.

The logic lives in src/core.rs and is tested natively, so most of the behaviour is covered without a WebAssembly toolchain. src/lib.rs is the thin #[wasm_bindgen] layer over it.

Security

No network access, no filesystem access, external entities never dereferenced, entity expansion bounded, #![forbid(unsafe_code)].

A WebAssembly module is sandboxed by the runtime as well, so the worst a hostile document can do is exhaust the module's memory.

See https://github.com/sebastienrousseau/oxml/blob/main/doc/SECURITY-MODEL.md.

Documentation

Acknowledgements

oxml-wasm exists because of work that came before it:

  • wasm-bindgen and wasm-pack — the toolchain that makes a Rust crate a JavaScript package.
  • lxml and libxml2 — decades of hard-won correctness, and the yardstick for behaviour.
  • W3C — for the XPath 1.0 specification this follows.

License

Licensed under either of Apache-2.0 (LICENSE-APACHE) or MIT (LICENSE-MIT), at your option.