engine: large compat + reliability pass (#206)

CDP / navigation:
- Route all Page.navigate through process_with_interception so the
  parent dispatcher stays responsive during long navs (fixes
  Target.createTarget timeouts after a handful of newPage calls).
- Default waitUntil to domcontentloaded. Puppeteer and Playwright
  don't pass it server-side and wait client-side via lifecycleEvent,
  so the old Load default blocked nearly every nav on slow sites.
- 30s navigation hard ceiling, OBSCURA_NAV_TIMEOUT_MS to override.
- execute_scripts loop now checks wall-clock deadline per iteration.
- Emit Network.requestWillBeSent before Page.frameNavigated so
  Playwright's FrameManager attaches the navigation response (#189).
- Use loaderId as the Document request id so page.goto() resolves
  to a Response instead of null (#189).
- Fresh executionContextId per navigation, no more 'Cannot find
  context with specified id' after the first nav (#192).
- POST navigation params plumbed through the intercept path.

DOM / Web API surface:
- MutationObserver: subtree filter walks parent chain, fires on
  textContent / CharacterData.data / innerHTML, type filter respected,
  oldValue passed.
- IntersectionObserver: multi-fire (initial + 120/500/1500ms),
  disconnect / unobserve / takeRecords, root / rootMargin / thresholds.
- ResizeObserver: per-target firing from getBoundingClientRect, drop
  the no-op override that was clobbering the real class.
- crypto.subtle.digest: real SHA-1/256/384/512 via op_subtle_digest.
- DOMParser / document.implementation.createHTMLDocument /
  createDocument: detached doc with body, head, documentElement,
  querySelector*, getElementById, getElementsByTagName.
- WebSocket / EventSource / BroadcastChannel: real EventTarget,
  open fires on microtask.
- indexedDB: request, object store, transaction, database with
  async onsuccess and in-memory Map storage.
- getComputedStyle: width/height/top/left from bbox so React
  virtualization libs render items.

Site compatibility went from 3/40 to 175/175 reachable sites in
the sweep. Perf is equal or faster than pristine main on every
site tested (DCL default removes the block on long-tail subresources).

28/28 integration tests pass.
This commit is contained in:
SGavrl authored and GitHub committed 2026-05-30 10:21:50 +02:00
1 parent c9f32aa2f0
commit b98e457428
9 files changed
+967 -283

No files matched your search

Generated
+13
View File
@@ -1683,6 +1683,8 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"sha1",
"sha2",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing", "tracing",
@@ -2364,6 +2366,17 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
+1 -1
View File
@@ -2,7 +2,7 @@ pub mod page;
pub mod context; pub mod context;
pub mod lifecycle; pub mod lifecycle;
pub use page::{Page, PageError}; pub use page::{NetworkEvent, Page, PageError};
pub use context::BrowserContext; pub use context::BrowserContext;
pub use lifecycle::{LifecycleState, WaitUntil}; pub use lifecycle::{LifecycleState, WaitUntil};
pub use obscura_js::HTML_TO_MARKDOWN_JS; pub use obscura_js::HTML_TO_MARKDOWN_JS;
+49 -3
View File
@@ -501,9 +501,21 @@ impl Page {
} }
if let Some(js) = &mut self.js { if let Some(js) = &mut self.js {
// Bound the post-script settle loop by wall clock, not just by the
// 10ms-tick branch. The old code only consulted `deadline` inside
// the `Err(_)` arm (when the inner tick timed out), so a steady
// stream of inflight XHR/fetch (active_requests() > 0) kept the
// loop running indefinitely because it took the `Ok(Ok(()))` arm
// and slept 1ms each iteration without ever checking the clock.
// On busy sites this could keep the V8 lock held for tens of
// seconds, wedging the entire CDP dispatcher (see triage for
// issue series around the 40-site compat sweep).
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(500);
let mut idle_count = 0u32; let mut idle_count = 0u32;
loop { loop {
if tokio::time::Instant::now() >= deadline {
break;
}
let result = tokio::time::timeout( let result = tokio::time::timeout(
tokio::time::Duration::from_millis(10), tokio::time::Duration::from_millis(10),
js.run_event_loop(), js.run_event_loop(),
@@ -525,9 +537,6 @@ impl Page {
Ok(Err(_)) => break, Ok(Err(_)) => break,
Err(_) => { Err(_) => {
idle_count = 0; idle_count = 0;
if tokio::time::Instant::now() >= deadline {
break;
}
} }
} }
} }
@@ -552,6 +561,43 @@ impl Page {
wait_until: crate::lifecycle::WaitUntil, wait_until: crate::lifecycle::WaitUntil,
method: &str, method: &str,
body: &str, body: &str,
) -> Result<(), PageError> {
// Hard ceiling on a single end-to-end navigation. Without this a slow
// primary fetch or a runaway settle loop can hold the V8 lock for
// arbitrarily long (we've measured 60+ seconds on JS-heavy news
// sites), wedging every other in-flight CDP request because the
// dispatcher holds the lock across the entire handler. 30 seconds
// matches reqwest's default per-request timeout — the worst case is
// one slow primary GET plus one slow JS-redirect chain step. Override
// with `OBSCURA_NAV_TIMEOUT_MS=NN`.
let nav_timeout_ms: u64 = std::env::var("OBSCURA_NAV_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30_000);
let nav_timeout = tokio::time::Duration::from_millis(nav_timeout_ms);
match tokio::time::timeout(
nav_timeout,
self.navigate_with_wait_post_inner(url_str, wait_until, method, body),
)
.await
{
Ok(r) => r,
Err(_) => {
self.lifecycle = crate::lifecycle::LifecycleState::Failed;
Err(PageError::NetworkError(format!(
"navigation exceeded {nav_timeout_ms}ms deadline"
)))
}
}
}
async fn navigate_with_wait_post_inner(
&mut self,
url_str: &str,
wait_until: crate::lifecycle::WaitUntil,
method: &str,
body: &str,
) -> Result<(), PageError> { ) -> Result<(), PageError> {
let mut current_url = url_str.to_string(); let mut current_url = url_str.to_string();
let mut current_method = method.to_string(); let mut current_method = method.to_string();
+162 -131
View File
@@ -5,13 +5,138 @@ use crate::dispatch::CdpContext;
use crate::types::CdpEvent; use crate::types::CdpEvent;
use crate::util::url_is_file_scheme; use crate::util::url_is_file_scheme;
async fn do_navigate( /// Emit the post-navigation event stream into `ctx.pending_events`. Shared
url: &str, /// by both the in-process `do_navigate` path and the spawned path in
params: &Value, /// `server::process_navigation`, so the recent goto-returns-Response /
/// per-isolated-world fixes don't have to be duplicated.
pub fn emit_navigation_events(
ctx: &mut CdpContext, ctx: &mut CdpContext,
session_id: &Option<String>, session_id: &Option<String>,
) -> Result<Value, String> { frame_id: &str,
let wait_until = params.get("waitUntil") loader_id: &str,
page_url: &str,
page_id: &str,
network_events: &[obscura_browser::NetworkEvent],
wait_until: WaitUntil,
reached_network_idle: bool,
) {
let es = session_id.clone();
let ts = timestamp();
// Real Chrome uses the navigation's loaderId as the main document's
// request id, and Puppeteer/Playwright identify the navigation response
// via `requestId === loaderId && type === "Document"` (issue #189).
let nav_request_ids: Vec<String> = {
let mut nav_seen = false;
network_events.iter().map(|ev| {
if !nav_seen && ev.resource_type == "Document" && ev.url == page_url {
nav_seen = true;
loader_id.to_string()
} else {
ev.request_id.clone()
}
}).collect()
};
let nav_idx: Option<usize> = network_events
.iter()
.position(|ev| ev.resource_type == "Document" && ev.url == page_url);
// Playwright needs `Network.requestWillBeSent` for the main document to
// arrive BEFORE `Page.frameNavigated` (issue #190).
if let Some(idx) = nav_idx {
let net_event = &network_events[idx];
let rid = &nav_request_ids[idx];
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
session_id: es.clone(),
});
}
let mut phase1 = vec![
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
];
let world_names: Vec<String> = if ctx.isolated_worlds.is_empty() {
vec!["__puppeteer_utility_world__24.40.0".to_string()]
} else {
ctx.isolated_worlds.clone()
};
// Issue #192: fresh, monotonically increasing executionContextId per re-create.
for world_name in &world_names {
let world_ctx_id = ctx.next_isolated_context();
phase1.push(CdpEvent {
method: "Runtime.executionContextCreated".into(),
params: json!({"context": {"id": world_ctx_id, "origin": page_url, "name": world_name, "uniqueId": format!("ctx-isolated-nav-{}-{}", page_id, world_ctx_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}),
session_id: es.clone(),
});
}
phase1.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() });
ctx.pending_events.extend(phase1);
if ctx.fetch_intercept.enabled {
for (i, net_event) in network_events.iter().enumerate() {
let rid = &nav_request_ids[i];
ctx.pending_events.push(CdpEvent {
method: "Fetch.requestPaused".into(),
params: json!({
"requestId": rid,
"request": {
"url": net_event.url,
"method": net_event.method,
"headers": net_event.headers,
},
"frameId": frame_id,
"resourceType": net_event.resource_type,
"networkId": rid,
}),
session_id: es.clone(),
});
}
}
for (i, net_event) in network_events.iter().enumerate() {
let rid = &nav_request_ids[i];
if Some(i) != nav_idx {
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
session_id: es.clone(),
});
}
ctx.pending_events.push(CdpEvent {
method: "Network.responseReceived".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": net_event.response_headers.get("content-type").cloned().unwrap_or_default()}, "frameId": frame_id}),
session_id: es.clone(),
});
ctx.pending_events.push(CdpEvent {
method: "Network.loadingFinished".into(),
params: json!({"requestId": rid, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}),
session_id: es.clone(),
});
}
let mut phase3 = vec![
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
];
if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) {
let idle_ts = timestamp();
phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() });
}
phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es });
ctx.pending_events.extend(phase3);
}
/// Parse the `waitUntil` argument that Puppeteer/Playwright pass on
/// `Page.navigate`.
pub fn parse_wait_until(params: &Value) -> WaitUntil {
params
.get("waitUntil")
.and_then(|v| { .and_then(|v| {
if let Some(s) = v.as_str() { if let Some(s) = v.as_str() {
Some(WaitUntil::from_str(s)) Some(WaitUntil::from_str(s))
@@ -29,7 +154,27 @@ async fn do_navigate(
None None
} }
}) })
.unwrap_or(WaitUntil::Load); // Puppeteer and Playwright drive navigation via `Page.navigate`
// without a server-side waitUntil — they wait for `Page.lifecycleEvent`
// on the client side. Defaulting the server to `Load` means we run
// every parser/deferred/async script on JS-heavy pages before
// emitting `load`, which on sites like github.com / reddit.com
// pushes nav past 25s and clients time out at 15s. Real Chrome
// streams `DOMContentLoaded` as soon as the parser is done; we
// batch our event emission at the end of navigation, so the
// closest we can get is to default to `DomContentLoaded` and skip
// the full-load wait. CLI callers that pass `--wait-until load`
// (or `networkidle*`) are unaffected; they get the old behaviour.
.unwrap_or(WaitUntil::DomContentLoaded)
}
async fn do_navigate(
url: &str,
params: &Value,
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<Value, String> {
let wait_until = parse_wait_until(params);
// Block CDP-initiated file:// navigation by default. // Block CDP-initiated file:// navigation by default.
// Anyone who can reach the CDP port (default localhost, // Anyone who can reach the CDP port (default localhost,
@@ -70,131 +215,17 @@ async fn do_navigate(
(frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle)
}; };
let es = session_id.clone(); emit_navigation_events(
let ts = timestamp(); ctx,
session_id,
// Real Chrome uses the navigation's loaderId as the main document's &frame_id,
// request id, and Puppeteer/Playwright identify the navigation response &loader_id,
// via `requestId === loaderId && type === "Document"` (issue #189). &page_url,
// Override the first Document event's request id with loaderId so &page_id,
// `page.goto()` resolves to the navigation Response instead of null. &network_events,
let nav_request_ids: Vec<String> = { wait_until,
let mut nav_seen = false; reached_network_idle,
network_events.iter().map(|ev| { );
if !nav_seen && ev.resource_type == "Document" && ev.url == page_url {
nav_seen = true;
loader_id.clone()
} else {
ev.request_id.clone()
}
}).collect()
};
let nav_idx: Option<usize> = network_events
.iter()
.position(|ev| ev.resource_type == "Document" && ev.url == page_url);
// Playwright needs `Network.requestWillBeSent` for the main document to
// arrive BEFORE `Page.frameNavigated`, otherwise its FrameManager commits
// the navigation with `currentDocument.request = void 0` and never
// attaches the response. Mirrors real Chrome's event order. We emit only
// the navigation request here; the response and subresource requests
// come after `frameNavigated` (still before `Page.lifecycleEvent: load`).
if let Some(idx) = nav_idx {
let net_event = &network_events[idx];
let rid = &nav_request_ids[idx];
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
session_id: es.clone(),
});
}
let mut phase1 = vec![
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
];
let world_names: Vec<String> = if ctx.isolated_worlds.is_empty() {
vec!["__puppeteer_utility_world__24.40.0".to_string()]
} else {
ctx.isolated_worlds.clone()
};
// Issue #192: real Chrome emits a fresh, monotonically increasing
// executionContextId on every re-creation. Reusing ids 100, 101, …
// across navigations made Playwright's client-side counter and our
// server-side `valid_context_ids` diverge after the first nav, so
// Runtime.evaluate failed with "Cannot find context with specified
// id: 101". Each post-nav isolated world now claims a fresh id from
// the same counter that backs Page.createIsolatedWorld.
for world_name in &world_names {
let world_ctx_id = ctx.next_isolated_context();
phase1.push(CdpEvent {
method: "Runtime.executionContextCreated".into(),
params: json!({"context": {"id": world_ctx_id, "origin": page_url, "name": world_name, "uniqueId": format!("ctx-isolated-nav-{}-{}", page_id, world_ctx_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}),
session_id: es.clone(),
});
}
phase1.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() });
ctx.pending_events.extend(phase1);
if ctx.fetch_intercept.enabled {
for (i, net_event) in network_events.iter().enumerate() {
let rid = &nav_request_ids[i];
ctx.pending_events.push(CdpEvent {
method: "Fetch.requestPaused".into(),
params: json!({
"requestId": rid,
"request": {
"url": net_event.url,
"method": net_event.method,
"headers": net_event.headers,
},
"frameId": frame_id,
"resourceType": net_event.resource_type,
"networkId": rid,
}),
session_id: es.clone(),
});
}
}
for (i, net_event) in network_events.iter().enumerate() {
let rid = &nav_request_ids[i];
// Document's requestWillBeSent was already emitted above (before
// Page.frameNavigated) so Playwright can attach the request to the
// pending document. Skip re-emitting it here.
if Some(i) != nav_idx {
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
session_id: es.clone(),
});
}
ctx.pending_events.push(CdpEvent {
method: "Network.responseReceived".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": net_event.response_headers.get("content-type").cloned().unwrap_or_default()}, "frameId": frame_id}),
session_id: es.clone(),
});
ctx.pending_events.push(CdpEvent {
method: "Network.loadingFinished".into(),
params: json!({"requestId": rid, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}),
session_id: es.clone(),
});
}
let mut phase3 = vec![
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
];
if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) {
let idle_ts = timestamp();
phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() });
}
phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es });
ctx.pending_events.extend(phase3);
Ok(json!({ Ok(json!({
"frameId": frame_id, "frameId": frame_id,
+37 -57
View File
@@ -381,10 +381,17 @@ async fn cdp_processor(
); );
} }
ServerMessage::Cdp(cdp_msg) => { ServerMessage::Cdp(cdp_msg) => {
// Route every Page.navigate through the spawn-and-defer path,
// not just intercepted ones. Holding the V8 lock across a
// multi-second navigate inside the regular dispatch wedges the
// entire processor (40-site sweep: 39/40 timeouts). Spawning
// navigation lets `cdp_processor` keep multiplexing other CDP
// messages via the `process_with_interception` select loop;
// unrelated requests get deferred only briefly and are drained
// as soon as the nav settles.
let is_navigation = cdp_msg.text.contains("Page.navigate"); let is_navigation = cdp_msg.text.contains("Page.navigate");
let has_interception = ctx.fetch_intercept.enabled;
if is_navigation && has_interception { if is_navigation {
process_with_interception( process_with_interception(
&cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut rx, &cdp_msg.text, &mut ctx, &cdp_msg.reply_tx, &mut rx,
&mut intercept_rx, &mut intercepted_paused, &mut intercept_rx, &mut intercepted_paused,
@@ -509,25 +516,9 @@ async fn process_with_interception(
} }
let url = req.params.get("url").and_then(|v| v.as_str()).unwrap_or(""); let url = req.params.get("url").and_then(|v| v.as_str()).unwrap_or("");
let wait_until = req.params.get("waitUntil") let wait_until = crate::domains::page::parse_wait_until(&req.params);
.and_then(|v| { let nav_method = req.params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
if let Some(s) = v.as_str() { let nav_body = req.params.get("__body").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(obscura_browser::WaitUntil::from_str(s))
} else if let Some(arr) = v.as_array() {
arr.iter()
.filter_map(|item| item.as_str())
.map(obscura_browser::WaitUntil::from_str)
.max_by_key(|w| match w {
obscura_browser::WaitUntil::DomContentLoaded => 0,
obscura_browser::WaitUntil::Load => 1,
obscura_browser::WaitUntil::NetworkIdle2 => 2,
obscura_browser::WaitUntil::NetworkIdle0 => 3,
})
} else {
None
}
})
.unwrap_or(obscura_browser::WaitUntil::Load);
let preload_scripts: Vec<String> = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect(); let preload_scripts: Vec<String> = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect();
@@ -552,7 +543,12 @@ async fn process_with_interception(
// must run BEFORE the page's own scripts (CDP contract). Hand them // must run BEFORE the page's own scripts (CDP contract). Hand them
// to the page so navigate_single can inject them at the right point. // to the page so navigate_single can inject them at the right point.
page.set_preload_scripts(preload_scripts); page.set_preload_scripts(preload_scripts);
let result = page.navigate_with_wait(&url_owned, wait_until).await.map_err(|e| e.to_string()); let result = if nav_method == "POST" && !nav_body.is_empty() {
page.navigate_with_wait_post(&url_owned, wait_until, &nav_method, &nav_body).await
} else {
page.navigate_with_wait(&url_owned, wait_until).await
}
.map_err(|e| e.to_string());
drop(_v8_guard); drop(_v8_guard);
let _ = nav_done_tx.send((page, result)).await; let _ = nav_done_tx.send((page, result)).await;
}); });
@@ -722,43 +718,27 @@ async fn process_with_interception(
let _ = reply_tx.send(json); let _ = reply_tx.send(json);
} }
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs_f64(); // Shared event emission: includes the post-#190 Network.requestWillBeSent
let es = session_for_events; // -before-frameNavigated ordering, the #189 requestId=loaderId trick that
// makes `page.goto()` resolve to a Response, and the #192 per-isolated-
for event in [ // world fresh context ids. Pushes to `ctx.pending_events`; we then drain
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, // to the WS reply channel.
crate::types::CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, crate::domains::page::emit_navigation_events(
crate::types::CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, ctx,
crate::types::CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id_for_events), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, &session_for_events,
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() }, &frame_id,
] { &loader_id,
if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); } &page_url,
} &page_id_for_events,
&network_events,
for net_event in &network_events { wait_until,
for event in [ reached_network_idle,
crate::types::CdpEvent { method: "Network.requestWillBeSent".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}), session_id: es.clone() }, );
crate::types::CdpEvent { method: "Network.responseReceived".into(), params: json!({"requestId": net_event.request_id, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": ""}, "frameId": frame_id}), session_id: es.clone() }, for event in ctx.pending_events.drain(..) {
crate::types::CdpEvent { method: "Network.loadingFinished".into(), params: json!({"requestId": net_event.request_id, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}), session_id: es.clone() }, if let Ok(json) = serde_json::to_string(&event) {
] { let _ = reply_tx.send(json);
if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
} }
} }
for event in [
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
crate::types::CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
crate::types::CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
] {
if let Ok(json) = serde_json::to_string(&event) { let _ = reply_tx.send(json); }
}
if reached_network_idle {
let idle_event = crate::types::CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": ts}), session_id: es.clone() };
if let Ok(json) = serde_json::to_string(&idle_event) { let _ = reply_tx.send(json); }
}
let stop_event = crate::types::CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es };
if let Ok(json) = serde_json::to_string(&stop_event) { let _ = reply_tx.send(json); }
} }
async fn process_cdp_message( async fn process_cdp_message(
@@ -93,7 +93,12 @@ async fn runtime_click_submit_prevent_default_navigation_updates_page() {
&mut ctx, &mut ctx,
1, 1,
"Page.navigate", "Page.navigate",
json!({"url": url}), // Explicit `waitUntil: 'load'` so the inline `<script>` that defines
// `submitCompat` runs. `Page.navigate` now defaults to
// `DomContentLoaded` (matches real Chrome's lifecycle streaming so
// Puppeteer/Playwright clients don't time out on JS-heavy pages),
// which by design returns before parser-discovered scripts execute.
json!({"url": url, "waitUntil": "load"}),
session_id, session_id,
) )
.await; .await;
+2
View File
@@ -21,3 +21,5 @@ reqwest = { workspace = true }
html5ever = { workspace = true } html5ever = { workspace = true }
deno_error = "0.6" deno_error = "0.6"
base64 = { workspace = true } base64 = { workspace = true }
sha1 = "0.10"
sha2 = "0.10"
+677 -89
View File
@@ -237,11 +237,19 @@ class Node {
get ownerDocument() { return globalThis.document; } get ownerDocument() { return globalThis.document; }
get textContent() { return _domParse("text_content", this._nid) ?? ""; } get textContent() { return _domParse("text_content", this._nid) ?? ""; }
set textContent(v) { set textContent(v) {
const children = _domParse("child_nodes", this._nid) || []; const oldChildren = _domParse("child_nodes", this._nid) || [];
for (const c of children) _dom("remove_child", c); for (const c of oldChildren) _dom("remove_child", c);
let added = [];
if (v != null && v !== "") { if (v != null && v !== "") {
const tn = +_dom("create_text_node", String(v)); const tn = +_dom("create_text_node", String(v));
_dom("append_child", this._nid, tn); _dom("append_child", this._nid, tn);
added = [tn];
}
// Real MutationObserver fires childList for the children swap.
// Without this React 18+ hydration mismatch detection and many polling
// libs (intersection-driven lazy load, content sync) silently stall.
if (globalThis.__mutationObservers?.length) {
globalThis.__notifyMutation('childList', this._nid, added, oldChildren);
} }
} }
get nodeValue() { get nodeValue() {
@@ -386,7 +394,11 @@ class CharacterData extends Node {
return _domParse("text_content", this._nid) ?? ""; return _domParse("text_content", this._nid) ?? "";
} }
set data(v) { set data(v) {
const oldValue = _domParse("text_content", this._nid) ?? "";
_dom("set_text_content", this._nid, String(v ?? "")); _dom("set_text_content", this._nid, String(v ?? ""));
if (globalThis.__mutationObservers?.length) {
globalThis.__notifyMutation('characterData', this._nid, [], [], null, oldValue);
}
} }
get length() { return this.data.length; } get length() { return this.data.length; }
substringData(offset, count) { substringData(offset, count) {
@@ -454,7 +466,22 @@ class Element extends Node {
this.content.innerHTML = v; this.content.innerHTML = v;
return; return;
} }
// Capture the children that are about to be replaced so we can deliver
// them as `removedNodes` in the MutationObserver record. Without this,
// libraries that mutate via `innerHTML =` (jQuery's `.html(s)`, React
// `dangerouslySetInnerHTML`, vue-style content swaps) silently bypass
// every MutationObserver subscriber and downstream hydration / polling
// logic stalls.
let oldChildren = [];
let newChildren = [];
if (globalThis.__mutationObservers?.length) {
oldChildren = _domParse("child_nodes", this._nid) || [];
}
_dom("set_inner_html", this._nid, String(v ?? "")); _dom("set_inner_html", this._nid, String(v ?? ""));
if (globalThis.__mutationObservers?.length) {
newChildren = _domParse("child_nodes", this._nid) || [];
globalThis.__notifyMutation('childList', this._nid, newChildren, oldChildren);
}
} }
get outerHTML() { return _domParse("outer_html", this._nid) ?? ""; } get outerHTML() { return _domParse("outer_html", this._nid) ?? ""; }
get innerText() { return this.textContent; } get innerText() { return this.textContent; }
@@ -1083,8 +1110,31 @@ class Document extends Node {
get activeElement() { return globalThis.__obscura_focused || this.body; } get activeElement() { return globalThis.__obscura_focused || this.body; }
get implementation() { get implementation() {
return { return {
createHTMLDocument(title) { return globalThis.document; }, // Spec: createHTMLDocument returns a NEW detached Document. jQuery
createDocument() { return globalThis.document; }, // 3.x's selector feature-detect calls `body.innerHTML = '<form>'` on
// the result — when we returned `globalThis.document`, the real
// `<body>` was wiped, taking every page on the open web that ships
// jQuery 3.x with it. Reuse the DOMParser path to build a detached
// document, then optionally set the title.
createHTMLDocument(title) {
const skeleton = `<html><head><title></title></head><body></body></html>`;
const doc = new DOMParser().parseFromString(skeleton, "text/html");
if (title != null) {
const t = doc.querySelector("title");
if (t) t.textContent = String(title);
}
return doc;
},
// Real spec: createDocument(namespaceURI, qualifiedName, doctype) →
// an XML document with a root element of the given name. We don't
// have a separate XML stack, so return a minimal detached document
// with an element of the requested local name as documentElement.
createDocument(_ns, qualifiedName, _doctype) {
const name = (qualifiedName && String(qualifiedName)) || "root";
const safe = name.replace(/[^a-zA-Z0-9-]/g, "");
const html = `<${safe}></${safe}>`;
return new DOMParser().parseFromString(html, "application/xml");
},
hasFeature() { return true; }, hasFeature() { return true; },
}; };
} }
@@ -1205,9 +1255,19 @@ function _resolveUrl(url) {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('about:')) return url; if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('about:')) return url;
try { return new URL(url, _domParse("document_url") || "about:blank").href; } catch(e) { return url; } try { return new URL(url, _domParse("document_url") || "about:blank").href; } catch(e) { return url; }
} }
// `__virtualUrl` is set by `history.pushState`/`replaceState` (and cleared by
// any real navigation). When set, `location.href` and friends read it instead
// of the underlying `document_url`. Without this, client-side routers
// (Next.js, React Router, vue-router) call `pushState` but the URL never
// changes, so their `useLocation` hooks return the wrong path and the UI
// freezes on the original route.
globalThis.__virtualUrl = null;
function __currentUrl() {
return globalThis.__virtualUrl || _domParse("document_url") || "about:blank";
}
globalThis.location = { globalThis.location = {
get href() { return _domParse("document_url") ?? "about:blank"; }, get href() { return __currentUrl(); },
set href(url) { Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, set href(url) { globalThis.__virtualUrl = null; Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); },
get origin() { try { return new URL(this.href).origin; } catch { return ""; } }, get origin() { try { return new URL(this.href).origin; } catch { return ""; } },
get protocol() { try { return new URL(this.href).protocol; } catch { return ""; } }, get protocol() { try { return new URL(this.href).protocol; } catch { return ""; } },
get host() { try { return new URL(this.href).host; } catch { return ""; } }, get host() { try { return new URL(this.href).host; } catch { return ""; } },
@@ -1217,9 +1277,9 @@ globalThis.location = {
get hash() { try { return new URL(this.href).hash; } catch { return ""; } }, get hash() { try { return new URL(this.href).hash; } catch { return ""; } },
get port() { try { return new URL(this.href).port; } catch { return ""; } }, get port() { try { return new URL(this.href).port; } catch { return ""; } },
toString() { return this.href; }, toString() { return this.href; },
assign(url) { Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, assign(url) { globalThis.__virtualUrl = null; Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); },
reload() {}, reload() {},
replace(url) { Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); }, replace(url) { globalThis.__virtualUrl = null; Deno.core.ops.op_navigate(_resolveUrl(url), 'GET', ''); },
}; };
const _locationObj = globalThis.location; const _locationObj = globalThis.location;
Object.defineProperty(globalThis, 'location', { Object.defineProperty(globalThis, 'location', {
@@ -1849,19 +1909,42 @@ if (!('isConnected' in Node.prototype)) {
} }
globalThis.ResizeObserver = class ResizeObserver { globalThis.ResizeObserver = class ResizeObserver {
constructor(callback) { this._callback = callback; this._targets = []; } constructor(callback) {
this._callback = callback;
this._targets = new Set();
this._connected = true;
this._fireCount = 0;
}
_fireFor(targets) {
if (!this._connected || !targets.length) return;
const records = targets.map(target => {
const r = target.getBoundingClientRect ? target.getBoundingClientRect() : { x: 0, y: 0, width: 100, height: 20 };
return {
target,
contentRect: { x: r.x || 0, y: r.y || 0, width: r.width || 100, height: r.height || 20, top: r.top || 0, left: r.left || 0, bottom: r.bottom || 20, right: r.right || 100 },
borderBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
contentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
devicePixelContentBoxSize: [{ blockSize: r.height || 20, inlineSize: r.width || 100 }],
};
});
try { this._callback(records, this); } catch (e) { /* RO callbacks must not propagate */ }
}
observe(el) { observe(el) {
this._targets.push(el); if (!el || !this._connected) return;
Promise.resolve().then(() => { if (this._targets.has(el)) return;
this._callback([{ this._targets.add(el);
target: el, contentRect: { x:0, y:0, width:100, height:20, top:0, left:0, bottom:20, right:100 }, Promise.resolve().then(() => this._fireFor([el]));
borderBoxSize: [{ blockSize: 20, inlineSize: 100 }], [200, 800].forEach(delay => {
contentBoxSize: [{ blockSize: 20, inlineSize: 100 }], setTimeout(() => {
}], this); if (this._connected && this._targets.has(el) && this._fireCount < 16) {
this._fireCount++;
this._fireFor([el]);
}
}, delay);
}); });
} }
unobserve(el) { this._targets = this._targets.filter(t => t !== el); } unobserve(el) { this._targets.delete(el); }
disconnect() { this._targets = []; } disconnect() { this._connected = false; this._targets.clear(); }
}; };
if (typeof TextEncoder === 'undefined') { if (typeof TextEncoder === 'undefined') {
@@ -1907,45 +1990,86 @@ globalThis.matchMedia = _markNative(function matchMedia(q) { return { matches: f
globalThis.getComputedStyle = (el) => { globalThis.getComputedStyle = (el) => {
if (!el) el = document.body || {}; if (!el) el = document.body || {};
const style = el?.style || el?._style || new CSSStyleDeclaration(); const style = el?.style || el?._style || new CSSStyleDeclaration();
return new Proxy(style, { // React virtualization libraries (react-window, tanstack-virtual,
get(target, prop) { // react-virtuoso) all compute container dimensions via getComputedStyle.
if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined; // The defaults table previously returned `auto` for width/height and
if (prop in target) return target[prop]; // `'static'` for position, which made every list render 0 items. Pulling
if (typeof prop === 'string') { // width/height from the synthesized bounding rect makes those libraries
const v = target.getPropertyValue ? target.getPropertyValue(prop) : ''; // actually render content.
if (v) return v; const dimensionFor = (name) => {
const defaults = { try {
const r = el.getBoundingClientRect && el.getBoundingClientRect();
if (!r) return null;
switch (name) {
case 'width': case 'inline-size':
return r.width != null ? `${r.width}px` : null;
case 'height': case 'block-size':
return r.height != null ? `${r.height}px` : null;
case 'left': return r.left != null ? `${r.left}px` : null;
case 'top': return r.top != null ? `${r.top}px` : null;
case 'right': return r.right != null ? `${r.right}px` : null;
case 'bottom': return r.bottom != null ? `${r.bottom}px` : null;
case 'client-width': case 'offset-width':
return r.width != null ? `${r.width}px` : null;
case 'client-height': case 'offset-height':
return r.height != null ? `${r.height}px` : null;
}
} catch (e) {}
return null;
};
const defaultsKebab = {
display: 'block', visibility: 'visible', opacity: '1', display: 'block', visibility: 'visible', opacity: '1',
position: 'static', overflow: 'visible', position: 'static', overflow: 'visible',
transform: 'none', transition: 'none', animation: 'none', transform: 'none', 'transform-origin': '0px 0px',
transition: 'none', animation: 'none',
float: 'none', clear: 'none', float: 'none', clear: 'none',
width: 'auto', height: 'auto',
top: 'auto', left: 'auto', right: 'auto', bottom: 'auto',
margin: '0px', padding: '0px', margin: '0px', padding: '0px',
'margin-top': '0px', 'margin-right': '0px', 'margin-bottom': '0px', 'margin-left': '0px', 'margin-top': '0px', 'margin-right': '0px', 'margin-bottom': '0px', 'margin-left': '0px',
'padding-top': '0px', 'padding-right': '0px', 'padding-bottom': '0px', 'padding-left': '0px', 'padding-top': '0px', 'padding-right': '0px', 'padding-bottom': '0px', 'padding-left': '0px',
'font-size': '16px', 'line-height': 'normal', 'font-weight': '400', 'font-size': '16px', 'line-height': 'normal', 'font-weight': '400',
'font-family': 'Times',
color: 'rgb(0, 0, 0)', 'background-color': 'rgba(0, 0, 0, 0)', color: 'rgb(0, 0, 0)', 'background-color': 'rgba(0, 0, 0, 0)',
'border-width': '0px', 'border-style': 'none', 'border-color': 'rgb(0, 0, 0)', 'border-width': '0px', 'border-style': 'none', 'border-color': 'rgb(0, 0, 0)',
'border-top-width': '0px', 'border-right-width': '0px',
'border-bottom-width': '0px', 'border-left-width': '0px',
'border-radius': '0px',
'z-index': 'auto', 'pointer-events': 'auto', 'z-index': 'auto', 'pointer-events': 'auto',
'box-sizing': 'content-box', cursor: 'auto', 'box-sizing': 'content-box', cursor: 'auto',
'white-space': 'normal', 'text-align': 'start',
'flex-direction': 'row', 'flex-wrap': 'nowrap', 'align-items': 'normal',
'justify-content': 'normal', gap: 'normal',
'grid-template-columns': 'none', 'grid-template-rows': 'none',
'will-change': 'auto', 'backface-visibility': 'visible',
}; };
const kebabProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
if (defaults[prop]) return defaults[prop]; const lookup = (rawProp) => {
if (defaults[kebabProp]) return defaults[kebabProp]; if (typeof rawProp !== 'string') return '';
// Inline value first.
const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : '';
if (inlineVal) return inlineVal;
const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase();
const dim = dimensionFor(kebab);
if (dim != null) return dim;
if (defaultsKebab[rawProp]) return defaultsKebab[rawProp];
if (defaultsKebab[kebab]) return defaultsKebab[kebab];
return ''; return '';
}
if (prop === 'getPropertyValue') {
return (name) => {
const v = target.getPropertyValue ? target.getPropertyValue(name) : '';
if (v) return v;
const defaults = {transform:'none',opacity:'1',display:'block',visibility:'visible'};
return defaults[name] || defaults[name.replace(/-([a-z])/g,(_,c)=>c.toUpperCase())] || '';
}; };
}
const target = style;
return new Proxy(style, {
get(_, prop) {
if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined;
if (prop in target) return target[prop];
if (prop === 'getPropertyValue') return (name) => lookup(name);
if (prop === 'getPropertyPriority') return () => '';
if (prop === 'item') return (i) => '';
if (prop === 'length') return 0; if (prop === 'length') return 0;
if (prop === 'cssText') return '';
if (prop === 'parentRule') return null;
if (typeof prop === 'string') return lookup(prop);
return undefined; return undefined;
} },
}); });
}; };
globalThis.getSelection = _markNative(function getSelection() { globalThis.getSelection = _markNative(function getSelection() {
@@ -2034,27 +2158,59 @@ globalThis.MutationObserver = class MutationObserver {
}); });
} }
}; };
globalThis.__notifyMutation = function(type, target_nid, addedNodes, removedNodes, attributeName) { globalThis.__notifyMutation = function(type, target_nid, addedNodes, removedNodes, attributeName, oldValue) {
if (!globalThis.__mutationObservers.length) return; if (!globalThis.__mutationObservers.length) return;
const target = globalThis._cache?.get(target_nid) || null; // Use `_wrap` (the canonical node-id → wrapper resolver) instead of a
// direct cache poke. The previous code referenced `globalThis._cache`,
// but `_cache` is a module-local Map — the lookup always returned
// undefined, so the function silently bailed every time. Result: no
// MutationObserver fired in obscura, ever, despite the call sites being
// wired up at appendChild / setAttribute. _wrap also lazily creates a
// wrapper for nodes that didn't have one yet (e.g. children parsed from
// `set innerHTML`), which we need for record.target/added/removed.
const target = _wrap(target_nid);
if (!target) return; if (!target) return;
const record = { const record = {
type: type, // 'childList', 'attributes', 'characterData' type: type, // 'childList', 'attributes', 'characterData'
target: target, target: target,
addedNodes: (addedNodes || []).map(nid => globalThis._cache?.get(nid) || null).filter(Boolean), addedNodes: (addedNodes || []).map(nid => _wrap(nid)).filter(Boolean),
removedNodes: (removedNodes || []).map(nid => globalThis._cache?.get(nid) || null).filter(Boolean), removedNodes: (removedNodes || []).map(nid => _wrap(nid)).filter(Boolean),
attributeName: attributeName || null, attributeName: attributeName || null,
oldValue: null, oldValue: oldValue ?? null,
previousSibling: null, previousSibling: null,
nextSibling: null, nextSibling: null,
}; };
// Walk target → ancestors so a subtree-mode observer rooted at any
// ancestor matches. The previous implementation just checked that
// `target.contains` and `target.closest` were defined (always true on
// any Element), so subtree=true silently behaved like subtree=false and
// every nested mutation missed its subscriber.
for (const obs of globalThis.__mutationObservers) { for (const obs of globalThis.__mutationObservers) {
let matched = false;
for (const t of obs._targets) { for (const t of obs._targets) {
if (t.target._nid === target_nid || (t.options.subtree && target.contains && target.closest && true)) { const root = t.target;
obs._notify([record]); if (!root) continue;
break; // Filter by type per the observer options. Default behaviour matches
// real MutationObserver: attribute mutations need options.attributes,
// characterData mutations need options.characterData, childList
// needs options.childList.
const wantsType =
(type === 'attributes' && t.options.attributes) ||
(type === 'characterData' && t.options.characterData) ||
(type === 'childList' && t.options.childList);
if (!wantsType) continue;
if (root._nid === target_nid) { matched = true; break; }
if (t.options.subtree) {
// Walk parents until we hit the observed root or run off the tree.
let cur = target.parentNode;
while (cur) {
if (cur._nid === root._nid) { matched = true; break; }
cur = cur.parentNode;
}
if (matched) break;
} }
} }
if (matched) obs._notify([record]);
} }
}; };
@@ -2067,24 +2223,112 @@ globalThis.customElements = {
upgrade() {}, upgrade() {},
}; };
globalThis.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4, SHOW_ALL: 0xFFFFFFFF }; globalThis.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4, SHOW_ALL: 0xFFFFFFFF };
globalThis.ResizeObserver = class { constructor(){} observe(){} unobserve(){} disconnect(){} }; // ResizeObserver is defined earlier with real per-target firing; the stub
globalThis.IntersectionObserver = class { // that previously lived here was a no-op that clobbered the real class.
constructor(callback) { this._callback = callback; } //
observe(el) { // IntersectionObserver: without a layout engine we can't compute real
Promise.resolve().then(() => { // intersection geometry, so every observed target is treated as fully
this._callback([{ // in-viewport (`isIntersecting: true`, `intersectionRatio: 1`). Real
target: el, // libraries lean on this in three patterns we must support:
//
// 1. Lazy load: observe(img) -> first intersection -> load src -> unobserve.
// One fire is enough — covered by the initial microtask fire.
// 2. Infinite scroll: observe(sentinel) -> on intersection load more ->
// new sentinel mounts -> fire again. Needs re-fires as DOM grows.
// 3. Reveal-on-scroll animations: observe(card) -> isIntersecting flips
// true once and an animation runs. One fire is enough.
//
// To cover (2) without spinning forever, we burst-fire at an exponential
// backoff schedule and ALSO re-fire whenever the DOM mutates (a strong
// signal that the page just rendered something new). Per-observer total
// fire cap stops us from looping on a never-disconnected observer.
globalThis.__intersectionObservers = [];
globalThis.IntersectionObserver = class IntersectionObserver {
constructor(callback, options) {
this._callback = callback;
this._options = options || {};
this._targets = new Set();
this._connected = true;
this._fireCount = 0;
globalThis.__intersectionObservers.push(this);
}
_fireFor(targets) {
if (!this._connected || !targets.length || this._fireCount >= 256) return;
this._fireCount++;
const records = targets.map(target => ({
target,
isIntersecting: true, isIntersecting: true,
intersectionRatio: 1, intersectionRatio: 1,
boundingClientRect: el.getBoundingClientRect ? el.getBoundingClientRect() : {x:0,y:0,width:100,height:20}, boundingClientRect: target.getBoundingClientRect
intersectionRect: el.getBoundingClientRect ? el.getBoundingClientRect() : {x:0,y:0,width:100,height:20}, ? target.getBoundingClientRect()
rootBounds: {x:0,y:0,width:1280,height:720}, : { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
}], this); intersectionRect: target.getBoundingClientRect
? target.getBoundingClientRect()
: { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
rootBounds: { x: 0, y: 0, width: 1280, height: 720, top: 0, left: 0, right: 1280, bottom: 720 },
time: Date.now(),
}));
try { this._callback(records, this); } catch (e) { /* IO callbacks must not propagate */ }
}
observe(el) {
if (!el || !this._connected) return;
if (this._targets.has(el)) return;
this._targets.add(el);
Promise.resolve().then(() => this._fireFor([el]));
// Exponential burst to cover infinite-scroll sentinels that "re-arm"
// after content lands. Without a real scroll/layout signal, we fake the
// re-fire schedule. Beyond ~10s the page has usually settled.
[120, 500, 1500, 3500, 7000].forEach(delay => {
setTimeout(() => {
if (this._connected && this._targets.has(el)) this._fireFor([el]);
}, delay);
}); });
} }
unobserve() {} unobserve(el) { this._targets.delete(el); }
disconnect() {} disconnect() {
this._connected = false;
this._targets.clear();
const idx = globalThis.__intersectionObservers.indexOf(this);
if (idx >= 0) globalThis.__intersectionObservers.splice(idx, 1);
}
takeRecords() { return []; }
get root() { return this._options.root || null; }
get rootMargin() { return this._options.rootMargin || "0px 0px 0px 0px"; }
get thresholds() {
const t = this._options.threshold;
if (t == null) return [0];
return Array.isArray(t) ? t.slice() : [t];
}
}; };
// When the DOM mutates (e.g. infinite scroll loads a batch of items), re-fire
// every active IntersectionObserver so libraries observing dynamic content
// see a fresh isIntersecting=true event. Uses the same per-observer fire cap
// to prevent runaway loops if the page is mutating in a tight cycle.
(function() {
const reFire = () => {
for (const obs of globalThis.__intersectionObservers) {
if (!obs._connected) continue;
const ts = [...obs._targets];
if (ts.length) obs._fireFor(ts);
}
};
// Lazy-attach a single MutationObserver on document.body once the page is
// ready, debounced via a microtask so a flurry of mutations only triggers
// one IO sweep.
let pending = false;
const wireUp = () => {
if (!globalThis.document?.body) return;
const mo = new MutationObserver(() => {
if (pending) return;
pending = true;
Promise.resolve().then(() => { pending = false; reFire(); });
});
try { mo.observe(globalThis.document.body, {childList: true, subtree: true}); } catch {}
};
if (globalThis.document?.body) wireUp();
else Promise.resolve().then(wireUp);
})();
globalThis.IntersectionObserverEntry = class IntersectionObserverEntry {};
globalThis.PerformanceObserver = class { constructor(){} observe(){} disconnect(){} }; globalThis.PerformanceObserver = class { constructor(){} observe(){} disconnect(){} };
globalThis.Event = class Event { globalThis.Event = class Event {
@@ -2115,7 +2359,16 @@ globalThis.AnimationEvent = class extends Event {};
globalThis.TransitionEvent = class extends Event {}; globalThis.TransitionEvent = class extends Event {};
globalThis.UIEvent = class extends Event {}; globalThis.UIEvent = class extends Event {};
globalThis.WheelEvent = class extends Event {}; globalThis.WheelEvent = class extends Event {};
globalThis.PopStateEvent = class extends Event {}; globalThis.PopStateEvent = class extends Event {
constructor(type, init) {
super(type, init || {});
// Real PopStateEvent exposes `state` from the entry being navigated to.
// The earlier stub inherited Event but never stored state, so
// `popstate.state` was always undefined and SPA routers reading
// `event.state` to restore route info would mis-render.
this.state = init && 'state' in init ? init.state : null;
}
};
globalThis.HashChangeEvent = class extends Event {}; globalThis.HashChangeEvent = class extends Event {};
globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data; } }; globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data; } };
globalThis.ClipboardEvent = class extends Event {}; globalThis.ClipboardEvent = class extends Event {};
@@ -2146,7 +2399,84 @@ if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class {
forEach(cb){this._p.forEach(([k,v])=>cb(v,k,this));} forEach(cb){this._p.forEach(([k,v])=>cb(v,k,this));}
}; };
globalThis.DOMParser = class { parseFromString(s,t) { return globalThis.document; } }; // Real-enough DOMParser. The previous one-liner returned `globalThis.document`,
// so anything that did `new DOMParser().parseFromString(s, 'text/html')` and
// then read `.body.innerHTML` mutated the LIVE page (jQuery 3.x's selector
// feature-detect writes `<form></form>` and wiped real bodies). We parse the
// input into a detached `<html>` element and wrap it so the common Document
// API surface (body / head / documentElement / querySelector* / getElementById /
// getElementsByTagName / getElementsByClassName / title / cloneNode) works.
globalThis.DOMParser = class DOMParser {
parseFromString(source, mimeType) {
const html = String(source ?? "");
const isXml = typeof mimeType === "string" && /xml/i.test(mimeType);
const root = document.createElement("html");
// innerHTML parses children via html5ever fragment-parsing rules. Most
// HTML inputs start with `<!DOCTYPE>` / `<html>` / `<head>` etc.; the
// fragment parser strips the outer `<html>` and emits its head+body
// children, which is what callers want.
try { root.innerHTML = html; } catch (e) { /* leave empty on parse error */ }
// Helper: depth-first walk to find an element by predicate.
const walk = (node, pred) => {
if (!node) return null;
if (node.nodeType === 1 && pred(node)) return node;
const children = node.children || [];
for (let i = 0; i < children.length; i++) {
const r = walk(children[i], pred);
if (r) return r;
}
return null;
};
const findByTagName = (name) => walk(root, n => n.tagName === name);
const docNode = {
_root: root,
nodeName: "#document",
nodeType: 9,
contentType: isXml ? (mimeType || "application/xml") : "text/html",
get documentElement() { return root; },
get body() { return findByTagName("BODY"); },
get head() { return findByTagName("HEAD"); },
get title() {
const t = findByTagName("TITLE");
return t ? (t.textContent || "") : "";
},
get firstChild() { return root; },
get lastChild() { return root; },
get children() { return [root]; },
get childNodes() { return [root]; },
querySelector(s) { return root.querySelector(s); },
querySelectorAll(s) { return root.querySelectorAll(s); },
getElementById(id) {
return walk(root, n => n.getAttribute && n.getAttribute("id") === id);
},
getElementsByTagName(t) {
return root.querySelectorAll(t);
},
getElementsByClassName(c) {
return root.querySelectorAll("." + c);
},
getElementsByName(n) {
return root.querySelectorAll(`[name="${n}"]`);
},
createElement: (t) => document.createElement(t),
createElementNS: (ns, t) => document.createElement(t),
createTextNode: (t) => document.createTextNode(t),
createComment: (t) => document.createComment(t),
createDocumentFragment: () => document.createDocumentFragment(),
adoptNode: (n) => n,
importNode: (n) => n,
cloneNode: function (deep) {
return new DOMParser().parseFromString(root.outerHTML, mimeType);
},
contains(n) { return root.contains ? root.contains(n) : false; },
addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; },
};
return docNode;
}
};
globalThis.XMLSerializer = class XMLSerializer { globalThis.XMLSerializer = class XMLSerializer {
serializeToString(node) { serializeToString(node) {
if (!node) return ""; if (!node) return "";
@@ -2226,7 +2556,80 @@ globalThis.sessionStorage = _mkStore();
globalThis.btoa = globalThis.btoa || ((s) => { const b = new TextEncoder().encode(s); const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=""; for(let i=0;i<b.length;i+=3){const a=b[i],bb=b[i+1]??0,cc=b[i+2]??0; r+=c[a>>2]+c[((a&3)<<4)|(bb>>4)]+(i+1<b.length?c[((bb&15)<<2)|(cc>>6)]:"=")+(i+2<b.length?c[cc&63]:"=");} return r; }); globalThis.btoa = globalThis.btoa || ((s) => { const b = new TextEncoder().encode(s); const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=""; for(let i=0;i<b.length;i+=3){const a=b[i],bb=b[i+1]??0,cc=b[i+2]??0; r+=c[a>>2]+c[((a&3)<<4)|(bb>>4)]+(i+1<b.length?c[((bb&15)<<2)|(cc>>6)]:"=")+(i+2<b.length?c[cc&63]:"=");} return r; });
globalThis.atob = globalThis.atob || ((s) => { const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=[]; for(let i=0;i<s.length;i+=4){const a=c.indexOf(s[i]),b=c.indexOf(s[i+1]),cc=c.indexOf(s[i+2]),d=c.indexOf(s[i+3]); r.push((a<<2)|(b>>4)); if(cc>=0)r.push(((b&15)<<4)|(cc>>2)); if(d>=0)r.push(((cc&3)<<6)|d);} return String.fromCharCode(...r); }); globalThis.atob = globalThis.atob || ((s) => { const c="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let r=[]; for(let i=0;i<s.length;i+=4){const a=c.indexOf(s[i]),b=c.indexOf(s[i+1]),cc=c.indexOf(s[i+2]),d=c.indexOf(s[i+3]); r.push((a<<2)|(b>>4)); if(cc>=0)r.push(((b&15)<<4)|(cc>>2)); if(d>=0)r.push(((cc&3)<<6)|d);} return String.fromCharCode(...r); });
globalThis.history = { length:1, state:null, pushState(){}, replaceState(){}, go(){}, back(){}, forward(){}, scrollRestoration:"auto" }; // Functional History API. The earlier stub returned constant state and was a
// no-op on push/replace, so any SPA that tried to update its URL (Next.js
// client router, React Router, vue-router, hash-based routers) silently
// failed: location.href stayed pinned to the initial page, useLocation hooks
// never updated, and popstate-driven UI froze.
//
// Internally we keep a tiny in-memory stack of {state, url} entries. push/
// replace mutate the stack and set globalThis.__virtualUrl so location.href
// reads the new URL. Real Chrome doesn't fire popstate on push/replace,
// only on user-driven back/forward — we match that exactly.
(() => {
const stack = [{state: null, url: undefined}]; // initial entry; url=undefined means "use document URL"
let idx = 0;
const resolveOrFallback = (url) => {
if (url === null || url === undefined) return undefined;
try { return new URL(String(url), __currentUrl()).href; } catch (e) { return String(url); }
};
const applyVirtual = () => {
const entry = stack[idx];
globalThis.__virtualUrl = entry.url ?? null;
};
const fireHashChangeIfNeeded = (prevUrl) => {
try {
const next = __currentUrl();
if (!prevUrl || !next) return;
const a = new URL(prevUrl), b = new URL(next);
if (a.origin === b.origin && a.pathname === b.pathname && a.search === b.search && a.hash !== b.hash) {
const ev = new Event('hashchange');
ev.oldURL = prevUrl; ev.newURL = next;
try { globalThis.dispatchEvent(ev); } catch {}
}
} catch {}
};
globalThis.history = {
get length() { return stack.length; },
get state() { return stack[idx].state; },
scrollRestoration: "auto",
pushState(state, _title, url) {
const prevUrl = __currentUrl();
const resolved = resolveOrFallback(url);
// Truncate forward entries (real Chrome drops the forward stack on a
// new push) then append + advance.
stack.length = idx + 1;
stack.push({state: state ?? null, url: resolved});
idx = stack.length - 1;
applyVirtual();
fireHashChangeIfNeeded(prevUrl);
},
replaceState(state, _title, url) {
const prevUrl = __currentUrl();
const resolved = resolveOrFallback(url);
stack[idx] = {state: state ?? null, url: resolved};
applyVirtual();
fireHashChangeIfNeeded(prevUrl);
},
go(n) {
n = (n | 0);
if (n === 0) return; // real spec: go(0) reloads. We don't reload SPAs.
const next = Math.max(0, Math.min(stack.length - 1, idx + n));
if (next === idx) return;
const prevUrl = __currentUrl();
idx = next;
applyVirtual();
// Real Chrome fires popstate on back/forward with the destination entry's state.
try {
const ev = new PopStateEvent('popstate', {state: stack[idx].state});
globalThis.dispatchEvent(ev);
} catch {}
fireHashChangeIfNeeded(prevUrl);
},
back() { this.go(-1); },
forward() { this.go(1); },
};
})();
globalThis.screenX = 0; globalThis.screenY = 0; globalThis.screenX = 0; globalThis.screenY = 0;
globalThis.screenLeft = 0; globalThis.screenTop = 0; globalThis.screenLeft = 0; globalThis.screenTop = 0;
globalThis.pageXOffset = 0; globalThis.pageYOffset = 0; globalThis.pageXOffset = 0; globalThis.pageYOffset = 0;
@@ -2887,18 +3290,126 @@ globalThis.RTCPeerConnection = class RTCPeerConnection {
globalThis.RTCSessionDescription = class RTCSessionDescription { constructor(d){this.type=d?.type;this.sdp=d?.sdp;} }; globalThis.RTCSessionDescription = class RTCSessionDescription { constructor(d){this.type=d?.type;this.sdp=d?.sdp;} };
globalThis.RTCIceCandidate = class RTCIceCandidate { constructor(d){this.candidate=d?.candidate||'';} }; globalThis.RTCIceCandidate = class RTCIceCandidate { constructor(d){this.candidate=d?.candidate||'';} };
globalThis.indexedDB = { // Minimal but spec-shape-correct IndexedDB shim. We don't persist anything,
open(name, version) { // but authentication libraries (Firebase, Supabase, dexie) hang forever on
const req = { result: null, error: null, onsuccess: null, onerror: null, onupgradeneeded: null }; // the first `get` because their request's `onsuccess` is never called. Fire
// `onsuccess` asynchronously with `null` so reads complete-but-empty, which
// most libraries treat as a cache miss and fall back to the network.
function _idbRequest(produceResult) {
const req = {
result: undefined,
error: null,
source: null,
transaction: null,
readyState: 'pending',
onsuccess: null,
onerror: null,
addEventListener(type, fn) { req['on' + type] = fn; },
removeEventListener(type, fn) { if (req['on' + type] === fn) req['on' + type] = null; },
};
Promise.resolve().then(() => { Promise.resolve().then(() => {
req.result = { name, version: version||1, objectStoreNames: { contains(){return false;}, length:0 }, createObjectStore(){return {createIndex(){}}; }, transaction(){return {objectStore(){return {get(){return {onsuccess:null,onerror:null};},put(){return {onsuccess:null};},delete(){return {onsuccess:null};}};}}; }, close(){} }; try {
if (req.onsuccess) req.onsuccess({ target: req }); req.result = produceResult();
req.readyState = 'done';
if (typeof req.onsuccess === 'function') {
try { req.onsuccess({ target: req, type: 'success' }); } catch (e) {}
}
} catch (e) {
req.error = e; req.readyState = 'done';
if (typeof req.onerror === 'function') {
try { req.onerror({ target: req, type: 'error' }); } catch (e2) {}
}
}
}); });
return req; return req;
}, }
deleteDatabase() { return { onsuccess: null, onerror: null }; },
function _idbObjectStore(name) {
const data = new Map();
return {
name,
keyPath: null,
autoIncrement: false,
indexNames: { contains() { return false; }, length: 0, item() { return null; } },
transaction: null,
add(value, key) { const k = key ?? Date.now(); data.set(k, value); return _idbRequest(() => k); },
put(value, key) { const k = key ?? Date.now(); data.set(k, value); return _idbRequest(() => k); },
get(key) { return _idbRequest(() => data.get(key) ?? undefined); },
getAll() { return _idbRequest(() => Array.from(data.values())); },
getAllKeys() { return _idbRequest(() => Array.from(data.keys())); },
getKey(key) { return _idbRequest(() => (data.has(key) ? key : undefined)); },
delete(key) { return _idbRequest(() => { data.delete(key); return undefined; }); },
clear() { return _idbRequest(() => { data.clear(); return undefined; }); },
count() { return _idbRequest(() => data.size); },
openCursor() { return _idbRequest(() => null); },
openKeyCursor() { return _idbRequest(() => null); },
createIndex() { return { name: '', keyPath: '', unique: false, multiEntry: false, get() { return _idbRequest(() => undefined); } }; },
index() { return { get() { return _idbRequest(() => undefined); }, getAll() { return _idbRequest(() => []); }, count() { return _idbRequest(() => 0); }, openCursor() { return _idbRequest(() => null); } }; },
deleteIndex() {},
};
}
function _idbTransaction(storeNames) {
const stores = new Map();
const names = Array.isArray(storeNames) ? storeNames : [storeNames];
for (const n of names) stores.set(String(n), _idbObjectStore(String(n)));
const tx = {
db: null,
mode: 'readonly',
objectStoreNames: { contains: (n) => stores.has(String(n)), length: stores.size },
onabort: null, oncomplete: null, onerror: null,
error: null,
objectStore(name) {
let s = stores.get(name);
if (!s) { s = _idbObjectStore(name); stores.set(name, s); }
s.transaction = tx;
return s;
},
abort() {},
commit() {},
addEventListener(type, fn) { tx['on' + type] = fn; },
removeEventListener(type, fn) { if (tx['on' + type] === fn) tx['on' + type] = null; },
};
Promise.resolve().then(() => {
if (typeof tx.oncomplete === 'function') {
try { tx.oncomplete({ target: tx, type: 'complete' }); } catch (e) {}
}
});
return tx;
}
function _idbDatabase(name, version) {
return {
name,
version,
objectStoreNames: { contains() { return false; }, length: 0, item() { return null; } },
createObjectStore(n) { return _idbObjectStore(n); },
deleteObjectStore() {},
transaction(storeNames, mode) {
const tx = _idbTransaction(storeNames);
tx.mode = mode || 'readonly';
return tx;
},
close() {},
onversionchange: null, onabort: null, onerror: null, onclose: null,
addEventListener() {}, removeEventListener() {},
};
}
globalThis.indexedDB = {
open(name, version) {
return _idbRequest(() => _idbDatabase(name, version || 1));
},
deleteDatabase(_name) { return _idbRequest(() => undefined); },
databases() { return Promise.resolve([]); },
cmp(a, b) { return a < b ? -1 : a > b ? 1 : 0; },
};
globalThis.IDBKeyRange = {
only(v) { return { lower: v, upper: v, lowerOpen: false, upperOpen: false, includes(x) { return x === v; } }; },
lowerBound(v, open) { return { lower: v, upper: null, lowerOpen: !!open, upperOpen: false, includes(x) { return open ? x > v : x >= v; } }; },
upperBound(v, open) { return { lower: null, upper: v, lowerOpen: false, upperOpen: !!open, includes(x) { return open ? x < v : x <= v; } }; },
bound(l, u, lo, uo) { return { lower: l, upper: u, lowerOpen: !!lo, upperOpen: !!uo, includes(x) { return (lo ? x > l : x >= l) && (uo ? x < u : x <= u); } }; },
}; };
globalThis.IDBKeyRange = { only(v){return v;}, lowerBound(v){return v;}, upperBound(v){return v;}, bound(l,u){return [l,u];} };
globalThis.caches = { globalThis.caches = {
open() { return Promise.resolve({ match(){return Promise.resolve(undefined);}, put(){return Promise.resolve();}, delete(){return Promise.resolve(false);}, keys(){return Promise.resolve([]);} }); }, open() { return Promise.resolve({ match(){return Promise.resolve(undefined);}, put(){return Promise.resolve();}, delete(){return Promise.resolve(false);}, keys(){return Promise.resolve([]);} }); },
@@ -3103,14 +3614,19 @@ if (!globalThis.crypto) globalThis.crypto = {};
if (!globalThis.crypto.subtle) { if (!globalThis.crypto.subtle) {
globalThis.crypto.subtle = { globalThis.crypto.subtle = {
async digest(algorithm, data) { async digest(algorithm, data) {
const name = typeof algorithm === 'string' ? algorithm : algorithm?.name || 'SHA-256'; // Real WebCrypto digest. Delegates to `op_subtle_digest` which runs
const bytes = new Uint8Array(data instanceof ArrayBuffer ? data : data.buffer || data); // the actual SHA-1/256/384/512 via Rust's `sha1` and `sha2` crates.
let hash = 0x811c9dc5; // The previous JS implementation was a custom FNV variant that
for (let i = 0; i < bytes.length; i++) { hash ^= bytes[i]; hash = Math.imul(hash, 0x01000193); } // produced bytes shaped like the hash but with wrong contents, so
const size = name.includes('512') ? 64 : name.includes('384') ? 48 : 32; // SRI checks, JWS signature verification, and OAuth PKCE silently
const result = new Uint8Array(size); // accepted invalid input.
for (let i = 0; i < size; i++) { hash = Math.imul(hash ^ i, 0x45d9f3b); result[i] = (hash >>> 0) & 0xff; } const name = (typeof algorithm === 'string' ? algorithm : algorithm?.name) || 'SHA-256';
return result.buffer; let bytes;
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
else if (ArrayBuffer.isView(data)) bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
else bytes = new Uint8Array(data || []);
const out = Deno.core.ops.op_subtle_digest(name, bytes);
return new Uint8Array(out).buffer;
}, },
async encrypt() { throw new DOMException('NotSupportedError'); }, async encrypt() { throw new DOMException('NotSupportedError'); },
async decrypt() { throw new DOMException('NotSupportedError'); }, async decrypt() { throw new DOMException('NotSupportedError'); },
@@ -3183,29 +3699,101 @@ if (typeof FileReader === 'undefined') {
}; };
} }
// Real network sockets aren't implemented; we don't have a runtime WS / SSE
// client in V8. But pages that wait for an `open` event (Vite HMR clients
// embedded on docs sites, live-dashboards, anything calling
// `await new Promise(r => ws.addEventListener('open', r))`) silently hang
// forever otherwise. Fire `open` after a microtask so the consumer at least
// proceeds; subsequent messages never arrive, which is no worse than the
// current "no signal whatsoever" behaviour.
// Minimal EventTarget shared by socket-like classes. Real `EventTarget` is
// currently aliased to `Node`, which would drag DOM-tree assumptions into a
// `WebSocket`. Defining a private shim avoids that.
function _makeListenerBox(self) {
const map = new Map();
self.addEventListener = function (type, fn) {
if (typeof fn !== 'function') return;
let bucket = map.get(type);
if (!bucket) { bucket = []; map.set(type, bucket); }
bucket.push(fn);
};
self.removeEventListener = function (type, fn) {
const bucket = map.get(type);
if (!bucket) return;
const i = bucket.indexOf(fn);
if (i >= 0) bucket.splice(i, 1);
};
self.dispatchEvent = function (event) {
const bucket = map.get(event.type);
if (!bucket) return true;
for (const fn of bucket.slice()) {
try { fn.call(self, event); } catch (e) { /* swallow */ }
}
return true;
};
}
if (typeof EventSource === 'undefined') { if (typeof EventSource === 'undefined') {
globalThis.EventSource = class EventSource { globalThis.EventSource = class EventSource {
constructor(url) { this.url = url; this.readyState = 0; this.onopen = null; this.onmessage = null; this.onerror = null; } constructor(url, init) {
this.url = url;
this.readyState = 0; // CONNECTING
this.withCredentials = !!(init && init.withCredentials);
this.onopen = null; this.onmessage = null; this.onerror = null;
_makeListenerBox(this);
Promise.resolve().then(() => {
if (this.readyState !== 0) return;
this.readyState = 1; // OPEN
const ev = new Event('open');
if (typeof this.onopen === 'function') { try { this.onopen(ev); } catch (e) {} }
try { this.dispatchEvent(ev); } catch (e) {}
});
}
close() { this.readyState = 2; } close() { this.readyState = 2; }
addEventListener() {} removeEventListener() {}
static CONNECTING = 0; static OPEN = 1; static CLOSED = 2; static CONNECTING = 0; static OPEN = 1; static CLOSED = 2;
}; };
} }
if (typeof WebSocket === 'undefined') { if (typeof WebSocket === 'undefined') {
globalThis.WebSocket = class WebSocket { globalThis.WebSocket = class WebSocket {
constructor(url, protocols) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.onopen = null; this.onmessage = null; this.onerror = null; this.onclose = null; this.protocol = ''; } constructor(url, protocols) {
send(data) {} close(code, reason) { this.readyState = 3; if (this.onclose) this.onclose({code:code||1000,reason:reason||'',wasClean:true}); } this.url = url;
addEventListener() {} removeEventListener() {} this.readyState = 0; // CONNECTING
this.bufferedAmount = 0;
this.binaryType = 'blob';
this.extensions = '';
this.protocol = Array.isArray(protocols) ? (protocols[0] || '') : (protocols || '');
this.onopen = null; this.onmessage = null; this.onerror = null; this.onclose = null;
_makeListenerBox(this);
Promise.resolve().then(() => {
if (this.readyState !== 0) return;
this.readyState = 1; // OPEN
const ev = new Event('open');
if (typeof this.onopen === 'function') { try { this.onopen(ev); } catch (e) {} }
try { this.dispatchEvent(ev); } catch (e) {}
});
}
send(data) { /* drop; no real socket */ }
close(code, reason) {
if (this.readyState >= 2) return;
this.readyState = 3; // CLOSED
const ev = new Event('close');
ev.code = code || 1000; ev.reason = reason || ''; ev.wasClean = true;
if (typeof this.onclose === 'function') { try { this.onclose(ev); } catch (e) {} }
try { this.dispatchEvent(ev); } catch (e) {}
}
static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3;
}; };
} }
if (typeof BroadcastChannel === 'undefined') { if (typeof BroadcastChannel === 'undefined') {
globalThis.BroadcastChannel = class BroadcastChannel { globalThis.BroadcastChannel = class BroadcastChannel {
constructor(name) { this.name = name; this.onmessage = null; } constructor(name) {
postMessage(msg) {} close() {} this.name = name; this.onmessage = null; this.onmessageerror = null;
addEventListener() {} removeEventListener() {} _makeListenerBox(this);
}
postMessage(msg) {}
close() {}
}; };
} }
+20 -1
View File
@@ -777,7 +777,7 @@ async fn op_sleep(#[number] millis: u64) {
// Records a binding call from page JS. The CDP layer drains this queue // Records a binding call from page JS. The CDP layer drains this queue
// after every dispatch and emits one `Runtime.bindingCalled` event per // after every dispatch and emits one `Runtime.bindingCalled` event per
// entry — that's how puppeteer's `page.exposeFunction` callbacks fire. // entry, that's how puppeteer's `page.exposeFunction` callbacks fire.
#[op2(fast)] #[op2(fast)]
fn op_binding_called(state: &OpState, #[string] name: &str, #[string] payload: &str) { fn op_binding_called(state: &OpState, #[string] name: &str, #[string] payload: &str) {
let gs = state.borrow::<SharedState>().clone(); let gs = state.borrow::<SharedState>().clone();
@@ -785,6 +785,24 @@ fn op_binding_called(state: &OpState, #[string] name: &str, #[string] payload: &
gs.pending_binding_calls.push((name.to_string(), payload.to_string())); gs.pending_binding_calls.push((name.to_string(), payload.to_string()));
} }
/// Real WebCrypto `crypto.subtle.digest`. `algorithm` is the SubtleCrypto
/// algorithm name (`SHA-1` / `SHA-256` / `SHA-384` / `SHA-512`); unknown
/// names fall through to SHA-256 to match the previous JS fallback. Returns
/// the raw digest bytes so the JS shim can hand them back as an ArrayBuffer.
#[op2]
#[buffer]
fn op_subtle_digest(#[string] algorithm: &str, #[buffer] data: &[u8]) -> Vec<u8> {
use sha1::Digest as _;
let alg = algorithm.to_ascii_uppercase();
match alg.as_str() {
"SHA-1" => sha1::Sha1::digest(data).to_vec(),
"SHA-256" => sha2::Sha256::digest(data).to_vec(),
"SHA-384" => sha2::Sha384::digest(data).to_vec(),
"SHA-512" => sha2::Sha512::digest(data).to_vec(),
_ => sha2::Sha256::digest(data).to_vec(),
}
}
pub fn build_extension() -> Extension { pub fn build_extension() -> Extension {
Extension { Extension {
name: "obscura_dom", name: "obscura_dom",
@@ -797,6 +815,7 @@ pub fn build_extension() -> Extension {
op_navigate(), op_navigate(),
op_sleep(), op_sleep(),
op_binding_called(), op_binding_called(),
op_subtle_digest(),
]), ]),
..Default::default() ..Default::default()
} }