diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs
index ccbedd1..d281ecf 100644
--- a/crates/obscura-browser/src/page.rs
+++ b/crates/obscura-browser/src/page.rs
@@ -282,8 +282,31 @@ impl Page {
self.js = Some(rt);
}
+ /// Resolve the document base URL per HTML spec:
+ /// https://html.spec.whatwg.org/multipage/urls-and-fetching.html#document-base-url
+ /// Falls back to self.url when no exists.
+ fn resolve_base_url(&self) -> Option {
+ let doc_url = self.url.as_ref()?;
+ let base_href: Option = self.js.as_ref().and_then(|js| {
+ js.with_dom(|dom| {
+ match dom.query_selector("base[href]") {
+ Ok(Some(nid)) => {
+ dom.get_node(nid).and_then(|n| n.get_attribute("href").map(|s| s.to_string()))
+ }
+ _ => None,
+ }
+ }).flatten()
+ });
+ match base_href {
+ Some(href) => doc_url.join(&href).ok(),
+ None => Some(doc_url.clone()),
+ }
+ }
+
async fn execute_scripts(&mut self) {
tracing::info!("execute_scripts called, js runtime exists: {}", self.js.is_some());
+ // Compute document base URL, respecting .
+ let document_base = self.resolve_base_url();
// Soft deadline on the entire script-execution phase. Heavy SPAs
// (GitHub, Linear, CodeSandbox) ship 50+ scripts and our serial
// fetch + execute loop can blow past a 25s Puppeteer goto timeout.
@@ -397,7 +420,7 @@ impl Page {
if let Some(src_url) = &script.src {
let full_url = if src_url.starts_with("http://") || src_url.starts_with("https://") {
src_url.clone()
- } else if let Some(base) = &self.url {
+ } else if let Some(base) = &document_base {
base.join(src_url).map(|u| u.to_string()).unwrap_or_else(|_| src_url.clone())
} else {
src_url.clone()
@@ -557,7 +580,7 @@ impl Page {
if let Some(ref src) = module_script.src {
let full_url = if src.starts_with("http://") || src.starts_with("https://") {
src.clone()
- } else if let Some(base) = &self.url {
+ } else if let Some(base) = &document_base {
base.join(src).map(|u| u.to_string()).unwrap_or_else(|_| src.clone())
} else {
src.clone()
diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js
index a2083d0..11c2713 100644
--- a/crates/obscura-js/js/bootstrap.js
+++ b/crates/obscura-js/js/bootstrap.js
@@ -283,6 +283,23 @@ class Node {
get nodeType() { return +_dom("node_type", this._nid); }
get nodeName() { return _domParse("node_name", this._nid) || ""; }
get ownerDocument() { return globalThis.document; }
+ // https://dom.spec.whatwg.org/#dom-node-baseuri
+ get baseURI() {
+ try {
+ const doc = globalThis.document;
+ const docUrl = (doc && doc.URL) || "";
+ const baseEl = (doc && doc.querySelector) ? doc.querySelector("base[href]") : null;
+ if (baseEl) {
+ const href = baseEl.getAttribute("href");
+ if (href) {
+ return docUrl ? new URL(href, docUrl).href : href;
+ }
+ }
+ return docUrl;
+ } catch (e) {
+ return "";
+ }
+ }
get textContent() { return _domParse("text_content", this._nid) ?? ""; }
set textContent(v) {
const oldChildren = _domParse("child_nodes", this._nid) || [];
@@ -2584,7 +2601,42 @@ if (typeof Headers === "undefined") {
};
}
-globalThis.XMLHttpRequest = class XMLHttpRequest {
+// XMLHttpRequestEventTarget — spec-required ancestor for XHR EventTarget methods.
+// zone.js prefers to walk XMLHttpRequestEventTarget.prototype for addEventListener/
+// removeEventListener/dispatchEvent descriptors before falling back to XHR.prototype.
+class XMLHttpRequestEventTarget {
+ addEventListener(type, handler) {
+ if (!this._listeners) this._listeners = {};
+ if (!this._listeners[type]) this._listeners[type] = [];
+ this._listeners[type].push(handler);
+ }
+ removeEventListener(type, handler) {
+ if (this._listeners && this._listeners[type]) {
+ this._listeners[type] = this._listeners[type].filter(h => h !== handler);
+ }
+ }
+ dispatchEvent(event) {
+ if (!event || !event.type) return false;
+ const ev = (typeof event === 'object') ? event : { type: event };
+ ev.target = ev.target || this;
+ ev.currentTarget = ev.currentTarget || this;
+ const type = ev.type;
+ const handlers = (this._listeners && this._listeners[type]) || [];
+ for (const h of handlers) { try { h.call(this, ev); } catch (e) {} }
+ const prop = 'on' + type;
+ if (typeof this[prop] === 'function') {
+ try { this[prop](ev); } catch (e) {}
+ }
+ return true;
+ }
+}
+globalThis.XMLHttpRequestEventTarget = XMLHttpRequestEventTarget;
+_markNative(XMLHttpRequestEventTarget);
+_markNative(XMLHttpRequestEventTarget.prototype.addEventListener);
+_markNative(XMLHttpRequestEventTarget.prototype.removeEventListener);
+_markNative(XMLHttpRequestEventTarget.prototype.dispatchEvent);
+
+globalThis.XMLHttpRequest = class XMLHttpRequest extends XMLHttpRequestEventTarget {
static UNSENT = 0;
static OPENED = 1;
static HEADERS_RECEIVED = 2;
@@ -2593,6 +2645,7 @@ globalThis.XMLHttpRequest = class XMLHttpRequest {
UNSENT = 0; OPENED = 1; HEADERS_RECEIVED = 2; LOADING = 3; DONE = 4;
constructor() {
+ super();
this.readyState = 0;
this.status = 0;
this.statusText = "";
@@ -2755,6 +2808,23 @@ globalThis.XMLHttpRequest = class XMLHttpRequest {
}
}
+ // Per WHATWG DOM spec — required by zone.js which patches XHR via
+ // Object.getOwnPropertyDescriptor on XMLHttpRequestEventTarget.prototype.
+ dispatchEvent(event) {
+ if (!event || !event.type) return false;
+ const ev = (typeof event === 'object') ? event : { type: event };
+ ev.target = ev.target || this;
+ ev.currentTarget = ev.currentTarget || this;
+ const type = ev.type;
+ const handlers = (this._listeners && this._listeners[type]) || [];
+ for (const h of handlers) { try { h.call(this, ev); } catch (e) {} }
+ const prop = 'on' + type;
+ if (typeof this[prop] === 'function') {
+ try { this[prop](ev); } catch (e) {}
+ }
+ return true;
+ }
+
_setReadyState(state) {
this.readyState = state;
this._fireEvent('readystatechange');
@@ -2778,6 +2848,9 @@ _markNative(XMLHttpRequest.prototype.open);
_markNative(XMLHttpRequest.prototype.send);
_markNative(XMLHttpRequest.prototype.abort);
_markNative(XMLHttpRequest.prototype.setRequestHeader);
+_markNative(XMLHttpRequest.prototype.addEventListener);
+_markNative(XMLHttpRequest.prototype.removeEventListener);
+_markNative(XMLHttpRequest.prototype.dispatchEvent);
_markNative(XMLHttpRequest.prototype.getResponseHeader);
_markNative(XMLHttpRequest.prototype.getAllResponseHeaders);
diff --git a/crates/obscura/src/browser.rs b/crates/obscura/src/browser.rs
index d7193a1..2c30d95 100644
--- a/crates/obscura/src/browser.rs
+++ b/crates/obscura/src/browser.rs
@@ -22,15 +22,6 @@ impl Browser {
}
pub fn build(config: BrowserConfig) -> Result {
- let cookie_jar = Arc::new(CookieJar::new());
-
- if let Some(ref dir) = config.storage_dir {
- let cookie_path = dir.join("cookies.json");
- if cookie_path.exists() {
- let _ = cookie_jar.load_from_file(&cookie_path);
- }
- }
-
let context = if let Some(ref dir) = config.storage_dir {
BrowserContext::with_storage_full(
"api".to_string(),
@@ -48,10 +39,10 @@ impl Browser {
)
};
- Ok(Browser {
- context: Arc::new(context),
- cookie_jar,
- })
+ let context = Arc::new(context);
+ let cookie_jar = context.cookie_jar.clone();
+
+ Ok(Browser { context, cookie_jar })
}
pub fn builder() -> BrowserBuilder {
diff --git a/crates/obscura/src/page.rs b/crates/obscura/src/page.rs
index 0bdad3a..f8f1b33 100644
--- a/crates/obscura/src/page.rs
+++ b/crates/obscura/src/page.rs
@@ -84,6 +84,15 @@ impl Page {
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
+
+ /// Drive the page's JS event loop for up to `max_ms` milliseconds.
+ ///
+ /// Call this after `evaluate()` kicks off async work (Promises, fetch,
+ /// setTimeout, RxJS subscribers) to let the V8 event loop pump and
+ /// resolve scheduled microtasks/macrotasks before the next `evaluate()`.
+ pub async fn settle(&mut self, max_ms: u64) {
+ self.inner.settle(max_ms).await
+ }
}
/// Handle to a DOM element.