@miaskiewicz/turbo-dom 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -488,9 +488,18 @@ dependencies = [
488
488
  "utf-8",
489
489
  ]
490
490
 
491
+ [[package]]
492
+ name = "turbo-dom"
493
+ version = "0.3.1"
494
+ dependencies = [
495
+ "html5ever",
496
+ "markup5ever_rcdom",
497
+ "rustc-hash",
498
+ ]
499
+
491
500
  [[package]]
492
501
  name = "turbo-dom-parser"
493
- version = "0.2.4"
502
+ version = "0.3.1"
494
503
  dependencies = [
495
504
  "html5ever",
496
505
  "markup5ever_rcdom",
package/Cargo.toml CHANGED
@@ -1,6 +1,11 @@
1
+ [workspace]
2
+ # The root package (turbo-dom-parser) plus the standalone Rust-native DOM runtime
3
+ # crate published to crates.io as `turbo-dom`. The parser ships to npm.
4
+ members = ["crates/turbo-dom"]
5
+
1
6
  [package]
2
7
  name = "turbo-dom-parser"
3
- version = "0.2.4"
8
+ version = "0.3.1"
4
9
  edition = "2021"
5
10
  description = "html5ever-backed native parser for turbo-dom (Layer 1)"
6
11
 
@@ -29,6 +34,13 @@ napi-build = { version = "2", optional = true }
29
34
  default = ["napi-bind"]
30
35
  napi-bind = ["dep:napi", "dep:napi-derive", "dep:napi-build"]
31
36
  wasm-bind = ["dep:wasm-bindgen", "dep:serde", "dep:serde-wasm-bindgen"]
37
+ # Phase-1 boundary spike: Rust DOM runtime over the SoA, exported to JS via wasm-bindgen.
38
+ wasm-runtime = ["dep:wasm-bindgen"]
39
+ # Pure-Rust DOM runtime (the `rtdom` module). Off by default so the published
40
+ # napi `.node` + wasm parser artifacts stay lean — no JS consumer uses rtdom (a
41
+ # JS-side WASM/napi boundary measured SLOWER than the JS runtime). A Rust consumer
42
+ # adds this crate as a dep and enables this feature; deps are pure std + core.
43
+ rust-runtime = []
32
44
 
33
45
  [profile.release]
34
46
  lto = true
package/README.md CHANGED
@@ -188,6 +188,46 @@ The parser runs in Rust (compute-bound, one boundary crossing per parse). The DO
188
188
  JS (chatty, fine-grained) but pays only for what a test touches. Full design notes:
189
189
  [turbo-dom-spec.md](./turbo-dom-spec.md).
190
190
 
191
+ ## Rust-native DOM runtime (`rtdom`) — for Rust consumers
192
+
193
+ Everything above is the **JS-consumer** path: the DOM is JS objects, so React/RTL touch it with
194
+ zero boundary — that's why it's fast for vitest/jest. A **Rust** consumer (crawler, extractor,
195
+ server-side scraper) wants the opposite: the DOM in-process in Rust, no JS at all. For that there's
196
+ **`rtdom`** — a pure-Rust port of the runtime (lazy copy-on-write tree over the same SoA buffer,
197
+ version-cached queries, partial `getComputedStyle`, events, shadow DOM, serialize).
198
+
199
+ Why a separate runtime instead of exposing this one to Rust via WASM? Measured: a Rust-DOM-in-WASM
200
+ called *from JS* is **~0.55×** the JS runtime (the boundary crossing dominates — exactly what the
201
+ [spec §3](./turbo-dom-spec.md) predicted), while `rtdom` run **in-process from Rust** is **~2.7×**
202
+ the JS runtime on the same chatty workload (zero boundary). So: JS consumers keep the JS runtime,
203
+ Rust consumers use `rtdom`.
204
+
205
+ Add it to a Rust project from crates.io (the `turbo-dom` crate is the Rust-native
206
+ runtime; the npm `@miaskiewicz/turbo-dom` is the JS path):
207
+
208
+ ```bash
209
+ cargo add turbo-dom
210
+ ```
211
+
212
+ ```rust
213
+ use turbo_dom::{Dom, DocumentExt};
214
+ use turbo_dom::rtdom::cascade;
215
+
216
+ let mut dom = Dom::parse("<main class=grid><div class=card id=hero>hi</div></main>");
217
+ let cards = dom.tree.query_selector_all("div.card"); // version-cached, in-process
218
+ let id = dom.tree.get_attribute(cards[0], "id"); // Some("hero") — plain Rust call
219
+ let style = cascade::computed_style(&dom.tree, cards[0]); // partial honest cascade
220
+ ```
221
+
222
+ - **crates.io:** [`turbo-dom`](https://crates.io/crates/turbo-dom) — the self-contained,
223
+ publishable crate (workspace member `crates/turbo-dom/`) with minimal deps
224
+ (`html5ever` + `rustc-hash`, no napi/wasm) and a runnable `examples/crawl.rs`. 192 tests.
225
+ - **In this repo (parser crate):** the same runtime also lives at `src/rtdom/` behind an
226
+ off-by-default `rust-runtime` cargo feature, so the published npm `.node`/wasm parser artifacts
227
+ stay lean. Build it with `npm run build:rtdom`
228
+ (= `cargo build --release --no-default-features --features rust-runtime`). 100% line coverage,
229
+ a direct html5lib-tests gate at **99.75%** (`npm run conformance:rtdom`).
230
+
191
231
  ## Limitations (by design)
192
232
 
193
233
  - **No layout.** `getBoundingClientRect()` returns zeros; `getClientRects()` is empty.
@@ -226,14 +266,21 @@ Requires Node ≥ 18 and a Rust toolchain (`rustup`, stable).
226
266
 
227
267
  ```bash
228
268
  npm install
229
- npm run build # native addon (.node)
230
- npm test # JS suite (unit, conformance, differential, gauntlets)
231
- npm run test:rust # Rust core tests
232
- npm run conformance # html5lib-tests report
233
- npm run bench:all # benchmarks
234
- npm run build:wasm # wasm32 fallback
269
+ npm run build # native addon (.node) + wasm — the JS-consumer artifacts
270
+ npm test # JS suite (unit, conformance, differential, gauntlets)
271
+ npm run test:rust # Rust tests: parser core + rtdom (uses --features rust-runtime)
272
+ npm run conformance # html5lib-tests report (parser)
273
+ npm run conformance:rtdom # html5lib-tests gate run through the rtdom tree
274
+ npm run bench:all # JS-runtime benchmarks
275
+ npm run build:wasm # wasm32 parser fallback
276
+ npm run build:rtdom # pure-Rust DOM runtime (no napi/wasm) — the Rust-consumer build
235
277
  ```
236
278
 
279
+ The JS runtime (`src/runtime/*.mjs`) and the Rust runtime (`src/rtdom/`, gated behind
280
+ `rust-runtime`) are independent — touching one never affects the other. See
281
+ [RUST_PORT_PLAN.md](./RUST_PORT_PLAN.md) for the dual-runtime architecture and
282
+ [RUST_PORT_PERF_HISTORY.md](./RUST_PORT_PERF_HISTORY.md) for how each JS perf win maps to Rust.
283
+
237
284
  Contributions welcome — issues and PRs at
238
285
  [github.com/miaskiewicz/turbo-dom](https://github.com/miaskiewicz/turbo-dom).
239
286
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miaskiewicz/turbo-dom",
3
- "version": "0.2.5",
3
+ "version": "0.3.1",
4
4
  "description": "Faster, more spec-correct DOM for test runners — native html5ever (Rust/WASM) parser + lazy copy-on-write DOM. A drop-in-style alternative to jsdom/happy-dom for vitest & jest.",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
@@ -76,7 +76,7 @@
76
76
  "build:wasm:web": "wasm-pack build --target web --out-dir pkg-web --no-default-features --features wasm-bind",
77
77
  "test": "node --test 'test/*.mjs'",
78
78
  "test:cov": "node --test --experimental-test-coverage --test-coverage-include='src/runtime/**' --test-coverage-lines=99 --test-coverage-functions=95 --test-coverage-branches=95 'test/*.mjs'",
79
- "test:rust": "cargo test --lib",
79
+ "test:rust": "cargo test --lib --features rust-runtime",
80
80
  "test:all": "npm run build && npm run test:rust && npm test",
81
81
  "prepare": "git config core.hooksPath .githooks || true",
82
82
  "conformance": "node harness/conformance.mjs",
@@ -90,7 +90,11 @@
90
90
  "test:vitest": "vitest run -c test-vitest/vitest.config.mjs",
91
91
  "test:jest": "jest -c test-jest/jest.config.cjs",
92
92
  "bench:query": "node bench/query.mjs",
93
- "bench:scorecard": "node bench/scorecard.mjs"
93
+ "bench:scorecard": "node bench/scorecard.mjs",
94
+ "conformance:rtdom": "cargo test --lib --features rust-runtime rtdom::conformance::rtdom_html5lib_conformance_full_document -- --nocapture",
95
+ "build:rtdom": "cargo build --release --no-default-features --features rust-runtime",
96
+ "bench:compare": "node bench/dom-compare.mjs",
97
+ "bench:compare:all": "RTDOM_JSON=\"$(cargo test --release --lib --features rust-runtime rtdom::bench::compare_all -- --ignored --nocapture 2>/dev/null | sed -n 's/.*RTDOM_JSON //p')\" node --expose-gc --max-old-space-size=8192 bench/compare-all.mjs"
94
98
  },
95
99
  "keywords": [
96
100
  "dom",
Binary file
Binary file
package/src/lib.rs CHANGED
@@ -6,6 +6,15 @@
6
6
 
7
7
  pub mod core;
8
8
 
9
+ // Pure-Rust DOM runtime (native Rust API; no wasm-bindgen / napi). Off by default
10
+ // so shipped parser artifacts stay lean — a Rust consumer enables `rust-runtime`.
11
+ #[cfg(feature = "rust-runtime")]
12
+ pub mod rtdom;
13
+
14
+ // Phase-1 boundary spike (Rust DOM runtime exported to JS via wasm-bindgen).
15
+ #[cfg(feature = "wasm-runtime")]
16
+ pub mod spike;
17
+
9
18
  // ---------------------------------------------------------------------------
10
19
  // napi-rs front-end (native addon)
11
20
  // ---------------------------------------------------------------------------
package/src/spike.rs ADDED
@@ -0,0 +1,301 @@
1
+ //! Phase-1 boundary spike (feature = "wasm-runtime"). NOT the final runtime.
2
+ //!
3
+ //! Goal: measure the JS<->WASM boundary cost of a Rust-core DOM accessed from JS
4
+ //! on the chattiest hot paths (querySelectorAll + getAttribute + parent-walk +
5
+ //! listener-less dispatch walk), vs the current pure-JS runtime. Read-only tree
6
+ //! over `core::Soa` (handle = node index) — no mutation/COW needed for the gate.
7
+ //!
8
+ //! Two access shapes are exported so the bench can A/B them:
9
+ //! * MITIGATED (Option A) — qsa returns a packed Uint32Array in ONE crossing;
10
+ //! structure reads return ints (handles/ids); strings resolved JS-side from
11
+ //! bulk blobs pulled once. Boundary crossings ~ O(results), not O(nodes*fields).
12
+ //! * NAIVE — per-node string returns (tag_name/get_attr as String) to show the
13
+ //! cost the mitigations avoid.
14
+
15
+ use crate::core::{self, Soa};
16
+ use std::cell::RefCell;
17
+ use wasm_bindgen::prelude::*;
18
+
19
+ const ELEMENT_NODE: u8 = 1;
20
+
21
+ struct Doc {
22
+ soa: Soa,
23
+ }
24
+
25
+ thread_local! {
26
+ static DOCS: RefCell<Vec<Doc>> = RefCell::new(Vec::new());
27
+ }
28
+
29
+ fn with_doc<R>(doc: u32, f: impl FnOnce(&Doc) -> R) -> R {
30
+ DOCS.with(|d| f(&d.borrow()[doc as usize]))
31
+ }
32
+
33
+ /// Parse + register a document. Returns the doc handle. ONE crossing per parse.
34
+ #[wasm_bindgen]
35
+ pub fn create(html: &str) -> u32 {
36
+ let soa = core::parse_html_soa(html);
37
+ DOCS.with(|d| {
38
+ let mut v = d.borrow_mut();
39
+ v.push(Doc { soa });
40
+ (v.len() - 1) as u32
41
+ })
42
+ }
43
+
44
+ /// Drop a doc (free reset path).
45
+ #[wasm_bindgen]
46
+ pub fn destroy(doc: u32) {
47
+ DOCS.with(|d| {
48
+ let mut v = d.borrow_mut();
49
+ if (doc as usize) < v.len() {
50
+ v[doc as usize] = Doc { soa: core::parse_html_soa("") };
51
+ }
52
+ });
53
+ }
54
+
55
+ // --- bulk string blobs: pulled ONCE per doc, split JS-side into a string table ---
56
+ // `\u{1f}` (unit separator) join — safe vs HTML text which never contains it.
57
+ fn join_blob(v: &[String]) -> String {
58
+ v.join("\u{1f}")
59
+ }
60
+
61
+ #[wasm_bindgen]
62
+ pub fn tag_names_blob(doc: u32) -> String {
63
+ with_doc(doc, |d| join_blob(&d.soa.tag_names))
64
+ }
65
+ #[wasm_bindgen]
66
+ pub fn attr_names_blob(doc: u32) -> String {
67
+ with_doc(doc, |d| join_blob(&d.soa.attr_names))
68
+ }
69
+ #[wasm_bindgen]
70
+ pub fn attr_values_blob(doc: u32) -> String {
71
+ with_doc(doc, |d| join_blob(&d.soa.attr_values))
72
+ }
73
+ #[wasm_bindgen]
74
+ pub fn strings_blob(doc: u32) -> String {
75
+ with_doc(doc, |d| join_blob(&d.soa.strings))
76
+ }
77
+
78
+ #[wasm_bindgen]
79
+ pub fn node_count(doc: u32) -> u32 {
80
+ with_doc(doc, |d| d.soa.node_type.len() as u32)
81
+ }
82
+
83
+ // --- MITIGATED per-node structure reads (ints only, no string marshaling) ---
84
+ #[wasm_bindgen]
85
+ pub fn node_type(doc: u32, h: u32) -> u8 {
86
+ with_doc(doc, |d| d.soa.node_type[h as usize])
87
+ }
88
+ #[wasm_bindgen]
89
+ pub fn tag_id(doc: u32, h: u32) -> i32 {
90
+ with_doc(doc, |d| {
91
+ let i = h as usize;
92
+ if d.soa.node_type[i] == ELEMENT_NODE { d.soa.tag_id[i] as i32 } else { -1 }
93
+ })
94
+ }
95
+ #[wasm_bindgen]
96
+ pub fn parent(doc: u32, h: u32) -> i32 {
97
+ with_doc(doc, |d| d.soa.parent[h as usize])
98
+ }
99
+ #[wasm_bindgen]
100
+ pub fn first_child(doc: u32, h: u32) -> i32 {
101
+ with_doc(doc, |d| d.soa.first_child[h as usize])
102
+ }
103
+ #[wasm_bindgen]
104
+ pub fn next_sib(doc: u32, h: u32) -> i32 {
105
+ with_doc(doc, |d| d.soa.next_sib[h as usize])
106
+ }
107
+ #[wasm_bindgen]
108
+ pub fn text_id(doc: u32, h: u32) -> i32 {
109
+ with_doc(doc, |d| d.soa.text_id[h as usize])
110
+ }
111
+
112
+ /// MITIGATED getAttribute: returns the interned attr_value id (-1 if absent).
113
+ /// JS resolves the id against the attr_values string table (no per-call string).
114
+ /// `name` still crosses as &str (RTL passes a literal) — small inbound cost.
115
+ #[wasm_bindgen]
116
+ pub fn get_attr_id(doc: u32, h: u32, name: &str) -> i32 {
117
+ with_doc(doc, |d| attr_value_id(&d.soa, h as usize, name).map_or(-1, |v| v as i32))
118
+ }
119
+
120
+ /// NAIVE getAttribute: returns the value String (per-call UTF-8 marshal).
121
+ #[wasm_bindgen]
122
+ pub fn get_attr_str(doc: u32, h: u32, name: &str) -> Option<String> {
123
+ with_doc(doc, |d| {
124
+ attr_value_id(&d.soa, h as usize, name).map(|v| d.soa.attr_values[v as usize].clone())
125
+ })
126
+ }
127
+
128
+ /// NAIVE tagName: per-call String marshal.
129
+ #[wasm_bindgen]
130
+ pub fn tag_name_str(doc: u32, h: u32) -> Option<String> {
131
+ with_doc(doc, |d| {
132
+ let i = h as usize;
133
+ if d.soa.node_type[i] == ELEMENT_NODE {
134
+ Some(d.soa.tag_names[d.soa.tag_id[i] as usize].clone())
135
+ } else {
136
+ None
137
+ }
138
+ })
139
+ }
140
+
141
+ fn attr_value_id(soa: &Soa, i: usize, name: &str) -> Option<u32> {
142
+ let start = soa.attr_start[i];
143
+ if start < 0 {
144
+ return None;
145
+ }
146
+ let start = start as usize;
147
+ let count = soa.attr_count[i] as usize;
148
+ for j in start..start + count {
149
+ let nid = soa.attr_name_id[j] as usize;
150
+ if soa.attr_names[nid] == name {
151
+ return Some(soa.attr_value_id[j]);
152
+ }
153
+ }
154
+ None
155
+ }
156
+
157
+ /// MITIGATED+: prefetch a node's whole record in ONE crossing. Layout:
158
+ /// [tag_id, parent, attr_count, (name_id, value_id)*]. JS caches it on the wrapper
159
+ /// and serves tagName/getAttribute/parentNode from the cache (0 further crossings
160
+ /// until the version bumps). This is the Option-A "cache scalars on the wrapper" path.
161
+ #[wasm_bindgen]
162
+ pub fn node_record(doc: u32, h: u32) -> Vec<i32> {
163
+ with_doc(doc, |d| {
164
+ let i = h as usize;
165
+ let tag = if d.soa.node_type[i] == ELEMENT_NODE { d.soa.tag_id[i] as i32 } else { -1 };
166
+ let start = d.soa.attr_start[i];
167
+ let count = if start < 0 { 0 } else { d.soa.attr_count[i] as usize };
168
+ let mut out = Vec::with_capacity(3 + count * 2);
169
+ out.push(tag);
170
+ out.push(d.soa.parent[i]);
171
+ out.push(count as i32);
172
+ if start >= 0 {
173
+ let s = start as usize;
174
+ for j in s..s + count {
175
+ out.push(d.soa.attr_name_id[j] as i32);
176
+ out.push(d.soa.attr_value_id[j] as i32);
177
+ }
178
+ }
179
+ out
180
+ })
181
+ }
182
+
183
+ /// Listener-less dispatch path: walk ancestors to the root, return path length.
184
+ /// Models the single ancestor-walk dispatch does (no JS callbacks crossed).
185
+ #[wasm_bindgen]
186
+ pub fn dispatch_walk(doc: u32, h: u32) -> u32 {
187
+ with_doc(doc, |d| {
188
+ let mut len = 1u32; // target
189
+ let mut cur = d.soa.parent[h as usize];
190
+ while cur >= 0 {
191
+ len += 1;
192
+ cur = d.soa.parent[cur as usize];
193
+ }
194
+ len
195
+ })
196
+ }
197
+
198
+ // --- MITIGATED querySelectorAll: matched entirely Rust-side, ONE crossing out ---
199
+ // Minimal selector support for the spike: a compound `tag`, `.class`, `#id`, or any
200
+ // concatenation (`div.card`, `.a.b`, `#id.x`). No combinators (enough for `div.card`).
201
+ struct Compound<'a> {
202
+ tag: Option<&'a str>,
203
+ id: Option<&'a str>,
204
+ classes: Vec<&'a str>,
205
+ }
206
+
207
+ fn parse_compound(sel: &str) -> Compound<'_> {
208
+ let s = sel.trim();
209
+ let mut c = Compound { tag: None, id: None, classes: Vec::new() };
210
+ let mut i = 0;
211
+ let bytes = s.as_bytes();
212
+ // leading type selector
213
+ if i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'#' {
214
+ let start = i;
215
+ while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'#' {
216
+ i += 1;
217
+ }
218
+ c.tag = Some(&s[start..i]);
219
+ }
220
+ while i < bytes.len() {
221
+ let kind = bytes[i];
222
+ i += 1;
223
+ let start = i;
224
+ while i < bytes.len() && bytes[i] != b'.' && bytes[i] != b'#' {
225
+ i += 1;
226
+ }
227
+ let tok = &s[start..i];
228
+ match kind {
229
+ b'.' => c.classes.push(tok),
230
+ b'#' => c.id = Some(tok),
231
+ _ => {}
232
+ }
233
+ }
234
+ c
235
+ }
236
+
237
+ /// whole-word class scan (alloc-free) — mirrors the JS `hasClass` discipline.
238
+ fn has_class(class_attr: &str, cls: &str) -> bool {
239
+ if cls.is_empty() {
240
+ return false;
241
+ }
242
+ let mut rest = class_attr;
243
+ loop {
244
+ match rest.find(cls) {
245
+ None => return false,
246
+ Some(pos) => {
247
+ let before_ok = pos == 0 || rest.as_bytes()[pos - 1].is_ascii_whitespace();
248
+ let end = pos + cls.len();
249
+ let after_ok = end == rest.len() || rest.as_bytes()[end].is_ascii_whitespace();
250
+ if before_ok && after_ok {
251
+ return true;
252
+ }
253
+ rest = &rest[end..];
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ fn matches(soa: &Soa, i: usize, c: &Compound) -> bool {
260
+ if soa.node_type[i] != ELEMENT_NODE {
261
+ return false;
262
+ }
263
+ if let Some(tag) = c.tag {
264
+ if soa.tag_names[soa.tag_id[i] as usize] != tag {
265
+ return false;
266
+ }
267
+ }
268
+ if let Some(id) = c.id {
269
+ match attr_value_id(soa, i, "id") {
270
+ Some(v) if soa.attr_values[v as usize] == id => {}
271
+ _ => return false,
272
+ }
273
+ }
274
+ if !c.classes.is_empty() {
275
+ let cv = match attr_value_id(soa, i, "class") {
276
+ Some(v) => &soa.attr_values[v as usize],
277
+ None => return false,
278
+ };
279
+ for cls in &c.classes {
280
+ if !has_class(cv, cls) {
281
+ return false;
282
+ }
283
+ }
284
+ }
285
+ true
286
+ }
287
+
288
+ #[wasm_bindgen]
289
+ pub fn qsa(doc: u32, selector: &str) -> Vec<u32> {
290
+ with_doc(doc, |d| {
291
+ let c = parse_compound(selector);
292
+ let n = d.soa.node_type.len();
293
+ let mut out = Vec::new();
294
+ for i in 0..n {
295
+ if matches(&d.soa, i, &c) {
296
+ out.push(i as u32);
297
+ }
298
+ }
299
+ out
300
+ })
301
+ }
Binary file
Binary file