rusteerio 0.1.0 → 1.0.0

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/dist/slim.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  dom,
4
4
  load,
5
5
  merge
6
- } from "./chunk-DKULX6VO.mjs";
6
+ } from "./chunk-DMRD3FGC.mjs";
7
7
  import "./chunk-OHPGEY3E.mjs";
8
8
 
9
9
  // src/slim.ts
@@ -51,6 +51,15 @@ impl DomNode {
51
51
  pub(crate) fn is_element(&self) -> bool {
52
52
  matches!(self.kind.as_str(), "tag" | "script" | "style")
53
53
  }
54
+ pub(crate) fn content_bytes(&self) -> usize {
55
+ let attributes: usize = self
56
+ .attribs
57
+ .values()
58
+ .filter_map(Value::as_str)
59
+ .map(str::len)
60
+ .sum();
61
+ self.data.as_ref().map_or(0, String::len) + attributes
62
+ }
54
63
  }
55
64
 
56
65
  #[derive(Clone, Debug)]
@@ -353,20 +362,31 @@ impl DomArena {
353
362
  let name = node.name.as_deref().unwrap_or("");
354
363
  output.push('<');
355
364
  output.push_str(name);
365
+ // parse5 qualifies foreign attributes; XML and htmlparser2
366
+ // rendering write the stored attribute names unchanged.
367
+ let qualifiers = (!xml
368
+ && options.get("_useHtmlParser2") != Some(&Value::Bool(true)))
369
+ .then(|| {
370
+ (
371
+ node.extras.get("attribsNamespace"),
372
+ node.extras.get("attribsPrefix"),
373
+ )
374
+ });
356
375
  for (name, value) in &node.attribs {
357
376
  output.push(' ');
358
- if !xml {
359
- if let Some(prefix) = node
360
- .extras
361
- .get("attribsPrefix")
362
- .and_then(|value| value.get(name))
363
- .and_then(Value::as_str)
364
- {
365
- output.push_str(prefix);
366
- output.push(':');
367
- }
377
+ match qualifiers {
378
+ Some((namespaces, prefixes)) => super::push_attribute_name(
379
+ &mut output,
380
+ name,
381
+ namespaces
382
+ .and_then(|map| map.get(name))
383
+ .and_then(Value::as_str),
384
+ prefixes
385
+ .and_then(|map| map.get(name))
386
+ .and_then(Value::as_str),
387
+ ),
388
+ None => output.push_str(name),
368
389
  }
369
- output.push_str(name);
370
390
  let value = value
371
391
  .as_str()
372
392
  .map(str::to_owned)
@@ -625,16 +645,25 @@ impl DomArena {
625
645
  .filter(|id| self.node(*id).is_some_and(DomNode::is_element)),
626
646
  ),
627
647
  "parents" | "ancestors" => {
648
+ // Like Cheerio, stop at the first document node. A template's
649
+ // contents live in a fragment, so they never report the
650
+ // template or its ancestors as their own.
628
651
  let mut parent = node.parent;
629
652
  let mut seen = HashSet::new();
630
653
  while let Some(id) = parent {
631
654
  if !seen.insert(id) {
632
655
  break;
633
656
  }
634
- if self.node(id).is_some_and(DomNode::is_element) {
657
+ let Some(ancestor) = self.node(id) else {
658
+ break;
659
+ };
660
+ if ancestor.kind == "root" {
661
+ break;
662
+ }
663
+ if ancestor.is_element() {
635
664
  result.push(id);
636
665
  }
637
- parent = self.node(id).and_then(|node| node.parent);
666
+ parent = ancestor.parent;
638
667
  }
639
668
  }
640
669
  "next" | "prev" | "nextAll" | "prevAll" => {
package/native/src/lib.rs CHANGED
@@ -35,8 +35,40 @@ pub use runtime::{configure_worker_pool, worker_pool_info, WorkerPoolInfo};
35
35
  pub use stream::NativeStream;
36
36
 
37
37
  const HTML_NS: &str = "http://www.w3.org/1999/xhtml";
38
+ const XML_NS: &str = "http://www.w3.org/XML/1998/namespace";
39
+ const XMLNS_NS: &str = "http://www.w3.org/2000/xmlns/";
40
+ const XLINK_NS: &str = "http://www.w3.org/1999/xlink";
41
+
42
+ /// Writes an attribute's serialized name. Like parse5, the namespace rather
43
+ /// than the stored prefix selects the qualifier: HTML5 gives foreign `xmlns`
44
+ /// attributes an empty prefix, which must not become `:xmlns`. Other namespaces
45
+ /// keep a non-empty prefix; an empty or missing one writes the bare name.
46
+ pub(crate) fn push_attribute_name(
47
+ output: &mut String,
48
+ local: &str,
49
+ namespace: Option<&str>,
50
+ prefix: Option<&str>,
51
+ ) {
52
+ match namespace.filter(|namespace| !namespace.is_empty()) {
53
+ None => {}
54
+ Some(XML_NS) => output.push_str("xml:"),
55
+ Some(XMLNS_NS) => {
56
+ if local != "xmlns" {
57
+ output.push_str("xmlns:");
58
+ }
59
+ }
60
+ Some(XLINK_NS) => output.push_str("xlink:"),
61
+ Some(_) => {
62
+ if let Some(prefix) = prefix.filter(|prefix| !prefix.is_empty()) {
63
+ output.push_str(prefix);
64
+ output.push(':');
65
+ }
66
+ }
67
+ }
68
+ output.push_str(local);
69
+ }
38
70
 
39
- #[napi]
71
+ #[napi(custom_finalize)]
40
72
  pub struct NativeDocument {
41
73
  html: RefCell<Option<Html>>,
42
74
  // Stable public IDs are preorder positions, rather than parser allocation
@@ -51,10 +83,43 @@ pub struct NativeDocument {
51
83
  managed: Cell<bool>,
52
84
  diagnostics: RefCell<Vec<Value>>,
53
85
  locations: RefCell<HashMap<u32, Value>>,
86
+ // V8 cannot see native allocations. Reporting them lets its GC collect
87
+ // unreachable documents under native memory pressure.
88
+ content_bytes: Cell<usize>,
89
+ reported_heap_bytes: Cell<i64>,
90
+ }
91
+
92
+ impl ObjectFinalize for NativeDocument {
93
+ fn finalize(self, mut env: Env) -> Result<()> {
94
+ let reported = self.reported_heap_bytes.get();
95
+ if reported != 0 {
96
+ // Accounting is advisory; a failure must not throw from a finalizer.
97
+ let _ = env.adjust_external_memory(-reported);
98
+ }
99
+ Ok(())
100
+ }
54
101
  }
55
102
 
103
+ fn html_node_content_bytes(node: &Node) -> usize {
104
+ match node {
105
+ Node::Text(text) => text.len(),
106
+ Node::Comment(comment) => comment.len(),
107
+ Node::Element(element) => element.attrs.iter().map(|(_, value)| value.len()).sum(),
108
+ _ => 0,
109
+ }
110
+ }
111
+
112
+ /// Approximate retained bytes per node in each representation, excluding the
113
+ /// text and attribute content counted separately. V8 treats the total only as
114
+ /// allocation pressure, so these err on the high side.
115
+ const IMMUTABLE_NODE_BYTES: usize = 256;
116
+ const ARENA_NODE_BYTES: usize = 512;
117
+ /// Smaller changes to an already reported size are skipped to keep operations cheap.
118
+ const HEAP_REPORT_STEP: i64 = 64 * 1024;
119
+
56
120
  impl NativeDocument {
57
121
  pub(crate) fn from_arena(arena: arena::DomArena, options: Value) -> Self {
122
+ let content_bytes = arena.nodes.iter().map(arena::DomNode::content_bytes).sum();
58
123
  Self {
59
124
  html: RefCell::new(None),
60
125
  ids: RefCell::new(Vec::new()),
@@ -67,6 +132,8 @@ impl NativeDocument {
67
132
  managed: Cell::new(false),
68
133
  diagnostics: RefCell::new(Vec::new()),
69
134
  locations: RefCell::new(HashMap::new()),
135
+ content_bytes: Cell::new(content_bytes),
136
+ reported_heap_bytes: Cell::new(0),
70
137
  }
71
138
  }
72
139
  pub(crate) fn set_diagnostic_records(&self, records: Vec<Value>) {
@@ -82,6 +149,39 @@ impl NativeDocument {
82
149
  Self::parse_options(source, is_document, serde_json::json!({}), None)
83
150
  }
84
151
 
152
+ fn estimated_heap_bytes(&self) -> i64 {
153
+ let live_arena_nodes = self.arena.borrow().as_ref().map_or(0, |arena| {
154
+ arena.nodes.len().saturating_sub(arena.free.len())
155
+ });
156
+ let immutable_nodes = self.ids.borrow().len();
157
+ let retained_source = self.source.borrow().as_ref().map_or(0, String::capacity);
158
+ let content = if live_arena_nodes + immutable_nodes == 0 {
159
+ 0
160
+ } else {
161
+ self.content_bytes.get()
162
+ };
163
+ let bytes = content
164
+ + retained_source
165
+ + immutable_nodes * IMMUTABLE_NODE_BYTES
166
+ + live_arena_nodes * ARENA_NODE_BYTES;
167
+ i64::try_from(bytes).unwrap_or(i64::MAX)
168
+ }
169
+
170
+ fn report_heap_bytes(&self, mut env: Env) {
171
+ let reported = self.reported_heap_bytes.get();
172
+ let estimated = self.estimated_heap_bytes();
173
+ let change = estimated - reported;
174
+ let is_small_update = reported != 0 && estimated != 0 && change.abs() < HEAP_REPORT_STEP;
175
+ if change != 0 && !is_small_update && env.adjust_external_memory(change).is_ok() {
176
+ self.reported_heap_bytes.set(estimated);
177
+ }
178
+ }
179
+
180
+ fn reported<T>(&self, env: Env, value: T) -> T {
181
+ self.report_heap_bytes(env);
182
+ value
183
+ }
184
+
85
185
  pub(crate) fn parse_options(
86
186
  source: &str,
87
187
  is_document: bool,
@@ -262,6 +362,7 @@ impl NativeDocument {
262
362
  && value != &&serde_json::json!("")
263
363
  })
264
364
  .and(source);
365
+ let content_bytes = html.tree.values().map(html_node_content_bytes).sum();
265
366
  Self {
266
367
  html: RefCell::new(Some(html)),
267
368
  ids: RefCell::new(ids),
@@ -274,6 +375,8 @@ impl NativeDocument {
274
375
  managed: Cell::new(false),
275
376
  diagnostics: RefCell::new(diagnostics),
276
377
  locations: RefCell::new(locations),
378
+ content_bytes: Cell::new(content_bytes),
379
+ reported_heap_bytes: Cell::new(0),
277
380
  }
278
381
  }
279
382
 
@@ -583,7 +686,16 @@ impl NativeDocument {
583
686
  }
584
687
  }
585
688
  }
586
- #[napi]
689
+ #[napi(js_name = "collectGarbage")]
690
+ pub fn collect_garbage_and_report(&self, env: Env) -> MemoryStats {
691
+ let stats = self.collect_garbage();
692
+ self.report_heap_bytes(env);
693
+ MemoryStats {
694
+ external_bytes: self.reported_heap_bytes.get(),
695
+ ..stats
696
+ }
697
+ }
698
+
587
699
  pub fn collect_garbage(&self) -> MemoryStats {
588
700
  self.needs_collect.set(false);
589
701
  if self.arena.borrow().is_none() && self.managed.get() && self.retained.borrow().is_empty()
@@ -599,6 +711,7 @@ impl NativeDocument {
599
711
  retained: 0,
600
712
  reclaimed,
601
713
  immutable_nodes: 0,
714
+ external_bytes: self.reported_heap_bytes.get(),
602
715
  };
603
716
  }
604
717
  if let Some(arena) = self.arena.borrow_mut().as_mut() {
@@ -610,6 +723,7 @@ impl NativeDocument {
610
723
  retained: stats.retained,
611
724
  reclaimed: stats.reclaimed,
612
725
  immutable_nodes: 0,
726
+ external_bytes: self.reported_heap_bytes.get(),
613
727
  }
614
728
  } else {
615
729
  let count = self.ids.borrow().len() as u32;
@@ -619,6 +733,7 @@ impl NativeDocument {
619
733
  retained: self.retained.borrow().len() as u32,
620
734
  reclaimed: 0,
621
735
  immutable_nodes: count,
736
+ external_bytes: self.reported_heap_bytes.get(),
622
737
  }
623
738
  }
624
739
  }
@@ -629,19 +744,30 @@ impl NativeDocument {
629
744
  }
630
745
 
631
746
  #[napi]
632
- pub fn root_id(&self) -> u32 {
747
+ pub fn root_id(&self, env: Env) -> u32 {
748
+ self.report_heap_bytes(env);
633
749
  0
634
750
  }
635
751
 
636
- #[napi]
752
+ #[napi(js_name = "select")]
753
+ pub fn select_and_report(
754
+ &self,
755
+ env: Env,
756
+ selector: String,
757
+ contexts: Option<Vec<u32>>,
758
+ ) -> Result<Vec<u32>> {
759
+ self.reported(env, self.select(selector, contexts))
760
+ }
761
+
637
762
  pub fn select(&self, selector: String, contexts: Option<Vec<u32>>) -> Result<Vec<u32>> {
638
763
  self.select_ids(&selector, contexts.as_deref())
639
764
  }
640
765
 
641
766
  #[napi]
642
- pub fn matches(&self, selector: String, ids: Vec<u32>) -> Result<Vec<u32>> {
767
+ pub fn matches(&self, env: Env, selector: String, ids: Vec<u32>) -> Result<Vec<u32>> {
643
768
  self.ensure_arena()?;
644
- selector::filter(self.arena.borrow().as_ref().unwrap(), &selector, &ids)
769
+ let matched = selector::filter(self.arena.borrow().as_ref().unwrap(), &selector, &ids);
770
+ self.reported(env, matched)
645
771
  }
646
772
 
647
773
  #[napi]
@@ -671,7 +797,11 @@ impl NativeDocument {
671
797
  self.text_ids(&ids)
672
798
  }
673
799
 
674
- #[napi]
800
+ #[napi(js_name = "innerHtml")]
801
+ pub fn inner_html_and_report(&self, env: Env, id: u32) -> String {
802
+ self.reported(env, self.inner_html(id))
803
+ }
804
+
675
805
  pub fn inner_html(&self, id: u32) -> String {
676
806
  if (self.tag_name(id).as_deref() == Some("template") || self.arena.borrow().is_some())
677
807
  && self.ensure_arena().is_ok()
@@ -704,7 +834,11 @@ impl NativeDocument {
704
834
  .flatten()
705
835
  }
706
836
 
707
- #[napi]
837
+ #[napi(js_name = "exportTree")]
838
+ pub fn export_tree_and_report(&self, env: Env) -> Result<String> {
839
+ self.reported(env, self.export_tree())
840
+ }
841
+
708
842
  pub fn export_tree(&self) -> Result<String> {
709
843
  if self.source.borrow().is_some() {
710
844
  self.ensure_arena()?;
@@ -718,7 +852,11 @@ impl NativeDocument {
718
852
  serde_json::to_string(&records).map_err(json_error)
719
853
  }
720
854
 
721
- #[napi]
855
+ #[napi(js_name = "node")]
856
+ pub fn node_and_report(&self, env: Env, id: u32) -> Result<String> {
857
+ self.reported(env, self.node(id))
858
+ }
859
+
722
860
  pub fn node(&self, id: u32) -> Result<String> {
723
861
  if self.source.borrow().is_some() {
724
862
  self.ensure_arena()?;
@@ -729,13 +867,26 @@ impl NativeDocument {
729
867
  serde_json::to_string(&self.arena.borrow().as_ref().unwrap().node(id)).map_err(json_error)
730
868
  }
731
869
 
732
- #[napi]
870
+ #[napi(js_name = "create")]
871
+ pub fn create_and_report(
872
+ &self,
873
+ env: Env,
874
+ html: String,
875
+ context_id: Option<u32>,
876
+ options_json: Option<String>,
877
+ ) -> Result<Vec<u32>> {
878
+ let created = self.create(html, context_id, options_json);
879
+ self.reported(env, created)
880
+ }
881
+
733
882
  pub fn create(
734
883
  &self,
735
884
  html: String,
736
885
  context_id: Option<u32>,
737
886
  _options_json: Option<String>,
738
887
  ) -> Result<Vec<u32>> {
888
+ self.content_bytes
889
+ .set(self.content_bytes.get().saturating_add(html.len()));
739
890
  self.ensure_arena()?;
740
891
  self.arena
741
892
  .borrow_mut()
@@ -746,7 +897,19 @@ impl NativeDocument {
746
897
 
747
898
  /// Move a branch between native documents. JavaScript only redirects its
748
899
  /// existing stable handles using the returned complete descendant mapping.
749
- #[napi]
900
+ #[napi(js_name = "adopt")]
901
+ pub fn adopt_and_report(
902
+ &self,
903
+ env: Env,
904
+ source: &NativeDocument,
905
+ ids: Vec<u32>,
906
+ detach: Option<bool>,
907
+ ) -> Result<String> {
908
+ let adopted = self.adopt(source, ids, detach);
909
+ source.report_heap_bytes(env);
910
+ self.reported(env, adopted)
911
+ }
912
+
750
913
  pub fn adopt(
751
914
  &self,
752
915
  source: &NativeDocument,
@@ -840,7 +1003,11 @@ impl NativeDocument {
840
1003
  .map_err(json_error)
841
1004
  }
842
1005
 
843
- #[napi]
1006
+ #[napi(js_name = "op")]
1007
+ pub fn op_and_report(&self, env: Env, command: String, args_json: String) -> Result<String> {
1008
+ self.reported(env, self.op(command, args_json))
1009
+ }
1010
+
844
1011
  pub fn op(&self, command: String, args_json: String) -> Result<String> {
845
1012
  self.maybe_collect();
846
1013
  let args: Value = serde_json::from_str(&args_json).map_err(json_error)?;
@@ -964,6 +1131,8 @@ pub struct MemoryStats {
964
1131
  pub retained: u32,
965
1132
  pub reclaimed: u32,
966
1133
  pub immutable_nodes: u32,
1134
+ /// Native bytes currently reported to V8 for this document.
1135
+ pub external_bytes: i64,
967
1136
  }
968
1137
 
969
1138
  /// The Cheerio DOM adapter exposes foreign attributes by local name, whereas
@@ -1156,11 +1325,12 @@ fn serialize_node(node: NodeRef<'_, Node>, output: &mut String) {
1156
1325
  output.push_str(element.name());
1157
1326
  for (name, value) in &element.attrs {
1158
1327
  output.push(' ');
1159
- if let Some(prefix) = &name.prefix {
1160
- output.push_str(prefix);
1161
- output.push(':');
1162
- }
1163
- output.push_str(&name.local);
1328
+ push_attribute_name(
1329
+ output,
1330
+ &name.local,
1331
+ Some(name.ns.as_ref()),
1332
+ name.prefix.as_deref(),
1333
+ );
1164
1334
  output.push_str("=\"");
1165
1335
  escape_html(value, true, output);
1166
1336
  output.push('"');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rusteerio",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Cheerio-compatible HTML processing with a Rust engine and parallel, non-blocking extraction",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
package/src/router.ts CHANGED
@@ -2,10 +2,11 @@ import { requireNative, type NativeDocument, type NativeNodeRecord } from './nat
2
2
  import { hasParserHooks, parseNativeDocument } from './hooks.js';
3
3
  import type { CheerioOptions } from './types.js';
4
4
 
5
+ interface NativeMemoryStats { live: number; slots: number; retained: number; reclaimed: number; immutableNodes: number; externalBytes: number }
5
6
  interface ManagedNativeDocument extends NativeDocument {
6
7
  retain?(ids: number[]): void;
7
8
  release?(ids: number[]): void;
8
- collectGarbage?(): { live: number; slots: number; retained: number; reclaimed: number };
9
+ collectGarbage?(): NativeMemoryStats;
9
10
  }
10
11
  interface PinRecord { global: number; document: WeakRef<ManagedNativeDocument>; local: number }
11
12
  interface Handle { global: number; document: ManagedNativeDocument; local: number; data?: object; pin: PinRecord }
@@ -105,7 +106,7 @@ export class RoutedDocument implements NativeDocument {
105
106
  retainNode(id: number): void { this.lookup(id); }
106
107
  retainNodes(ids: number[]): object { return ids.filter((id) => id !== undefined).map((id) => this.lookup(id)); }
107
108
  nodeDocument(id: number): NativeDocument { return new RoutedDocument(this.raw, this.options, this.lookup(id)); }
108
- collectGarbage(): { live: number; slots: number; retained: number; reclaimed: number } | undefined { registry.snapshots.delete(this.raw); return this.raw.collectGarbage?.(); }
109
+ collectGarbage(): NativeMemoryStats | undefined { registry.snapshots.delete(this.raw); return this.raw.collectGarbage?.(); }
109
110
  retainNodeData(id: number, data: object): void { this.lookup(id).data = data; }
110
111
  nodeData(id: number): object | undefined { return this.lookup(id).data; }
111
112
  nodeDataOperation(id: number): (command: string, args: any) => any {