* perf: cache HTTP clients + selector parsing + exponential wait backoff
Three low-risk wins discovered while reading the hot paths.
1. op_fetch_url cached its reqwest::Client. Previously every JS-side
fetch(), XHR, and dynamic script load built a fresh client, which
re-loaded TLS roots and started with an empty connection pool. On
an asset-heavy page with 30+ subresources this added ~150ms of
wasted setup with zero connection reuse. The cache is a process-
wide map keyed by proxy URL (empty string for no-proxy), so the
first fetch on each proxy pays the build cost once and every
subsequent fetch reuses the same pool.
Also bumped pool_idle_timeout to 5 minutes and tcp_keepalive to
60s so connections stay warm for SPA-heavy workloads that hit
the same origin dozens of times across a navigation.
2. DomTree::parse_selector now caches parsed selectors in a thread-
local HashMap (cap 256). querySelector / querySelectorAll re-
parsed the selector string every call before; batch DOM access
patterns (an agent scraping a table row-by-row, a framework
polling for elements) paid the cssparser cost N times.
3. browser_wait_for / browser_wait_for_text now exponentially back
off (5 -> 10 -> 20 -> ... -> 200 ms). The old fixed 200ms tick
meant a selector that appeared 30ms after the call still took
the next full 200ms tick to be detected. Worst case is still
200ms per check, but the median wait drops sharply for fast
appearances.
28/28 integration tests pass. Asset-heavy bench (rust-lang/rust,
docs.stripe.com, etc) doesn't regress.
* perf: module loader client cache, fetch concurrency cap, snapshot hide list, cookie alloc
Round two of perf wins after a deep code audit.
1. ES module loader (module_loader.rs) was building a fresh
reqwest::Client on every dynamic import. Modern SPAs (Next, Vite)
load 20-50 chunks via dynamic import; each one used to spin up an
empty connection pool with fresh TLS init. Now reuses the
process-wide FETCH_CLIENT_CACHE that op_fetch_url already uses
(the cached_request_client helper). Saves ~30-100ms on chunk-
heavy first navigations.
2. Script and CSS fetches in execute_scripts used futures::join_all
with unbounded concurrency. A 100-script page would open 100 TCP
sockets at once, exhausting the connection pool and the OS
ephemeral port range. Capped at 16 in-flight (well above the per-
host pool ceiling real Chrome uses).
3. bootstrap.js __obscura_init() ran Object.keys(globalThis).filter()
on every page setup to find internals to hide. The set of keys
is static at snapshot-build time, so we now precompute it once
(top-level statement just below the function) and __obscura_init
just iterates the precomputed array. Was 5-40ms per page on heavy
SPAs (1000+ globals); now ~0.2ms per page.
4. CookieJar::domain_matches allocated 2 lowercase Strings and a
format! per call. The lookup runs per fetch per cookie domain;
on a heavily-cookied site with 100 cookies across 30 domains
that was thousands of allocations per page. Now zero-alloc with
eq_ignore_ascii_case and char-boundary checks.
Fixes the remaining two paths from #139: JS-level fetch()/XHR
(op_fetch_url) and dynamic ES module imports (ObscuraModuleLoader) both
built their reqwest clients with `Client::builder().build()` — no
proxy — so every JS-initiated network call silently bypassed
BrowserContext.proxy_url. (The third path, StealthHttpClient, was
already fixed in main: page.rs:61 uses with_proxy.)
Changes:
* obscura-net: expose ObscuraHttpClient::proxy_url() so callers outside
the crate can route their own reqwest requests through the same
upstream proxy. The field was already there — just lacked an accessor.
* obscura-js/module_loader: add `proxy_url: Option<String>` and
`with_proxy(base_url, proxy_url)`. ObscuraModuleLoader::new keeps the
pre-#139 direct-connection behaviour. load() builds a reqwest client
per import, threading the proxy and bubbling up "Invalid module proxy"
on bad URLs.
* obscura-js/ops.rs: replace the process-wide
`static SHARED_HTTP_CLIENT: OnceLock<reqwest::Client>` (which could
never be reconfigured after first call) with a per-request
`build_request_client(proxy_url)` that pulls the configured proxy out
of SharedState.http_client.proxy_url() each call. Per-request build
cost is ~1ms vs the network round-trip, and we no longer have a
global singleton that lies about its config.
* obscura-js/runtime: add ObscuraJsRuntime::with_base_url_and_proxy.
with_base_url delegates to it with None so existing tests / callers
are unaffected.
* obscura-browser/page.rs: thread context.proxy_url through to
with_base_url_and_proxy when constructing the JS runtime.