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 <a>/<area> 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<SelectorList<ObscuraSelector>, 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<Option<NodeId>, 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<Option<NodeId>, 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:(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()); }