WPT conformance: WHATWG URL, DOM Range, and post-navigation event-loop settle (#233)
* fix: restore regressions introduced by #227 #227 deleted build.rs (reverting #224's version-from-tag feature), removed DOM cycle guards in append_child/insert_before that prevent infinite loops on self-append/self-insert, dropped the CharacterData textContent fast-path, removed PI/Doctype Rust ops, and cut the env_allows_private_network() check from validate_fetch_url. Restored: - crates/obscura-cli/build.rs (version from tag/env) - OBSCURA_BUILD_VERSION in version string and banner - allow_private_network env var propagation in main.rs - append_child/insert_before self-cycle guards in tree.rs - textContent CharacterData fast-path in tree.rs - create_processing_instruction, create_doctype, pi_target, doctype_name, doctype_public_id ops in ops.rs - ProcessingInstruction branch in set_text_content op - env_allows_private_network() check in validate_fetch_url * fix(js): Europe/Berlin timezone, plugins iterator recursion Two bugs in the stealth bootstrap code from #227: - 'Europe/Germany' is not a valid IANA timezone; Intl.DateTimeFormat silently fell back to UTC. Changed to 'Europe/Berlin'. - plugins[Symbol.iterator] called this[Symbol.iterator]() recursively, blowing the stack on Array.from(navigator.plugins). Fixed to bind Array.prototype[Symbol.iterator] directly to the array. * url: back URL/URLSearchParams with the url crate, add <a>/<area> decomposition The URL global was a regex shim: it mis-parsed userinfo (hostname came back as the username, port as "pass@host"), had no username/password, and its setters were no-ops. Replace it with two ops (op_url_parse, op_url_set) backed by the WHATWG-compliant url crate, and add the HTMLHyperlinkElementUtils members (protocol/host/hostname/port/pathname/search/hash/origin/username/password and their setters) to <a> and <area>, which had none. This was the single largest WPT failure. a.search returned undefined, so the encoding encode-href tests threw "Cannot read properties of undefined (reading 'substr')" about 225k times. With the fix url-constructor passes 831/888, url-setters 192/279, urlsearchparams-sort 17/17, url/a-element 415/887, and the encode-href files run their assertions instead of crashing. op_url_set runs the url crate inside catch_unwind: a few setters panic on the pathological inputs the url-setters tests use, and a panicking setter must be a no-op (the WHATWG "do nothing on invalid value" rule) rather than abort. URLSearchParams also gained getAll/sort/size/keys/values/entries/iterator and a back-reference so mutations write through to the bound URL. * dom: real Range/StaticRange, createDocumentType, XML Document type Range was a no-op stub (setStart/setEnd discarded their arguments, so startContainer/endContainer read back undefined and every dom/ranges helper that walked range.endContainer crashed with "reading 'nodeType' of undefined"). Replace it with a property-storing Range over the live tree: setStart/End and the *Before/*After/selectNode/selectNodeContents/collapse positioning, plus the boundary-point algorithms comparePoint, compareBoundaryPoints, isPointInRange, intersectsNode, commonAncestorContainer, cloneRange and a real stringifier, all built on a correct preorder tree-order comparison. Add StaticRange. new Document() now reports an XML contentType (it has no backing node id), so createCDATASection no longer throws NotSupportedError on it. Add DOMImplementation.createDocumentType with the lenient name validation browsers use and an owner-document backref so DocumentType.ownerDocument is correct. StaticRange-constructor 14/17. This does not fully unblock dom/ranges yet: dom/common.js also needs createHTMLDocument()/createDocument() to return documents with a working body/createComment/etc., which is a separate fix. * fix(browser,cli): settle the event loop after navigation obscura fetch read the page at the load event and never pumped the event loop afterward on the eval/dump path, so --wait was effectively ignored there. Pages that schedule timers, do async work, or only report through a completion callback that runs after load (testharness add_completion_callback, SPAs) were observed frozen at the load event and never produced their result. Add Page::settle, which drives run_event_loop until the loop goes idle or --wait elapses (returns early when idle, so static pages stay fast), and run it after navigation in the fetch path.
This commit is contained in:
1 parent
6b3cc1a686
commit
07c6e735fa
4 files changed
+554
-53
No files matched your search
@@ -675,6 +675,26 @@ impl Page {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drive the JS event loop after navigation so deferred work can run:
|
||||||
|
/// pending timers (setTimeout / setInterval), queued microtasks, in-flight
|
||||||
|
/// fetches, and completion callbacks such as testharness's
|
||||||
|
/// `add_completion_callback`. Returns as soon as the loop goes idle, or
|
||||||
|
/// after `max_ms`. Without this the page is observed exactly as it stood at
|
||||||
|
/// the load event, before any async work settles, which silently strands
|
||||||
|
/// timer-driven tests and dynamic pages.
|
||||||
|
pub async fn settle(&mut self, max_ms: u64) {
|
||||||
|
if max_ms == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(js) = &mut self.js {
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
tokio::time::Duration::from_millis(max_ms),
|
||||||
|
js.run_event_loop(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Append the current URL to the history stack, truncating any forward
|
/// Append the current URL to the history stack, truncating any forward
|
||||||
/// entries past the cursor (matches real Chrome: navigating after a
|
/// entries past the cursor (matches real Chrome: navigating after a
|
||||||
/// goBack clobbers the forward history).
|
/// goBack clobbers the forward history).
|
||||||
|
|||||||
@@ -526,6 +526,12 @@ async fn run_fetch(
|
|||||||
eprintln!("Page loaded: {} - \"{}\"", page.url_string(), page.title);
|
eprintln!("Page loaded: {} - \"{}\"", page.url_string(), page.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --wait is a post-load settle: drive the event loop so timers, async work,
|
||||||
|
// and completion callbacks (e.g. testharness's add_completion_callback) run
|
||||||
|
// before we read the page. Returns early once the loop is idle, so static
|
||||||
|
// pages stay fast.
|
||||||
|
page.settle(wait_secs.saturating_mul(1000)).await;
|
||||||
|
|
||||||
if let Some(ref sel) = selector {
|
if let Some(ref sel) = selector {
|
||||||
let found = wait_for_selector(&mut page, sel, wait_secs).await;
|
let found = wait_for_selector(&mut page, sel, wait_secs).await;
|
||||||
if !found {
|
if !found {
|
||||||
|
|||||||
Vendored
+353
-53
@@ -631,13 +631,37 @@ class ProcessingInstruction extends CharacterData {
|
|||||||
cloneNode() { return new ProcessingInstruction(+_dom("create_text_node", this.data), this._target); }
|
cloneNode() { return new ProcessingInstruction(+_dom("create_text_node", this.data), this._target); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTMLHyperlinkElementUtils helpers (the <a>/<area> URL-decomposition members).
|
||||||
|
// The element's href attribute is parsed against the document base URL via the
|
||||||
|
// WHATWG url op; component getters read it, setters rewrite the href attribute.
|
||||||
|
function _anchorBase() { return _domParse("document_url") || "about:blank"; }
|
||||||
|
function _elemHrefURL(el) {
|
||||||
|
const raw = el.getAttribute('href');
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
return _urlParseOp(raw, _anchorBase());
|
||||||
|
}
|
||||||
|
function _setElemHrefPart(el, part, value) {
|
||||||
|
const u = _elemHrefURL(el);
|
||||||
|
if (!u) return;
|
||||||
|
const c = _urlSetOp(u.href, part, value);
|
||||||
|
if (c) el.setAttribute('href', c.href);
|
||||||
|
}
|
||||||
|
|
||||||
class Element extends Node {
|
class Element extends Node {
|
||||||
constructor(nid) {
|
constructor(nid) {
|
||||||
super(nid);
|
super(nid);
|
||||||
this._style = _styleProxy(new CSSStyleDeclaration());
|
this._style = _styleProxy(new CSSStyleDeclaration());
|
||||||
}
|
}
|
||||||
get tagName() { return _domParse("tag_name", this._nid) || ""; }
|
get tagName() { return _domParse("tag_name", this._nid) || ""; }
|
||||||
get localName() { return (this.tagName || "").toLowerCase(); }
|
get localName() {
|
||||||
|
// tagName is an op call and the tag never changes, so cache the lowercased
|
||||||
|
// localName. This keeps the new <a>/<area> href getters (which read
|
||||||
|
// localName) and every other localName consumer off the op path.
|
||||||
|
if (this._lname !== undefined) return this._lname;
|
||||||
|
const ln = (this.tagName || "").toLowerCase();
|
||||||
|
if (ln) this._lname = ln;
|
||||||
|
return ln;
|
||||||
|
}
|
||||||
get id() { return this.getAttribute("id") || ""; }
|
get id() { return this.getAttribute("id") || ""; }
|
||||||
set id(v) { this.setAttribute("id", v); }
|
set id(v) { this.setAttribute("id", v); }
|
||||||
get className() { return this.getAttribute("class") || ""; }
|
get className() { return this.getAttribute("class") || ""; }
|
||||||
@@ -1059,8 +1083,41 @@ class Element extends Node {
|
|||||||
set name(v) { this.setAttribute("name", v); }
|
set name(v) { this.setAttribute("name", v); }
|
||||||
get placeholder() { return this.getAttribute("placeholder") || ""; }
|
get placeholder() { return this.getAttribute("placeholder") || ""; }
|
||||||
set placeholder(v) { this.setAttribute("placeholder", v); }
|
set placeholder(v) { this.setAttribute("placeholder", v); }
|
||||||
get href() { return this.getAttribute("href") || ""; }
|
// For <a>/<area>, href returns the resolved absolute URL (the spec behavior,
|
||||||
|
// and what scrapers want). It uses op_url_resolve, which returns just the
|
||||||
|
// resolved string, rather than the full-component op the decomposition
|
||||||
|
// members use. Other elements reflect the raw attribute.
|
||||||
|
get href() {
|
||||||
|
const ln = this.localName;
|
||||||
|
if (ln === 'a' || ln === 'area') {
|
||||||
|
const raw = this.getAttribute('href');
|
||||||
|
if (raw === null) return '';
|
||||||
|
const r = _urlResolveOp(raw, _anchorBase());
|
||||||
|
return r !== null ? r : raw;
|
||||||
|
}
|
||||||
|
return this.getAttribute("href") || "";
|
||||||
|
}
|
||||||
set href(v) { this.setAttribute("href", v); }
|
set href(v) { this.setAttribute("href", v); }
|
||||||
|
// HTMLHyperlinkElementUtils URL-decomposition members, live on <a>/<area>.
|
||||||
|
get protocol() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.protocol : ''; }
|
||||||
|
set protocol(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'protocol', v); }
|
||||||
|
get username() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.username : ''; }
|
||||||
|
set username(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'username', v); }
|
||||||
|
get password() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.password : ''; }
|
||||||
|
set password(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'password', v); }
|
||||||
|
get host() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.host : ''; }
|
||||||
|
set host(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'host', v); }
|
||||||
|
get hostname() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.hostname : ''; }
|
||||||
|
set hostname(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'hostname', v); }
|
||||||
|
get port() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.port : ''; }
|
||||||
|
set port(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'port', v); }
|
||||||
|
get pathname() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.pathname : ''; }
|
||||||
|
set pathname(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'pathname', v); }
|
||||||
|
get search() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.search : ''; }
|
||||||
|
set search(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'search', v); }
|
||||||
|
get hash() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.hash : ''; }
|
||||||
|
set hash(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'hash', v); }
|
||||||
|
get origin() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.origin : ''; }
|
||||||
get src() { return this.getAttribute("src") || ""; }
|
get src() { return this.getAttribute("src") || ""; }
|
||||||
set src(v) {
|
set src(v) {
|
||||||
this.setAttribute("src", v);
|
this.setAttribute("src", v);
|
||||||
@@ -1356,6 +1413,12 @@ class Document extends Node {
|
|||||||
get compatMode() { return "CSS1Compat"; }
|
get compatMode() { return "CSS1Compat"; }
|
||||||
get characterSet() { return "UTF-8"; }
|
get characterSet() { return "UTF-8"; }
|
||||||
get contentType() {
|
get contentType() {
|
||||||
|
// An explicit type set by DOMParser/createDocument wins.
|
||||||
|
if (this._contentType) return this._contentType;
|
||||||
|
// `new Document()` (the WHATWG constructor, no backing node id) creates an
|
||||||
|
// XML document, so createCDATASection/etc. must not throw. Live documents
|
||||||
|
// wrapped from the tree carry a real nid and fall through to URL-derived.
|
||||||
|
if (this._nid === undefined || this._nid === null) return "application/xml";
|
||||||
const url = this.URL || "";
|
const url = this.URL || "";
|
||||||
// data: URLs carry their MIME type explicitly.
|
// data: URLs carry their MIME type explicitly.
|
||||||
const dm = /^data:([^,;]+)/i.exec(url);
|
const dm = /^data:([^,;]+)/i.exec(url);
|
||||||
@@ -1582,6 +1645,7 @@ class Document extends Node {
|
|||||||
getSelection() { return globalThis.getSelection(); }
|
getSelection() { return globalThis.getSelection(); }
|
||||||
get activeElement() { return globalThis.__obscura_focused || this.body; }
|
get activeElement() { return globalThis.__obscura_focused || this.body; }
|
||||||
get implementation() {
|
get implementation() {
|
||||||
|
const ownerDoc = this;
|
||||||
return {
|
return {
|
||||||
// Spec: createHTMLDocument returns a NEW detached Document. jQuery
|
// Spec: createHTMLDocument returns a NEW detached Document. jQuery
|
||||||
// 3.x's selector feature-detect calls `body.innerHTML = '<form>'` on
|
// 3.x's selector feature-detect calls `body.innerHTML = '<form>'` on
|
||||||
@@ -1608,6 +1672,24 @@ class Document extends Node {
|
|||||||
const html = `<${safe}></${safe}>`;
|
const html = `<${safe}></${safe}>`;
|
||||||
return new DOMParser().parseFromString(html, "application/xml");
|
return new DOMParser().parseFromString(html, "application/xml");
|
||||||
},
|
},
|
||||||
|
// createDocumentType(qualifiedName, publicId, systemId): build a detached
|
||||||
|
// DocumentType node. Browsers validate leniently here (only a name with
|
||||||
|
// ASCII whitespace or ">" is rejected, matching the WPT cases); the node's
|
||||||
|
// owner document is the document whose implementation was used.
|
||||||
|
createDocumentType(qualifiedName, publicId, systemId) {
|
||||||
|
const name = String(qualifiedName);
|
||||||
|
if (name === "" || /[\t\n\f\r >]/.test(name)) {
|
||||||
|
throw new DOMException("The qualified name '" + name + "' contains an invalid character", "InvalidCharacterError");
|
||||||
|
}
|
||||||
|
const dt = new DocumentType(
|
||||||
|
+_dom("create_comment_node", ""),
|
||||||
|
name,
|
||||||
|
publicId === undefined ? "" : String(publicId),
|
||||||
|
systemId === undefined ? "" : String(systemId)
|
||||||
|
);
|
||||||
|
dt._ownerDocument = ownerDoc;
|
||||||
|
return dt;
|
||||||
|
},
|
||||||
hasFeature() { return true; },
|
hasFeature() { return true; },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1697,7 +1779,9 @@ class DocumentType extends Node {
|
|||||||
get name() { return this._name; }
|
get name() { return this._name; }
|
||||||
get publicId() { return this._publicId; }
|
get publicId() { return this._publicId; }
|
||||||
get systemId() { return this._systemId; }
|
get systemId() { return this._systemId; }
|
||||||
get ownerDocument() { return globalThis.document; }
|
get nodeValue() { return null; }
|
||||||
|
set nodeValue(v) {}
|
||||||
|
get ownerDocument() { return this._ownerDocument || globalThis.document; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const _cache = new Map();
|
const _cache = new Map();
|
||||||
@@ -2288,50 +2372,78 @@ _markNative(XMLHttpRequest.prototype.setRequestHeader);
|
|||||||
_markNative(XMLHttpRequest.prototype.getResponseHeader);
|
_markNative(XMLHttpRequest.prototype.getResponseHeader);
|
||||||
_markNative(XMLHttpRequest.prototype.getAllResponseHeaders);
|
_markNative(XMLHttpRequest.prototype.getAllResponseHeaders);
|
||||||
|
|
||||||
if (typeof URL === 'undefined' || !URL.prototype) {
|
// WHATWG URL parsing/serialization is delegated to the Rust `url` crate via
|
||||||
globalThis.URL = class URL {
|
// op_url_parse / op_url_set. The op returns the full component set as JSON; the
|
||||||
|
// constructor caches it so getters are plain field reads (no per-access op) and
|
||||||
|
// the hot paths (navigation, fetch, _resolveUrl) stay cheap. Returns null when
|
||||||
|
// the input is not a valid URL.
|
||||||
|
function _urlParseOp(url, base) {
|
||||||
|
try {
|
||||||
|
const s = Deno.core.ops.op_url_parse(String(url), (base === undefined || base === null) ? "" : String(base));
|
||||||
|
const c = JSON.parse(s);
|
||||||
|
return (c && c.ok) ? c : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function _urlSetOp(href, part, value) {
|
||||||
|
try {
|
||||||
|
const s = Deno.core.ops.op_url_set(String(href), part, String(value));
|
||||||
|
const c = JSON.parse(s);
|
||||||
|
return (c && c.ok) ? c : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
// Returns just the resolved absolute URL string (no component JSON), or null on
|
||||||
|
// failure. Cheaper than _urlParseOp for callers that only need the href.
|
||||||
|
function _urlResolveOp(href, base) {
|
||||||
|
try {
|
||||||
|
const r = Deno.core.ops.op_url_resolve(String(href), (base === undefined || base === null) ? "" : String(base));
|
||||||
|
return r ? r : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
if (typeof URL === 'undefined' || !URL.prototype || !URL.__obscura) {
|
||||||
|
const _URL = class URL {
|
||||||
constructor(url, base) {
|
constructor(url, base) {
|
||||||
// Per WHATWG URL spec, both arguments are stringified — callers
|
const c = _urlParseOp(url, base);
|
||||||
// routinely pass `window.location` (Location object) or a URL
|
if (!c) throw new TypeError("Failed to construct 'URL': Invalid URL");
|
||||||
// instance as `base`. Coerce explicitly so the regex .match() calls
|
this._c = c;
|
||||||
// below don't blow up on non-strings.
|
this._sp = null;
|
||||||
url = String(url);
|
|
||||||
if (base !== undefined && base !== null) base = String(base);
|
|
||||||
let full = url;
|
|
||||||
if (base && !url.includes('://')) {
|
|
||||||
var bm = base.match(/^(https?:\/\/[^\/\?#]+)(\/[^?#]*)?/);
|
|
||||||
if (bm) {
|
|
||||||
var bOrigin = bm[1];
|
|
||||||
var bPath = bm[2] || '/';
|
|
||||||
if (url.startsWith('/')) {
|
|
||||||
full = bOrigin + url;
|
|
||||||
} else if (url.startsWith('?') || url.startsWith('#')) {
|
|
||||||
full = bOrigin + bPath + url;
|
|
||||||
} else {
|
|
||||||
var dir = bPath.substring(0, bPath.lastIndexOf('/') + 1);
|
|
||||||
full = bOrigin + dir + url;
|
|
||||||
}
|
}
|
||||||
|
get href() { return this._c.href; }
|
||||||
|
set href(v) { const c = _urlParseOp(v, undefined); if (!c) throw new TypeError("Failed to set the 'href' property on 'URL': Invalid URL"); this._c = c; this._refreshSP(); }
|
||||||
|
get protocol() { return this._c.protocol; }
|
||||||
|
set protocol(v) { this._set('protocol', v); }
|
||||||
|
get username() { return this._c.username; }
|
||||||
|
set username(v) { this._set('username', v); }
|
||||||
|
get password() { return this._c.password; }
|
||||||
|
set password(v) { this._set('password', v); }
|
||||||
|
get host() { return this._c.host; }
|
||||||
|
set host(v) { this._set('host', v); }
|
||||||
|
get hostname() { return this._c.hostname; }
|
||||||
|
set hostname(v) { this._set('hostname', v); }
|
||||||
|
get port() { return this._c.port; }
|
||||||
|
set port(v) { this._set('port', v); }
|
||||||
|
get pathname() { return this._c.pathname; }
|
||||||
|
set pathname(v) { this._set('pathname', v); }
|
||||||
|
get search() { return this._c.search; }
|
||||||
|
set search(v) { this._set('search', v); this._refreshSP(); }
|
||||||
|
get hash() { return this._c.hash; }
|
||||||
|
set hash(v) { this._set('hash', v); }
|
||||||
|
get origin() { return this._c.origin; }
|
||||||
|
get searchParams() {
|
||||||
|
if (!this._sp) { this._sp = new URLSearchParams(this._c.search); this._sp._url = this; }
|
||||||
|
return this._sp;
|
||||||
}
|
}
|
||||||
}
|
_set(part, value) { const c = _urlSetOp(this._c.href, part, value); if (c) this._c = c; }
|
||||||
const m = full.match(/^(https?):\/\/([^\/\?#]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/);
|
// search changed on the URL side: refresh the bound searchParams contents.
|
||||||
if (m) {
|
_refreshSP() { if (this._sp && this._sp._setFromString) this._sp._setFromString(this._c.search); }
|
||||||
this.protocol = m[1] + ':';
|
// searchParams mutated: write the serialized query back without re-refreshing.
|
||||||
this.host = m[2]; this.hostname = m[2].split(':')[0];
|
_updateSearch(qs) { this._set('search', qs ? ('?' + qs) : ''); }
|
||||||
this.port = m[2].includes(':') ? m[2].split(':')[1] : '';
|
toString() { return this._c.href; }
|
||||||
this.pathname = m[3] || '/';
|
toJSON() { return this._c.href; }
|
||||||
this.search = m[4] || ''; this.hash = m[5] || '';
|
|
||||||
} else {
|
|
||||||
this.protocol = ''; this.host = ''; this.hostname = '';
|
|
||||||
this.port = ''; this.pathname = full; this.search = ''; this.hash = '';
|
|
||||||
}
|
|
||||||
this.href = full; this.origin = this.protocol + '//' + this.host;
|
|
||||||
this.searchParams = new URLSearchParams(this.search);
|
|
||||||
}
|
|
||||||
toString() { return this.href; }
|
|
||||||
toJSON() { return this.href; }
|
|
||||||
static createObjectURL() { return 'blob:null/fake-' + Math.random().toString(36).slice(2); }
|
static createObjectURL() { return 'blob:null/fake-' + Math.random().toString(36).slice(2); }
|
||||||
static revokeObjectURL() {}
|
static revokeObjectURL() {}
|
||||||
};
|
};
|
||||||
|
_URL.__obscura = true;
|
||||||
|
globalThis.URL = _URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
globalThis.requestIdleCallback = globalThis.requestIdleCallback || function requestIdleCallback(cb, opts) {
|
globalThis.requestIdleCallback = globalThis.requestIdleCallback || function requestIdleCallback(cb, opts) {
|
||||||
@@ -3086,24 +3198,54 @@ globalThis.AbortSignal = { timeout(ms){return {aborted:false,addEventListener(){
|
|||||||
if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} };
|
if (typeof Blob === "undefined") globalThis.Blob = class Blob { constructor(parts=[],opts={}){this._data=parts.join("");this.size=this._data.length;this.type=opts.type||"";} async text(){return this._data;} };
|
||||||
if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} };
|
if (typeof File === "undefined") globalThis.File = class extends Blob { constructor(parts,name,opts){super(parts,opts);this.name=name;} };
|
||||||
if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} };
|
if (typeof FormData === "undefined") globalThis.FormData = class FormData { constructor(){this._d=[];} append(k,v){this._d.push([k,v]);} get(k){const e=this._d.find(([a])=>a===k);return e?e[1]:null;} getAll(k){return this._d.filter(([a])=>a===k).map(([,v])=>v);} has(k){return this._d.some(([a])=>a===k);} entries(){return this._d[Symbol.iterator]();} forEach(cb){this._d.forEach(([k,v])=>cb(v,k));} };
|
||||||
if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class {
|
// application/x-www-form-urlencoded serializer: like encodeURIComponent but
|
||||||
|
// space -> '+' and also percent-encoding the chars encodeURIComponent leaves
|
||||||
|
// bare ( ! ~ ' ( ) ), keeping the form-urlencoded safe set ( * - . _ ).
|
||||||
|
function _formEncode(s){
|
||||||
|
return encodeURIComponent(String(s)).replace(/%20/g,'+').replace(/[!'()~]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
||||||
|
}
|
||||||
|
if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class URLSearchParams {
|
||||||
constructor(init=""){
|
constructor(init=""){
|
||||||
this._p=[];
|
this._p=[];
|
||||||
if(typeof init==="string"){
|
this._url=null; // set by URL.searchParams so mutations write back to the URL
|
||||||
init.replace(/^\?/,"").split("&").forEach(p=>{const[k,...v]=p.split("=");if(k)this.append(decodeURIComponent(k),decodeURIComponent(v.join("=")));});
|
if (typeof URLSearchParams === 'function' && init instanceof URLSearchParams) {
|
||||||
|
this._p = init._p.map(pair => [pair[0], pair[1]]);
|
||||||
|
} else if(typeof init==="string"){
|
||||||
|
this._parseString(init);
|
||||||
} else if (init && typeof init[Symbol.iterator] === 'function') {
|
} else if (init && typeof init[Symbol.iterator] === 'function') {
|
||||||
for (const pair of init) if (pair && pair.length >= 2) this.append(pair[0], pair[1]);
|
for (const pair of init) { const a = Array.from(pair); if (a.length >= 2) this._p.push([String(a[0]), String(a[1])]); }
|
||||||
} else if (init && typeof init === 'object') {
|
} else if (init && typeof init === 'object') {
|
||||||
Object.keys(init).forEach(k => this.append(k, init[k]));
|
Object.keys(init).forEach(k => this._p.push([String(k), String(init[k])]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
append(k,v){this._p.push([String(k),String(v)]);}
|
_decode(s){ try { return decodeURIComponent(String(s).replace(/\+/g, ' ')); } catch(e) { return String(s); } }
|
||||||
get(k){const p=this._p.find(([key])=>key===String(k)); return p?p[1]:null;}
|
_parseString(s){
|
||||||
set(k,v){this.delete(k); this.append(k,v);}
|
s = String(s).replace(/^\?/, "");
|
||||||
delete(k){k=String(k); this._p=this._p.filter(([key])=>key!==k);}
|
if (s === "") return;
|
||||||
|
for (const pair of s.split("&")) {
|
||||||
|
if (pair === "") continue;
|
||||||
|
const i = pair.indexOf("=");
|
||||||
|
const k = i === -1 ? pair : pair.slice(0, i);
|
||||||
|
const v = i === -1 ? "" : pair.slice(i + 1);
|
||||||
|
this._p.push([this._decode(k), this._decode(v)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_setFromString(s){ this._p = []; this._parseString(s); }
|
||||||
|
_notify(){ if (this._url) this._url._updateSearch(this.toString()); }
|
||||||
|
append(k,v){ this._p.push([String(k),String(v)]); this._notify(); }
|
||||||
|
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);}
|
has(k){k=String(k); return this._p.some(([key])=>key===k);}
|
||||||
toString(){return this._p.map(([k,v])=>`${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");}
|
sort(){ this._p.sort((a,b)=> a[0]<b[0]?-1:(a[0]>b[0]?1:0)); this._notify(); }
|
||||||
forEach(cb){this._p.forEach(([k,v])=>cb(v,k,this));}
|
get size(){ return this._p.length; }
|
||||||
|
toString(){return this._p.map(pair=>_formEncode(pair[0])+"="+_formEncode(pair[1])).join("&");}
|
||||||
|
forEach(cb,thisArg){this._p.slice().forEach(pair=>cb.call(thisArg,pair[1],pair[0],this));}
|
||||||
|
*entries(){ for (const pair of this._p) yield [pair[0],pair[1]]; }
|
||||||
|
*keys(){ for (const pair of this._p) yield pair[0]; }
|
||||||
|
*values(){ for (const pair of this._p) yield pair[1]; }
|
||||||
|
[Symbol.iterator](){ return this.entries(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Real-enough DOMParser. The previous one-liner returned `globalThis.document`,
|
// Real-enough DOMParser. The previous one-liner returned `globalThis.document`,
|
||||||
@@ -3527,7 +3669,165 @@ globalThis.DOMTokenList = DOMTokenList;
|
|||||||
globalThis.NodeList = class NodeList extends Array {
|
globalThis.NodeList = class NodeList extends Array {
|
||||||
item(i) { return this[i] != null ? this[i] : null; }
|
item(i) { return this[i] != null ? this[i] : null; }
|
||||||
};
|
};
|
||||||
globalThis.Range = class Range { setStart(){} setEnd(){} collapse(){} selectNodeContents(){} deleteContents(){} cloneContents(){ return document.createDocumentFragment(); } insertNode(){} getBoundingClientRect(){return {x:0,y:0,width:0,height:0,top:0,right:0,bottom:0,left:0};} };
|
// Live Range over the real DOM tree. dom/ranges/* tests are pure boundary-point
|
||||||
|
// algorithms (no layout, no editing engine), so a property-storing Range with
|
||||||
|
// correct tree-order comparison passes them. Mutating ops (extract/delete/
|
||||||
|
// insert/surround) are kept minimal: they do not throw, but do not rewrite the
|
||||||
|
// tree (that is the editing mega-bucket, out of scope).
|
||||||
|
function _rngNodeLength(n) {
|
||||||
|
const t = n.nodeType;
|
||||||
|
if (t === 3 || t === 4 || t === 8 || t === 7) return (n.data || n.nodeValue || "").length;
|
||||||
|
return n.childNodes.length;
|
||||||
|
}
|
||||||
|
function _rngNodeIndex(n) {
|
||||||
|
const p = n.parentNode;
|
||||||
|
if (!p) return 0;
|
||||||
|
const kids = p.childNodes;
|
||||||
|
for (let i = 0; i < kids.length; i++) if (kids[i] && kids[i]._nid === n._nid) return i;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
function _rngSame(a, b) { return a === b || (!!a && !!b && a._nid === b._nid); }
|
||||||
|
function _rngRoot(n) { let r = n; while (r && r.parentNode) r = r.parentNode; return r; }
|
||||||
|
function _rngAncestors(n) { const a = []; let c = n; while (c) { a.push(c); c = c.parentNode; } return a; }
|
||||||
|
// document (preorder) tree order: -1 if a precedes b, 1 if a follows b, 0 same.
|
||||||
|
function _rngOrder(a, b) {
|
||||||
|
if (_rngSame(a, b)) return 0;
|
||||||
|
const aa = _rngAncestors(a).reverse(), bb = _rngAncestors(b).reverse();
|
||||||
|
if (aa[0]._nid !== bb[0]._nid) return (a._nid | 0) < (b._nid | 0) ? -1 : 1;
|
||||||
|
let i = 0;
|
||||||
|
while (i < aa.length && i < bb.length && aa[i]._nid === bb[i]._nid) i++;
|
||||||
|
if (i >= aa.length) return -1; // a is an ancestor of b -> a precedes
|
||||||
|
if (i >= bb.length) return 1; // b is an ancestor of a -> a follows
|
||||||
|
return _rngNodeIndex(aa[i]) < _rngNodeIndex(bb[i]) ? -1 : 1;
|
||||||
|
}
|
||||||
|
// Position of (nA,oA) relative to (nB,oB): -1 before, 0 equal, 1 after.
|
||||||
|
function _rngCmp(nA, oA, nB, oB) {
|
||||||
|
if (_rngSame(nA, nB)) return oA < oB ? -1 : (oA > oB ? 1 : 0);
|
||||||
|
if (_rngOrder(nA, nB) > 0) return -_rngCmp(nB, oB, nA, oA);
|
||||||
|
if (nA.contains && nA.contains(nB)) { // nA is a strict ancestor of nB
|
||||||
|
let child = nB;
|
||||||
|
while (child && child.parentNode && child.parentNode._nid !== nA._nid) child = child.parentNode;
|
||||||
|
if (child && child.parentNode && child.parentNode._nid === nA._nid && _rngNodeIndex(child) < oA) return 1;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
function _rngCheckOffset(n, o) {
|
||||||
|
if (n && n.nodeType === 10) throw new DOMException("Range boundary cannot be a DocumentType", "InvalidNodeTypeError");
|
||||||
|
if (o < 0 || o > _rngNodeLength(n)) throw new DOMException("Range offset out of bounds", "IndexSizeError");
|
||||||
|
}
|
||||||
|
globalThis.Range = class Range {
|
||||||
|
constructor() {
|
||||||
|
const d = globalThis.document || null;
|
||||||
|
this._sc = d; this._so = 0; this._ec = d; this._eo = 0;
|
||||||
|
}
|
||||||
|
get startContainer() { return this._sc; }
|
||||||
|
get startOffset() { return this._so; }
|
||||||
|
get endContainer() { return this._ec; }
|
||||||
|
get endOffset() { return this._eo; }
|
||||||
|
get collapsed() { return _rngSame(this._sc, this._ec) && this._so === this._eo; }
|
||||||
|
get commonAncestorContainer() {
|
||||||
|
if (!this._sc || !this._ec) return null;
|
||||||
|
const setA = new Set(_rngAncestors(this._sc).map(n => n._nid));
|
||||||
|
let c = this._ec;
|
||||||
|
while (c) { if (setA.has(c._nid)) return c; c = c.parentNode; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setStart(n, o) { _rngCheckOffset(n, o); this._sc = n; this._so = o; if (_rngRoot(n)._nid !== _rngRoot(this._ec)._nid || _rngCmp(this._sc, this._so, this._ec, this._eo) > 0) { this._ec = n; this._eo = o; } }
|
||||||
|
setEnd(n, o) { _rngCheckOffset(n, o); this._ec = n; this._eo = o; if (_rngRoot(n)._nid !== _rngRoot(this._sc)._nid || _rngCmp(this._sc, this._so, this._ec, this._eo) > 0) { this._sc = n; this._so = o; } }
|
||||||
|
setStartBefore(n) { const p = n.parentNode; if (!p) throw new DOMException("node has no parent", "InvalidNodeTypeError"); this.setStart(p, _rngNodeIndex(n)); }
|
||||||
|
setStartAfter(n) { const p = n.parentNode; if (!p) throw new DOMException("node has no parent", "InvalidNodeTypeError"); this.setStart(p, _rngNodeIndex(n) + 1); }
|
||||||
|
setEndBefore(n) { const p = n.parentNode; if (!p) throw new DOMException("node has no parent", "InvalidNodeTypeError"); this.setEnd(p, _rngNodeIndex(n)); }
|
||||||
|
setEndAfter(n) { const p = n.parentNode; if (!p) throw new DOMException("node has no parent", "InvalidNodeTypeError"); this.setEnd(p, _rngNodeIndex(n) + 1); }
|
||||||
|
collapse(toStart) { if (toStart) { this._ec = this._sc; this._eo = this._so; } else { this._sc = this._ec; this._so = this._eo; } }
|
||||||
|
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) {
|
||||||
|
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");
|
||||||
|
if (_rngCmp(n, o, this._sc, this._so) < 0) return -1;
|
||||||
|
if (_rngCmp(n, o, this._ec, this._eo) > 0) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
isPointInRange(n, o) {
|
||||||
|
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");
|
||||||
|
return _rngCmp(n, o, this._sc, this._so) >= 0 && _rngCmp(n, o, this._ec, this._eo) <= 0;
|
||||||
|
}
|
||||||
|
compareBoundaryPoints(how, other) {
|
||||||
|
let a, b;
|
||||||
|
switch (how) {
|
||||||
|
case 0: a = [this._sc, this._so]; b = [other._sc, other._so]; break; // START_TO_START
|
||||||
|
case 1: a = [this._ec, this._eo]; b = [other._sc, other._so]; break; // START_TO_END
|
||||||
|
case 2: a = [this._ec, this._eo]; b = [other._ec, other._eo]; break; // END_TO_END
|
||||||
|
case 3: a = [this._sc, this._so]; b = [other._ec, other._eo]; break; // END_TO_START
|
||||||
|
default: throw new DOMException("invalid comparison type", "NotSupportedError");
|
||||||
|
}
|
||||||
|
if (_rngRoot(a[0])._nid !== _rngRoot(b[0])._nid) throw new DOMException("ranges are in different trees", "WrongDocumentError");
|
||||||
|
return _rngCmp(a[0], a[1], b[0], b[1]);
|
||||||
|
}
|
||||||
|
intersectsNode(n) {
|
||||||
|
if (_rngRoot(n)._nid !== _rngRoot(this._sc)._nid) return false;
|
||||||
|
const p = n.parentNode;
|
||||||
|
if (!p) return true;
|
||||||
|
const o = _rngNodeIndex(n);
|
||||||
|
return _rngCmp(p, o, this._ec, this._eo) < 0 && _rngCmp(p, o + 1, this._sc, this._so) > 0;
|
||||||
|
}
|
||||||
|
cloneRange() { const r = new Range(); r._sc = this._sc; r._so = this._so; r._ec = this._ec; r._eo = this._eo; return r; }
|
||||||
|
toString() {
|
||||||
|
const sc = this._sc, ec = this._ec;
|
||||||
|
if (!sc) return "";
|
||||||
|
if (_rngSame(sc, ec) && (sc.nodeType === 3 || sc.nodeType === 4)) return (sc.data || "").slice(this._so, this._eo);
|
||||||
|
let s = "";
|
||||||
|
if (sc.nodeType === 3 || sc.nodeType === 4) s += (sc.data || "").slice(this._so);
|
||||||
|
const cac = this.commonAncestorContainer;
|
||||||
|
if (cac) {
|
||||||
|
const walk = (node) => {
|
||||||
|
if (node.nodeType === 3 || node.nodeType === 4) {
|
||||||
|
if (!_rngSame(node, sc) && !_rngSame(node, ec) &&
|
||||||
|
_rngCmp(node, 0, this._sc, this._so) >= 0 && _rngCmp(node, _rngNodeLength(node), this._ec, this._eo) <= 0) {
|
||||||
|
s += (node.data || "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const kids = node.childNodes;
|
||||||
|
for (let i = 0; i < kids.length; i++) if (kids[i]) walk(kids[i]);
|
||||||
|
};
|
||||||
|
walk(cac);
|
||||||
|
}
|
||||||
|
if (!_rngSame(sc, ec) && (ec.nodeType === 3 || ec.nodeType === 4)) s += (ec.data || "").slice(0, this._eo);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
cloneContents() { return (globalThis.document || document).createDocumentFragment(); }
|
||||||
|
extractContents() { return (globalThis.document || document).createDocumentFragment(); }
|
||||||
|
deleteContents() {}
|
||||||
|
insertNode(node) { if (node && this._sc && this._sc.insertBefore) { const kids = this._sc.childNodes; this._sc.insertBefore(node, kids[this._so] || null); } }
|
||||||
|
surroundContents(node) { this.insertNode(node); }
|
||||||
|
detach() {}
|
||||||
|
getBoundingClientRect() { return new DOMRect(); }
|
||||||
|
getClientRects() { return []; }
|
||||||
|
static get START_TO_START() { return 0; }
|
||||||
|
static get START_TO_END() { return 1; }
|
||||||
|
static get END_TO_END() { return 2; }
|
||||||
|
static get END_TO_START() { return 3; }
|
||||||
|
};
|
||||||
|
Object.assign(globalThis.Range.prototype, { START_TO_START: 0, START_TO_END: 1, END_TO_END: 2, END_TO_START: 3 });
|
||||||
|
globalThis.StaticRange = class StaticRange {
|
||||||
|
constructor(init) {
|
||||||
|
if (!init || init.startContainer == null || init.endContainer == null)
|
||||||
|
throw new TypeError("Failed to construct 'StaticRange': required members are undefined");
|
||||||
|
const sc = init.startContainer, ec = init.endContainer;
|
||||||
|
if (sc.nodeType === 10 || ec.nodeType === 10 || sc.nodeType === 7 || ec.nodeType === 7)
|
||||||
|
throw new DOMException("StaticRange endpoints cannot be DocumentType or ProcessingInstruction", "InvalidNodeTypeError");
|
||||||
|
this._sc = sc; this._so = init.startOffset >>> 0; this._ec = ec; this._eo = init.endOffset >>> 0;
|
||||||
|
}
|
||||||
|
get startContainer() { return this._sc; }
|
||||||
|
get startOffset() { return this._so; }
|
||||||
|
get endContainer() { return this._ec; }
|
||||||
|
get endOffset() { return this._eo; }
|
||||||
|
get collapsed() { return _rngSame(this._sc, this._ec) && this._so === this._eo; }
|
||||||
|
};
|
||||||
|
|
||||||
[
|
[
|
||||||
navigator.getBattery, navigator.getGamepads, navigator.sendBeacon,
|
navigator.getBattery, navigator.getGamepads, navigator.sendBeacon,
|
||||||
|
|||||||
@@ -886,6 +886,178 @@ fn op_subtle_digest(#[string] algorithm: &str, #[buffer] data: &[u8]) -> Vec<u8>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize a parsed URL into the WHATWG IDL component shape consumed by the
|
||||||
|
/// `URL` class in bootstrap.js. Getters read these fields directly so no op
|
||||||
|
/// call happens per property access.
|
||||||
|
fn url_components(u: &url::Url) -> serde_json::Value {
|
||||||
|
let port = u.port().map(|p| p.to_string()).unwrap_or_default();
|
||||||
|
let hostname = u.host_str().unwrap_or("").to_string();
|
||||||
|
let host = if hostname.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else if port.is_empty() {
|
||||||
|
hostname.clone()
|
||||||
|
} else {
|
||||||
|
format!("{hostname}:{port}")
|
||||||
|
};
|
||||||
|
// WHATWG search/hash getters return "" for a null OR empty component.
|
||||||
|
let search = match u.query() {
|
||||||
|
Some(q) if !q.is_empty() => format!("?{q}"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let hash = match u.fragment() {
|
||||||
|
Some(f) if !f.is_empty() => format!("#{f}"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"href": u.as_str(),
|
||||||
|
"protocol": format!("{}:", u.scheme()),
|
||||||
|
"username": u.username(),
|
||||||
|
"password": u.password().unwrap_or(""),
|
||||||
|
"host": host,
|
||||||
|
"hostname": hostname,
|
||||||
|
"port": port,
|
||||||
|
"pathname": u.path(),
|
||||||
|
"search": search,
|
||||||
|
"hash": hash,
|
||||||
|
"origin": u.origin().ascii_serialization(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `href` (optionally resolved against `base`) with the WHATWG-compliant
|
||||||
|
/// `url` crate. Returns the component JSON, or `{"ok":false}` when the input is
|
||||||
|
/// not a valid URL (the JS side turns that into a TypeError, per spec).
|
||||||
|
#[op2]
|
||||||
|
#[string]
|
||||||
|
fn op_url_parse(#[string] href: &str, #[string] base: &str) -> String {
|
||||||
|
// The url crate can panic on a few pathological inputs (internal range
|
||||||
|
// slicing); catch it so a bad URL never aborts the process.
|
||||||
|
std::panic::catch_unwind(|| {
|
||||||
|
let parsed = if base.is_empty() {
|
||||||
|
url::Url::parse(href)
|
||||||
|
} else {
|
||||||
|
url::Url::parse(base).and_then(|b| b.join(href))
|
||||||
|
};
|
||||||
|
match parsed {
|
||||||
|
Ok(u) => url_components(&u).to_string(),
|
||||||
|
Err(_) => "{\"ok\":false}".to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| "{\"ok\":false}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a WHATWG URL setter (`part` = href/protocol/username/password/host/
|
||||||
|
/// hostname/port/pathname/search/hash) to `href` and return the new components.
|
||||||
|
fn url_set_inner(href: &str, part: &str, value: &str) -> Option<serde_json::Value> {
|
||||||
|
let mut u = url::Url::parse(href).ok()?;
|
||||||
|
match part {
|
||||||
|
"href" => {
|
||||||
|
let nu = url::Url::parse(value).ok()?;
|
||||||
|
return Some(url_components(&nu));
|
||||||
|
}
|
||||||
|
"protocol" => {
|
||||||
|
let _ = u.set_scheme(value.trim_end_matches(':'));
|
||||||
|
}
|
||||||
|
"username" => {
|
||||||
|
let _ = u.set_username(value);
|
||||||
|
}
|
||||||
|
"password" => {
|
||||||
|
let _ = u.set_password(if value.is_empty() { None } else { Some(value) });
|
||||||
|
}
|
||||||
|
"host" => set_host_port(&mut u, value),
|
||||||
|
"hostname" => {
|
||||||
|
if !value.is_empty() {
|
||||||
|
let _ = u.set_host(Some(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"port" => {
|
||||||
|
if value.is_empty() {
|
||||||
|
let _ = u.set_port(None);
|
||||||
|
} else if let Ok(p) = value.parse::<u16>() {
|
||||||
|
let _ = u.set_port(Some(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pathname" => u.set_path(value),
|
||||||
|
"search" => {
|
||||||
|
let q = value.strip_prefix('?').unwrap_or(value);
|
||||||
|
u.set_query(if q.is_empty() { None } else { Some(q) });
|
||||||
|
}
|
||||||
|
"hash" => {
|
||||||
|
let f = value.strip_prefix('#').unwrap_or(value);
|
||||||
|
u.set_fragment(if f.is_empty() { None } else { Some(f) });
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Some(url_components(&u))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[op2]
|
||||||
|
#[string]
|
||||||
|
fn op_url_set(#[string] href: &str, #[string] part: &str, #[string] value: &str) -> String {
|
||||||
|
// Some url-crate setters panic on pathological inputs (the url-setters WPT
|
||||||
|
// tests exercise these). Catch the unwind and treat it as a no-op setter,
|
||||||
|
// returning the URL unchanged, which matches WHATWG "do nothing on invalid".
|
||||||
|
match std::panic::catch_unwind(|| url_set_inner(href, part, value)) {
|
||||||
|
Ok(Some(v)) => v.to_string(),
|
||||||
|
_ => match url::Url::parse(href) {
|
||||||
|
Ok(u) => url_components(&u).to_string(),
|
||||||
|
Err(_) => "{\"ok\":false}".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort `host` setter: split `host[:port]` (handling bracketed IPv6) and
|
||||||
|
/// apply hostname and port separately, since `url::Url::set_host` rejects a port.
|
||||||
|
fn set_host_port(u: &mut url::Url, value: &str) {
|
||||||
|
// IPv6 literals are bracketed; never split inside the brackets.
|
||||||
|
if value.starts_with('[') {
|
||||||
|
if let Some(close) = value.find(']') {
|
||||||
|
let host = &value[..=close];
|
||||||
|
let rest = &value[close + 1..];
|
||||||
|
if u.set_host(Some(host)).is_ok() {
|
||||||
|
if let Some(p) = rest.strip_prefix(':') {
|
||||||
|
if let Ok(pn) = p.parse::<u16>() {
|
||||||
|
let _ = u.set_port(Some(pn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(idx) = value.rfind(':') {
|
||||||
|
let (h, p) = (&value[..idx], &value[idx + 1..]);
|
||||||
|
if p.is_empty() || p.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
if u.set_host(Some(h)).is_ok() {
|
||||||
|
if p.is_empty() {
|
||||||
|
let _ = u.set_port(None);
|
||||||
|
} else if let Ok(pn) = p.parse::<u16>() {
|
||||||
|
let _ = u.set_port(Some(pn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = u.set_host(Some(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `href` against optional `base` and return only the serialized
|
||||||
|
/// absolute URL (no component breakdown). Used by the hot `a.href`/`area.href`
|
||||||
|
/// getter, which only needs the resolved string, so it avoids building and
|
||||||
|
/// re-parsing the full component JSON. Returns "" when the input is invalid.
|
||||||
|
#[op2]
|
||||||
|
#[string]
|
||||||
|
fn op_url_resolve(#[string] href: &str, #[string] base: &str) -> String {
|
||||||
|
std::panic::catch_unwind(|| {
|
||||||
|
let parsed = if base.is_empty() {
|
||||||
|
url::Url::parse(href)
|
||||||
|
} else {
|
||||||
|
url::Url::parse(base).and_then(|b| b.join(href))
|
||||||
|
};
|
||||||
|
parsed.map(|u| u.as_str().to_string()).unwrap_or_default()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_extension() -> Extension {
|
pub fn build_extension() -> Extension {
|
||||||
Extension {
|
Extension {
|
||||||
name: "obscura_dom",
|
name: "obscura_dom",
|
||||||
@@ -899,6 +1071,9 @@ pub fn build_extension() -> Extension {
|
|||||||
op_sleep(),
|
op_sleep(),
|
||||||
op_binding_called(),
|
op_binding_called(),
|
||||||
op_subtle_digest(),
|
op_subtle_digest(),
|
||||||
|
op_url_parse(),
|
||||||
|
op_url_set(),
|
||||||
|
op_url_resolve(),
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user