The DOMSnapshot walk recursed one frame per nesting level. The MAX_NODES
cap bounds the node count but not the depth, so a deeply nested linear
chain (script can build thousands of nested elements) could overflow the
stack before the count guard triggered. Rewrite it with an explicit stack
so depth lives on the heap. Pre-order traversal and the MAX_NODES cap are
preserved.
Fixes#341.
The comment serializer wrote its contents verbatim. The HTML parser can
never produce a comment whose data contains "-->" (it ends the comment),
but script can via document.createComment("a-->b"). Serializing that
verbatim closed the comment early and let the trailing text escape into
markup. Replace "-->" with "-->" so the output round-trips as a single
comment.
Fixes#342.
Two standard-conformance fixes surfaced by #335 (the issue itself is a commercial anti-bot challenge and out of scope; these are the legitimate browser-compatibility gaps from it).
• checkbox/radio value: an input with no value attribute returns "on" in a real browser; obscura returned "". An explicit value attribute still wins.
• NodeList: childNodes returned a plain Array, and NodeList extended Array, so Array.isArray(list) was true and Object.prototype.toString reported "[object Array]". A real browser reports "[object NodeList]" with Array.isArray false. NodeList is now its own type with the array-like surface scripts use (indexed access, length, item, forEach, entries/keys/values, iteration), and childNodes goes through it.
Verified: checkbox/radio default "on" (explicit value preserved), childNodes and querySelectorAll are real NodeLists (constructor "NodeList", "[object NodeList]", Array.isArray false), and spread / for..of / Array.from still work. Obstacle course 33/33, obscura-dom 33/33.
Refs #335.
childNodes returned a plain Array (constructor "Array"), and NodeList
extended Array, so Array.isArray(list) was true and
Object.prototype.toString reported "[object Array]". A real browser
reports "[object NodeList]" and Array.isArray is false, which
feature-detection and fingerprinting scripts check.
NodeList is now its own type with the array-like surface scripts use
(indexed access, length, item, forEach, entries/keys/values, iteration),
and childNodes goes through it. Verified spread, for..of, and Array.from
still work.
Refs #335.
A checkbox or radio input with no value attribute returns "on" in a real
browser; obscura returned the empty string. Page scripts that read
input.value to build form submissions (and browser-environment checks)
saw the wrong default. An explicit value attribute still wins.
Refs #335.
The single-worker serve path honored --host, but run_multi_worker_serve hardcoded the load balancer to 127.0.0.1. With --host 0.0.0.0 and --workers >1 (the common Docker setup) the balancer bound loopback only, so the mapped port was refused from outside the container. Bind the balancer to the requested host. Workers stay on loopback and are reached only by the balancer.
Closes#336.
The single-worker serve path honored --host, but run_multi_worker_serve
hardcoded the load balancer to 127.0.0.1. With --host 0.0.0.0 and
--workers >1 (the common Docker setup) the balancer bound loopback only,
so the mapped port was refused from outside the container. Bind the
balancer to the requested host. Workers stay on loopback and are reached
only by the balancer.
Fixes#336.
serve --storage-dir saved cookies only on Ctrl-C (SIGINT). docker stop
and kill send SIGTERM, which was not caught, so the save_cookies() call
at shutdown never ran and cookies were lost on restart. Watch SIGTERM as
well as Ctrl-C on Unix.
querySelectorAll / --selector silently returned nothing for :has().
Two fixes were needed:
- Enable :has() parsing: the selectors crate gates it on the parser's
parse_has() (default false), so a:has(p) failed to parse and the error
was swallowed into an empty match set.
- Fix OpaqueElement stability: opaque() was keyed off the transient
DomElement (which is Copy and gets a fresh stack address each traversal
step), so the same node produced a different id every call. :has anchor
matching compares the anchor's opaque against the element reached by
walking up the tree, so it never matched. Key opaque() off the node's
stable slot in the tree's Vec instead.
SVG className and href reflected as plain strings, so el.className.animVal
and el.href.animVal were undefined, which made new URL(svgEl.href.animVal)
throw and broke any SVG-aware script. Add an SVGAnimatedString class
(baseVal/animVal both read the live attribute; baseVal writable) and make
the className and href getters return it for SVG-namespace elements. href
falls back to the legacy xlink:href. HTML elements are unchanged.
CookieJar::set_cookie and set_cookie_from_js started from the request host but
blindly overwrote it with any Domain attribute, so a response from
attacker.test could plant a cookie scoped to victim.test, which was then sent
on later requests to victim.test. The same sink backs document.cookie, HTTP
Set-Cookie, and CookieStore::set.
Add resolve_cookie_domain: with no Domain the cookie is host-only (exact host
only); a Domain attribute is honored only when it domain-matches the origin
(equal or a parent domain) and is not an obvious public suffix, otherwise the
attribute is ignored and the cookie is stored host-only on the origin. Track
host_only on CookieEntry so host-only cookies are not sent to subdomains.
A full public suffix list is not bundled, so multi-label public suffixes
(co.uk, github.io) are not rejected; the domain-match check still blocks the
reported cross-domain attack, and single-label suffixes (com, local) are.
Tests cover the cross-domain rejection for both HTTP and document.cookie,
public-suffix rejection, host-only subdomain scoping, and that legitimate
parent-domain cookies still work.
Co-authored-by: DavidCarliez <[email protected]>
The request interception API (#306) and the global --stealth flag are
merged, so:
- Use-as-a-Rust-library.md: add an "Intercept requests" section covering
on_request/on_response, enable_interception (Continue/Fulfill/Fail),
and add_preload_script, with examples.
- Intercept-and-modify-requests.md: point embedders at the native API.
- AGENTS.md: --stealth is now global (all subcommands incl. scrape, via
OBSCURA_STEALTH for workers); note the interception API on the obscura
crate and that op_fetch_url rewrites must stay behind validate_fetch_url.
- skills/obscura/SKILL.md: fix the build note (stealth is not a default
feature), correct "randomized" to a consistent fingerprint, add the
global --stealth flag, --dump assets/cookies, and an interception note.
- --stealth is now a global flag: document it on every subcommand
including scrape, and that it works before or after the subcommand.
- Correct the stealth description: it presents a consistent Chrome
fingerprint (TLS, headers, JS surfaces), not a randomized one, and
scripted fetch()/XHR share that fingerprint.
- Add --dump cookies to the CLI reference and the extract-data guide.
- Note --dump assets now also lists fetch()/XHR request URLs.
GitHub issue forms that enforce the fields needed to triage: obscura
version, OS and architecture, how it is being used, stealth mode, a
reproduction, expected vs actual, and whether headless Chrome behaves
the same. config.yml routes questions and API proposals to Discussions
and security reports to the private advisory flow.
SECURITY.md: private reporting via GitHub advisories with an email
fallback, a what-to-include checklist, response and disclosure policy, a
scope section (what is and is not a vulnerability, including stealth and
opt-in flags), third-party dependency handling, and the in-process
security model (V8 watchdog, CLI deadline, panic safety, default SSRF
egress block) with the note that it complements rather than replaces
OS-level isolation. Modeled on the Electron, Node, and Puppeteer policies.
CONTRIBUTING.md: code of conduct, an issue-first submission policy, build
and test workflow (nextest not cargo test, the 33/33 obstacle course
gate), the pre-PR checklist with performance as a hard constraint,
commit and PR conventions, and bug-report expectations. Points at
AGENTS.md for the deeper engine details instead of duplicating them.
• add_preload_script(&str) runs a script before any of the page's own <script> tags (the CDP Page.addScriptToEvaluateOnNewDocument contract). Call it before navigation.
• enable_interception() -> UnboundedReceiver<InterceptedRequest> yields every JS fetch()/XHR request. Resolve each via its resolver with InterceptResolution::{Continue, Fulfill, Fail} to pass, mock, or block it. Continue can rewrite url/method/headers/body.
• on_request(cb) / on_response(cb) register passive callbacks fired for every request and response (navigation plus JS fetch()/XHR), the response callback including the body. This is the main path for capturing SPA API payloads.
• op_fetch_url (the op backing JS fetch()/XHR) now invokes the on_request/on_response callbacks and honors Continue overrides. Before this they fired only for navigation-level requests. The stealth fetch path is wired too.
• Interception types (InterceptedRequest, InterceptResolution, RequestInfo, RequestCallback, ResponseCallback, ResourceType, Response) are re-exported up through obscura-browser to the public obscura crate.
• A Continue URL rewrite is re-validated against the SSRF/private-network gate (validate_fetch_url), the same as a redirect, so a rewrite can't reach an internal address the original request was blocked from.
• The callbacks are gated behind an emptiness check, so there is no per-request cost when no interceptor is registere
A Continue resolution that rewrites the request URL applied the new URL
after validate_fetch_url had already run on the original, so a rewrite
could reach a private or internal address the original request was
blocked from. Run the overridden URL back through validate_fetch_url,
matching how redirects are re-validated, and return a blocked response
if it fails.
Also fix a doc comment that ended up attached to the new fetch_response
helper instead of stealth_fetch_all.
op_fetch_url ignored the url/method/headers/body fields on
InterceptResolution::Continue, so the interception channel could pass, mock, or
block a request but not rewrite it. Apply them: shadow url/method/body and
derive custom_headers from the override after the channel resolves Continue.
Also fix enable_interception() called before the first navigation: init_js
propagated intercept_tx to the new runtime but not intercept_enabled, so the
runtime started with interception disabled and op_fetch_url never intercepted.
Propagate intercept_enabled in init_js as well.
Add a test that rewrites a fetch's url via Continue and asserts the rewritten
response is captured.
Expose the interception infrastructure that already existed internally but was
private, so embedders can intercept, observe, mock, or block JS fetch()/XHR
without forking or unsafe casts.
On obscura::Page:
- add_preload_script: inject JS before any page <script> (CDP
Page.addScriptToEvaluateOnNewDocument). The field already ran the scripts;
this only adds the method.
- enable_interception: returns a receiver for every JS fetch()/XHR request;
resolve each through its resolver with InterceptResolution::{Continue,
Fulfill, Fail} to pass, mock, or block (CDP Fetch.enable equivalent).
- on_request / on_response: passive callbacks. on_response is the main path for
capturing API response payloads from SPAs.
on_request/on_response previously fired only for navigation; op_fetch_url now
invokes them for JS fetch()/XHR too (both reqwest and stealth paths), gated on
a non-empty callback vector so the common no-callback case pays nothing.
Re-export InterceptedRequest, InterceptResolution, RequestInfo, ResourceType,
RequestCallback, ResponseCallback, Response from the obscura crate.
Integration test tests/interception.rs exercises all three against a local echo
server.
--stealth was a per-subcommand flag defined separately on fetch, serve,
and mcp, and missing from scrape entirely. You could not enable stealth
for parallel scraping, and `obscura --stealth fetch` (flag before the
subcommand) errored.
Promote --stealth to a single global flag like --proxy, so it is valid
before or after any subcommand and applies uniformly to fetch, serve,
scrape, and mcp. scrape now forwards it to its workers via OBSCURA_STEALTH
(mirroring OBSCURA_PROXY), and the bare `obscura` default CDP server
honors it too.
Verified: navigating directly to a blocklisted tracker renders 55503
chars without stealth and 39 (blocked) with it, identically on fetch and
scrape. Obstacle course 33/33.
**Fingerprint consistency (stealth)** The stealth TLS, navigator, and WebGL surfaces previously disagreed. The wreq client reported Windows Chrome but navigator reported Linux, and WebGL returned Direct3D11 renderers for a Linux UA — a combination no real browser ever produces. Everything now presents one consistent Windows Chrome 145 identity: the HTTP User-Agent header, sec-ch-ua-platform, navigator.userAgent, navigator.platform, userAgentData, and WebGL renderer all agree. userAgentData brands are derived from the Chrome major version via Chromium's GREASE algorithm instead of a single hardcoded token.
**sec-ch-ua client hints on the default (non-stealth) path** The plain navigation client hardcoded `sec-ch-ua-platform: "Linux"` and Chrome 149 brands regardless of which profile was selected. Both headers are now derived from the actual User-Agent using the same GREASE algorithm, so non-stealth requests are also internally consistent.
**Route scripted fetch()/XHR through the stealth TLS client** In stealth mode, JS `fetch()` and `XMLHttpRequest` were still going through the plain reqwest/rustls stack, so subresource requests (sensors, WAF challenge verify calls) arrived with a non-browser TLS fingerprint. They now share the navigation's Chrome TLS fingerprint and sec-ch-ua client hints. The non-stealth path is unchanged.
**FormData serialization in fetch() — the booking.com fix** Passing a `FormData` as a `fetch()` or `XHR.send()` body sent the literal string `"[object Object]"` instead of a multipart payload. booking.com's AWS WAF challenge POSTs its proof-of-work solution to `/mp_verify` as FormData, so the verify always failed and no `aws-waf-token` was issued. `FormData` is now serialized to a proper `multipart/form-data` body with a generated boundary; `URLSearchParams` gets `application/x-www-form-urlencoded`. XHR routes through `fetch()` so it is covered too.
**navigator.permissions.query defaults** `permissions.query` returned `"granted"` for every permission. Real Chrome defaults camera, microphone, geolocation, and midi to `"prompt"`. Fixed.
**SHA-512/224 and SHA-512/256 in crypto.subtle.digest** Added FIPS 180-4 truncated SHA-512 variants to the Rust op and the JS allowlist. WAF challenge scripts that use these algorithms now get real output instead of a NotSupportedError.
**location updates synchronously on navigation, Blob-URL Worker race fixed**
Resolved conflicts:
- page.rs: keep both set_blocked_urls (#315) and stealth client wiring
- ops.rs: keep SHA-512/224 + SHA-512/256 from stealth branch; keep
the unreachable catch-all (vec![]) from #314 instead of silent
SHA-256 fallback; updated comment accordingly
- bootstrap.js: extend the digest algorithm allowlist to include
SHA-512/224 and SHA-512/256 to match the Rust additions
The default HTTP path emitted headers with user-agent first and sec-fetch-dest
before sec-fetch-site, an order no real browser produces and a known
fingerprint signal. Reorder the explicit headers to Chrome's top-level
navigation order (sec-ch-ua family, upgrade-insecure-requests, user-agent,
accept, sec-fetch-site/mode/user/dest, accept-language). reqwest still appends
accept-encoding and host after these, so accept-encoding lands after
accept-language instead of before it; the rest matches Chrome exactly.
permissions.query returned "granted" for every permission except notifications.
Real Chrome defaults camera, microphone, geolocation, and midi to "prompt", so
answering "granted" for them is a bot tell. Return "prompt" for those (keeping
notifications in sync with Notification.permission) and "granted" for the rest.
The non-stealth navigation path hardcoded sec-ch-ua-platform "Linux" and a
fixed sec-ch-ua using Chrome 149's GREASE token, regardless of the selected
profile. With the default Windows Chrome 143 profile that meant a Windows UA
and navigator.userAgentData traveling with a Linux sec-ch-ua-platform header
and mismatched brands, a cross-layer inconsistency a site can check at a
glance. Derive both headers from the profile User-Agent using Chromium's
per-major GREASE algorithm, so the HTTP client hints agree with the JS surfaces.
Backspace always trimmed the last character and typing always appended,
ignoring any selection. So "select-all, then Backspace to clear" left
all but one character, and typing over a selection accumulated text
instead of replacing it.
Backspace now deletes the selected range when one is set, and typed
text replaces it, matching the browser. selectionStart is null during
ordinary typing, so the previous append/trim behavior is preserved when
no selection is tracked.
Pairs with the triple-click select-all in #320 to make the common
"triple-click, Backspace, retype" clear pattern work end to end. Also
fixes the same pattern when a page sets the selection via select() or
setSelectionRange().
fetch() and XHR did String(body), so passing a FormData as the request body
sent the literal "[object Object]" and lost the multipart payload along with
its boundary. Servers expecting multipart/form-data replied 400 "Invalid
boundary for multipart/form-data", which is what blocked booking.com: the AWS
WAF challenge POSTs its proof-of-work solution to /mp_verify as FormData, so
the verify always failed and no aws-waf-token was issued. Serialize FormData to
a multipart/form-data body with a generated boundary and set the matching
Content-Type, the way a real browser does; URLSearchParams bodies get
application/x-www-form-urlencoded. XHR.send routes through fetch() so it is
covered too. With this, obscura --stealth renders the real booking.com landing
page the same as headless Chrome.
URL.createObjectURL stored blob content asynchronously (blob.text().then) but
the Worker constructor read __blobStore synchronously, so new Worker(blobURL)
in the same tick raced the store, fell through to fetch(blobURL), and failed
with net::ERR_FAILED. This broke Blob-URL workers including the AWS WAF
challenge proof-of-work worker. Store synchronously from Blob._bytes, which the
obscura Blob materializes in its constructor.
location.reload was a no-op, so challenges that reload after setting a token
cookie never re-fetched. It now navigates like assign/replace.
location.href/assign/replace/reload cleared __virtualUrl immediately, so a
script that set location.href and re-read it saw the old URL until the async
navigation completed; real browsers update location.href synchronously. Preview
the resolved target in __virtualUrl and clear it in __obscura_init once the
navigation completes, so immediate reads match and redirects still win.
op_fetch_url backed JS fetch() and XHR with a plain reqwest/rustls client, so
in stealth mode scripted requests carried a non-browser TLS fingerprint and
none of the Chrome client hints the main navigation sends. Bot managers that
score subresource requests (the AWS WAF challenge verify call, Akamai sensors)
read that as a script and reject it. Add StealthHttpClient::send_single and a
stealth_fetch_all path that op_fetch_url uses when a stealth client is
installed, preserving the redirect, SSRF, and CORS handling so JS-level
requests share the navigation's Chrome TLS fingerprint and sec-ch-ua client
hints. The reqwest path is unchanged for non-stealth. Adds a stealth feature to
obscura-js gated on obscura-net/stealth.
Stealth mode forced a Linux navigator UA and platform but reported Windows
in userAgentData.getHighEntropyValues and served Windows Direct3D11 WebGL
renderers, a combination no real Linux Chrome ever produces and that
cross-layer fingerprint checks flag. Present one consistent Windows Chrome
145 identity instead: the wreq emulation uses Platform::Windows, and the
navigator surfaces read the UA and platform from shared constants so the
HTTP User-Agent header, sec-ch-ua-platform, navigator.userAgent, and
userAgentData all agree. userAgentData brands are now derived from the
Chrome major version via Chromium's GREASE algorithm rather than a single
hardcoded token, and a Linux WebGL renderer pool is added so a Linux UA can
never report a Direct3D11 renderer.
The digest op fell through to SHA-256 for any unrecognized algorithm name,
so SHA-512/224 and SHA-512/256 silently returned a SHA-256 hash. Add the
two FIPS 180-4 truncated variants, which the AWS WAF challenge proof-of-work
(used by booking.com and other CloudFront/WAF sites) requires.
Fixes#314.
Unknown algorithm names (SHA-512/224, MD5, etc.) were silently falling
back to SHA-256, producing wrong bytes with no error. Now throws
DOMException('NotSupportedError') to match Chrome behavior.
Supported: SHA-1, SHA-256, SHA-384, SHA-512.
These three interfaces were missing from the global scope entirely.
Site bundles that reference Plugin as a constructor threw
"ReferenceError: Plugin is not defined".
Added as plain function declarations (same pattern as PluginArray) so
they survive the V8 snapshot. navigator.plugins now returns real Plugin
instances and navigator.mimeTypes returns a real MimeTypeArray with
MimeType instances, so instanceof checks work correctly.
Repo guidance for AI coding agents and contributors, covering the things you
can't infer from the code: build (V8 from source, cmake for stealth, per-crate
scoping), why tests run under cargo nextest and not cargo test (one V8 isolate
per process), the obstacle course as the behavioral gate, the DOM/runtime
gotchas, the hang/panic-safety invariants, and a pre-finish validation
checklist.
Pages and scrapers that call document.evaluate() hit "TypeError: document.evaluate is not a function", which can break script execution and hydration. Add document.evaluate() plus the XPathResult global with the standard result-type constants so those paths work (needed for Maps scraping).
The evaluator handles the common relative forms: //tag and .//tag with attribute predicates (@attr, @attr='v', contains(@attr,'v'), starts-with(@attr,'v')), backed by querySelectorAll and attribute filtering. Absolute paths, positional predicates ([n]), text() and axes are not supported and return an empty result.
--dump assets only walked static DOM attributes (script[src], img[src], ...), so resources a page pulled in with fetch() or XHR never showed up (Closes#301).
Record each absolute URL requested through op_fetch_url in ObscuraState, expose it via the runtime and Page, and append those URLs to --dump assets as type "fetch", deduped against URLs already listed from the DOM.
--dump assets only walked static DOM attributes (script[src], img[src], ...),
so resources a page pulled in with fetch() or XHR never showed up (issue #301).
Record each absolute URL requested through op_fetch_url in ObscuraState,
expose it via the runtime and Page, and append those URLs to --dump assets as
type "fetch", deduped against URLs already listed from the DOM.
isTrusted was hardcoded to true for every Event, so new Event('x').isTrusted returned true. Real browsers return false for script-constructed events and true only for user-agent dispatched ones, so this was a trivial bot-detection tell (Closes#303).
Track trusted events in a closure-private WeakSet that page JS cannot read or forge; default isTrusted to false. The CDP input pipeline marks its synthesized mouse/key events trusted via a non-enumerable __obscura_markTrusted helper, so automation clicks stay trusted like real Chrome over CDP while page-built events report false.
isTrusted was hardcoded to true for every Event, so new Event('x').isTrusted
returned true. Real browsers return false for script-constructed events and
true only for user-agent dispatched ones, so this was a trivial bot-detection
tell (issue #303).
Track trusted events in a closure-private WeakSet that page JS cannot read or
forge; default isTrusted to false. The CDP input pipeline marks its synthesized
mouse/key events trusted via a non-enumerable __obscura_markTrusted helper, so
automation clicks stay trusted like real Chrome over CDP while page-built
events report false.
Mark the Event constructor as native so that Event.toString() returns "[native code]" instead of leaking the JavaScript implementation. This closes a stealth fingerprinting vector.
The method was missing, so calling it threw TypeError: ... is not a function, which broke Vue 3/Nuxt client hydration (Closes#297). Back it with the existing attribute_names op, the same source as the attributes getter, so the order matches attributes and outerHTML.
The method was missing, so calling it threw TypeError: ... is not a
function, which broke Vue 3/Nuxt client hydration (issue #297). Back it with
the existing attribute_names op, the same source as the attributes getter, so
the order matches attributes and outerHTML.
cargo install --features stealth failed on Windows with E0119 in cookie 0.18.1: its blanket From<T: Into<Option>> impl fails the coherence check on some toolchains (issue #295). obscura never uses reqwest's cookie store; it manages cookies through its own CookieJar and sets the Cookie/Set-Cookie headers directly. The cookies feature only pulled in the cookie/cookie_store crates (and the whole time subtree).
Drop it. Removes 12 packages including cookie, cookie_store, and time, so the failing crate is no longer compiled in any build. No behavior change: the cookie jar unit tests and the obstacle-course cookies stage still pass.
cargo install --features stealth failed on Windows with E0119 in cookie
0.18.1: its blanket From<T: Into<Option<OffsetDateTime>>> impl fails the
coherence check on some toolchains (issue #295). obscura never uses reqwest's
cookie store; it manages cookies through its own CookieJar and sets the
Cookie/Set-Cookie headers directly. The cookies feature only pulled in the
cookie/cookie_store crates (and the whole time subtree).
Drop it. Removes 12 packages including cookie, cookie_store, and time, so the
failing crate is no longer compiled in any build. No behavior change: the
cookie jar unit tests and the obstacle-course cookies stage still pass.
The x86_64-apple-darwin release crashed at V8 isolate init with SIGSEGV in ReadOnly snapshot deserialization (issue #290). obscura-js bakes a V8 startup snapshot at build time via a build script, which runs on the host arch. The job built x86_64 on macos-latest (Apple Silicon), so an arm64 snapshot got embedded into the Intel binary and V8 crashed deserializing it. Build on macos-15-intel so the snapshot matches the target.
Drop the aarch64-linux-cross artifact: it has the same defect (x86_64-host snapshot in an arm64 binary) and the native ARM runner already covers it.
Add a post-build smoke test that runs the V8 path on a data: URL so a release can never ship a binary that crashes at isolate init again.
Closes#290
The x86_64-apple-darwin release crashed at V8 isolate init with SIGSEGV in
ReadOnly snapshot deserialization (issue #290). obscura-js bakes a V8 startup
snapshot at build time via a build script, which runs on the host arch. The
job built x86_64 on macos-latest (Apple Silicon), so an arm64 snapshot got
embedded into the Intel binary and V8 crashed deserializing it. Build on
macos-15-intel so the snapshot matches the target.
Drop the aarch64-linux-cross artifact: it has the same defect (x86_64-host
snapshot in an arm64 binary) and the native ARM runner already covers it.
Add a post-build smoke test that runs the V8 path on a data: URL so a release
can never ship a binary that crashes at isolate init again.
hasIframeProxy fix contentWindow now returns null when parentNode === null (iframe never inserted into any tree). The previous attempt used isConnected, which broke getBehemothIframe by returning null for iframes inside the phantom iframe's detached document. That caused PHANTOM_DARKNESS to fall back to self (main window), making all DOM measurement ops run on the main document instead of the isolated iframe body, which corrupted the fingerprint element lookup.
Worker auto-execution Worker constructor now resolves relative URLs against location.href, fetches the script, and auto-runs it. The worker scope exposes WorkerGlobalScope so IS_WORKER_SCOPE resolves correctly in creepjs. All three worker types now complete within the 3s timeout instead of silently timing out.
AudioBuffer + AudioContext AudioBuffer class added to globalThis with getChannelData, copyFromChannel, copyToChannel. AudioContext.createBuffer returns a real instance. AudioContext.addEventListener was a no-op; startRendering now dispatches to stored listeners so the complete event fires.
Crypto Crypto class added to globalThis with randomUUID and getRandomValues on its prototype. Fixes ReferenceError in getPlatformEstimate.
NetworkInformation + ContentIndex NetworkInformation class with downlinkMax on the prototype clears noDownlinkMax. ContentIndex stub on globalThis clears noContentIndex. navigator.connection is now a real NetworkInformation instance.
getElementById / querySelector stale index fix id_index only registers nodes at creation time. When replaceChild(fragment, el) swapped in new nodes their IDs hit .or_insert() and were skipped, leaving the index pointing at the detached original. getElementById now verifies the indexed node is in the live document and falls back to a full attribute-selector scan. query_selector_from falls through to the full scan on both index miss and stale entry instead of returning None early.
matchMedia Handles common queries instead of always returning false. prefers-color-scheme: dark returns true (headless Chrome defaults to light mode, so returning true for light is a bot signal). any-pointer: fine, hover: hover, prefers-reduced-motion: no-preference, min/max-width, and (color) return sensible values.
Fingerprint consistency jsHeapSizeLimit raised to 4294705152 (4 GB, matching 64-bit Chrome). getHighEntropyValues platform corrected from "Linux" to "Windows", platformVersion from "6.1.0" to "10.0.0" to match the Windows NT 10.0 UA string. Canvas: real PNG encoder, arc fill, multiply blend mode. Screen class, performance.now, chrome runtime errors fixed.
Build note deviceMemory and hardwareConcurrency use the high-value pools ([4, 8] GB and [4, 6, 8, 12, 16] cores) only when built with --features stealth.
jsHeapSizeLimit raised to 4294705152 (~4 GB) to match 64-bit Chrome.
Was 2172649472 (~2 GB).
getHighEntropyValues platform corrected to "Windows" and platformVersion
to "10.0.0" to match the Windows NT 10.0 user agent string. Was "Linux"
/ "6.1.0", which mismatched the UA and could be detected.
matchMedia now handles common queries rather than always returning false:
prefers-color-scheme:dark returns true (avoiding prefersLightColor headless
flag — headless Chrome defaults to light so true is a bot signal),
any-pointer:fine, hover:hover, prefers-reduced-motion:no-preference,
min/max-width, and (color) return sensible values.
Result: 0% like-headless, 0% headless, 0% stealth, 33/33 obstacle course.
Note: build must include --features stealth for deviceMemory and
hardwareConcurrency to use the high-value pools ([4,8] and [4,6,8,12,16]).
NetworkInformation class with downlinkMax on its prototype clears
noDownlinkMax from the like-headless flag list. creepjs checks
'downlinkMax' in (window.NetworkInformation?.prototype || {}).
ContentIndex stub on globalThis clears noContentIndex. creepjs checks
'ContentIndex' in window.
navigator.connection is now a real NetworkInformation instance instead
of a plain object literal.
Result: 0% like-headless, 0% headless, 0% stealth on creepjs. 33/33
obstacle course unchanged.
Worker auto-execution: Worker constructor resolves relative URLs against
location.href, fetches the script, and auto-runs it. WorkerGlobalScope is
exposed so IS_WORKER_SCOPE resolves correctly. Worker data (navigator,
locale, timezone) now arrives via postMessage within the 3s timeout.
AudioBuffer class added to globalThis with getChannelData, copyFromChannel,
copyToChannel. AudioContext.createBuffer returns a real AudioBuffer. Fixes
ReferenceError that blocked the audio fingerprint section.
Crypto class added to globalThis with randomUUID and getRandomValues on its
prototype. Fixes ReferenceError in getPlatformEstimate.
AudioContext.addEventListener was a no-op; startRendering only fired
oncomplete. Add _listeners map and dispatch to stored listeners.
getElementById and querySelector('#id') returned null for elements inserted
via replaceChild(documentFragment, el). id_index only registers nodes at
creation time; .or_insert skipped IDs on cloned fragments; None caused
query_selector_from to short-circuit the full scan.
Fix querySelector: fall through to full scan on index miss or stale entry.
Fix getElementById: verify node is in live document; fall back to attribute
selector scan if not.
contentWindow getter: return null when parentNode is null (iframe was never
inserted into any tree). Previously returning null for all disconnected
iframes broke getBehemothIframe, which caused PHANTOM_DARKNESS to fall back
to self (main window) instead of the phantom iframe. That made DOC point to
the main document, so rect and font measurements wrote fragment nodes into
the main body, and the subsequent full-DOM scan in getElementById could not
find fingerprint-data through nested fragments in time.
Limiting the null return to parentNode===null (never inserted) means
getBehemothIframe gets a valid nested contentWindow as expected. creepjs
now reports hasIframeProxy: false and 0% stealth detection.
DocumentFragment constructor: pass nid through when provided.
Range.getBoundingClientRect / getClientRects: return commonAncestor rect.
Upgraded wreq to 6.0.0-rc.29 and wreq-util to 3.0.0-rc.12 to apply the latest Chrome 145 TLS fingerprint (16 cipher suites, proper extension order, ALPS, GREASE).
Introduced __obscura_stealth global to isolate stealth-specific JS behaviors from the standard execution path.
Overrode navigator.userAgentData and getHighEntropyValues() in stealth mode to perfectly match the sec-ch-ua network headers.
Restricted hardwareConcurrency and deviceMemory pools in stealth mode to desktop-realistic values (e.g., minimum 4 cores, 4GB RAM) to eliminate mobile-tier leaks.
Fixed usedJSHeapSize occasionally exceeding totalJSHeapSize.
wreq upgraded to 6.0.0-rc.29 and wreq-util to 3.0.0-rc.12 to apply the
latest Chrome 145 TLS fingerprint (16 cipher suites, proper extension
order, ALPS, GREASE).
__obscura_stealth global variable introduced and passed from Rust to JS,
allowing stealth-specific JS behaviors to run only when the stealth flag
is enabled.
Tightened hardwareConcurrency and deviceMemory pools in stealth mode
to desktop-realistic values (e.g. min 4GB RAM, 4 cores), eliminating
mobile-tier values that leak the spoof.
navigator.userAgentData and getHighEntropyValues() are now overridden
in stealth mode to perfectly match the sec-ch-ua header sent by
wreq_util::Profile::Chrome145.
Fixed usedJSHeapSize sometimes exceeding totalJSHeapSize.
OfflineAudioContext.startRendering() now fires oncomplete asynchronously
(microtask) so callers that set ctx.oncomplete after calling startRendering
(fpCollect pattern) get the callback. The rendered buffer contains a
triangle wave scaled to produce the Chrome Linux audio fingerprint sum
(~124.04). createBuffer() now uses persistent Float32Array channels so
written sample values are read back correctly.
screen is now an instance of a Screen class with native getters on the
prototype. Object.getOwnPropertyDescriptor(Object.getPrototypeOf(screen),
"width").get.toString() now returns the expected native-code format instead
of throwing.
chrome.runtime.connect() and sendMessage() now throw the Chrome error
("Could not establish connection. Receiving end does not exist.") instead
of returning silently, matching what fpCollect records for detailChrome.
performance.now() now advances by 0.1ms per call within the same clock
tick, simulating ~100us timer resolution. Previously it aliased Date.now()
which returns the same integer for all calls in one tick, making
redPill2 = Infinity (serialized as null).
WebSocket constructor now throws SyntaxError for non-ws/wss URLs, matching
Chrome's behavior and populating errorsGenerated correctly.
toDataURL() now encodes the real pixel buffer as a valid PNG using
stored-block DEFLATE, CRC32, and Adler32. The 106-byte stub it replaced
was a static value that any canvas fingerprinter could trivially flag.
fill() rasterizes arc paths from this._path using a distance-check
circle fill, so arc-based canvas tests produce real pixel data.
_setPixel() handles globalCompositeOperation='multiply' so fpCollect's
colored-circle blend test composites correctly.
Canvas dimensions now read from getAttribute('width'/'height') so
explicitly sized canvases (e.g. 400x200) use their declared dimensions
instead of always defaulting to 300x150.
canPlayType was undefined on <audio> and <video> elements. Adds HTMLMediaElement
extending Element with canPlayType returning values that match Chrome 145 on Linux
(probably/maybe/empty per MIME type). HTMLAudioElement and HTMLVideoElement extend
HTMLMediaElement. _elementClassFor routes AUDIO and VIDEO tags to the correct class.
Navigator: adds a global Navigator constructor so typeof Navigator !== 'undefined'.
Links it into the navigator prototype chain (_wdProto = Object.create(Navigator.prototype))
so navigator instanceof Navigator returns true, matching Chrome.
These are two of the signals in server-side fingerprint classifiers (fpCollect et al).
PluginArray: real browsers expose a native PluginArray type. navigator.plugins
was returning a plain Array, so `navigator.plugins instanceof PluginArray` and
`navigator.plugins[0].toString()` (which should return [object Plugin]) were
failing sannysoft-style checks. Adds a PluginArray constructor whose prototype
inherits from Array.prototype, with Symbol.toStringTag='PluginArray'. Each
plugin entry now carries Symbol.toStringTag='Plugin' so Object.prototype.toString
returns [object Plugin].
webdriver: moved the webdriver getter off the navigator instance and onto a
thin prototype so Object.getOwnPropertyDescriptor(navigator, 'webdriver')
returns undefined, matching Chrome where the property lives on Navigator.prototype.
Getter is marked native. sannysoft "WebDriver (New)" now reports "missing (passed)".
navigator.platform returned "Win32" while navigator.userAgent said Linux when
using the stealth client. page.rs now calls set_platform("Linux x86_64", "Linux", "")
alongside set_user_agent when the stealth client is active.
bootstrap.js: pre-declare 71 internal _* globals as enumerable:false before
var runs, so they don't show up in Object.keys(window) on real pages.
deviceandbrowserinfo.com detects CDP by setting Error.prepareStackTrace as a spy, then calling console.log(new Error()). _consoleFn accessed a.stack, which fires prepareStackTrace if set. Fix: save/clear/restore around the .stack access.
NetworkInformation (navigator.connection) and ServiceWorkerContainer (navigator.serviceWorker) are EventTargets in real browsers. React 18 and mobile SPAs call addEventListener on them; the stubs lacked it, so the app bundle crashed with 'addEventListener is not a function' (e.g. goofish.com).
CDP clients (browser-use and similar) focus an input via DOM.focus before
typing; Input.dispatchKeyEvent then targets document.activeElement. Without a
DOM.focus handler the focus call errored, keystrokes landed on nothing, and the
field stayed empty. Resolve the node and call its JS focus(), which sets
document.activeElement. Completes the type path for DOM-agent frameworks.
CDP DOM-agent frameworks (browser-use and similar) could connect but saw a
blank page: navigation never refreshed their cached target url/title, and their
DOM build aborted with "Unknown domain: DOMSnapshot".
- Emit Target.targetInfoChanged after navigation (both CDP Page.navigate and
JS-initiated) carrying the post-nav url/title. Strict clients refresh a target
only on this event; it also fixes Puppeteer/Playwright page.url() tracking.
- Implement DOMSnapshot.captureSnapshot, built from the DOM with synthetic
geometry (no paint engine) and plausible computed styles, keyed by the same
backendNodeId as DOM.getDocument. Enough for tag/ARIA/accessibility element
detection; it runs only when a client requests it, so the fetch path is
unaffected. Coordinate clicking still requires a real layout engine.
The wrapper Page cloned a second Arc<BrowserContext> the struct never read.
The inner obscura_browser::Page already holds the context and keeps it alive,
so the field was dead weight. Removes the dead_code warning reported on
aarch64
Closes#273
The geolocation shim returned a fixed Frankfurt position. With the timezone now
configurable, a deployment in another region would report a location that does
not match its exit IP or timezone, the mismatch issue #228 describes.
Set OBSCURA_GEOLOCATION="lat,lon" to override the reported coordinates; the
value is range-checked and falls back to the Frankfurt default when unset or
malformed. The existing positional jitter is preserved.
The AbortController/AbortSignal globals were minimal stubs: AbortSignal had no
prototype and no throwIfAborted, and AbortController.abort() only set a flag
without firing listeners. Pages that read AbortSignal.prototype or call
throwIfAborted threw non-fatal TypeErrors.
AbortSignal is now a class with a prototype, aborted/reason, throwIfAborted(),
working addEventListener/onabort firing, and static abort()/timeout()/any().
AbortController.abort(reason) fires the abort event. Bootstrap-only, no perf
impact.
serve rejected --quiet ("unexpected argument"), so there was no way to
silence the per-page WARN logs (obscura_browser::page script errors) that
flood the console when serving pages with broken scripts. Add the flag to
the Serve command and cover it in is_quiet_command, so it maps to the same
"off" log filter that fetch and scrape already use. Closes#264.
The default V8 flags only set --max-old-space-size (a ceiling, not actual
use). The young generation (default 16MB per semi-space) plus V8's
memory-heavy codegen inflate RSS during parse and JS allocation. Add
--max-semi-space-size=4 and --optimize-for-size to the defaults.
Cuts RSS ~10-17% on real pages (ycombinator.com 170MB -> 141MB,
react.dev 94MB -> 83MB, wikipedia 58MB -> 53MB) with no measurable speed
cost: a JIT-sensitive 8M-iteration loop is unchanged (V8 still optimizes
hot paths), and the obstacle course stays 33/33 at the same latency.
Users can still override via --v8-flags.
A slow non-essential ES module could block navigation for ~10s. On real
pages (www.ycombinator.com) obscura spent ~11.5s loading YC's bookface
module, whose top-level eval idle-waits forever, until the phase watchdog
killed it. The page renders fully without it (CPU was ~3s, the rest pure
idle), but wall time was ~2.5x lightpanda.
load_module / load_inline_module now take a per-module budget and bound
both the import-graph fetch and the eval loop with it. page.rs sizes the
budget by whether the body is already rendered: an enhancement module on
a populated page (>50 body descendants) gets a short budget (3s, override
OBSCURA_MODULE_BUDGET_MS); an unmounted SPA shell keeps the full script
budget so the app module still mounts (#205). Fetch concurrency is
unchanged; only the idle eval wait is bounded.
www.ycombinator.com: wall 13.1s -> 6.6s, still 475 links, CPU unchanged.
teragrowth.org (Vite SPA) still renders. Obstacle course 33/33 including
the es-modules / ssr-hydrate / spa stages. Post-drop eval works.
* Fix UTF-8 char boundary panic in browser_search
The search snippet window sliced body[..start] / body[end..] at byte
offsets derived from char counts and needle.len(), which can land inside
a multi-byte (CJK) character and panic, killing the MCP stdio transport.
Snap start/end to char boundaries before slicing and advance past
whitespace by the actual char width. Closes#257.
* Add setLineDash and friends to the canvas 2D mock
Charting libraries (Highcharts, ECharts) call setLineDash/getLineDash/
ellipse every animation frame. The missing setLineDash threw 'is not a
function' from a timer each tick, spamming the console. Adds the
line-dash methods plus ellipse/roundRect/createConicGradient/
getContextAttributes stubs. Addresses the error spam in #258; the
multi-tab isolate-teardown crash is a separate deeper issue.
* Fix multi-tab tab_close V8 isolate abort
rusty_v8 enters each isolate on creation and requires them dropped in
reverse order of creation. The MCP gave each tab its own isolate and
dropped them in arbitrary (user-chosen) order on tab_close, so closing a
non-newest tab aborted the process (Check failed: heap->isolate() ==
Isolate::TryGetCurrent()). Pages with active timers (Highcharts/ECharts)
made it easy to hit.
Enforce the CDP server's single-live-isolate discipline in the MCP: at
most one tab's isolate is ever live. page_mut calls a new activate() that
suspends every other tab (drops its isolate, keeps its DOM) and resumes
the active one. close_tab drops the closed tab's isolate via suspend_js
while it is still the only entered isolate, and tool_close suspends all
tabs before clearing. Confined to obscura-mcp; the engine, CDP server and
obstacle course are untouched, and peak memory drops to one isolate
instead of N. Closes#258.
HTMLScriptElement/HTMLImageElement .src reflected the literal attribute
instead of the resolved absolute URL. Loaders that derive their base with
new URL(document.currentScript.src).origin threw "Invalid URL" on a
relative src and aborted, so the page bundle never loaded and the DOM
stayed empty (issue #255). getAttribute("src") still returns the literal.
* DOM read fast paths: constant nodeType, fewer ops and allocations
- Element.nodeType is now a constant getter instead of inheriting Node's
dynamic op-backed getter. nodeType is invariant (1) for element wrappers
(wrappers are only built for element nodes and node ids are never freed
and reused), so this drops one JS/Rust op per read on a pervasive path.
Micro-bench: ~600k nodeType reads go from ~380ms to ~5ms.
- attributes getter reads each attribute once instead of twice.
- dataset Proxy is cached per element instead of reallocated on every
access, which also makes el.dataset === el.dataset (spec-correct).
- stylesheet <link> scan borrows the node (with_node) instead of deep
cloning it and compares rel via eq_ignore_ascii_case, dropping a clone
and a lowercased String alloc per link per navigation.
No regression: obstacle course 33/33, WPT dom/nodes Node- subset identical
to baseline (3551/4163 subtests), targeted DOM probe identical.
* DOM op reads: borrow the node instead of deep-cloning it
The scalar read arms in op_dom (node_type, node_name, tag_name,
get_attribute, attribute_names, parent/first/last/next/prev lookups,
has_child_nodes, pi_target, doctype_name, doctype_public_id) and the
node_root / node_ancestors / node_child_index walks called get_node,
which clones the whole Node (for elements a deep clone of the attribute
Vec and its Strings) just to read one field. Switch them to with_node,
which borrows under the RefCell. element_children and the
document_element / document_doctype child scans stay on get_node on
purpose (they iterate, so the borrow story is left untouched).
Output is identical. Traversal/attribute micro-bench ~25-30% faster with
fewer allocations per op. No regression: obstacle course 33/33, WPT
dom/nodes identical to baseline (7057/10620 across 314 files).
* base64 decode: reverse-lookup table instead of indexOf scan
_base64ToUint8Array decodes every fetch/XHR response body. It ran an
O(64) alphabet.indexOf plus a one-char substring alloc per character.
Build a charCode->6-bit reverse table once at module load and index it by
charCodeAt, folding out-of-range (NaN or code >= 128) to -1 so the
bitmath stays byte-identical to the old indexOf path on valid and
malformed input alike.
~20-25% faster decoding a 2MB body; output byte-identical (differential
test across valid, unpadded, '='-position, >127, non-alphabet and
truncated inputs matches the old code exactly). No regression: obstacle
course 33/33.
DOMTokenList.supports() threw "DOMTokenList has no supported tokens"
for relList and sandbox because they were created without a supported
set. Vite's modulepreload polyfill runs link.relList.supports('modulepreload')
at the top of every bundle, so the throw aborted the whole ES module and
React/Vite SPAs rendered blank. relList and sandbox now carry their token
sets; classList still has none and correctly throws.
Mirror the wiki content into a docs/ tree so GitBook Git Sync can connect to
this repo. Adds SUMMARY.md for the table of contents, .gitbook.yaml pointing
GitBook at docs/, and converts wiki page links to relative .md paths.
build_request_client (the client behind every JS fetch(), XHR, and ES-module
load) set no request timeout. A cross-origin XHR with a non-simple header
triggers a CORS preflight OPTIONS; if the server accepts the connection but never
answers it, op_fetch_url's send().await hung forever, the fetch promise never
settled, and the XHR was stuck at readyState 1 with no completion event ever
firing. That stranded Angular HttpClient on live.deutsche-boerse.com.
Give the client a 30s request timeout (matching the other clients in the
workspace); OBSCURA_FETCH_TIMEOUT_MS overrides it. On timeout the request errors,
op_fetch_url propagates it, and the fetch shim turns it into an XHR error/loadend,
so readyState advances to 4 and Angular's error path runs.
Verified: a server that hangs on OPTIONS now makes the XHR fire error at the
timeout (was stuck forever); a CORS-allowed cross-origin XHR still succeeds with
load/200; obstacle course 33/33.
Closes#251
A hung page (a runaway Runtime.evaluate, a synchronous DOM op) holds the
process-wide V8 lock and wedges every other session forever. The one-shot CLI has
a process-level hard deadline for this; the long-running server cannot force-exit,
so terminate just the offending isolate instead.
dispatch() arms a watchdog on the session page's isolate after taking the V8 lock
and disarms it after the handler; on overrun it terminates the isolate and clears
the termination flag so the session keeps working. OBSCURA_CDP_COMMAND_TIMEOUT_MS
tunes the bound (default 60s, 0 disables); navigation already self-bounds well
under it.
The watchdog is a single long-lived shared thread (cdp_watchdog), not a thread
per command: the V8 lock serializes commands so at most one is ever armed, and
arm/disarm are a mutex plus notify with no measurable per-command cost. Also
fixes a lost-wakeup in the per-call watchdog (spawn_watchdog) where disarming a
fast call before the watchdog thread started blocked for the full budget.
Verified: a hung Runtime.evaluate is terminated at the budget and the session
recovers; CDP integration 37/37, obstacle course 33/33; per-command latency
unchanged (watchdog on equals off within noise).
* fix(dom): read insert_before prev_sibling after detach to avoid a sibling cycle
insert_before captured the reference node's prev_sibling BEFORE detaching the
node being inserted. When page JS inserts a node before its own immediate
previous sibling (a no-op reorder frameworks do constantly), detach moves that
pointer, so the stale prev_id spliced new.next_sibling = new. The resulting
sibling self-cycle made descendants() inner next_sibling walk loop forever in
native Rust, uninterruptible by tokio or the V8 watchdog. This hung ebay.com,
found by a 1500-page crash/hang sweep.
Read prev_sibling after detach so it reflects the post-detach tree. Cap the inner
sibling-collection loops in descendants() and log when a descendants cap fires,
as defense in depth so a sibling cycle from any path cannot hang the engine. Add
test_insert_before_previous_sibling_no_cycle.
ebay.com now renders (192K chars) instead of hanging. Verified: 33/33 obscura-dom
tests, obstacle course 33/33.
* fix(cli): settle async --eval work before reading the page (#248)
--eval ran after the selector check and returned its value immediately, so an
expression that only kicks off async work (e.g. fetch().then(t => DOM write))
returned the still-pending Promise as {} and any --selector/--dump were ignored.
When --eval is combined with --selector and/or --dump, run the eval to kick off
its async work, settle the event loop again so it completes, then read the page
(selector wait + dump) instead of returning the Promise. A bare --eval still
returns its value unchanged, so synchronous expressions are not affected, and the
eval stays bounded by evaluate_with_timeout.
dump becomes Option<DumpFormat> so an explicit --dump is distinguishable from the
default. Verified: the repro now returns the fetched content, a plain
JSON.stringify eval is unchanged, 37 + 16 cli tests pass.
* dom: complete detached documents; add ProgressEvent and parseHTMLUnsafe
createHTMLDocument returned a document whose body was null: parsing the
skeleton string as innerHTML of <html> collapsed through the fragment parser
and kept only <title>. Build head>title and body explicitly instead. The
DOMParser doc node also gains appendChild/removeChild/insertBefore and a
doctype getter, and createDocument carries its doctype argument.
This lets dom/common.js setupRangeTests() finish (it builds fixtures with
new Document(), createHTMLDocument(), createDocument()) instead of aborting at
harness setup, so the dom/ranges files run. Range-collapse 186/186,
Range-attributes 1/1, Range-cloneRange 55/62, StaticRange-constructor 14/17.
The large O(ranges x points) matrix files (Range-set, comparePoint,
compareBoundaryPoints) still time out: the boundary-point comparison walks the
tree through per-step DOM ops, which is too slow for those matrices. Speeding
that up (a Rust node-position op) is a separate change.
Also add globalThis.ProgressEvent (Event subclass with lengthComputable/
loaded/total) and the static Document.parseHTMLUnsafe(html).
* perf(dom): compute Range tree-order in Rust ops
The Range boundary-point algorithms compared nodes by walking ancestor chains
and sibling lists from JS, one DOM op per step. The dom/ranges matrices
(testRanges x testPoints, thousands of comparisons) made that time out.
Add node_index, compare_order, and node_root ops that do the walk in Rust over
the DOM tree (slab lookups, no per-step FFI), and use them in _rngNodeIndex,
_rngOrder, and _rngRoot. Range-comparePoint 3534/5580, Range-isPointInRange
5197/5733, Range-compareBoundaryPoints 6441/9313, all completing in under 10s
where they previously did not finish. Not on any engine hot path (querySelector,
mutation, navigation, fetch do not use these).
* fileapi: real byte-backed Blob/File, FileReader state machine, Request.blob
Blob was a one-line stub that string-joined its parts (wrong size for binary
or multibyte input) and exposed only text(). Replace it with a Uint8Array-backed
Blob: normalizes string/ArrayBuffer/typed-array/Blob parts, correct size,
slice(), text()/arrayBuffer()/bytes(), type normalization (lowercased, cleared
when non-ASCII), the endings option (native CRLF normalization), and
Symbol.toStringTag. File adds name/lastModified, the 2-argument requirement, and
its own toStringTag.
FileReader becomes a real state machine: EMPTY/LOADING/DONE, throws
InvalidStateError when a read starts while already LOADING, and fires
loadstart/progress/load/loadend (and abort) to both on* handlers and
addEventListener. readAsText/DataURL/ArrayBuffer/BinaryString produce correct
results. Request gains blob().
FileReader-multiple-reads 6/6, File-constructor-endings 10/11, Blob-slice
141/150.
* encoding: TextDecoder supports legacy charsets via encoding_rs
TextDecoder was a UTF-8-only JS shim that ignored its label, so
new TextDecoder('shift_jis'|'euc-jp'|'big5'|'gbk'|'windows-1252'|'iso-8859-1')
.decode() returned garbage. That matters for scraping: pages and scripts decode
legacy-encoded bytes (XHR/fetch responses, File/Blob text) all the time.
Back it with encoding_rs (already used by the net layer for response bodies):
obscura-net gains label_name() and decode_with_label(), exposed to JS as
op_encoding_for_label and op_text_decode. The constructor validates the label
(RangeError) and sets the canonical .encoding/.fatal/.ignoreBOM; decode()
supports fatal mode (TypeError on invalid input) and BOM handling.
Plain UTF-8 non-fatal decoding keeps a pure-JS fast path (no op, no JSON), so
Response.text()/Blob.text() on large bodies are unchanged; the op only runs for
legacy encodings or fatal mode.
textdecoder-labels 222/222, textdecoder-fatal 36/36, byte-order-marks 3/3,
utf16-surrogates 10/10, single-byte-decoder 168/504.
* selection: real Selection over Range (single instance per document)
getSelection() returned a fresh plain object each call, so it was not a Selection
instance, had no identity (window.getSelection() !== document.getSelection()),
and was missing removeRange/empty/collapseToStart/collapseToEnd/setPosition/
setBaseAndExtent/modify while collapse/extend/getRangeAt were broken stubs. That
was ~4862 failing 'selection[method] is not a function' subtests.
Replace it with a real Selection class built on the live Range: one cached
instance per document (window.getSelection() === document.getSelection(), null
for documents with no defaultView), an internal range + direction, derived
anchor/focus/isCollapsed/type/rangeCount, and the full method set
(getRangeAt with IndexSizeError, addRange, removeRange, removeAllRanges/empty,
collapse/setPosition, collapseToStart/End, extend, setBaseAndExtent,
selectAllChildren, containsNode, toString) with the spec DOMException names.
modify() stays a no-op (needs visual layout). No ops.rs changes; not on any hot
path.
setBaseAndExtent 120/120, removeAllRanges 104/104, collapseToStartEnd 51/51,
type 26/26, extend-exception 1/1.
* forms: HTMLInputElement valueAsNumber/valueAsDate/stepUp/stepDown
These were absent. Implement the number/date conversion API for the applicable
types (number, range, date, month, week, time, datetime-local): parsers and
formatters per type (Date.UTC for date/datetime-local, ISO-week Monday math for
week, months-since-epoch for month), valueAsNumber get/set, valueAsDate get/set,
and stepUp/stepDown(n) with the step scale/default per type, step-base snapping,
and min/max clamping. Non-applicable types and step="any" throw InvalidStateError;
non-finite valueAsNumber throws TypeError; range with empty value reads as the
midpoint (50 for 0..100). Plus min/max/step attribute reflection.
All on Element accessors gated by input type, only evaluated on explicit access;
not on any hot path.
input-stepup 51/53, input-valueasnumber 60/64, input-valueasdate 27/30.
* perf(encoding): skip label op for no-arg TextDecoder (Response/Blob text path)
new TextDecoder() with no argument (what Response.text()/Blob.text() and most
pages use) is UTF-8; resolve that without the op_encoding_for_label round trip.
Explicit labels still validate via the op.
* core: HTMLDialogElement, Node.normalize, Range.createContextualFragment
HTMLDialogElement was a bare alias of Element, so ~40 Core files crashed at the
first dialog.show()/showModal(). Add the dialog members on Element.prototype
(matching the popover/input convention): open reflection, returnValue (internal
slot, String coercion), oncancel/onclose defaulting to null, show()/showModal()
/close()/requestClose() with the open-state transitions, InvalidStateError
throwing (show-on-modal, showModal-on-open, showModal-disconnected), the async
non-bubbling close event, the synchronous cancelable cancel event (no re-entrant
cancel), beforetoggle/toggle, and closedBy. matches(':modal') reports a modally
shown dialog (like :popover-open). Modal top-layer/focus/render stays unhandled
(layout).
Node.normalize() now merges adjacent Text nodes and drops empty ones (was a
no-op). Range.createContextualFragment(html) parses a fragment.
createContextualFragment 34/35, dialog-open 3/3, dialog-return-value 1/1,
dialog-showModal 5/10, dialog-requestclose 22/75. Not on hot paths.
* custom-elements: CustomElementRegistry class, getName, ElementInternals, HTMLUnknownElement
customElements was a bare object literal, so 'CustomElementRegistry is not
defined' crashed ~22 Core files at top level (idlharness/feature-detect), and
HTMLUnknownElement/ElementInternals/attachInternals/getName were missing.
Promote the registry to a real CustomElementRegistry class (window.customElements
is now an instance, so 'define in CustomElementRegistry.prototype' works and the
class is exposed). Add spec define() validation/throw order (TypeError for a
non-constructor, SyntaxError for an invalid name, NotSupportedError for a
duplicate name/constructor or reentrant define), getName(), and a SyntaxError
reject in whenDefined for invalid names. The existing define -> upgrade ->
connectedCallback path (used by lit/Polymer on real pages) is preserved.
Add HTMLUnknownElement, ElementInternals (setFormValue/setValidity/checkValidity/
validity/validationMessage/form/states), and Element.attachInternals() with its
NotSupportedError cases.
Does NOT include the [[HTMLConstructor]] (new.target) construction rework or the
reaction wiring into appendChild/setAttribute (hot paths) - deferred to avoid
element-model and perf risk. getName 4/4.
* shadow-dom: ShadowRoot.adoptedStyleSheets (mirror Document)
ShadowRoot lacked adoptedStyleSheets; constructible-stylesheet adoption into a
shadow root threw. Add the getter/setter on ShadowRoot.prototype, matching
Document.adoptedStyleSheets.
* conformance: charset/URL encoding override, IDL reflection, Range and Storage fixes
Encoding: track the detected document character encoding and expose it as
document.characterSet/charset/inputEncoding instead of hardcoding UTF-8. Add the
WHATWG URL encoding override for <a>/<area> hrefs, so a legacy-charset document
percent-encodes the query in its own encoding (EUC-JP U+8108 becomes %CC%AE).
ASCII delimiters stay literal so structured queries are unaffected, and
unmappable code points become %26%23NNN%3B. UTF-8 documents take an early-return
so the href getter hot path is unchanged. fetch()/XHR text() now decode using
the response Content-Type charset instead of always UTF-8. Adds op_url_encode_query
and a document_encoding op; encoding state is plumbed through Page and the runtime.
Reflection: install the reflected IDL attribute layer once on Element.prototype
(no per-element defineProperty, so element creation and query paths are
untouched): global attributes (title, lang, dir, accessKey, slot, autofocus,
hidden, tabIndex) with correct type coercion and enum canonicalization, plus the
ARIAMixin (aria-* reflected as the ariaXxx nullable DOMString properties).
Range: compareBoundaryPoints ToUint16-converts the how argument (NaN/Infinity
no longer false-throw) and raises a real WrongDocumentError DOMException for
ranges that do not share a root.
Storage: reimplement localStorage/sessionStorage as a WHATWG Proxy so property
access (localStorage.foo = x, delete, in, Object.keys) stays in sync with
length, key() and iteration.
* conformance: HTML attribute folding, compareDocumentPosition, URLSearchParams, DOMParser, fetch UA
Next WPT Core batch, all verified live and benchmarked for no hot-path regression.
DOM:
- compareDocumentPosition rewritten to spec: real PRECEDING/FOLLOWING via the
compare_order op, CONTAINS/CONTAINED_BY, and DISCONNECTED|IMPLEMENTATION_SPECIFIC
for different roots. Adds the DOCUMENT_POSITION_* and missing nodeType constants
on Node and its prototype. WPT getPosition() depends on this, so it cascades into
the Range comparePoint/compareBoundaryPoints/isPointInRange expectations.
- HTML elements now ASCII-lowercase attribute names in getAttribute/setAttribute/
removeAttribute/hasAttribute, so el.setAttribute('accessKey') reflects via
getAttribute('accesskey'). The fold is gated behind a cheap uppercase scan and a
namespaceURI HTML guard (SVG/foreign attributes keep their case), and getAttribute
only folds on a miss, so the all-lowercase hot path is unchanged (benchmarked flat).
- Range comparePoint/isPointInRange ToUint32-coerce the offset (negative throws
IndexSizeError); createTreeWalker/createNodeIterator keep an explicit whatToShow of 0.
URL:
- URLSearchParams uses a proper form-urlencoded byte decoder (partial/invalid escapes
stay literal instead of decodeURIComponent throwing on the whole string), 2-arg
has(name,value)/delete(name,value), and a sequence-pair TypeError. Adds URL.parse
and URL.canParse statics.
Other:
- fetch()/XHR send a default Chrome User-Agent (the navigation path set one but
scripted requests went out with none, so UA-gated servers rejected them); honors an
explicit override.
- DOMParser documents expose URL/compatMode/characterSet/readyState/styleSheets/
defaultView and createTreeWalker/createNodeIterator; XMLDocument is defined globally.
* perf(dom): O(1) querySelector('#id') via the id index
querySelector('#id') went through the full selector engine, scanning descendants
in tree order until the first match (O(n), ~68us per call over a 2000-element
document in the micro-bench). A bare ASCII id selector now resolves through the
existing id index in O(1): ~68us -> ~1.25us per call, about 55x faster, with no
change to any other path (create/getAttribute/setAttribute/querySelectorAll/href
all flat). querySelector by id is one of the hottest scraping operations.
The fast path is conservative: only a bare `#name` selector with an ASCII
letter/underscore-led identifier takes it; escapes, combinators, commas,
whitespace, compounds, and non-ASCII fall through to the full engine unchanged.
For a scoped element.querySelector it returns the indexed element only when it is
a strict descendant of the scope, and falls back to the scan otherwise, so
duplicate-id scoped queries stay correct.
Also makes the id index keep the FIRST element in tree order per id (or_insert at
parse time) instead of the last, so getElementById and querySelector('#id') return
the first-in-tree-order element on duplicate ids, per spec (previously last).
* fix: bound synchronous V8 hangs with a termination watchdog and add op panic safety
Heavy pages could hang obscura fetch indefinitely: tokio::time::timeout only
cancels at await points, so synchronous V8 work (a page's script bootstrap, a
microtask storm) pinned the thread and ran unbounded.
Add a reusable V8 terminate_execution watchdog (arm_watchdog/disarm_watchdog) and
apply it to every place that runs synchronous V8 work: the post-load settle, both
navigation event-loop pumps, the whole script-execution phase, and --eval. A CLI
process-level hard deadline backstops the rest, since a hang inside a Rust op
cannot be cancelled by tokio or terminate_execution.
Also harden against panics: op_dom is wrapped in catch_unwind so a DOM-op panic
degrades to a null result instead of unwinding into V8's FFI frame and aborting
the process; the append_text unwrap is removed; panic = unwind is pinned in the
release profile.
Verified: obstacle course 33/33, no perf regression (per-op deltas within the
measurement noise floor), 6 of 8 previously-hanging real sites now render and the
other 2 exit deterministically at the hard deadline.
* fix(dom): reject cyclic reparenting so descendants() cannot hang the engine
appendChild/insertBefore only guarded the self-append case (parent == child),
never the case where the moved node is an ancestor of the new parent. When page
JS does node.appendChild(ancestorOfNode) the parent/child graph becomes cyclic;
the next querySelector runs descendants() (a DFS with no visited set) and loops
forever, growing a Vec unbounded and pinning the thread in native Rust where
neither tokio nor the V8 watchdog can interrupt it. This hung techcrunch and
kayak.
Reject the cycle at the mutation site per the DOM HierarchyRequestError rule: if
the moved node is an inclusive ancestor of its target parent, treat the move as a
no-op. The walk is gated on the moved node having children, so a fresh/leaf
append (the common case) stays O(1); only relocating a populated subtree pays
O(depth). descendants() also gets a result-length cap as defense in depth so a
cycle from any future path cannot wedge the engine.
techcrunch and kayak now render (37K and 1.26M chars). Verified: new
test_reparent_cycle_is_rejected, obstacle course 33/33, no perf regression.
* dom: complete detached documents; add ProgressEvent and parseHTMLUnsafe
createHTMLDocument returned a document whose body was null: parsing the
skeleton string as innerHTML of <html> collapsed through the fragment parser
and kept only <title>. Build head>title and body explicitly instead. The
DOMParser doc node also gains appendChild/removeChild/insertBefore and a
doctype getter, and createDocument carries its doctype argument.
This lets dom/common.js setupRangeTests() finish (it builds fixtures with
new Document(), createHTMLDocument(), createDocument()) instead of aborting at
harness setup, so the dom/ranges files run. Range-collapse 186/186,
Range-attributes 1/1, Range-cloneRange 55/62, StaticRange-constructor 14/17.
The large O(ranges x points) matrix files (Range-set, comparePoint,
compareBoundaryPoints) still time out: the boundary-point comparison walks the
tree through per-step DOM ops, which is too slow for those matrices. Speeding
that up (a Rust node-position op) is a separate change.
Also add globalThis.ProgressEvent (Event subclass with lengthComputable/
loaded/total) and the static Document.parseHTMLUnsafe(html).
* perf(dom): compute Range tree-order in Rust ops
The Range boundary-point algorithms compared nodes by walking ancestor chains
and sibling lists from JS, one DOM op per step. The dom/ranges matrices
(testRanges x testPoints, thousands of comparisons) made that time out.
Add node_index, compare_order, and node_root ops that do the walk in Rust over
the DOM tree (slab lookups, no per-step FFI), and use them in _rngNodeIndex,
_rngOrder, and _rngRoot. Range-comparePoint 3534/5580, Range-isPointInRange
5197/5733, Range-compareBoundaryPoints 6441/9313, all completing in under 10s
where they previously did not finish. Not on any engine hot path (querySelector,
mutation, navigation, fetch do not use these).
* fileapi: real byte-backed Blob/File, FileReader state machine, Request.blob
Blob was a one-line stub that string-joined its parts (wrong size for binary
or multibyte input) and exposed only text(). Replace it with a Uint8Array-backed
Blob: normalizes string/ArrayBuffer/typed-array/Blob parts, correct size,
slice(), text()/arrayBuffer()/bytes(), type normalization (lowercased, cleared
when non-ASCII), the endings option (native CRLF normalization), and
Symbol.toStringTag. File adds name/lastModified, the 2-argument requirement, and
its own toStringTag.
FileReader becomes a real state machine: EMPTY/LOADING/DONE, throws
InvalidStateError when a read starts while already LOADING, and fires
loadstart/progress/load/loadend (and abort) to both on* handlers and
addEventListener. readAsText/DataURL/ArrayBuffer/BinaryString produce correct
results. Request gains blob().
FileReader-multiple-reads 6/6, File-constructor-endings 10/11, Blob-slice
141/150.
* encoding: TextDecoder supports legacy charsets via encoding_rs
TextDecoder was a UTF-8-only JS shim that ignored its label, so
new TextDecoder('shift_jis'|'euc-jp'|'big5'|'gbk'|'windows-1252'|'iso-8859-1')
.decode() returned garbage. That matters for scraping: pages and scripts decode
legacy-encoded bytes (XHR/fetch responses, File/Blob text) all the time.
Back it with encoding_rs (already used by the net layer for response bodies):
obscura-net gains label_name() and decode_with_label(), exposed to JS as
op_encoding_for_label and op_text_decode. The constructor validates the label
(RangeError) and sets the canonical .encoding/.fatal/.ignoreBOM; decode()
supports fatal mode (TypeError on invalid input) and BOM handling.
Plain UTF-8 non-fatal decoding keeps a pure-JS fast path (no op, no JSON), so
Response.text()/Blob.text() on large bodies are unchanged; the op only runs for
legacy encodings or fatal mode.
textdecoder-labels 222/222, textdecoder-fatal 36/36, byte-order-marks 3/3,
utf16-surrogates 10/10, single-byte-decoder 168/504.
* selection: real Selection over Range (single instance per document)
getSelection() returned a fresh plain object each call, so it was not a Selection
instance, had no identity (window.getSelection() !== document.getSelection()),
and was missing removeRange/empty/collapseToStart/collapseToEnd/setPosition/
setBaseAndExtent/modify while collapse/extend/getRangeAt were broken stubs. That
was ~4862 failing 'selection[method] is not a function' subtests.
Replace it with a real Selection class built on the live Range: one cached
instance per document (window.getSelection() === document.getSelection(), null
for documents with no defaultView), an internal range + direction, derived
anchor/focus/isCollapsed/type/rangeCount, and the full method set
(getRangeAt with IndexSizeError, addRange, removeRange, removeAllRanges/empty,
collapse/setPosition, collapseToStart/End, extend, setBaseAndExtent,
selectAllChildren, containsNode, toString) with the spec DOMException names.
modify() stays a no-op (needs visual layout). No ops.rs changes; not on any hot
path.
setBaseAndExtent 120/120, removeAllRanges 104/104, collapseToStartEnd 51/51,
type 26/26, extend-exception 1/1.
* forms: HTMLInputElement valueAsNumber/valueAsDate/stepUp/stepDown
These were absent. Implement the number/date conversion API for the applicable
types (number, range, date, month, week, time, datetime-local): parsers and
formatters per type (Date.UTC for date/datetime-local, ISO-week Monday math for
week, months-since-epoch for month), valueAsNumber get/set, valueAsDate get/set,
and stepUp/stepDown(n) with the step scale/default per type, step-base snapping,
and min/max clamping. Non-applicable types and step="any" throw InvalidStateError;
non-finite valueAsNumber throws TypeError; range with empty value reads as the
midpoint (50 for 0..100). Plus min/max/step attribute reflection.
All on Element accessors gated by input type, only evaluated on explicit access;
not on any hot path.
input-stepup 51/53, input-valueasnumber 60/64, input-valueasdate 27/30.
* perf(encoding): skip label op for no-arg TextDecoder (Response/Blob text path)
new TextDecoder() with no argument (what Response.text()/Blob.text() and most
pages use) is UTF-8; resolve that without the op_encoding_for_label round trip.
Explicit labels still validate via the op.
* core: HTMLDialogElement, Node.normalize, Range.createContextualFragment
HTMLDialogElement was a bare alias of Element, so ~40 Core files crashed at the
first dialog.show()/showModal(). Add the dialog members on Element.prototype
(matching the popover/input convention): open reflection, returnValue (internal
slot, String coercion), oncancel/onclose defaulting to null, show()/showModal()
/close()/requestClose() with the open-state transitions, InvalidStateError
throwing (show-on-modal, showModal-on-open, showModal-disconnected), the async
non-bubbling close event, the synchronous cancelable cancel event (no re-entrant
cancel), beforetoggle/toggle, and closedBy. matches(':modal') reports a modally
shown dialog (like :popover-open). Modal top-layer/focus/render stays unhandled
(layout).
Node.normalize() now merges adjacent Text nodes and drops empty ones (was a
no-op). Range.createContextualFragment(html) parses a fragment.
createContextualFragment 34/35, dialog-open 3/3, dialog-return-value 1/1,
dialog-showModal 5/10, dialog-requestclose 22/75. Not on hot paths.
* custom-elements: CustomElementRegistry class, getName, ElementInternals, HTMLUnknownElement
customElements was a bare object literal, so 'CustomElementRegistry is not
defined' crashed ~22 Core files at top level (idlharness/feature-detect), and
HTMLUnknownElement/ElementInternals/attachInternals/getName were missing.
Promote the registry to a real CustomElementRegistry class (window.customElements
is now an instance, so 'define in CustomElementRegistry.prototype' works and the
class is exposed). Add spec define() validation/throw order (TypeError for a
non-constructor, SyntaxError for an invalid name, NotSupportedError for a
duplicate name/constructor or reentrant define), getName(), and a SyntaxError
reject in whenDefined for invalid names. The existing define -> upgrade ->
connectedCallback path (used by lit/Polymer on real pages) is preserved.
Add HTMLUnknownElement, ElementInternals (setFormValue/setValidity/checkValidity/
validity/validationMessage/form/states), and Element.attachInternals() with its
NotSupportedError cases.
Does NOT include the [[HTMLConstructor]] (new.target) construction rework or the
reaction wiring into appendChild/setAttribute (hot paths) - deferred to avoid
element-model and perf risk. getName 4/4.
* shadow-dom: ShadowRoot.adoptedStyleSheets (mirror Document)
ShadowRoot lacked adoptedStyleSheets; constructible-stylesheet adoption into a
shadow root threw. Add the getter/setter on ShadowRoot.prototype, matching
Document.adoptedStyleSheets.
* conformance: charset/URL encoding override, IDL reflection, Range and Storage fixes
Encoding: track the detected document character encoding and expose it as
document.characterSet/charset/inputEncoding instead of hardcoding UTF-8. Add the
WHATWG URL encoding override for <a>/<area> hrefs, so a legacy-charset document
percent-encodes the query in its own encoding (EUC-JP U+8108 becomes %CC%AE).
ASCII delimiters stay literal so structured queries are unaffected, and
unmappable code points become %26%23NNN%3B. UTF-8 documents take an early-return
so the href getter hot path is unchanged. fetch()/XHR text() now decode using
the response Content-Type charset instead of always UTF-8. Adds op_url_encode_query
and a document_encoding op; encoding state is plumbed through Page and the runtime.
Reflection: install the reflected IDL attribute layer once on Element.prototype
(no per-element defineProperty, so element creation and query paths are
untouched): global attributes (title, lang, dir, accessKey, slot, autofocus,
hidden, tabIndex) with correct type coercion and enum canonicalization, plus the
ARIAMixin (aria-* reflected as the ariaXxx nullable DOMString properties).
Range: compareBoundaryPoints ToUint16-converts the how argument (NaN/Infinity
no longer false-throw) and raises a real WrongDocumentError DOMException for
ranges that do not share a root.
Storage: reimplement localStorage/sessionStorage as a WHATWG Proxy so property
access (localStorage.foo = x, delete, in, Object.keys) stays in sync with
length, key() and iteration.
* conformance: HTML attribute folding, compareDocumentPosition, URLSearchParams, DOMParser, fetch UA
Next WPT Core batch, all verified live and benchmarked for no hot-path regression.
DOM:
- compareDocumentPosition rewritten to spec: real PRECEDING/FOLLOWING via the
compare_order op, CONTAINS/CONTAINED_BY, and DISCONNECTED|IMPLEMENTATION_SPECIFIC
for different roots. Adds the DOCUMENT_POSITION_* and missing nodeType constants
on Node and its prototype. WPT getPosition() depends on this, so it cascades into
the Range comparePoint/compareBoundaryPoints/isPointInRange expectations.
- HTML elements now ASCII-lowercase attribute names in getAttribute/setAttribute/
removeAttribute/hasAttribute, so el.setAttribute('accessKey') reflects via
getAttribute('accesskey'). The fold is gated behind a cheap uppercase scan and a
namespaceURI HTML guard (SVG/foreign attributes keep their case), and getAttribute
only folds on a miss, so the all-lowercase hot path is unchanged (benchmarked flat).
- Range comparePoint/isPointInRange ToUint32-coerce the offset (negative throws
IndexSizeError); createTreeWalker/createNodeIterator keep an explicit whatToShow of 0.
URL:
- URLSearchParams uses a proper form-urlencoded byte decoder (partial/invalid escapes
stay literal instead of decodeURIComponent throwing on the whole string), 2-arg
has(name,value)/delete(name,value), and a sequence-pair TypeError. Adds URL.parse
and URL.canParse statics.
Other:
- fetch()/XHR send a default Chrome User-Agent (the navigation path set one but
scripted requests went out with none, so UA-gated servers rejected them); honors an
explicit override.
- DOMParser documents expose URL/compatMode/characterSet/readyState/styleSheets/
defaultView and createTreeWalker/createNodeIterator; XMLDocument is defined globally.
* perf(dom): O(1) querySelector('#id') via the id index
querySelector('#id') went through the full selector engine, scanning descendants
in tree order until the first match (O(n), ~68us per call over a 2000-element
document in the micro-bench). A bare ASCII id selector now resolves through the
existing id index in O(1): ~68us -> ~1.25us per call, about 55x faster, with no
change to any other path (create/getAttribute/setAttribute/querySelectorAll/href
all flat). querySelector by id is one of the hottest scraping operations.
The fast path is conservative: only a bare `#name` selector with an ASCII
letter/underscore-led identifier takes it; escapes, combinators, commas,
whitespace, compounds, and non-ASCII fall through to the full engine unchanged.
For a scoped element.querySelector it returns the indexed element only when it is
a strict descendant of the scope, and falls back to the scan otherwise, so
duplicate-id scoped queries stay correct.
Also makes the id index keep the FIRST element in tree order per id (or_insert at
parse time) instead of the last, so getElementById and querySelector('#id') return
the first-in-tree-order element on duplicate ids, per spec (previously last).
* dom: complete detached documents; add ProgressEvent and parseHTMLUnsafe
createHTMLDocument returned a document whose body was null: parsing the
skeleton string as innerHTML of <html> collapsed through the fragment parser
and kept only <title>. Build head>title and body explicitly instead. The
DOMParser doc node also gains appendChild/removeChild/insertBefore and a
doctype getter, and createDocument carries its doctype argument.
This lets dom/common.js setupRangeTests() finish (it builds fixtures with
new Document(), createHTMLDocument(), createDocument()) instead of aborting at
harness setup, so the dom/ranges files run. Range-collapse 186/186,
Range-attributes 1/1, Range-cloneRange 55/62, StaticRange-constructor 14/17.
The large O(ranges x points) matrix files (Range-set, comparePoint,
compareBoundaryPoints) still time out: the boundary-point comparison walks the
tree through per-step DOM ops, which is too slow for those matrices. Speeding
that up (a Rust node-position op) is a separate change.
Also add globalThis.ProgressEvent (Event subclass with lengthComputable/
loaded/total) and the static Document.parseHTMLUnsafe(html).
* perf(dom): compute Range tree-order in Rust ops
The Range boundary-point algorithms compared nodes by walking ancestor chains
and sibling lists from JS, one DOM op per step. The dom/ranges matrices
(testRanges x testPoints, thousands of comparisons) made that time out.
Add node_index, compare_order, and node_root ops that do the walk in Rust over
the DOM tree (slab lookups, no per-step FFI), and use them in _rngNodeIndex,
_rngOrder, and _rngRoot. Range-comparePoint 3534/5580, Range-isPointInRange
5197/5733, Range-compareBoundaryPoints 6441/9313, all completing in under 10s
where they previously did not finish. Not on any engine hot path (querySelector,
mutation, navigation, fetch do not use these).
* fileapi: real byte-backed Blob/File, FileReader state machine, Request.blob
Blob was a one-line stub that string-joined its parts (wrong size for binary
or multibyte input) and exposed only text(). Replace it with a Uint8Array-backed
Blob: normalizes string/ArrayBuffer/typed-array/Blob parts, correct size,
slice(), text()/arrayBuffer()/bytes(), type normalization (lowercased, cleared
when non-ASCII), the endings option (native CRLF normalization), and
Symbol.toStringTag. File adds name/lastModified, the 2-argument requirement, and
its own toStringTag.
FileReader becomes a real state machine: EMPTY/LOADING/DONE, throws
InvalidStateError when a read starts while already LOADING, and fires
loadstart/progress/load/loadend (and abort) to both on* handlers and
addEventListener. readAsText/DataURL/ArrayBuffer/BinaryString produce correct
results. Request gains blob().
FileReader-multiple-reads 6/6, File-constructor-endings 10/11, Blob-slice
141/150.
* encoding: TextDecoder supports legacy charsets via encoding_rs
TextDecoder was a UTF-8-only JS shim that ignored its label, so
new TextDecoder('shift_jis'|'euc-jp'|'big5'|'gbk'|'windows-1252'|'iso-8859-1')
.decode() returned garbage. That matters for scraping: pages and scripts decode
legacy-encoded bytes (XHR/fetch responses, File/Blob text) all the time.
Back it with encoding_rs (already used by the net layer for response bodies):
obscura-net gains label_name() and decode_with_label(), exposed to JS as
op_encoding_for_label and op_text_decode. The constructor validates the label
(RangeError) and sets the canonical .encoding/.fatal/.ignoreBOM; decode()
supports fatal mode (TypeError on invalid input) and BOM handling.
Plain UTF-8 non-fatal decoding keeps a pure-JS fast path (no op, no JSON), so
Response.text()/Blob.text() on large bodies are unchanged; the op only runs for
legacy encodings or fatal mode.
textdecoder-labels 222/222, textdecoder-fatal 36/36, byte-order-marks 3/3,
utf16-surrogates 10/10, single-byte-decoder 168/504.
* selection: real Selection over Range (single instance per document)
getSelection() returned a fresh plain object each call, so it was not a Selection
instance, had no identity (window.getSelection() !== document.getSelection()),
and was missing removeRange/empty/collapseToStart/collapseToEnd/setPosition/
setBaseAndExtent/modify while collapse/extend/getRangeAt were broken stubs. That
was ~4862 failing 'selection[method] is not a function' subtests.
Replace it with a real Selection class built on the live Range: one cached
instance per document (window.getSelection() === document.getSelection(), null
for documents with no defaultView), an internal range + direction, derived
anchor/focus/isCollapsed/type/rangeCount, and the full method set
(getRangeAt with IndexSizeError, addRange, removeRange, removeAllRanges/empty,
collapse/setPosition, collapseToStart/End, extend, setBaseAndExtent,
selectAllChildren, containsNode, toString) with the spec DOMException names.
modify() stays a no-op (needs visual layout). No ops.rs changes; not on any hot
path.
setBaseAndExtent 120/120, removeAllRanges 104/104, collapseToStartEnd 51/51,
type 26/26, extend-exception 1/1.
* forms: HTMLInputElement valueAsNumber/valueAsDate/stepUp/stepDown
These were absent. Implement the number/date conversion API for the applicable
types (number, range, date, month, week, time, datetime-local): parsers and
formatters per type (Date.UTC for date/datetime-local, ISO-week Monday math for
week, months-since-epoch for month), valueAsNumber get/set, valueAsDate get/set,
and stepUp/stepDown(n) with the step scale/default per type, step-base snapping,
and min/max clamping. Non-applicable types and step="any" throw InvalidStateError;
non-finite valueAsNumber throws TypeError; range with empty value reads as the
midpoint (50 for 0..100). Plus min/max/step attribute reflection.
All on Element accessors gated by input type, only evaluated on explicit access;
not on any hot path.
input-stepup 51/53, input-valueasnumber 60/64, input-valueasdate 27/30.
* perf(encoding): skip label op for no-arg TextDecoder (Response/Blob text path)
new TextDecoder() with no argument (what Response.text()/Blob.text() and most
pages use) is UTF-8; resolve that without the op_encoding_for_label round trip.
Explicit labels still validate via the op.
* core: HTMLDialogElement, Node.normalize, Range.createContextualFragment
HTMLDialogElement was a bare alias of Element, so ~40 Core files crashed at the
first dialog.show()/showModal(). Add the dialog members on Element.prototype
(matching the popover/input convention): open reflection, returnValue (internal
slot, String coercion), oncancel/onclose defaulting to null, show()/showModal()
/close()/requestClose() with the open-state transitions, InvalidStateError
throwing (show-on-modal, showModal-on-open, showModal-disconnected), the async
non-bubbling close event, the synchronous cancelable cancel event (no re-entrant
cancel), beforetoggle/toggle, and closedBy. matches(':modal') reports a modally
shown dialog (like :popover-open). Modal top-layer/focus/render stays unhandled
(layout).
Node.normalize() now merges adjacent Text nodes and drops empty ones (was a
no-op). Range.createContextualFragment(html) parses a fragment.
createContextualFragment 34/35, dialog-open 3/3, dialog-return-value 1/1,
dialog-showModal 5/10, dialog-requestclose 22/75. Not on hot paths.
* custom-elements: CustomElementRegistry class, getName, ElementInternals, HTMLUnknownElement
customElements was a bare object literal, so 'CustomElementRegistry is not
defined' crashed ~22 Core files at top level (idlharness/feature-detect), and
HTMLUnknownElement/ElementInternals/attachInternals/getName were missing.
Promote the registry to a real CustomElementRegistry class (window.customElements
is now an instance, so 'define in CustomElementRegistry.prototype' works and the
class is exposed). Add spec define() validation/throw order (TypeError for a
non-constructor, SyntaxError for an invalid name, NotSupportedError for a
duplicate name/constructor or reentrant define), getName(), and a SyntaxError
reject in whenDefined for invalid names. The existing define -> upgrade ->
connectedCallback path (used by lit/Polymer on real pages) is preserved.
Add HTMLUnknownElement, ElementInternals (setFormValue/setValidity/checkValidity/
validity/validationMessage/form/states), and Element.attachInternals() with its
NotSupportedError cases.
Does NOT include the [[HTMLConstructor]] (new.target) construction rework or the
reaction wiring into appendChild/setAttribute (hot paths) - deferred to avoid
element-model and perf risk. getName 4/4.
* shadow-dom: ShadowRoot.adoptedStyleSheets (mirror Document)
ShadowRoot lacked adoptedStyleSheets; constructible-stylesheet adoption into a
shadow root threw. Add the getter/setter on ShadowRoot.prototype, matching
Document.adoptedStyleSheets.
* conformance: charset/URL encoding override, IDL reflection, Range and Storage fixes
Encoding: track the detected document character encoding and expose it as
document.characterSet/charset/inputEncoding instead of hardcoding UTF-8. Add the
WHATWG URL encoding override for <a>/<area> hrefs, so a legacy-charset document
percent-encodes the query in its own encoding (EUC-JP U+8108 becomes %CC%AE).
ASCII delimiters stay literal so structured queries are unaffected, and
unmappable code points become %26%23NNN%3B. UTF-8 documents take an early-return
so the href getter hot path is unchanged. fetch()/XHR text() now decode using
the response Content-Type charset instead of always UTF-8. Adds op_url_encode_query
and a document_encoding op; encoding state is plumbed through Page and the runtime.
Reflection: install the reflected IDL attribute layer once on Element.prototype
(no per-element defineProperty, so element creation and query paths are
untouched): global attributes (title, lang, dir, accessKey, slot, autofocus,
hidden, tabIndex) with correct type coercion and enum canonicalization, plus the
ARIAMixin (aria-* reflected as the ariaXxx nullable DOMString properties).
Range: compareBoundaryPoints ToUint16-converts the how argument (NaN/Infinity
no longer false-throw) and raises a real WrongDocumentError DOMException for
ranges that do not share a root.
Storage: reimplement localStorage/sessionStorage as a WHATWG Proxy so property
access (localStorage.foo = x, delete, in, Object.keys) stays in sync with
length, key() and iteration.