oxml-wasm 0.0.11

WebAssembly bindings for oxml: XML parsing and XPath in the browser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
<!-- SPDX-License-Identifier: Apache-2.0 OR MIT -->

<h1 align="center">oxml-wasm</h1>

<p align="center">
  XML parsing and XPath 1.0 in the browser — WebAssembly bindings for
  <a href="https://github.com/sebastienrousseau/oxml">oxml</a>, with no C
  toolchain and zero <code>unsafe</code> code.
</p>

<p align="center">
  <a href="https://github.com/sebastienrousseau/oxml-wasm/actions"><img src="https://img.shields.io/github/actions/workflow/status/sebastienrousseau/oxml-wasm/ci.yml?style=for-the-badge&logo=github" alt="Build" /></a>
  <a href="https://crates.io/crates/oxml-wasm"><img src="https://img.shields.io/crates/v/oxml-wasm.svg?style=for-the-badge&color=fc8d62&logo=rust" alt="Crates.io" /></a>
  <a href="https://docs.rs/oxml-wasm"><img src="https://img.shields.io/badge/docs.rs-oxml--wasm-66c2a5?style=for-the-badge&labelColor=555555&logo=docs.rs" alt="Docs.rs" /></a>
  <a href="https://github.com/sebastienrousseau/oxml-wasm/actions/workflows/scorecard.yml"><img src="https://img.shields.io/github/actions/workflow/status/sebastienrousseau/oxml-wasm/scorecard.yml?style=for-the-badge&label=Scorecard%20scan&logo=openssf" alt="Scorecard scan workflow status" /></a>
  <a href="https://www.bestpractices.dev/projects/14314"><img src="https://img.shields.io/cii/level/14314?style=for-the-badge&label=OpenSSF%20Best%20Practices&logo=openssf" alt="OpenSSF Best Practices" /></a>
</p>

<p align="center">
  <img src=".github/demo.gif" alt="oxml-wasm Demo" width="100%" />
</p>

---

## Contents

**Getting started**

- [Why this is possible at all]#why-this-is-possible-at-all — a pure-Rust parser compiles to wasm; a C one does not
- [Install]#install — npm, from source
- [Quick Start]#quick-start — parse and query in ten lines

**The oxml ecosystem**

- [The oxml ecosystem]#the-oxml-ecosystem — seven crates, one version

**Reference**

- [API]#api — every exported method
- [Memory and lifetimes]#memory-and-lifetimes — why `free()` exists
- [Errors]#errors — what throws, and what it says
- [Migration]#migration — from `DOMParser`
- [Ecosystem comparison]#ecosystem-comparison — what each option costs you
- [Capabilities in 0.0.11]#capabilities-in-0011 — release inventory
- [Bundle size]#bundle-size — how to measure yours
- [Benchmarks]#benchmarks — the core operations, measured natively

**Practical**

- [Examples]#examples — Node and the browser
- [When not to use oxml-wasm]#when-not-to-use-oxml-wasm
- [FAQ]#faq
- [Development]#development
- [Security]#security
- [Roadmap]doc/ROADMAP.md
- [Documentation]#documentation
- [Acknowledgements]#acknowledgements
- [License]#license

---

## 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

```bash
npm install oxml-wasm@0.0.11
```

Or build it yourself:

```bash
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

```javascript
import init, { parse, isWellFormed } 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`]https://github.com/sebastienrousseau/oxml | The library: parser, tree, XPath 1.0 |
| [`xmlschema`]https://github.com/sebastienrousseau/xmlschema | XSD validation |
| [`oxml-cli`]https://github.com/sebastienrousseau/oxml-cli | The command line |
| **`oxml-wasm`** | **This crate — WebAssembly** |
| [`oxml-mcp`]https://github.com/sebastienrousseau/oxml-mcp | Model Context Protocol server |
| [`oxml-lsp`]https://github.com/sebastienrousseau/oxml-lsp | Language Server Protocol server |
| [`oxml-json`]https://github.com/sebastienrousseau/oxml-json | JSON support for the language server |

All seven 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:**

```javascript
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:

```javascript
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:

```javascript
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 | pass `PREFIX=URI` bindings with the query |

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.11

- 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

Serialization (`toXml`) and query namespace bindings are available.

**Not yet:** returning node handles, mutation, XSD validation and streaming.

## 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:

```bash
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

```bash
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](#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`]examples/node/basic.mjs | Every method, with assertions |
| [`examples/node/errors.mjs`]examples/node/errors.mjs | Malformed documents and invalid expressions |
| [`examples/node/memory.mjs`]examples/node/memory.mjs | `free()`, and what happens without it |
| [`examples/web/index.html`]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:

```javascript
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:

```javascript
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](#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

```bash
./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:

```bash
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.

## Security

Scorecard scans use a digest-pinned upstream image. Results are retained
as CI artifacts and repository posture findings in GitHub code scanning,
not published to Scorecard's public service. The badge shows workflow
status, not a security score; a successful scan can still report findings.

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

Parsing uses `Limits::strict()` (including 8 MiB source and decoded
document limits), and every XPath evaluation has fresh default work,
node and string budgets. The runtime must still enforce linear-memory
and execution limits; sandboxing is not a denial-of-service guarantee.

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

## Documentation

- [API documentation]https://docs.rs/oxml-wasm
- [ARCHITECTURE.md]https://github.com/sebastienrousseau/oxml-wasm/blob/main/doc/ARCHITECTURE.md
- [MEMORY.md]https://github.com/sebastienrousseau/oxml-wasm/blob/main/doc/MEMORY.md
- [MIGRATION-FROM-DOMPARSER.md]https://github.com/sebastienrousseau/oxml-wasm/blob/main/doc/MIGRATION-FROM-DOMPARSER.md
- [TESTING.md]https://github.com/sebastienrousseau/oxml-wasm/blob/main/doc/TESTING.md
- [CHANGELOG.md]https://github.com/sebastienrousseau/oxml-wasm/blob/main/CHANGELOG.md
- [CONTRIBUTING.md]https://github.com/sebastienrousseau/oxml-wasm/blob/main/CONTRIBUTING.md
- [SECURITY.md]https://github.com/sebastienrousseau/oxml-wasm/blob/main/SECURITY.md

## Acknowledgements

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

- **[wasm-bindgen]https://github.com/rustwasm/wasm-bindgen** and
  **[wasm-pack]https://github.com/rustwasm/wasm-pack** — the toolchain
  that makes a Rust crate a JavaScript package.
- **[lxml]https://lxml.de/** and
  **[libxml2]https://gitlab.gnome.org/GNOME/libxml2** — decades of
  hard-won correctness, and the yardstick for behaviour.
- **[W3C]https://www.w3.org/TR/1999/REC-xpath-19991116/** — for the
  XPath 1.0 specification this follows.

## License

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