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:
1 parent
c9f32aa2f0
commit
b98e457428
9 files changed
+979
-295
No files matched your search
Generated
+13
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
Vendored
+689
-101
@@ -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();
|
||||||
|
// React virtualization libraries (react-window, tanstack-virtual,
|
||||||
|
// react-virtuoso) all compute container dimensions via getComputedStyle.
|
||||||
|
// The defaults table previously returned `auto` for width/height and
|
||||||
|
// `'static'` for position, which made every list render 0 items. Pulling
|
||||||
|
// width/height from the synthesized bounding rect makes those libraries
|
||||||
|
// actually render content.
|
||||||
|
const dimensionFor = (name) => {
|
||||||
|
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',
|
||||||
|
position: 'static', overflow: 'visible',
|
||||||
|
transform: 'none', 'transform-origin': '0px 0px',
|
||||||
|
transition: 'none', animation: 'none',
|
||||||
|
float: 'none', clear: 'none',
|
||||||
|
margin: '0px', padding: '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',
|
||||||
|
'font-size': '16px', 'line-height': 'normal', 'font-weight': '400',
|
||||||
|
'font-family': 'Times',
|
||||||
|
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-top-width': '0px', 'border-right-width': '0px',
|
||||||
|
'border-bottom-width': '0px', 'border-left-width': '0px',
|
||||||
|
'border-radius': '0px',
|
||||||
|
'z-index': 'auto', 'pointer-events': '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 lookup = (rawProp) => {
|
||||||
|
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 '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const target = style;
|
||||||
return new Proxy(style, {
|
return new Proxy(style, {
|
||||||
get(target, prop) {
|
get(_, prop) {
|
||||||
if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined;
|
if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined;
|
||||||
if (prop in target) return target[prop];
|
if (prop in target) return target[prop];
|
||||||
if (typeof prop === 'string') {
|
if (prop === 'getPropertyValue') return (name) => lookup(name);
|
||||||
const v = target.getPropertyValue ? target.getPropertyValue(prop) : '';
|
if (prop === 'getPropertyPriority') return () => '';
|
||||||
if (v) return v;
|
if (prop === 'item') return (i) => '';
|
||||||
const defaults = {
|
|
||||||
display: 'block', visibility: 'visible', opacity: '1',
|
|
||||||
position: 'static', overflow: 'visible',
|
|
||||||
transform: 'none', transition: 'none', animation: 'none',
|
|
||||||
float: 'none', clear: 'none',
|
|
||||||
width: 'auto', height: 'auto',
|
|
||||||
top: 'auto', left: 'auto', right: 'auto', bottom: 'auto',
|
|
||||||
margin: '0px', padding: '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',
|
|
||||||
'font-size': '16px', 'line-height': 'normal', 'font-weight': '400',
|
|
||||||
color: 'rgb(0, 0, 0)', 'background-color': 'rgba(0, 0, 0, 0)',
|
|
||||||
'border-width': '0px', 'border-style': 'none', 'border-color': 'rgb(0, 0, 0)',
|
|
||||||
'z-index': 'auto', 'pointer-events': 'auto',
|
|
||||||
'box-sizing': 'content-box', cursor: 'auto',
|
|
||||||
};
|
|
||||||
const kebabProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
||||||
if (defaults[prop]) return defaults[prop];
|
|
||||||
if (defaults[kebabProp]) return defaults[kebabProp];
|
|
||||||
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())] || '';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
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; }
|
//
|
||||||
|
// IntersectionObserver: without a layout engine we can't compute real
|
||||||
|
// intersection geometry, so every observed target is treated as fully
|
||||||
|
// in-viewport (`isIntersecting: true`, `intersectionRatio: 1`). Real
|
||||||
|
// 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,
|
||||||
|
intersectionRatio: 1,
|
||||||
|
boundingClientRect: target.getBoundingClientRect
|
||||||
|
? target.getBoundingClientRect()
|
||||||
|
: { x: 0, y: 0, width: 100, height: 20, top: 0, left: 0, right: 100, bottom: 20 },
|
||||||
|
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) {
|
observe(el) {
|
||||||
Promise.resolve().then(() => {
|
if (!el || !this._connected) return;
|
||||||
this._callback([{
|
if (this._targets.has(el)) return;
|
||||||
target: el,
|
this._targets.add(el);
|
||||||
isIntersecting: true,
|
Promise.resolve().then(() => this._fireFor([el]));
|
||||||
intersectionRatio: 1,
|
// Exponential burst to cover infinite-scroll sentinels that "re-arm"
|
||||||
boundingClientRect: el.getBoundingClientRect ? el.getBoundingClientRect() : {x:0,y:0,width:100,height:20},
|
// after content lands. Without a real scroll/layout signal, we fake the
|
||||||
intersectionRect: el.getBoundingClientRect ? el.getBoundingClientRect() : {x:0,y:0,width:100,height:20},
|
// re-fire schedule. Beyond ~10s the page has usually settled.
|
||||||
rootBounds: {x:0,y:0,width:1280,height:720},
|
[120, 500, 1500, 3500, 7000].forEach(delay => {
|
||||||
}], this);
|
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||'';} };
|
||||||
|
|
||||||
|
// Minimal but spec-shape-correct IndexedDB shim. We don't persist anything,
|
||||||
|
// but authentication libraries (Firebase, Supabase, dexie) hang forever on
|
||||||
|
// 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(() => {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
globalThis.indexedDB = {
|
||||||
open(name, version) {
|
open(name, version) {
|
||||||
const req = { result: null, error: null, onsuccess: null, onerror: null, onupgradeneeded: null };
|
return _idbRequest(() => _idbDatabase(name, version || 1));
|
||||||
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(){} };
|
|
||||||
if (req.onsuccess) req.onsuccess({ target: req });
|
|
||||||
});
|
|
||||||
return req;
|
|
||||||
},
|
},
|
||||||
deleteDatabase() { return { onsuccess: null, onerror: null }; },
|
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() {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user