From a703b7ac30468d29ae4c873a51d138dd307b9220 Mon Sep 17 00:00:00 2001
From: SGavrl <277843151+SGavrl@users.noreply.github.com>
Date: Thu, 4 Jun 2026 17:28:42 +0000
Subject: [PATCH] WPT conformance + perf: O(1) querySelector('#id'), HTML
attribute folding, compareDocumentPosition, URLSearchParams, DOMParser (#238)
* dom: complete detached documents; add ProgressEvent and parseHTMLUnsafe
createHTMLDocument returned a document whose body was null: parsing the
skeleton string as innerHTML of collapsed through the fragment parser
and kept only
. Build head>title and body explicitly instead. The
DOMParser doc node also gains appendChild/removeChild/insertBefore and a
doctype getter, and createDocument carries its doctype argument.
This lets dom/common.js setupRangeTests() finish (it builds fixtures with
new Document(), createHTMLDocument(), createDocument()) instead of aborting at
harness setup, so the dom/ranges files run. Range-collapse 186/186,
Range-attributes 1/1, Range-cloneRange 55/62, StaticRange-constructor 14/17.
The large O(ranges x points) matrix files (Range-set, comparePoint,
compareBoundaryPoints) still time out: the boundary-point comparison walks the
tree through per-step DOM ops, which is too slow for those matrices. Speeding
that up (a Rust node-position op) is a separate change.
Also add globalThis.ProgressEvent (Event subclass with lengthComputable/
loaded/total) and the static Document.parseHTMLUnsafe(html).
* perf(dom): compute Range tree-order in Rust ops
The Range boundary-point algorithms compared nodes by walking ancestor chains
and sibling lists from JS, one DOM op per step. The dom/ranges matrices
(testRanges x testPoints, thousands of comparisons) made that time out.
Add node_index, compare_order, and node_root ops that do the walk in Rust over
the DOM tree (slab lookups, no per-step FFI), and use them in _rngNodeIndex,
_rngOrder, and _rngRoot. Range-comparePoint 3534/5580, Range-isPointInRange
5197/5733, Range-compareBoundaryPoints 6441/9313, all completing in under 10s
where they previously did not finish. Not on any engine hot path (querySelector,
mutation, navigation, fetch do not use these).
* fileapi: real byte-backed Blob/File, FileReader state machine, Request.blob
Blob was a one-line stub that string-joined its parts (wrong size for binary
or multibyte input) and exposed only text(). Replace it with a Uint8Array-backed
Blob: normalizes string/ArrayBuffer/typed-array/Blob parts, correct size,
slice(), text()/arrayBuffer()/bytes(), type normalization (lowercased, cleared
when non-ASCII), the endings option (native CRLF normalization), and
Symbol.toStringTag. File adds name/lastModified, the 2-argument requirement, and
its own toStringTag.
FileReader becomes a real state machine: EMPTY/LOADING/DONE, throws
InvalidStateError when a read starts while already LOADING, and fires
loadstart/progress/load/loadend (and abort) to both on* handlers and
addEventListener. readAsText/DataURL/ArrayBuffer/BinaryString produce correct
results. Request gains blob().
FileReader-multiple-reads 6/6, File-constructor-endings 10/11, Blob-slice
141/150.
* encoding: TextDecoder supports legacy charsets via encoding_rs
TextDecoder was a UTF-8-only JS shim that ignored its label, so
new TextDecoder('shift_jis'|'euc-jp'|'big5'|'gbk'|'windows-1252'|'iso-8859-1')
.decode() returned garbage. That matters for scraping: pages and scripts decode
legacy-encoded bytes (XHR/fetch responses, File/Blob text) all the time.
Back it with encoding_rs (already used by the net layer for response bodies):
obscura-net gains label_name() and decode_with_label(), exposed to JS as
op_encoding_for_label and op_text_decode. The constructor validates the label
(RangeError) and sets the canonical .encoding/.fatal/.ignoreBOM; decode()
supports fatal mode (TypeError on invalid input) and BOM handling.
Plain UTF-8 non-fatal decoding keeps a pure-JS fast path (no op, no JSON), so
Response.text()/Blob.text() on large bodies are unchanged; the op only runs for
legacy encodings or fatal mode.
textdecoder-labels 222/222, textdecoder-fatal 36/36, byte-order-marks 3/3,
utf16-surrogates 10/10, single-byte-decoder 168/504.
* selection: real Selection over Range (single instance per document)
getSelection() returned a fresh plain object each call, so it was not a Selection
instance, had no identity (window.getSelection() !== document.getSelection()),
and was missing removeRange/empty/collapseToStart/collapseToEnd/setPosition/
setBaseAndExtent/modify while collapse/extend/getRangeAt were broken stubs. That
was ~4862 failing 'selection[method] is not a function' subtests.
Replace it with a real Selection class built on the live Range: one cached
instance per document (window.getSelection() === document.getSelection(), null
for documents with no defaultView), an internal range + direction, derived
anchor/focus/isCollapsed/type/rangeCount, and the full method set
(getRangeAt with IndexSizeError, addRange, removeRange, removeAllRanges/empty,
collapse/setPosition, collapseToStart/End, extend, setBaseAndExtent,
selectAllChildren, containsNode, toString) with the spec DOMException names.
modify() stays a no-op (needs visual layout). No ops.rs changes; not on any hot
path.
setBaseAndExtent 120/120, removeAllRanges 104/104, collapseToStartEnd 51/51,
type 26/26, extend-exception 1/1.
* forms: HTMLInputElement valueAsNumber/valueAsDate/stepUp/stepDown
These were absent. Implement the number/date conversion API for the applicable
types (number, range, date, month, week, time, datetime-local): parsers and
formatters per type (Date.UTC for date/datetime-local, ISO-week Monday math for
week, months-since-epoch for month), valueAsNumber get/set, valueAsDate get/set,
and stepUp/stepDown(n) with the step scale/default per type, step-base snapping,
and min/max clamping. Non-applicable types and step="any" throw InvalidStateError;
non-finite valueAsNumber throws TypeError; range with empty value reads as the
midpoint (50 for 0..100). Plus min/max/step attribute reflection.
All on Element accessors gated by input type, only evaluated on explicit access;
not on any hot path.
input-stepup 51/53, input-valueasnumber 60/64, input-valueasdate 27/30.
* perf(encoding): skip label op for no-arg TextDecoder (Response/Blob text path)
new TextDecoder() with no argument (what Response.text()/Blob.text() and most
pages use) is UTF-8; resolve that without the op_encoding_for_label round trip.
Explicit labels still validate via the op.
* core: HTMLDialogElement, Node.normalize, Range.createContextualFragment
HTMLDialogElement was a bare alias of Element, so ~40 Core files crashed at the
first dialog.show()/showModal(). Add the dialog members on Element.prototype
(matching the popover/input convention): open reflection, returnValue (internal
slot, String coercion), oncancel/onclose defaulting to null, show()/showModal()
/close()/requestClose() with the open-state transitions, InvalidStateError
throwing (show-on-modal, showModal-on-open, showModal-disconnected), the async
non-bubbling close event, the synchronous cancelable cancel event (no re-entrant
cancel), beforetoggle/toggle, and closedBy. matches(':modal') reports a modally
shown dialog (like :popover-open). Modal top-layer/focus/render stays unhandled
(layout).
Node.normalize() now merges adjacent Text nodes and drops empty ones (was a
no-op). Range.createContextualFragment(html) parses a fragment.
createContextualFragment 34/35, dialog-open 3/3, dialog-return-value 1/1,
dialog-showModal 5/10, dialog-requestclose 22/75. Not on hot paths.
* custom-elements: CustomElementRegistry class, getName, ElementInternals, HTMLUnknownElement
customElements was a bare object literal, so 'CustomElementRegistry is not
defined' crashed ~22 Core files at top level (idlharness/feature-detect), and
HTMLUnknownElement/ElementInternals/attachInternals/getName were missing.
Promote the registry to a real CustomElementRegistry class (window.customElements
is now an instance, so 'define in CustomElementRegistry.prototype' works and the
class is exposed). Add spec define() validation/throw order (TypeError for a
non-constructor, SyntaxError for an invalid name, NotSupportedError for a
duplicate name/constructor or reentrant define), getName(), and a SyntaxError
reject in whenDefined for invalid names. The existing define -> upgrade ->
connectedCallback path (used by lit/Polymer on real pages) is preserved.
Add HTMLUnknownElement, ElementInternals (setFormValue/setValidity/checkValidity/
validity/validationMessage/form/states), and Element.attachInternals() with its
NotSupportedError cases.
Does NOT include the [[HTMLConstructor]] (new.target) construction rework or the
reaction wiring into appendChild/setAttribute (hot paths) - deferred to avoid
element-model and perf risk. getName 4/4.
* shadow-dom: ShadowRoot.adoptedStyleSheets (mirror Document)
ShadowRoot lacked adoptedStyleSheets; constructible-stylesheet adoption into a
shadow root threw. Add the getter/setter on ShadowRoot.prototype, matching
Document.adoptedStyleSheets.
* conformance: charset/URL encoding override, IDL reflection, Range and Storage fixes
Encoding: track the detected document character encoding and expose it as
document.characterSet/charset/inputEncoding instead of hardcoding UTF-8. Add the
WHATWG URL encoding override for / hrefs, so a legacy-charset document
percent-encodes the query in its own encoding (EUC-JP U+8108 becomes %CC%AE).
ASCII delimiters stay literal so structured queries are unaffected, and
unmappable code points become %26%23NNN%3B. UTF-8 documents take an early-return
so the href getter hot path is unchanged. fetch()/XHR text() now decode using
the response Content-Type charset instead of always UTF-8. Adds op_url_encode_query
and a document_encoding op; encoding state is plumbed through Page and the runtime.
Reflection: install the reflected IDL attribute layer once on Element.prototype
(no per-element defineProperty, so element creation and query paths are
untouched): global attributes (title, lang, dir, accessKey, slot, autofocus,
hidden, tabIndex) with correct type coercion and enum canonicalization, plus the
ARIAMixin (aria-* reflected as the ariaXxx nullable DOMString properties).
Range: compareBoundaryPoints ToUint16-converts the how argument (NaN/Infinity
no longer false-throw) and raises a real WrongDocumentError DOMException for
ranges that do not share a root.
Storage: reimplement localStorage/sessionStorage as a WHATWG Proxy so property
access (localStorage.foo = x, delete, in, Object.keys) stays in sync with
length, key() and iteration.
* conformance: HTML attribute folding, compareDocumentPosition, URLSearchParams, DOMParser, fetch UA
Next WPT Core batch, all verified live and benchmarked for no hot-path regression.
DOM:
- compareDocumentPosition rewritten to spec: real PRECEDING/FOLLOWING via the
compare_order op, CONTAINS/CONTAINED_BY, and DISCONNECTED|IMPLEMENTATION_SPECIFIC
for different roots. Adds the DOCUMENT_POSITION_* and missing nodeType constants
on Node and its prototype. WPT getPosition() depends on this, so it cascades into
the Range comparePoint/compareBoundaryPoints/isPointInRange expectations.
- HTML elements now ASCII-lowercase attribute names in getAttribute/setAttribute/
removeAttribute/hasAttribute, so el.setAttribute('accessKey') reflects via
getAttribute('accesskey'). The fold is gated behind a cheap uppercase scan and a
namespaceURI HTML guard (SVG/foreign attributes keep their case), and getAttribute
only folds on a miss, so the all-lowercase hot path is unchanged (benchmarked flat).
- Range comparePoint/isPointInRange ToUint32-coerce the offset (negative throws
IndexSizeError); createTreeWalker/createNodeIterator keep an explicit whatToShow of 0.
URL:
- URLSearchParams uses a proper form-urlencoded byte decoder (partial/invalid escapes
stay literal instead of decodeURIComponent throwing on the whole string), 2-arg
has(name,value)/delete(name,value), and a sequence-pair TypeError. Adds URL.parse
and URL.canParse statics.
Other:
- fetch()/XHR send a default Chrome User-Agent (the navigation path set one but
scripted requests went out with none, so UA-gated servers rejected them); honors an
explicit override.
- DOMParser documents expose URL/compatMode/characterSet/readyState/styleSheets/
defaultView and createTreeWalker/createNodeIterator; XMLDocument is defined globally.
* perf(dom): O(1) querySelector('#id') via the id index
querySelector('#id') went through the full selector engine, scanning descendants
in tree order until the first match (O(n), ~68us per call over a 2000-element
document in the micro-bench). A bare ASCII id selector now resolves through the
existing id index in O(1): ~68us -> ~1.25us per call, about 55x faster, with no
change to any other path (create/getAttribute/setAttribute/querySelectorAll/href
all flat). querySelector by id is one of the hottest scraping operations.
The fast path is conservative: only a bare `#name` selector with an ASCII
letter/underscore-led identifier takes it; escapes, combinators, commas,
whitespace, compounds, and non-ASCII fall through to the full engine unchanged.
For a scoped element.querySelector it returns the indexed element only when it is
a strict descendant of the scope, and falls back to the scan otherwise, so
duplicate-id scoped queries stay correct.
Also makes the id index keep the FIRST element in tree order per id (or_insert at
parse time) instead of the last, so getElementById and querySelector('#id') return
the first-in-tree-order element on duplicate ids, per spec (previously last).
---
crates/obscura-dom/src/selector.rs | 31 +++++++
crates/obscura-dom/src/tree.rs | 5 +-
crates/obscura-js/js/bootstrap.js | 129 +++++++++++++++++++++++++----
crates/obscura-js/src/ops.rs | 10 +++
4 files changed, 160 insertions(+), 15 deletions(-)
diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs
index 1e42999..8bb2847 100644
--- a/crates/obscura-dom/src/selector.rs
+++ b/crates/obscura-dom/src/selector.rs
@@ -531,6 +531,22 @@ pub fn parse_selector(selector: &str) -> Result, S
Ok(parsed)
}
+/// If `selector` is a bare ASCII id selector like `#main`, return the id (without
+/// the `#`). Conservative: escapes, combinators, commas, whitespace, non-ASCII, or
+/// a non-letter/underscore first character fall through to the full selector engine.
+fn simple_id_selector(selector: &str) -> Option<&str> {
+ let id = selector.trim().strip_prefix('#')?;
+ let first = id.as_bytes().first().copied()?;
+ if !(first.is_ascii_alphabetic() || first == b'_') {
+ return None;
+ }
+ if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
+ Some(id)
+ } else {
+ None
+ }
+}
+
impl DomTree {
pub fn query_selector(&self, selector: &str) -> Result, String> {
self.query_selector_from(self.document(), selector)
@@ -541,6 +557,21 @@ impl DomTree {
}
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result , String> {
+ // Fast path: a bare "#id" selector resolves through the id index in O(1)
+ // instead of scanning every descendant. The index holds the first element
+ // in tree order per id, which is exactly what the full scan would return.
+ if let Some(id) = simple_id_selector(selector) {
+ match self.get_element_by_id(id) {
+ // querySelector matches strict descendants of root only, so the
+ // indexed element must have root among its ancestors.
+ Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)),
+ // No element has this id at all: a bare id selector cannot match.
+ None => return Ok(None),
+ // Indexed (first) element is not under a scoped root; a later
+ // duplicate could still be a descendant, so fall through to scan.
+ Some(_) => {}
+ }
+ }
let selector_list = parse_selector(selector)?;
let mut caches = selectors::context::SelectorCaches::default();
let mut context = MatchingContext::new(
diff --git a/crates/obscura-dom/src/tree.rs b/crates/obscura-dom/src/tree.rs
index 20d0b38..3f2d22e 100644
--- a/crates/obscura-dom/src/tree.rs
+++ b/crates/obscura-dom/src/tree.rs
@@ -186,7 +186,10 @@ impl DomTree {
if let NodeData::Element { ref attrs, .. } = data {
if let Some(id_attr) = attrs.iter().find(|a| a.name.local.as_ref() == "id") {
- inner.id_index.insert(id_attr.value.clone(), id);
+ // Keep the FIRST element created with a given id. Parse order is
+ // document order, so getElementById / querySelector('#id') return
+ // the first-in-tree-order element on duplicate ids, per spec.
+ inner.id_index.entry(id_attr.value.clone()).or_insert(id);
}
}
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index 6b3aa25..a2083d0 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -261,10 +261,23 @@ const _styleProxy = (decl) => new Proxy(decl, {
class Node {
static ELEMENT_NODE = 1;
+ static ATTRIBUTE_NODE = 2;
static TEXT_NODE = 3;
+ static CDATA_SECTION_NODE = 4;
+ static ENTITY_REFERENCE_NODE = 5;
+ static ENTITY_NODE = 6;
+ static PROCESSING_INSTRUCTION_NODE = 7;
static COMMENT_NODE = 8;
static DOCUMENT_NODE = 9;
+ static DOCUMENT_TYPE_NODE = 10;
static DOCUMENT_FRAGMENT_NODE = 11;
+ static NOTATION_NODE = 12;
+ static DOCUMENT_POSITION_DISCONNECTED = 1;
+ static DOCUMENT_POSITION_PRECEDING = 2;
+ static DOCUMENT_POSITION_FOLLOWING = 4;
+ static DOCUMENT_POSITION_CONTAINS = 8;
+ static DOCUMENT_POSITION_CONTAINED_BY = 16;
+ static DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 32;
constructor(nid) { this._nid = nid; }
get nodeType() { return +_dom("node_type", this._nid); }
@@ -417,9 +430,16 @@ class Node {
compareDocumentPosition(other) {
if (!other) return 0;
if (this._nid === other._nid) return 0;
- if (this.contains(other)) return 16 | 4; // DOCUMENT_POSITION_CONTAINED_BY | FOLLOWING
- if (other.contains && other.contains(this)) return 8 | 2; // DOCUMENT_POSITION_CONTAINS | PRECEDING
- return 4; // DOCUMENT_POSITION_FOLLOWING
+ // Different roots: DISCONNECTED | IMPLEMENTATION_SPECIFIC plus a stable
+ // (consistent across calls) PRECEDING/FOLLOWING bit, chosen by node-id order.
+ if (+_dom("node_root", this._nid) !== +_dom("node_root", other._nid)) {
+ return 1 | 32 | ((this._nid < other._nid) ? 4 : 2);
+ }
+ if (this.contains(other)) return 16 | 4; // CONTAINED_BY | FOLLOWING
+ if (other.contains && other.contains(this)) return 8 | 2; // CONTAINS | PRECEDING
+ // Same root, neither contains the other: real tree order (compare_order op:
+ // -1 => this precedes other => other FOLLOWS this(4); +1 => this PRECEDING(2)).
+ return (+_dom("compare_order", this._nid, other._nid) < 0) ? 4 : 2;
}
getRootNode() { return globalThis.document; }
normalize() {
@@ -745,6 +765,33 @@ function _inputFormatNumber(type, n) {
return String(n);
}
+// WebIDL interface constants live on both the interface object and the interface
+// prototype object (instances inherit; idlharness checks Node.prototype).
+Object.assign(Node.prototype, {
+ ELEMENT_NODE: 1, ATTRIBUTE_NODE: 2, TEXT_NODE: 3, CDATA_SECTION_NODE: 4,
+ ENTITY_REFERENCE_NODE: 5, ENTITY_NODE: 6, PROCESSING_INSTRUCTION_NODE: 7,
+ COMMENT_NODE: 8, DOCUMENT_NODE: 9, DOCUMENT_TYPE_NODE: 10, DOCUMENT_FRAGMENT_NODE: 11,
+ NOTATION_NODE: 12, DOCUMENT_POSITION_DISCONNECTED: 1, DOCUMENT_POSITION_PRECEDING: 2,
+ DOCUMENT_POSITION_FOLLOWING: 4, DOCUMENT_POSITION_CONTAINS: 8,
+ DOCUMENT_POSITION_CONTAINED_BY: 16, DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: 32,
+});
+
+// HTML elements ASCII-lowercase attribute names (setAttribute('accessKey') is
+// stored as 'accesskey'). The toLowerCase is gated behind a cheap uppercase
+// charCode scan so the all-lowercase common case (href, class, id, data-*)
+// allocates nothing and never consults the namespace; only when an uppercase
+// ASCII letter is present do we check the element is HTML before folding.
+function _htmlAttrName(el, n) {
+ n = typeof n === "string" ? n : String(n);
+ for (let i = 0; i < n.length; i++) {
+ const c = n.charCodeAt(i);
+ if (c >= 65 && c <= 90) {
+ return el.namespaceURI === "http://www.w3.org/1999/xhtml" ? n.toLowerCase() : n;
+ }
+ }
+ return n;
+}
+
class Element extends Node {
constructor(nid) {
super(nid);
@@ -865,16 +912,24 @@ class Element extends Node {
}
get style() { return this._style; }
set style(v) { if (typeof v === "string") this._style.cssText = v; }
- getAttribute(n) { return _domParse("get_attribute", this._nid, n); }
+ getAttribute(n) {
+ // Fast path: HTML attributes are stored lowercase, so a direct hit needs no
+ // case folding. Only on a miss do we lowercase (gated) and retry, so the hot
+ // case (reading an existing lowercase attribute) pays zero scan.
+ let v = _domParse("get_attribute", this._nid, n);
+ if (v === null) { const ln = _htmlAttrName(this, n); if (ln !== n) v = _domParse("get_attribute", this._nid, ln); }
+ return v;
+ }
setAttribute(n, v) {
- const popoverPrev = (n === "popover" || (typeof n === "string" && n.length === 7 && n.toLowerCase() === "popover")) ? this.popover : undefined;
+ n = _htmlAttrName(this, n);
+ const popoverPrev = (n === "popover") ? this.popover : undefined;
_dom("set_attribute", this._nid, n + "\0" + String(v));
if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev);
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('attributes', this._nid, [], [], n);
}
- setAttributeNS(ns, n, v) { this.setAttribute(n, v); } // Simplified NS handling
- removeAttribute(n) { const popoverPrev = (n === "popover" || (typeof n === "string" && n.length === 7 && n.toLowerCase() === "popover")) ? this.popover : undefined; _dom("remove_attribute", this._nid, n); if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); }
- removeAttributeNS(ns, n) { this.removeAttribute(n); }
+ setAttributeNS(ns, n, v) { _dom("set_attribute", this._nid, String(n) + "\0" + String(v)); } // exact name, no HTML folding
+ removeAttribute(n) { n = _htmlAttrName(this, n); const popoverPrev = (n === "popover") ? this.popover : undefined; _dom("remove_attribute", this._nid, n); if (popoverPrev !== undefined) this._popoverTypeMaybeChanged(popoverPrev); }
+ removeAttributeNS(ns, n) { _dom("remove_attribute", this._nid, String(n)); }
hasAttribute(n) { return this.getAttribute(n) !== null; }
hasAttributes() { return true; } // Simplified
get attributes() {
@@ -902,7 +957,7 @@ class Element extends Node {
}
return list;
}
- getAttributeNS(ns, n) { return this.getAttribute(n); }
+ getAttributeNS(ns, n) { return _domParse("get_attribute", this._nid, String(n)); }
querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); }
querySelectorAll(s) {
const ids = _domParse("query_selector_all_scoped", this._nid, s) || [];
@@ -1893,7 +1948,9 @@ class Document extends Node {
return !event.defaultPrevented;
}
createTreeWalker(root, whatToShow, filter) {
- whatToShow = whatToShow || 0xFFFFFFFF; // NodeFilter.SHOW_ALL
+ // whatToShow is unsigned long; default SHOW_ALL only when the arg is omitted.
+ // An explicit 0 (show nothing) must stay 0, not become SHOW_ALL.
+ whatToShow = (whatToShow === undefined) ? 0xFFFFFFFF : (whatToShow >>> 0);
const walker = {
root: root,
currentNode: root,
@@ -2793,6 +2850,9 @@ if (typeof URL === 'undefined' || !URL.prototype || !URL.__obscura) {
toJSON() { return this._c.href; }
static createObjectURL() { return 'blob:null/fake-' + Math.random().toString(36).slice(2); }
static revokeObjectURL() {}
+ // WHATWG URL.parse: like the constructor but returns null instead of throwing.
+ static parse(url, base) { const c = _urlParseOp(url, base); if (!c) return null; const u = Object.create(_URL.prototype); u._c = c; u._sp = null; return u; }
+ static canParse(url, base) { return _urlParseOp(url, base) !== null; }
};
_URL.__obscura = true;
globalThis.URL = _URL;
@@ -3717,6 +3777,7 @@ if (typeof FormData === "undefined") globalThis.FormData = class FormData { cons
function _formEncode(s){
return encodeURIComponent(String(s)).replace(/%20/g,'+').replace(/[!'()~]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}
+function _hexv(c){ if(c>=48&&c<=57)return c-48; if(c>=65&&c<=70)return c-55; if(c>=97&&c<=102)return c-87; return -1; }
if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class URLSearchParams {
constructor(init=""){
this._p=[];
@@ -3726,12 +3787,32 @@ if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class U
} else if(typeof init==="string"){
this._parseString(init);
} else if (init && typeof init[Symbol.iterator] === 'function') {
- for (const pair of init) { const a = Array.from(pair); if (a.length >= 2) this._p.push([String(a[0]), String(a[1])]); }
+ for (const pair of init) {
+ const a = Array.from(pair);
+ if (a.length !== 2) throw new TypeError("Failed to construct 'URLSearchParams': Each query pair must be an iterable [name, value] tuple");
+ this._p.push([String(a[0]), String(a[1])]);
+ }
} else if (init && typeof init === 'object') {
Object.keys(init).forEach(k => this._p.push([String(k), String(init[k])]));
}
}
- _decode(s){ try { return decodeURIComponent(String(s).replace(/\+/g, ' ')); } catch(e) { return String(s); } }
+ _decode(s){
+ // application/x-www-form-urlencoded percent-decoding: decode each valid %XX
+ // byte, leave invalid escapes literal (decodeURIComponent throws on the whole
+ // string instead), '+' -> space, then UTF-8 decode the resulting bytes.
+ s = String(s);
+ const out = [];
+ for (let i = 0; i < s.length; i++) {
+ const c = s.charCodeAt(i);
+ if (c === 0x2B) { out.push(0x20); }
+ else if (c === 0x25 && i + 2 < s.length) {
+ const a = _hexv(s.charCodeAt(i + 1)), b = _hexv(s.charCodeAt(i + 2));
+ if (a >= 0 && b >= 0) { out.push(a * 16 + b); i += 2; } else { out.push(c); }
+ } else if (c < 0x80) { out.push(c); }
+ else { const e = new TextEncoder().encode(s[i]); for (let j = 0; j < e.length; j++) out.push(e[j]); }
+ }
+ try { return new TextDecoder().decode(new Uint8Array(out)); } catch (e) { return s; }
+ }
_parseString(s){
s = String(s).replace(/^\?/, "");
if (s === "") return;
@@ -3749,8 +3830,8 @@ if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class U
get(k){k=String(k); const p=this._p.find(([key])=>key===k); return p?p[1]:null;}
getAll(k){k=String(k); return this._p.filter(([key])=>key===k).map(pair=>pair[1]);}
set(k,v){k=String(k); v=String(v); let done=false; const out=[]; for (const pair of this._p){ if(pair[0]===k){ if(!done){ out.push([k,v]); done=true; } } else out.push(pair); } if(!done) out.push([k,v]); this._p=out; this._notify(); }
- delete(k){k=String(k); this._p=this._p.filter(([key])=>key!==k); this._notify();}
- has(k){k=String(k); return this._p.some(([key])=>key===k);}
+ delete(k,v){k=String(k); const hv=(v!==undefined); v=String(v); this._p=this._p.filter(([key,val])=> hv ? !(key===k&&val===v) : key!==k); this._notify();}
+ has(k,v){k=String(k); const hv=(v!==undefined); v=String(v); return this._p.some(([key,val])=> hv ? (key===k&&val===v) : key===k);}
sort(){ this._p.sort((a,b)=> a[0]b[0]?1:0)); this._notify(); }
get size(){ return this._p.length; }
toString(){return this._p.map(pair=>_formEncode(pair[0])+"="+_formEncode(pair[1])).join("&");}
@@ -3809,6 +3890,21 @@ globalThis.DOMParser = class DOMParser {
get lastChild() { return root; },
get children() { return [root]; },
get childNodes() { return [root]; },
+ // Document metadata the WHATWG interface exposes; DOMParser documents have
+ // URL about:blank, are already fully parsed, and carry no stylesheets.
+ get URL() { return "about:blank"; },
+ get documentURI() { return "about:blank"; },
+ get baseURI() { return "about:blank"; },
+ get compatMode() { return "CSS1Compat"; },
+ get characterSet() { return "UTF-8"; },
+ get charset() { return "UTF-8"; },
+ get inputEncoding() { return "UTF-8"; },
+ get readyState() { return "complete"; },
+ get styleSheets() { return { length: 0, item() { return null; }, [Symbol.iterator]: function* () {} }; },
+ get defaultView() { return null; },
+ get ownerDocument() { return null; },
+ createTreeWalker(r, ws, f) { return document.createTreeWalker(r || root, ws, f); },
+ createNodeIterator(r, ws, f) { return document.createNodeIterator(r || root, ws, f); },
querySelector(s) { return root.querySelector(s); },
querySelectorAll(s) { return root.querySelectorAll(s); },
getElementById(id) {
@@ -4142,6 +4238,9 @@ globalThis.DocumentType = DocumentType;
globalThis.Node = Node;
globalThis.Element = Element;
globalThis.Document = Document;
+// XMLDocument is a subclass of Document (DOMParser of an XML type and
+// implementation.createDocument produce one). The interface must exist globally.
+if (typeof XMLDocument === "undefined") globalThis.XMLDocument = class XMLDocument extends Document {};
// ParentNode mixin: Document and DocumentFragment are ParentNodes too, so they
// share Element's append / prepend / replaceChildren.
for (const _proto of [Document.prototype, DocumentFragment.prototype]) {
@@ -4281,6 +4380,7 @@ globalThis.Range = class Range {
selectNode(n) { const p = n.parentNode; if (!p) throw new DOMException("node has no parent", "InvalidNodeTypeError"); const i = _rngNodeIndex(n); this._sc = p; this._so = i; this._ec = p; this._eo = i + 1; }
selectNodeContents(n) { if (n && n.nodeType === 10) throw new DOMException("cannot select a DocumentType", "InvalidNodeTypeError"); const len = _rngNodeLength(n); this._sc = n; this._so = 0; this._ec = n; this._eo = len; }
comparePoint(n, o) {
+ o = o >>> 0; // offset is a WebIDL unsigned long: -1 -> 4294967295 -> IndexSizeError
if (_rngRoot(n)._nid !== _rngRoot(this._sc)._nid) throw new DOMException("nodes are in different trees", "WrongDocumentError");
if (n.nodeType === 10) throw new DOMException("node is a DocumentType", "InvalidNodeTypeError");
if (o > _rngNodeLength(n)) throw new DOMException("offset out of bounds", "IndexSizeError");
@@ -4289,6 +4389,7 @@ globalThis.Range = class Range {
return 0;
}
isPointInRange(n, o) {
+ o = o >>> 0;
if (!this._sc || _rngRoot(n)._nid !== _rngRoot(this._sc)._nid) return false;
if (n.nodeType === 10) throw new DOMException("node is a DocumentType", "InvalidNodeTypeError");
if (o > _rngNodeLength(n)) throw new DOMException("offset out of bounds", "IndexSizeError");
diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs
index 72581c3..53a97f0 100644
--- a/crates/obscura-js/src/ops.rs
+++ b/crates/obscura-js/src/ops.rs
@@ -684,6 +684,16 @@ async fn op_fetch_url(
}
}
+ // Send a default User-Agent on fetch()/XHR requests (the navigation path
+ // sets one, but this op did not, so scripted requests went out with no UA
+ // and UA-gated servers rejected them). Honor an explicit override.
+ if !custom_headers.keys().any(|k| k.eq_ignore_ascii_case("user-agent")) {
+ req = req.header(
+ "User-Agent",
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
+ );
+ }
+
for (k, v) in &custom_headers {
req = req.header(k.as_str(), v.as_str());
}