Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

zendriver-rs is an async-first browser-automation library for Rust with a coherent stealth identity and explicit anti-detection controls, on by default. It drives a real Chrome instance over the Chrome DevTools Protocol (CDP) directly — no WebDriver shim, no Selenium grid, no JSON wire — and ships with anti-detection patches that pass mainstream fingerprint checks (e.g. sannysoft, areyouheadless) out of the box. No automation stack can guarantee invisibility to a determined, adaptively-defended site — see Stealth for what the patches actually cover and their limits.

It is a Rust port of the Python zendriver / nodriver projects, with the API redesigned around Rust's type system: builder patterns where Python uses kwargs, traits where Python uses duck-typed protocols, Result where Python uses exceptions, and explicit lifetimes for query scopes that the borrow checker tracks instead of letting them drift across await points.

Use cases

  • Scraping sites that block headless browsers. The spoofed stealth profile patches navigator.webdriver, the Chrome runtime object, the permissions API, and a half-dozen other tells. Many Cloudflare Turnstile, PerimeterX, and DataDome challenges pass without manual headers; sites with more aggressive detection layers may still need the dedicated bypass features (see Cloudflare / DataDome) or still catch a scripted session.
  • End-to-end testing of real-world web apps. First-class multi-tab, cross-origin iframe (OOPIF) support, network interception, and a Playwright-style expect() pre-register surface make whole-flow tests expressive without the wire-protocol churn of WebDriver.
  • Browser automation pipelines under load. Tab handles are `Send + Sync
    • Clone, the transport is a single Tokio actor, and queries are zero-copy &strselectors — comfortable inside anytokio::spawn`'d worker pool.
  • Drop-in replacement for chromiumoxide callers who want stealth, multi-tab, and an ergonomic find().css("...").one() query surface instead of hand-rolling Page.querySelector calls.

What makes it different

  • CDP-direct. Every method maps to one or two CDP commands. There is no WebDriver-style adapter layer, so latency is one network round-trip per call — typically under 1 ms on localhost.
  • Anti-detection controls on by default. StealthProfile::native is the suggested starting point — UA scrub plus Emulation overrides, no JS bootstrap, no prototype patching. StealthProfile::spoofed adds Navigator-prototype patches that pass sannysoft + areyouheadless. Neither is a guarantee against a determined, adaptive detector — see Stealth for the tradeoffs. Use StealthProfile::off when you want a vanilla browser for reproduction.
  • Async-first. Built on Tokio. Every call returns a Future. No blocking, no block_on, no tokio::task::spawn_blocking. Browser / Tab / Element handles are Clone + Send + Sync so they cross .await boundaries and task spawns without ceremony.
  • Rust-native. Errors are typed via thiserror and surfaced through Result. Selectors are checked at call time, not parsed at startup. Resources clean up via Drop (Chrome subprocess gets SIGTERM when the last Browser clone drops). The borrow checker tracks query scopes for you.

Comparison

featurezendriver-rschromiumoxidethirtyfourfantoccini
TransportCDP-directCDP-directWebDriverWebDriver
Stealth out of the boxyesnonono
Builder-style queriesyespartialnono
Cross-origin iframesyespartialyesyes
Send+Sync handlesyesyesyesyes
Async runtimeTokioasync-std/TokioTokioTokio
Network interceptionyesyeslimitedlimited
Multi-tab orchestrationyesmanualmanualmanual

Comparisons against Playwright + Selenium are covered in Migration from Playwright.

How this book is organized

The API rustdoc on docs.rs/zendriver is the source of truth for the public surface. The book covers the how and why; rustdoc covers the what.

Install

zendriver-rs is published on crates.io as zendriver. The base crate gives you everything you need for navigation, queries, input, multi-tab, frames, and stealth. Optional Cargo features turn on network interception, the expect() surface, Cloudflare bypass, and the Chrome for Testing downloader.

Basic install

For the standard always-on surface (Browser + Tab + Element + Frame + StealthProfile + queries + input + cookies + storage + screenshots):

[dependencies]
zendriver = "0.1"
tokio = { version = "1", features = ["full"] }

This pulls in zendriver, zendriver-transport, and zendriver-stealth transitively. No system dependencies beyond Chrome (or Chromium / Edge — anything that speaks CDP).

Minimum install

zendriver requires a Tokio runtime. The smallest viable setup:

[dependencies]
zendriver = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

You give up the convenience features of tokio = "full" but pay less in compile time. The macros feature is required for #[tokio::main] and #[tokio::test]; rt-multi-thread is required by zendriver's internal spawn calls.

Feature matrix

FeaturePulls inUse caseExtra deps
(default)zendriver-transport, -stealthNavigation, queries, input, cookies, storage, screenshots, multi-tabnone
interceptionzendriver-interceptionBlock/modify/serve requests via the Fetch CDP domainnone
expect(in-tree module)Playwright-style expect_request / expect_response / expect_dialognone
monitor(in-tree module)tab.monitor() — persistent Stream<NetworkEvent> (HTTP / WS / SSE)none
cloudflarezendriver-cloudflare, interceptionAuto-solve Cloudflare Turnstile challengesnone
fetcherzendriver-fetcherDownload Chrome for Testing on-demand via the official JSON APIreqwest, zip, sha2, dirs

Enable features additively. For example, an automation script that needs to block ads and bypass Cloudflare:

[dependencies]
zendriver = { version = "0.1", features = ["interception", "cloudflare"] }
tokio = { version = "1", features = ["full"] }

A scraper that needs all of it:

[dependencies]
zendriver = { version = "0.1", features = [
    "interception",
    "expect",
    "cloudflare",
    "fetcher",
] }
tokio = { version = "1", features = ["full"] }

Re-exports

zendriver re-exports the types you'll typically need from the sub-crates, so a single use zendriver::* will reach Browser, Tab, Element, Frame, StealthProfile, Platform, Key, KeyModifiers, SpecialKey, ClickOptions, CookieJar, and the Queryable / Evaluable traits. The sub-crate paths (e.g. zendriver::stealth::*, zendriver::interception::*) stay available for the rare cases where you need a type the prelude doesn't surface.

MSRV

zendriver targets Rust 1.75 minimum. The MSRV bumps follow SemVer — a Rust version bump counts as a minor change in the 0.x series and a major change post-1.0. See SEMVER.md in the repository for the full policy.

Platform support

"Tested in CI" below means a CI job actually runs the test suite on that platform — not that a binary merely compiles for it.

PlatformSupportedNotes
Linux (x86_64)yesFully tested in CI on ubuntu-latest: fmt, clippy, unit, doc and real-Chrome integration tests. Recommended for headless scraping.
Linux (aarch64)yesRelease binary is cross-built in CI; no tests run on this target.
macOS (x86_64)yesRelease binary is built in CI; no tests run on this target. Tested locally by maintainers.
macOS (Apple Si)yesRelease binary is built in CI; no tests run on this target. Tested locally by maintainers.
WindowsyesReal-Chrome integration tests run in CI on windows-latest; fmt/clippy/unit/doc jobs are Linux-only. Path semantics differ slightly.

Chrome (or Chromium / Edge / any Chromium-derived browser) must be on $PATH, or you must pass an explicit chrome_path to the builder, or you must enable the fetcher feature and let zendriver download Chrome for Testing at startup.

Verifying the install

A 10-line smoke test you can drop into src/main.rs:

#[tokio::main]
async fn main() -> zendriver::Result<()> {
    let browser = zendriver::Browser::builder()
        .headless(true)
        .launch()
        .await?;
    let tab = browser.main_tab();
    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;
    let h1 = tab.find().css("h1").one().await?;
    println!("{}", h1.inner_text().await?);
    browser.close().await?;
    Ok(())
}

If this prints Example Domain, the install is working. See Quickstart for a walkthrough of what each line is doing.

Quickstart

This chapter walks line-by-line through the "hello world" example from crates/zendriver/examples/hello.rs. After this chapter you'll know how to launch a browser, navigate to a URL, run a query, read element text, and shut everything down.

The example

//! Phase 1 exit example: launch Chrome, navigate to example.com, find <h1>,
//! print its text.

use zendriver::Browser;

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();
    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;

    let h1 = tab.find().css("h1").one().await?;
    let text = h1.inner_text().await?;
    println!("h1 text: {text}");

    browser.close().await?;
    Ok(())
}

You can run it from the workspace root:

cargo run --example hello -p zendriver

It launches Chrome headless, navigates to https://example.com, finds the <h1> element on the page, prints its inner text, then shuts the browser down. Expected output:

h1 text: Example Domain

Walkthrough

1. Launch the browser

let browser = Browser::builder().headless(true).launch().await?;

Browser::builder() returns a BrowserBuilder with sensible defaults. The chain pattern lets you customize before launch:

  • .headless(true) — runs Chrome without a UI window.
  • .headless(false) — runs headed; useful while developing scripts.
  • .user_data_dir(path) — pins Chrome's profile to a directory so cookies and localStorage persist across runs.
  • .stealth(StealthProfile::native()) — opt into the anti-detection patches (covered in Stealth).
  • .chrome_path(path) — bypass the $PATH lookup when you want a specific Chrome binary.

.launch() is async because it has to spin up the Chrome subprocess, wait for the CDP WebSocket endpoint to come up, perform the initial handshake, and attach to the first auto-opened tab.

2. Grab the main tab

let tab = browser.main_tab();

Chrome always opens with one tab at about:blank. zendriver registers that tab eagerly at launch and exposes it via Browser::main_tab(). The returned Tab is Clone + Send + Sync, so you can stash it in a struct, clone it across spawns, and pass it into helpers freely — every clone refers to the same underlying CDP session.

3. Navigate

tab.goto("https://example.com").await?;
tab.wait_for_load().await?;

goto dispatches Page.navigate and returns as soon as Chrome acknowledges the request — not when the page is done loading. Call wait_for_load afterwards if you want to block until the load event fires. (You can also use wait_for_idle for "no network requests in-flight", or set up expect_response ahead of the navigation for a targeted wait — covered in Expect().)

4. Query an element

let h1 = tab.find().css("h1").one().await?;

Tab::find() returns a FindBuilder in the configure phase. The chain encodes both the selector and any modifiers:

  • .css("h1") — CSS selector (the most common selector kind).
  • .text("Submit") — text-content matcher (anchor-style "find by visible text").
  • .xpath("//div[@id='main']") — XPath escape hatch.
  • .role(AriaRole::Button) — ARIA-role + accessible-name match (Playwright-style).
  • .tag("button").attr_contains("class", "primary").containing_text("Buy") — bs4-like combinable predicate finders (tag, attr, attr_contains, attr_starts_with, attr_ends_with, has_attr, attr_regex, containing_text, text_equals, text_matches), all AND-ed together. Every value-bearing matcher has a case-insensitive _i sibling (attr_i, attr_contains_i, attr_starts_with_i, attr_ends_with_i, containing_text_i, text_equals_i) — e.g. .attr_i("class", "primary") matches class="Primary" too. Predicate methods can't be mixed with the single-selector methods above on one query (doing so errors with ConflictingSelectors).
  • .nth(2) — pick the 2nd match.
  • .visible_only() — skip display:none / zero-bbox elements.
  • .timeout(Duration::from_secs(5)) — override the default 30s wait.

A terminal method (one, first, many, many_or_empty, count, exists) consumes the builder and dispatches. .one() waits for exactly one match — errors with ElementNotUnique if there are zero or more than one — making it perfect for queries that should be deterministic.

For the common CSS case, tab.select("h1") / tab.select_all("nav a") are Python-parity convenience aliases for find().css(...).one() / find_all().css(...).many(). The same pair exists on Frame and Element (Element::select scopes to that element's subtree).

5. Read element text

let text = h1.inner_text().await?;
println!("h1 text: {text}");

Element::inner_text() dispatches a Runtime.callFunctionOn against the cached RemoteObjectId for this element. There is no extra DOM query — the Element handle remembers its CDP node, so reads / attribute lookups / clicks / typing all share that single handle.

zendriver also auto-refreshes stale handles: if the page re-rendered and your handle's RemoteObjectId was discarded, the next method call silently re-runs the original query, gets a fresh handle, and retries. You get to write straight-line code as if elements were durable.

6. Shut down

browser.close().await?;

Browser::close() is the graceful shutdown path: send Browser.close, wait for the Chrome subprocess to exit, then drop the transport actor. You can also rely on Drop — the last Browser clone going out of scope will fire SIGTERM at the subprocess — but explicit browser.close().await? is preferred so you can surface shutdown failures in your Result.

Next steps

  • Stealth — turn on anti-detection for sites that block headless browsers.
  • Input — realistic typing, mouse clicks with Bezier-path cursor moves, modifier keys.
  • Multi-tabBrowser::new_tab / Browser::new_tab_at, tab iteration, activate.
  • Frames — querying inside cross-origin iframes, the FindBuilder::in_frame modifier.

Stealth

zendriver-rs ships with three stealth profiles selecting different tradeoffs between launch overhead, detectability, and CSP compatibility. Pick the profile that matches your target site's detection layer; tweak the fingerprint with builder methods when you need to pin a specific identity.

None of these profiles is a guarantee of invisibility — no automation stack has one against a determined, adaptively-defended site. What they provide is a coherent, plausible browser identity plus explicit controls over the specific tells that mainstream fingerprint checks probe for.

The three profiles

ProfileLaunch flagsUA scrubEmulation overridesJS bootstrapBypass CSPUse case
off()nonenonononenoReproducing issues in vanilla Chrome.
native()yesyesyesnonenoMost sites. Default recommendation.
spoofed()yesyesyesNavigator JSyes (on)Sites with active fingerprint detection.

StealthProfile::off()

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let browser = Browser::builder()
    .stealth(StealthProfile::off())
    .launch()
    .await?;
Ok(()) }
}

No launch flags. No UA scrub. No CDP overrides. Page.setBypassCSP is not called. This is what you get from a stock chromiumoxide launch. Use this when you're debugging whether a bug reproduces in a vanilla Chrome — if it does, the cause is unrelated to zendriver's stealth machinery.

StealthProfile::native()

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let browser = Browser::builder()
    .stealth(StealthProfile::native())
    .launch()
    .await?;
Ok(()) }
}

The default recommendation. Patches the layer that protocol-level fingerprinters see, without touching JS object prototypes:

  • Launch flags--disable-blink-features=AutomationControlled, --disable-features=IsolateOrigins,site-per-process (toggleable), and a curated list of flags that turn off the "this browser is controlled by automation" infobar plus various leaks.
  • UA scrub — strips the HeadlessChrome segment from the User-Agent string and the Sec-CH-UA brand list.
  • Emulation overridesEmulation.setUserAgentOverride / Emulation.setHardwareConcurrencyOverride / Emulation.setDeviceMetricsOverride set a coherent identity.

Safe against Function.prototype.toString detection because it patches nothing at the JS level — there's no [native code] mismatch to detect. Passes most consumer site detectors. Doesn't pass sannysoft's deeper Navigator-prototype checks.

StealthProfile::spoofed()

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let browser = Browser::builder()
    .stealth(StealthProfile::spoofed())
    .launch()
    .await?;
Ok(()) }
}

native() plus Navigator-prototype JS patches injected via Page.addScriptToEvaluateOnNewDocument. Restores or overrides:

  • navigator.webdriver (deletes it so 'webdriver' in navigator is false).
  • navigator.permissions.query({ name: "notifications" }) (returns "prompt" instead of the headless-Chrome "denied").
  • navigator.plugins + navigator.mimeTypes (returns plausible-length arrays).
  • navigator.chrome (installs the runtime object headless Chrome doesn't ship).
  • WebGL vendor / renderer, plus every other readable WebGL value, resolved from one capability tier (defaults to a captured Apple Metal device: "Google Inc. (Apple)" / "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)"). The WebGPU adapter is derived from that same renderer string, so the two APIs never name different GPUs.
  • ChunkSplit + iframe-contentWindow guards so the patches survive cross-realm escape attempts.

Toggles Page.setBypassCSP on by default so the bootstrap script can install on pages with strict CSP headers. Pass .bypass_csp(false) to opt out when you want to test against a real CSP-restricted page.

Passes sannysoft, areyouheadless, and most active detectors. Pays a small per-navigation cost (the JS bootstrap runs on every new document).

Customizing the fingerprint

All three profiles return a builder that lets you override individual fingerprint fields. The values are validated and clamped at resolve time (e.g. memory_gb is clamped to a plausible W3C-rounded value; cpu_count is clamped to 2..=32).

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};
use zendriver::stealth::Platform;

async fn ex() -> zendriver::Result<()> {
let profile = StealthProfile::spoofed()
    .memory_gb(8)               // navigator.deviceMemory
    .cpu_count(8)               // navigator.hardwareConcurrency
    .chrome_version(126)        // Chrome major in UA + Sec-CH-UA
    .platform(Platform::Win32)  // navigator.platform + OS in UA
    .locale("en-US")            // navigator.language + Accept-Language + --lang
    .timezone("America/New_York");

let browser = Browser::builder()
    .stealth(profile)
    .launch()
    .await?;
Ok(()) }
}

locale alone is usually enough. It sets the --lang launch flag, the JS-visible locale that navigator.language and Intl read, and an Accept-Language derived from it (en-US gives en-US,en;q=0.9). Reach for .languages([...]) when you need the header to advertise a longer or differently-weighted list. Its first entry then becomes navigator.language as well, because Chrome always reports that as navigator.languages[0], and a locale sitting outside the advertised list is a mismatch no real browser produces.

A Persona carries the same timezone / locale / languages / screen fields, and whichever ones it sets take precedence over the profile's. Anything it leaves unset falls through to the values above, so a persona can pin one axis without restating the rest.

You can also override the User-Agent string verbatim — useful when you need an exact UA that doesn't match the auto-composed one:

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let profile = StealthProfile::native()
    .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...");

let browser = Browser::builder()
    .stealth(profile)
    .launch()
    .await?;
Ok(()) }
}

user_agent() skips the auto-composition step entirely — prefer platform() + chrome_version() unless you need a bit-for-bit specific UA.

Opting into real site isolation + real WebGL (native_isolation)

native() and spoofed() both disable Chrome's render-process site isolation (--disable-features=IsolateOrigins,...,site-per-process), and spoofed() additionally patches WebGLRenderingContext.getParameter() /getSupportedExtensions() to report a coherent GPU identity — every readable parameter resolved from one capability tier, defaulting to an Apple Metal device — regardless of the host's actual GPU. .native_isolation(true) opts a profile out of both:

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let profile = StealthProfile::spoofed().native_isolation(true);

let browser = Browser::builder()
    .stealth(profile)
    .launch()
    .await?;
Ok(()) }
}

With this set:

  • The launch flags omit IsolateOrigins/site-per-process from --disable-features=... — Chrome runs with its normal render-process isolation boundary. (The unrelated DisableLoadExtensionCommandLineSwitch feature name stays disabled either way — it controls --load-extension, not isolation.)
  • For spoofed(), the bootstrap script omits the WebGL vendor/renderer patch entirely — getParameter(UNMASKED_VENDOR_WEBGL) etc. return the host's real values instead of the spoofed default.

This is a trade-off, not a strict stealth improvement. The defaults it opts out of exist as anti-detection measures: the WebGL patch in particular is an anti-WAF coherence defense — some WAFs (Imperva/Incapsula) cross-check the WebGL identity against the rest of the fingerprint and flag a real, host-specific renderer string as a bot tell when it doesn't match. Reach for native_isolation(true) when you need the host's actual GPU behavior (WebGL-heavy rendering, screenshot fidelity, visual regression testing) or want Chrome's stock process-isolation security boundary, and evasion isn't the priority — not because it's "more stealthy." It isn't; it removes a defense.

It's off by default on every profile, so existing native() / spoofed() callers see no behavior change unless they opt in explicitly.

WebGL and WebGPU stay coherent. When native_isolation drops the WebGL patch, the WebGPU value adapter spoof (navigator.gpu, driven by the Persona webgpu surface) is skipped along with it, so navigator.gpu reports the real host adapter instead of one derived from a renderer the WebGL patch no longer applies — no cross-API mismatch. The same holds for the per-surface Strategy::Native on Webgl, which leaves the real renderer in place for one persona rather than the whole profile. An explicit Webgpu Block (hiding navigator.gpu) is renderer-neutral, so it is still honored if you set it.

End-to-end example

This example launches with a custom UA, locale, and platform, then reads them back via navigator.* to prove the overrides took:

//! Port of `zendriver/examples/set_user_agent.py`.
//!
//! Launch Chrome with a custom User-Agent, locale, and platform configured
//! via [`StealthProfile`], then read them back via `navigator.*` to verify
//! the override took effect.
//!
//! Python `tab.set_user_agent("...", accept_language="de", platform="Win32")`
//! is a single-call helper that internally drives
//! `Emulation.setUserAgentOverride`. zendriver-rs lifts that into the
//! `StealthProfile` builder because the launcher already wires UA overrides
//! through `StealthObserver`, so per-tab mutation has no equivalent yet.
//! Setting the UA at launch matches the spec's "no JS-visible drift between
//! launch and first frame" stealth property.
//!
//! `navigator.platform` reads as `Win32` once `Platform::Win32` is set.

use zendriver::Browser;
use zendriver::stealth::{Platform, StealthProfile};

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let profile = StealthProfile::native()
        .user_agent("My user agent")
        .locale("de")
        .platform(Platform::Win32);

    let browser = Browser::builder()
        .headless(true)
        .stealth(profile)
        .launch()
        .await?;
    let tab = browser.main_tab();
    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;

    let ua: String = tab.evaluate("navigator.userAgent").await?;
    let lang: String = tab.evaluate("navigator.language").await?;
    let platform: String = tab.evaluate("navigator.platform").await?;

    println!("{ua}"); // My user agent
    println!("{lang}"); // de
    println!("{platform}"); // Win32

    browser.close().await?;
    Ok(())
}

Expected output:

My user agent
de
Win32

The override surface is intentionally narrow — anything that would let you set incoherent values (e.g. Linux UA + navigator.platform = Win32) goes through the Fingerprint resolver, which composes a coherent identity.

When to use which

  • Headless scraping of public sites — start with native(). Most sites don't actively probe Navigator prototypes; the cheaper profile is plenty.
  • Sites with active bot detection (Cloudflare, PerimeterX, DataDome, Akamai) — use spoofed(). Pair with the cloudflare Cargo feature when you specifically need Turnstile bypass — see Cloudflare.
  • Sites with strict CSPspoofed() defaults to bypass_csp = true, which is normally what you want. If you're testing a real CSP, override it with .bypass_csp(false) (and expect the JS bootstrap to fail to install).
  • Sites that read Function.prototype.toString looking for [native code] mismatchesnative() rather than spoofed(), because spoofed()'s prototype patches leave detectable fingerprints in the function-source readouts. zendriver's bootstrap papers over the obvious patches, but a determined adversary will still find drift.

Fingerprint spoofing

zendriver-rs ships a first-class fingerprint layer that lets you control what every browser surface reveals to detection scripts — canvas pixel noise, WebGL renderer strings, WebRTC IP candidates, hardware hints, and more — without touching any CDP internals directly.

Two orthogonal axes

Fingerprint control lives on two independent axes:

AxisWhat it controlsWhere it lives
Persona sourceThe identity values injected (UA, platform, WebGL vendor, seed, …)zendriver-stealth (core), or zendriver-fingerprints (pool / generative)
Per-surface render strategyHow each surface is modified in-pageStrategy enum, set per Surface

You can mix any persona source with any per-surface strategy independently.

The 8 surfaces

SurfaceKindDefault strategyWhat it affects
CanvasNoiseSeededPixel readback: getImageData, toDataURL, toBlob, and probe-shaped readPixels
AudioNoiseSeededAnalyserNode frequency / time-domain data
ClientRectsNoiseSeededgetBoundingClientRect sub-pixel dimensions
WebglValueValueEvery static device capability getParameter reports (WebGL1 + WebGL2), both extension lists, and getShaderPrecisionFormat — not just UNMASKED_VENDOR_WEBGL/UNMASKED_RENDERER_WEBGL. Per-context mutable state stays the backend's
WebgpuValueValueGPUAdapterInfo (vendor/architecture/device/description) + optional .limits/.features
FontsValueValuemeasureText width noise + FontFaceSet.check allow-list
HardwareValueValueBattery level, media-device count, speech voices
WebrtcPolicyBlockICE candidate leak suppression / fake IP

The 5 strategies

StrategyEffect
NativeNo patch — raw browser output.
SeededDeterministic per-(seed, content) noise: the persona's fixed seed → reproducible across separate runs, and stable across repeat reads of identical content within a page.
RandomSame content-keyed noise as Seeded, but the seed itself is a fresh Math.random() draw made once per page load — so repeat reads within one page load are stable, while separate page loads (a new navigation or browser launch) get independent noise.
BlockEmpty / zero output (appropriate for policy surfaces).
ValueSubstitute a specific value from the Persona spec.

Both noise strategies key their PRNG by the surface's own content (pixel bytes, audio samples, rect geometry) on every read, not one stream that advances across the whole page — so neither strategy "reseeds on every call" in a way that makes repeat reads of the same content diverge.

Noise surfaces (Canvas, Audio, ClientRects) accept Native, Seeded, Random, Block. Value surfaces (Webgl, Webgpu, Fonts, Hardware) accept Native, Value, Block. The policy surface (Webrtc) accepts Native, Block, Value (fake IP). Requesting a meaningless combination logs a warning and falls back to the surface's kind default.

Native on Webgl also silences the Webgpu value spoof. A native WebGL surface reports the host's real renderer, so a substituted GPUAdapterInfo — derived from the renderer that was not applied — would have navigator.gpu naming a GPU that getParameter(UNMASKED_RENDERER_WEBGL) never claimed, which is exactly the cross-API mismatch a fingerprinter looks for. The same coupling applies to native_isolation, which drops the WebGL patch profile-wide. An explicit Webgpu Block names no GPU at all, so it stays honored either way.

Persona sources

Persona::system() — host-probed, cached

Reads the real machine's platform, CPU count, and memory via sysinfo. The result is cached in a OnceLock — first call probes, subsequent calls clone. A random seed is generated per process.

#![allow(unused)]
fn main() {
use zendriver::{Browser, Persona};

let browser = Browser::builder()
    .persona(Persona::system())
    .launch().await?;
}

Persona::builder() — explicit

Build any combination of fields; unset fields inherit from system().

#![allow(unused)]
fn main() {
use zendriver::{Browser, Persona, Seed};

let persona = Persona::builder()
    .seed(Seed::from_u64(42))       // reproducible noise
    .device_memory_gb(16)
    .timezone("America/Los_Angeles")
    .build();

let browser = Browser::builder()
    .persona(persona)
    .launch().await?;
}

Persona::from_browser(tab) — live probe

Read the real browser's values (WebGL renderer, timezone, locale, …) from a running Tab and produce a maximally coherent Persona. Useful when you want to match the identity of an existing browser session.

#![allow(unused)]
fn main() {
use zendriver::{Browser, Persona};

let browser = Browser::builder().launch().await?;
let tab = browser.main_tab();
tab.goto("about:blank").await?;

let persona = Persona::from_browser(tab).await?;
println!("{:?}", persona.webgl);
}

Seed::from_system() — machine-stable seed

Produces the same seed on every run on the same machine (derived from the platform machine ID + hostname). Useful when you want a consistent identity per machine without a user_data_dir.

#![allow(unused)]
fn main() {
use zendriver::{Browser, Persona, Seed};

let persona = Persona::builder()
    .seed(Seed::from_system())
    .build();
}

Pool + generative sources (zendriver-fingerprints)

For real-device personas drawn from a dataset or a Bayesian network, add the optional zendriver-fingerprints crate and enable the pool or generative feature:

[dependencies]
zendriver-fingerprints = { version = "0.1", features = ["pool"] }
#![allow(unused)]
fn main() {
use zendriver_fingerprints::pool::PoolSet;
use zendriver_stealth::Seed;

// Build from a local JSON array (or load with load_or_download(url, policy)).
let pool = PoolSet::from_json(include_str!("pool.json"))?;
let persona = pool.sample(Seed::from_u64(42));

// Pass to Browser::builder() in the zendriver crate.
}

Cache freshness (CachePolicy)

pool::load_or_download and generative::Generator::load_or_download both download-on-first-use into a local cache file (dirs::cache_dir()/zendriver/fingerprints/...). Freshness is controlled by a CachePolicy, checked on access (at load time) — there is no background scheduler:

#![allow(unused)]
fn main() {
use zendriver_fingerprints::CachePolicy;
use zendriver_fingerprints::pool::load_or_download;
use std::time::Duration;

async fn example() -> Result<(), Box<dyn std::error::Error>> {
// Default: permanent cache — a cache hit is used forever (unchanged from
// before this knob existed).
let pool = load_or_download("https://example.com/pool.json", CachePolicy::default()).await?;

// Re-download once the cached file is older than a day.
let pool = load_or_download(
    "https://example.com/pool.json",
    CachePolicy::with_ttl(Duration::from_secs(86_400)),
)
.await?;

// Always re-download, ignoring any cache hit.
let pool = load_or_download("https://example.com/pool.json", CachePolicy::force_refresh()).await?;
let _ = pool;
Ok(())
}
}

CachePolicy::default() is byte-for-byte identical to the pre-CachePolicy behavior: permanent cache, and — since ttl: None short-circuits before any mtime read — zero added filesystem calls. Clock skew (a cache file with a future-dated mtime) fails closed: it's treated as stale and re-downloaded, never as fresh and never a panic.

Per-surface strategy overrides

Override any surface's render strategy on top of the persona:

#![allow(unused)]
fn main() {
use zendriver::{Browser, Persona, Seed, Strategy, Surface};

let browser = Browser::builder()
    .persona(Persona::builder().seed(Seed::from_u64(42)).build())
    .surface(Surface::Webrtc, Strategy::Native)  // allow real IP
    .surface(Surface::Canvas, Strategy::Random)  // fresh per-page-load seed
    .launch().await?;
}

Canvas (pixel readback farbling)

The Canvas surface perturbs pixels on their way out, so the image a probe hashes differs per persona while the visible canvas is untouched. The perturbation is a palette: one table per colour channel, built from the seed, mapping each 8-bit value to itself plus or minus one. Alpha is left alone, since perturbing it changes compositing and it rarely carries fingerprint weight.

A palette rather than per-pixel noise, because per-pixel noise is self-refuting. Keying the noise on a pixel's position gives every pixel an independent draw, and that fails two checks a page can run in a few lines:

  • A flat fill must come back flat. Rendering is a function of its input, so identical pixels come back identical on every real GPU. Position-keyed noise broke that. Measured before the rewrite: a uniform WebGL clear read back with three different red values.
  • Every readback path must agree. readPixels returns rows bottom-up and getImageData returns them top-down, so a position-keyed scheme cannot agree with itself across paths even in principle. A page could render one scene, read it both ways and compare. A palette has no position to disagree about.

Anti-linkage survives the change: a different seed permutes the palette differently, so the hash still differs per persona, and it stays stable across repeat reads because the mapping is a pure function.

readPixels is farbled only when the read looks like a probe — a whole small drawing buffer, read as RGBA/UNSIGNED_BYTE. GPU picking reads a 1x1 rectangle off a large buffer and compares it against an exact id colour, so perturbing that breaks real pages; float readbacks are compute output, where a one-LSB change is meaningless at best. The residue, stated rather than hidden: a page that reads 1x1 and reads the full buffer can see that only one of them moved. That is a contrived probe, and the alternative is breaking picking on sites that use it.

WebGL (full-surface value spoof, resolved from measured tiers)

The Webgl surface's default Value strategy no longer substitutes a handful of hand-picked numbers. It resolves and serves every static device capability a page can read — 18 getParameter values on a WebGL1 context and 47 on a WebGL2 one — plus both contexts' getSupportedExtensions lists (in Chrome's own order, which is itself a fingerprint input) and every getShaderPrecisionFormat result. That is the whole surface that identifies the GPU, not just the vendor/renderer pair.

The rest of getParameter is deliberately left to the real backend. The other ~85 values it answers are not device capabilities: they are per-context mutable state (VIEWPORT, BLEND, SCISSOR_BOX, the STENCIL_*, PACK_* and UNPACK_* families, DRAW_BUFFERn, …), values fixed by the context attributes the page asked for (RED_BITS, STENCIL_BITS, SAMPLESgetContext('webgl', {stencil: true}) changes them), or the extension-dependent COMPRESSED_TEXTURE_FORMATS. Every real Chrome reports the same defaults for all of them, so they carry no entropy to spoof (with one partial exception, DRAW_BUFFERn, covered below), and serving them from a table would be a tell rather than a disguise: gl.enable(gl.BLEND); gl.getParameter(gl.BLEND) would answer false forever, and a resized canvas would report a stale viewport beside its real drawingBufferWidth. Delegating also keeps real WebGL pages working — state-caching renderers (deck.gl's withParameters, Babylon's state cache) save and restore through getParameter.

Values come from measured capability tiers, not per-parameter guesses. How far one capture reaches depends on the backend, because ANGLE (Chrome's GL layer) decides these values differently per backend. Six tiers ship today, and all but the last generalize:

  • SwiftShader — Chrome's software rasterizer, the same numbers on every OS.

  • Metal on macOS — ANGLE's Metal backend, covering every Mac. The values are compile-time constants in ANGLE's DisplayMtl.mm TARGET_OS_OSX branch, so an Intel Mac and an Apple silicon one report the same numbers.

  • D3D11 (feature level 11_0+) — ANGLE's Direct3D 11 backend on Windows, in three tiers. ANGLE derives almost all of these values from D3D11_REQ_* constants rather than from the card, so a catch-all tier covers most parts at that feature level. Two refinements sit above it, and both were found by probing a second device rather than predicted:

    • NVIDIA. renderer11_utils.cpp enables skipVSConstantRegisterZero when and only when the vendor is NVIDIA, which docks MAX_VERTEX_UNIFORM_VECTORS from 4096 to 4095 and shifts the two values derived from it. Nothing else moves: probing an RTX 4090 and an AMD Radeon on one machine found no other difference in either WebGL parameter set, the extension lists, the shader precisions, or any WebGPU limit or feature.
    • Intel Gen9. Two values on this backend are read off the device rather than off the feature level, and an Intel HD Graphics 520 reports both differently from the Radeon: MAX_SAMPLES is 16 against 8 (ANGLE fills it by asking CheckMultisampleQualityLevels per renderable format), and the WebGPU adapter enumerates 16 features against 19, lacking shader-f16, subgroups and bgra8unorm-storage. Everything else matched, including all 82 WebGL1 parameters, the other 131 WebGL2 ones, both extension lists in content and order, and all 36 WebGPU limits.

    Only Gen9 is routed to that third tier, which is a deliberate limit rather than an oversight. Gen11 (Iris Plus G4/G7) and Gen12 (Iris Xe, Arc) stay on the catch-all one: nobody has probed them, both of the values that moved are device-derived, and Iris Xe is the heaviest single entry in the device catalogue — so a guess there would be wrong at the largest available scale. Closing that gap needs a Gen11 and a Gen12 capture.

    The limit applies backwards too, and it turns on a detail worth knowing before extending the routing. Intel spelled Broadwell (Gen8) with four digits, HD Graphics 5500, where Gen9 used three, HD Graphics 520 — so matching on an HD Graphics 5 prefix quietly takes a generation nobody probed. Routing counts the digits instead, and Broadwell stays on the catch-all tier.

  • Intel Iris Pro Graphics 580 (Skylake GT4e) under Mesa 25.2.8 — ANGLE's Vulkan backend on Linux. This one covers that GPU under that driver and nothing else: vk_caps_utils.cpp fills its caps straight from VkPhysicalDeviceLimits (max2DTextureSize is min(limitsVk.maxFramebufferWidth, limitsVk.maxImageDimension2D), the viewport bounds come from limitsVk.maxViewportDimensions), so a different Intel part — or the same part on a different Mesa release — is a different tier and needs its own capture. That is why it is named for the device and the driver rather than for the backend, and why no "Linux" or "Vulkan" tier exists to generalize it.

    That reasoning has since been measured rather than left as a reading of ANGLE's source. A second Mesa/Vulkan device — AMD RDNA2 under RADV, same Chrome build — differs from this tier in 12 WebGL2 parameters, including MAX_3D_TEXTURE_SIZE 8192 against 2048 and UNIFORM_BUFFER_OFFSET_ALIGNMENT 4 against 64, and the two disagree on extensions as well. Worth having, because D3D11_REQ_* was the other source-argument in this chapter and it turned out to have two escapes.

The measurement shows the split rather than just asserting it. The Vulkan capture is closest to SwiftShader (7 of 82 WebGL1 parameters differ and 21 of 132 WebGL2, against 10/26 for D3D11 and 9/23 for Metal), because SwiftShader's renderer string also says Vulkan 1.3.0 and it runs through the same ANGLE backend. The 21 that remain between two Vulkan-backed captures on one Chrome build are exactly the device-derived limits.

A tier is shared capability values, never shared identity. Pin an Intel or AMD D3D11 renderer and you get that tier's numbers above your own vendor and renderer strings — UNMASKED_VENDOR_WEBGL is derived from the renderer you pinned (ANGLE (Intel, …)Google Inc. (Intel)), not from the NVIDIA card the tier happened to be captured on.

When a persona names no renderer, the default is chosen from its platform. A renderer string is read beside navigator.platform, so the two have to be a pair Chrome can actually produce. A MacIntel persona gets the Apple Metal row, a Win32 persona the D3D11 row, and a LinuxX86_64 persona the Intel Iris Pro 580 Mesa/Vulkan row — all three ordinary hardware identities whose name and numbers come from the same probe.

Linux used to get a SwiftShader row instead, which was real (Chrome reports it on a GPU-blocklisted machine, a VM, or a headless container) but announced "no usable GPU", something some fingerprinters weight on its own. Capturing the Vulkan tier retired that last fallback: every platform default is now hardware.

SwiftShader's numbers are platform-independent; its renderer string is not. Probing Ubuntu 24 (Chrome 150.0.7871.114, GPU-less VM) against the flags used for the macOS capture reproduced it exactly — no WebGL1 or WebGL2 parameter differed, and the extension and precision lists matched — while the renderer string differed in one token, because SwiftShader chooses its JIT backend at build time and Chrome prints the choice:

SwiftShader buildrenderer string
Linux and Windows (both measured)ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)
macOSANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)

So one capability tier ships under two identity strings. Neither picks a default any more — every platform resolves captured hardware — but the split still decides which row a persona lands on when it pins a SwiftShader renderer itself, and it must be the build that platform's Chrome really prints. Windows Chrome's SwiftShader build prints Subzero too (measured on Windows 10.0.21996, Chrome 150.0.7871.186).

A renderer you pin yourself that matches no tier falls back to your platform's default device and logs a warning. Serving an unrecognized renderer's name above a different backend's numbers is its own incoherence. A desktop-GL string is expected to be unmatched, and so is any Vulkan device other than the captured Iris Pro — widening that row to cover Linux generally is exactly what its device-derived limits forbid. Adding a tier requires probing real hardware with that backend; values are never invented. See the capture-gpu-tier skill for the procedure if you hit this warning and have the hardware to fix it.

The tables describe the claimed device, not the host that runs the page. A served capability can exceed what the backend underneath can actually do, and nothing in the tables can change that — the numbers are read from a table, the work is done by real hardware. MAX_TEXTURE_SIZE 16384 sits above a backend that fails a 16384 texImage2D; MAX_SAMPLES 8 sits above one whose getInternalformatParameter offers fewer; MAX_DRAW_BUFFERS 8 sits above one with six real DRAW_BUFFERn enums. The last of those is cheap enough to close outright, and the patch does close it (it answers the ES 3.0 default for any index the served cap claims and the backend has no constant for). The rest are not: a script that exercises a limit rather than reading it can tell the claim from the capability, and no table can fabricate the capability itself. Where that fidelity matters, pair the persona's tier with a gpu_backend that really has those limits — a MacIntel persona (Apple Metal tier), a Win32 one (D3D11 tier), or a LinuxX86_64 one (the Mesa/Vulkan tier) on GpuBackend::Native hardware, rather than over SwiftShader, which is the default. No platform resolves the SwiftShader tier by default any more, so no platform is trivially coherent with that backend; pinning a SwiftShader renderer yourself is what makes it so.

Persona.gpu: Option<GpuProfile> lets you pin a whole coherent device. Unset, it resolves from the persona's WebGL renderer string, matched against the shipped devices above. Set, it overlays the resolved tier key-wise, so a partial profile only overrides the keys it sets. It merges as one atomic value across personas (like screen), never field-by-field — composing two devices' values could describe hardware that exists nowhere. The finer-grained WebglSpec (the unmasked_vendor/unmasked_renderer strings) still overlays on top of whatever Persona.gpu produces, so you can pin just the renderer string without restating an entire device's parameter table.

Strategy::Native on Webgl emits no WebGL patch at allgetParameter and friends return the host's real, unmodified values. As covered above, this also suppresses the Webgpu value spoof, so neither surface serves a value that contradicts the other.

The tables are generated, never hand-edited. crates/zendriver-stealth/src/gpu/tiers.rs is produced by the gpu-tier-gen crate from committed probe captures (crates/zendriver-stealth/data/gpu-tiers/*.json); a CI step regenerates it and fails the build on any diff, so the shipped file can never drift from its source captures.

What the tier tables buy, and what they do not

Worth stating plainly, because the mechanism above is easy to mistake for more than it is.

What they remove is a positive signal. Before, a persona reported MAX_VIEWPORT_DIMS from one backend beside MAX_TEXTURE_SIZE from another — a pair no device produces, checkable in two lines. Same for NVIDIA's 4095 under an AMD name, and for DRAW_BUFFER6 answering past its own advertised cap. Those were detectably fake, and they are gone. Going from wrong to not-wrong is worth doing, and it is not the same as convincing.

What they cannot do is survive a render. A page that hashes canvas or WebGL pixels reads what actually rendered, and on the default software rasterizer that is not what the claimed device would produce. No amount of metadata fixes it, and nothing here could: matching a specific GPU's pixels means reproducing a proprietary shader compiler's optimisation choices bit-for-bit and tracking them across driver releases. Fused multiply-add alone rounds differently from a separate multiply and add, which is a different last bit and a different hash.

So treat this as a floor rather than a defence. It is sufficient against a checker that reads values only — common, because rendering and reading back costs real time — and insufficient against one that renders. For the latter the honest answer is GpuBackend::Native on hardware that matches the persona, where pixels and values agree because both come from the same machine.

The catalogue's strongest justification is a different one. It is not fooling a hardware check. Before it existed, every zendriver user on Windows reported the same RTX 4090 — a constant shared across the entire user base, which is a library fingerprint rather than a GPU one. That signal is independent of pixels, no render defeats it, and the catalogue kills it.

Naming a GPU from the device catalogue

The tiers decide what a device can do. The catalogue decides which device it is — 482 identities, against the one default each platform used to get.

use zendriver::stealth::{GpuDevice, Persona, Platform};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let persona = Persona {
    platform: Some(Platform::Win32),
    ..Persona::builder()
        .gpu_device(GpuDevice::by_name("NVIDIA GeForce RTX 4090")?)
        .build()
};
Ok(())
}

gpu_device sets nothing but the renderer string, because that string is a device's whole contribution: it already selects the capability tier, the WebGPU adapter, and the vendor through the machinery above. There is no separate GPU field to keep in sync.

Drawing one instead of naming it. A fleet wants variety, and ten personas that agree on every readable GPU value are ten personas that can be grouped:

#![allow(unused)]
fn main() {
use zendriver::stealth::{GpuDevice, Platform, Seed};

// Same seed, same device, every run.
let uniform = GpuDevice::from_seed(Seed(42), Platform::Win32);
// Weighted by how common the device actually is.
let realistic = GpuDevice::by_share(Seed(42), Platform::Win32);
}

by_share is usually the one you want. Over 5000 Win32 draws it yields 238 distinct devices led by Intel UHD Graphics at 15%, Iris Xe at 15% and AMD integrated at 9% — a browser population, laptop iGPUs first. from_seed draws uniformly, which makes a GeForce 210 as likely as the commonest laptop chip.

Those weights come from the same fingerprint corpus the device names do, as marginal probabilities over its user-agent prior. They are deliberately not Steam Hardware Survey numbers: Steam skews toward discrete gaming cards, and this is a browser tool.

Why the weighting matters more than it looks

The instinct is that a spoof succeeds by being correct. For fingerprinting it succeeds by being common, and those are different targets.

A detection vendor sitting in the request path sees hundreds of millions of real sessions, each handing over a renderer string, a canvas hash, a font list, an audio hash, and timings, all correlated. That is a continuously-updating census of what real browsers report, and it costs them nothing beyond being there — it even self-updates through driver releases, because real users update drivers. Nobody needs a reference lab of every GPU on every driver version when the population reports itself.

So the practical check is not "is this what an RTX 4090 should render?" but "does this combination show up across thousands of unrelated sessions?" Rarity is the signal, not incorrectness. A device nobody else reports is conspicuous even when every value in it is internally perfect.

That is the real argument for by_share: it lands in the dense part of the distribution. A uniform draw is just as coherent and considerably rarer, and a GeForce 210 in current traffic is a small population to hide in.

It also sharpens a tradeoff the surface farbling makes. Per-persona noise defeats linkage — two sessions cannot be tied together by a shared canvas hash — while making each session's hash unique, and uniqueness is the anomaly a consensus check looks for. Those goals genuinely oppose each other. Defending against being followed and defending against being spotted are not the same problem, and no single setting wins both.

(Reasoning about incentives and cost, not inside knowledge of any vendor's implementation. The conclusion is robust either way: a common device is never the worse choice.)

What the catalogue will not do.

  • Invent a device id. A D3D11 renderer string carries one by construction, so a model the sources never pair with an id is dropped rather than given a placeholder. The generated table names the ones it dropped.
  • Cross a platform. Every draw is filtered through the same skew check the invariants use, so a Win32 persona cannot draw an Apple identity.
  • Cover Linux. ANGLE's Vulkan backend reads its limits off the physical device, so there is no shared tier for a Linux identity to layer over and from_seed answers None there.
  • Claim a feature level it does not have. ANGLE writes the feature level into the renderer string as its shader model, so pre-FL11 cards are excluded rather than filed under an FL11 tier.

Matching the host's own GPU. zendriver::nearest_gpu_device() launches a short-lived browser on the native backend, reads what this machine reports, and returns the closest catalogued device — exact identity first, then the same model, then the same vendor on the same backend, then the same backend.

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
if let Some(device) = zendriver::nearest_gpu_device().await? {
    println!("closest catalogued GPU: {}", device.model());
}
Ok(())
}
}

It is a function you call, never a default. Probing the host to decide what a persona claims is detect-and-adjust, which this project does not do implicitly; the named-opt-in shape is the same one geo_auto uses. It answers None rather than reaching for something plausible when the host's backend has no catalogue — Linux and software rendering — because a Windows GPU is not a reasonable answer for a Linux host merely because one was requested.

Pairing it with GpuBackend::Native is the one configuration where the values and the pixels agree by construction, because both come off the same physical card. Everywhere else they are two separate claims that happen to be consistent with each other, and only one of them survives being rendered. If a target is known to run WebGL challenges, this pairing is the answer and no amount of catalogue work substitutes for it.

by_name refuses ambiguity rather than guessing — "rtx 40" returns every candidate — while an exact model name always wins, since several catalogued names are prefixes of others.

WebGPU (opt-in adapter override / fabrication)

By default (Persona.webgpu = None, or Some(WebgpuSpec::default())), the Webgpu surface only DECORATES a real navigator.gpu adapter's .info with a vendor/architecture DERIVED from the Webgl surface's renderer (never fabricated) — the same behavior it always had. Only a renderer naming a GPU family zendriver recognizes yields a vendor and architecture; anything else — including any SwiftShader renderer — derives both as "", which is what Chrome itself reports for an adapter it cannot classify. A software rasterizer has no vendor, and naming one beside a SwiftShader WebGL renderer would be the cross-API contradiction this derivation exists to prevent. The Mesa/Vulkan row a LinuxX86_64 persona defaults to derives intel with an empty architecture — Intel is what its renderer names, and nothing measured that part's architecture token, so it stays empty rather than guessed.

.limits and .features come from the same measured tier the WebGL surface serves. The probe captures each tier's navigator.gpu adapter alongside its WebGL blocks, in one run on one machine, so the two APIs answer for one device: a Win32 persona reports the D3D11 tiers' 2 GiB maxBufferSize and 19 features (16 on Intel Gen9), a MacIntel persona the Metal tier's 4 GiB - 4 and its 22. Before this they were left at the host's, so an adapter could name an NVIDIA card above a Metal buffer limit — the same gap the tier tables closed for WebGL.

Two tiers serve neither, and in both cases that is the measurement rather than a hole. Chrome on SwiftShader resolves requestAdapter() to null, and the Linux machine the Mesa/Vulkan tier was probed on has WebGPU off by default (navigator.gpu exists, requestAdapter() resolves null) — so neither has an adapter to describe, and the host's own values are left untouched. Substituting a neighbouring tier's numbers would hand a persona that just told WebGL it has a software rasterizer, or a machine with WebGPU disabled, a hardware adapter's capabilities.

One honest caveat, the mirror of the D3D11 tier's WebGL story — and one that has since been half-measured. Most WebGL values generalize across FL11+ cards because ANGLE derives them from D3D11_REQ_* constants, and the WebGPU limits generalize the same way (Dawn's D3D12 backend derives them from binding-tier constants). A few features are genuinely hardware-gated, though — shader-f16, subgroups, bgra8unorm-storage — and the catch-all tier's list was measured on desktop hardware.

Taking the capture this predicted is what closed half of it: an Intel HD Graphics 520 enumerates exactly those three fewer features, and Gen9 iGPUs now resolve their own tier with its own 16-feature list rather than borrowing the desktop one. What is still open is Gen11 and Gen12, which remain on the catch-all tier because nobody has probed them — so pinning an Iris Xe renderer still serves a feature list measured on another card. If that fidelity matters before someone captures those, pin features yourself through WebgpuSpec.

WebgpuSpec (mirroring WebglSpec's strategy+values shape) adds two OPT-IN capabilities on top:

  1. Caller-supplied adapter identity. Set vendor / architecture / device / description / limits / features explicitly instead of letting vendor/architecture derive from the WebGL renderer and limits/features come from its tier. limits overlays key-wise, so pinning one limit keeps the tier's value for every other; features replaces the tier's list wholesale.

  2. Synthetic adapter fabrication (fabricate_when_absent: true) — when the host has no real WebGPU adapter, resolve a synthetic one built from your supplied values. This covers both GPU-less shapes:

    • navigator.gpu entirely absent ('gpu' in navigator === false). This is governed by the page, not by launch flags: navigator.gpu is [SecureContext]-gated, so it is absent on an opaque origin such as about:blank or a data: URL no matter what hardware or flags are in play. On a secure page under zendriver's default flags, 'gpu' in navigator is true and requestAdapter() merely resolves null (measured on Chrome 150). Where it is absent, a synthetic navigator.gpu is created on Navigator.prototype, flipping 'gpu' in navigator to true — coherent for a modern-Chrome persona, since real modern Chrome exposes navigator.gpu even with no usable GPU.
    • navigator.gpu present but requestAdapter() returns null: the real requestAdapter is wrapped so a null result falls back to the synthetic adapter (a real adapter passes through untouched).

    Requires BOTH vendor AND limits to be set explicitly — a bare fabricate_when_absent: true with nothing else is refused (no-op): this project never auto-invents fingerprint values.

#![allow(unused)]
fn main() {
use zendriver::{Browser, Persona, WebgpuSpec};

let persona = Persona {
    webgpu: Some(WebgpuSpec {
        vendor: Some("apple".into()),
        architecture: Some("metal-3".into()),
        ..Default::default()
    }),
    ..Persona::default()
};

let browser = Browser::builder().persona(persona).launch().await?;
}

Or via JSON (works with fabricate_when_absent + limits/features too):

#![allow(unused)]
fn main() {
use zendriver::Persona;

let persona: Persona = Persona::try_from_json(r#"{
  "webgpu": {
    "vendor": "apple",
    "architecture": "metal-3",
    "limits": { "maxTextureDimension2D": 16384 },
    "features": ["texture-compression-bc"],
    "fabricate_when_absent": true
  }
}"#).unwrap();
}

You own value accuracy. Every WebgpuSpec field is caller-supplied — nothing is probed or invented from a real GPU. A vendor/limits/features combination that doesn't correspond to any real device is more detectable than leaving the field None: fingerprinting scripts cross-check GPUAdapterInfo against GPUSupportedLimits/GPUSupportedFeatures and against the WebGL renderer string, so an incoherent combination reads as a bot faster than honest absence does. Only set these to values verified against a real device.

v1 limitations: a fabricated synthetic adapter's requestDevice() always REJECTS — there is no way to fabricate a working GPUDevice without a real GPU behind it. Fabrication only makes requestAdapter() resolve a coherent adapter for detection scripts that stop there; it does not unlock actual WebGPU rendering on a GPU-less host.

adapter.limits and adapter.features are the real GPUSupportedLimits and GPUSupportedFeatures objects wherever those classes exist: the patch overrides their prototypes' accessors and setlike members rather than handing back a plain object and a Set, so constructor.name, instanceof, Object.prototype.toString and own-property count all read as a genuine adapter's while the values are the claimed device's (verified against real Chrome in crates/zendriver/tests/gpu_profile.rs). What still differs: the iterators features.keys() / values() / entries() return are ordinary Array Iterators rather than GPUSupportedFeatures Iterators — has, size, spread and for...of all read correctly, only the iterator's own type tag differs.

requestDevice() is held to the same claim. A served limit that only survives being read is a fingerprint of its own: the adapter advertises the tier's numbers, so a page can ask for exactly what it was just told, and the request would go straight to hardware that never had it (a Win32 persona advertises 16 storage buffers per shader stage; a Metal host supports 10). On the decorate path the patch wraps requestDevice so both directions agree with the advertisement — a requiredLimits / requiredFeatures request beyond it rejects with Chrome's own error and message, and one within it is translated down to what the hardware can actually give so the call succeeds. The resulting GPUDevice reports the requested values on its .limits (and the spec defaults for everything it did not request, exactly as a real device does), on a genuine GPUSupportedLimits. Its adapterInfo names the same adapter too — before this it answered the host's, so adapter.info.vendor read nvidia and device.adapterInfo.vendor read apple one line later.

That closes the interrogation divergence, not the capability gap. A page that goes on to allocate at the claimed capability still fails, because no patch can conjure hardware — the same honest limit as SwiftShader's pixels not being an NVIDIA GPU's pixels.

On a GPU-equipped host, GpuBackend::Native sidesteps WebgpuSpec fabrication entirely. Instead of faking an adapter and accepting the requestDevice()-rejects / no-real-rendering limitations above, Native has Chrome render on the host's real GPU: a real adapter, a real working requestDevice(), real limits and features — no patch involved. The trade-off moves in the other direction: Native reports whatever GPU the host actually has, with no caller-supplied identity, so it doesn't help when you need a specific (rather than coherent) GPU identity. See the GPU backend chapter for the full comparison.

Country → locale + timezone overlay (geo_locale)

The optional geo feature adds BrowserBuilder::geo_locale, which maps an ISO 3166-1 alpha-2 country code (e.g. "US", "de") to a coherent locale + languages (Accept-Language) set drawn from a bundled CLDR-derived table, plus a representative IANA timezone drawn from a bundled tz-database table (wired through to Emulation.setTimezoneOverride). It is layered as a persona overlay, so it composes with .persona(..) and is overridden by an explicit .persona_overlay(..) locale. An invalid / unknown country code is ignored (logged) — the value is never locked.

Representative-zone caveat: countries spanning multiple timezones (the US, Russia, Canada, Australia, Brazil, ...) resolve to a single representative zone (the country's first zone1970.tab entry, with a few curated overrides — e.g. RUEurope/Moscow, not Europe/Kaliningrad), not any particular visitor's actual local zone. Treat it as a coherent default, not a precise one — set .persona(Persona::builder().timezone("America/Los_Angeles").build()) (or .persona_overlay(..)) when a specific zone within the country matters.

[dependencies]
zendriver = { version = "0.1", features = ["geo"] }
#![allow(unused)]
fn main() {
use zendriver::Browser;

let browser = Browser::builder()
    .geo_locale("DE")   // de-DE locale + matching Accept-Language
    .launch().await?;
}

Auto IP-geo resolution (geo_auto)

geo_locale requires knowing the country up front. When you don't — e.g. the browser is routed through a rotating or third-party proxy pool and you want the locale to match wherever that proxy happens to exit — use BrowserBuilder::geo_auto instead. It probes the exit IP through a bundled IpApiResolver (a proxied GET against ip-api.com) and folds the resulting country's locale/languages into the persona overlay, with the exact same precedence as geo_locale: an explicit .persona(..)/.persona_overlay(..) locale always wins and skips the probe entirely.

Timezone precision beats geo_locale here: ip-api.com's response carries the exit IP's exact IANA timezone, not just its country, so geo_auto uses that EXACT zone instead of the country-representative one — multi-timezone countries (US, RU, CA, AU, BR, ...) get the visitor's real local zone, not an approximation. (A custom [GeoResolver] that can't determine an exact zone returns timezone: None, and geo_auto falls back to the same country-representative zone geo_locale uses.) Precedence: explicit .persona(..)/.persona_overlay(..) timezone > exact probe timezone > country-representative timezone.

#![allow(unused)]
fn main() {
use zendriver::Browser;

let browser = Browser::builder()
    .proxy("http://user:pass@residential-proxy.example:8000")
    .geo_auto()   // probes the exit IP through the proxy above, credentials included
    .launch().await?;
}

geo_auto() mirrors the proxy's credentials into the probe too (via reqwest::Proxy::basic_auth, never embedded in a URL string), so an authenticated proxy like the one above is probed authenticated — the probe would otherwise 407 silently and fail soft with no overlay.

Privacy: the bundled ip-api.com probe fires ONLY when .geo_auto() (or .geo_resolver()) is called — it is fully opt-in, never implicit. Failure (no network, proxy down, unrecognized country) is fail-soft: a tracing::warn! is logged and launch() proceeds with no overlay; it never blocks or fails the launch. The default endpoint (http://ip-api.com/json) is plaintext HTTP — a proxy operator can observe or tamper with the response in transit; override IpApiResolver::endpoint to an HTTPS service if response integrity matters for your threat model.

Structured proxy(..)

BrowserBuilder::proxy parses a scheme://[user:pass@]host:port URL, strips the userinfo before emitting --proxy-server= (Chrome ignores credentials there), and auto-wires proxy_auth from the userinfo when set (requires the interception feature to actually answer the Fetch.authRequired challenge). It also makes geo_auto()'s probe traffic mirror the same upstream proxy the browser itself will use, so the resolved country matches the exit IP Chrome actually sees.

Custom resolver (geo_resolver)

Swap the bundled ip-api.com probe for your own service, an offline MaxMind-style DB, or a test double by implementing zendriver_stealth::geo::GeoResolver and passing it to BrowserBuilder::geo_resolver. resolve() returns a ResolvedGeo — the country plus an optional exact timezone; return timezone: None if your source can't determine one more precise than the country-representative zone:

#![allow(unused)]
fn main() {
use async_trait::async_trait;
use zendriver::Browser;
use zendriver_stealth::geo::{Country, GeoResolver, ResolvedGeo};

struct MyResolver;

#[async_trait]
impl GeoResolver for MyResolver {
    async fn resolve(&self) -> Option<ResolvedGeo> {
        // Query your own service / offline DB instead of ip-api.com.
        Some(ResolvedGeo {
            country: Country::try_from("DE").ok()?,
            timezone: Some("Europe/Berlin".to_string()),
        })
    }
}

let browser = Browser::builder()
    .geo_resolver(MyResolver)
    .launch().await?;
}

Only ONE of geo_auto() / geo_resolver(..) takes effect (the last one called wins — both set the same underlying resolver slot).

JSON persona (try_from_json)

Any Persona can be expressed as a JSON object and round-trips cleanly. Fields are snake_case; all fields are optional. Useful for configuration files or environment variables:

#![allow(unused)]
fn main() {
use zendriver::Persona;

let persona: Persona = Persona::try_from_json(r#"{
  "timezone": "Europe/Berlin",
  "device_memory_gb": 8,
  "seed": 12345,
  "webgl": {
    "unmasked_vendor":   "Google Inc. (NVIDIA)",
    "unmasked_renderer": "ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)"
  },
  "webrtc": { "strategy": "Block" }
}"#).unwrap();
}

You can also parse via FromStr:

#![allow(unused)]
fn main() {
use zendriver::Persona;

let persona: Persona = r#"{"seed": 99, "timezone": "UTC"}"#.parse().unwrap();
}

GPU backend

By default zendriver launches Chrome with --disable-gpu and no ANGLE backend named. Bare --disable-gpu does not by itself guarantee a working WebGL context — Chrome ≥116 returns null from canvas.getContext('webgl') without --enable-unsafe-swiftshader. It's the spoofed stealth profile that forces a working software fallback, by unconditionally adding --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader so a WebGL context exists at all under headless. That is a safe default for headless CI, but it produces a GPU surface no real device ever produces: numeric WebGL capability limits and the SwiftShader renderer string that no laptop or workstation reports.

GpuBackend is an opt-in BrowserBuilder option that lets Chrome use the host's real GPU instead.

#![allow(unused)]
fn main() {
use zendriver::{Browser, GpuBackend};

async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let browser = Browser::builder()
    .gpu_backend(GpuBackend::Native)
    .launch()
    .await?;
Ok(())
}
}

The three variants

VariantWhat it doesWhen to reach for it
GpuBackend::Disabled (default)--disable-gpu under headless, no ANGLE backend forced — Chrome picks its own fallback. Byte-for-byte today's behavior.The historical default; nothing changes if you never touch this option.
GpuBackend::SwiftShaderForces ANGLE's SwiftShader software rasterizer explicitly (--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader).You want a guaranteed-working WebGL context on a host with no GPU, and are fine with a software-rasterizer fingerprint.
GpuBackend::NativeDrops --disable-gpu and names the platform's ANGLE backend (metal on macOS, d3d11 on Windows, vulkan elsewhere), so Chrome renders on the host's real GPU.You have real GPU hardware available and want a coherent, non-software GPU surface — WebGL capability values, WebGPU adapter, and pixel output that all match what a real device reports.

Disabled and SwiftShader both still emit --disable-gpu under headless; only Native omits it.

Why one enum owns both decisions

Dropping --disable-gpu without also naming an ANGLE backend hangs headless Chrome — measured directly on this project's dev host, twice, killed at the 30-second mark both times. The two decisions (suppress --disable-gpu, name a backend) are coupled, so GpuBackend owns both together rather than leaving it to the caller to combine two separate flags correctly.

Native reports the host's GPU, not a chosen one

GpuBackend::Native gives a fully coherent GPU surface, but it does not give identity control. Chrome reports whatever GPU is physically present. Every browser launched with Native on the same host reports the same adapter, the same WebGL renderer string, the same capability limits — a fleet sharing one host shares one GPU fingerprint. If you need distinct GPU identities across a fleet, Native is not that tool; look at WebgpuSpec for caller-supplied adapter values instead. Native makes the GPU surface coherent; it does not give you control over what it says.

The reverse is also worth knowing. Everything in the fingerprint chapter gives you control without coherence past the metadata: it can make a page read any catalogued GPU's values, but the pixels still come from whatever actually rendered. Native is the only backend where a page that renders and hashes the result sees a real device, which is why nearest_gpu_device() exists — it names the catalogued identity closest to the card you are already rendering on, so the claim and the pixels point at the same hardware.

The launch is validated, and there is no automatic fallback

Chrome starting is not evidence that it got a GPU. On a GPU-less Ubuntu 24 VM, Chrome 150 launched successfully under Native and then returned null from both canvas.getContext('webgl') and getContext('webgl2'). That is a browser strictly more detectable than the default — a missing WebGL context is one of the oldest and cheapest headless tells, and the stealth WebGL patch cannot repair it, because it patches prototypes and there is no context to patch.

So Native verifies the launch. After the CDP handshake, zendriver asks Chrome what it actually initialized (SystemInfo.getInfo, a browser-level domain — no page, no navigation, one round-trip) and reads gpu.featureStatus.webgl. Measured on Chrome 150.0.7871.186 on the darwin dev host: Native reports enabled, SwiftShader reports enabled_readback, Disabled reports disabled_off. Anything but a hardware status terminates the Chrome that was just spawned and fails the launch with BrowserError::GpuBackendUnavailable.

If the GPU cannot be verifiedSystemInfo.getInfo unavailable, or answering with a status string zendriver does not recognize — the launch logs a warning and proceeds. A missing diagnostic API is not evidence of a missing GPU, and refusing to launch over one would be worse than the problem the check addresses.

There is no fallback. Failing rather than retrying on SwiftShader is deliberate: falling back automatically would serve a software rasterizer's values under a "native" label — the same incoherent, mixed software/hardware fingerprint that choosing a backend explicitly exists to avoid. If a launch might land on a GPU-less host, catch GpuBackendUnavailable and retry with GpuBackend::SwiftShader or GpuBackend::Disabled yourself.

Measured comparison

All figures below are from real Chrome (150.0.7871.186) on this project's darwin dev host (Apple M4 Pro), probed with the probe_gpu example. WebGL figures are from a secure-context (file://) page — see the note on navigator.gpu below for why that matters. The SwiftShader column is GpuBackend::SwiftShader (equivalently: GpuBackend::Disabled with a spoofed stealth profile attached, since that profile forces the same flags — see the intro above). Bare GpuBackend::Disabled with no spoofed profile was not measured here and is not shown — it emits no ANGLE flags, so nothing above the "does a WebGL context exist at all" question applies to it.

SwiftShaderNative
WebGPU adapternone (requestAdapter() resolves null)real: vendor: "apple", architecture: "metal-3"
requestDevice()n/a (no adapter)succeeds
WebGPU adapter limitsn/a~36 limits
WebGL renderer stringANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)
WebGL MAX_TEXTURE_SIZE819216384
WebGL extension count3036

Whether MAX_TEXTURE_SIZE and the other numeric WebGL caps are read from the GPU depends on the backend, and that is what decides how far one probe generalizes:

  • D3D11 and Metal-on-macOS: not read from the device. ANGLE computes them from constants branched on the feature level (renderer11_utils.cpp) or from plain compile-time constants (DisplayMtl.mm's TARGET_OS_OSX arm), so one probe covers every card on that backend.
  • SwiftShader: no device to read. It is a software rasterizer, which is why the row above is identical regardless of what real GPU sits underneath.
  • Vulkan: read straight off the device. vk_caps_utils.cpp fills its caps from VkPhysicalDeviceLimits, so a Linux probe describes that GPU under that Mesa build and nothing else. zendriver's Vulkan tier is named for its device for exactly this reason (Intel Iris Pro Graphics 580, Mesa 25.2.8), and covering another Linux GPU means capturing it.

The one part of the SwiftShader row that is host-specific is the renderer string. Re-probing the same flags on Ubuntu 24 (Chrome 150.0.7871.114) reproduced every capability value above, but reported SwiftShader Device (Subzero) rather than (LLVM 10.0.0) — SwiftShader picks its JIT backend at build time and Chrome prints the choice. Windows reports Subzero too (measured on Windows 10.0.21996, Chrome 150.0.7871.186).

The spoofed profile now serves neither string by default on any platform: a Win32 persona resolves the captured D3D11 tier and a LinuxX86_64 persona the captured Mesa/Vulkan one, both real hardware rather than a software rasterizer. A SwiftShader row is reached only by pinning such a renderer yourself, and which of the two builds you land on then follows the persona's platform. See the WebGL section of the fingerprint chapter.

navigator.gpu is [SecureContext]-gated. On an opaque-origin page — about:blank, a bare data: URL — navigator.gpu is absent no matter which GpuBackend you pick, headless or headful. This is easy to misread as a launch-flag effect because about:blank is also zendriver's typical first page; it isn't one. Load a secure-context page (https://, or file:// for local probing) before checking 'gpu' in navigator.

Probing your own host

The probe_gpu example dumps the full WebGPU + WebGL surface as JSON for any backend:

cargo run -p zendriver --example probe_gpu -- native
cargo run -p zendriver --example probe_gpu -- swiftshader
cargo run -p zendriver --example probe_gpu -- disabled

From a browser, with no toolchain

The same measurement runs as a page: GPU tier probe. Open it on the machine you want to read and it produces the identical capture file, which matters when that machine is an old laptop or a phone where installing a Rust toolchain is the whole obstacle. The measurement runs locally and nothing is uploaded; the page offers a download, a share sheet where the platform has one, and a two-step path to opening a pull request.

It refuses to export rather than hand back a plausible wrong answer. A page served insecurely reports no WebGPU adapter on a machine that has one, and a privacy extension that farbles WebGL produces a capture that is well-formed and describes a GPU that does not exist. The page checks for both, along with whether the renderer is ANGLE's at all — Safari and every iOS browser use WebKit's own stack, so their numbers are real but belong to a graphics layer these tiers do not model.

The browser path cannot select a backend the way the arguments above do, since that needs launch flags. It captures whatever the browser is already using, which for ordinary Chrome on a working GPU is the native backend — the one worth capturing.

MCP

browser_open exposes this as the gpu_backend option ("disabled" | "swift_shader" | "native", default "disabled"). See the MCP chapter.

Multi-tab

Chrome opens with one tab. zendriver-rs treats every additional tab as a first-class Tab handle — the same type as the main tab, with the same query / input / evaluate surface. Tabs are tracked in a browser-wide registry that you can iterate, look up, or close from any clone of the Browser.

Opening tabs

Two constructors:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
// Open about:blank and return as soon as the registrar sees the new tab.
let blank = browser.new_tab().await?;

// Open a URL — equivalent to new_tab().await? then goto(url).await?
let live = browser.new_tab_at("https://example.com").await?;
live.wait_for_load().await?;
Ok(()) }
}

Browser::new_tab() and Browser::new_tab_at() both go through Target.createTarget at browser scope (no sessionId). Each returns a fully-initialised Tab:

  1. Page/DOM/Runtime/Network CDP domains enabled.
  2. Stealth bootstrap re-applied via the auto-attach observer chain.
  3. Isolated-world ready for evaluate() calls.

Internally, new_tab* polls the tab registry every 50 ms for up to 5 s waiting for the new target to register — typically returns within a few milliseconds. If the auto-attach observer crashes or is misconfigured, you'll get ZendriverError::TabNotFound after the 5 s window.

Iterating tabs

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
for tab in browser.tabs().await {
    println!("tab {}: {}", tab.target_id(), tab.url().await?);
}
Ok(()) }
}

Browser::tabs() returns a snapshot Vec<Tab> covering every currently-registered tab, including:

  • The main tab (the one Chrome opened with).
  • Tabs you opened via new_tab*.
  • Tabs page scripts opened via window.open(...) (auto-attach wires these into the registrar).

Order is unspecified — the registry is a HashMap keyed by sessionId. Tabs that close concurrently disappear from the snapshot on the next call.

Browser::tab_count() is the cheap len-read on the same registry — prefer it over browser.tabs().await.len() when you only need the count.

Activating a tab

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.new_tab().await?;
tab.activate().await?;
Ok(()) }
}

Tab::activate() sends Target.activateTarget, which is what "clicking the tab in Chrome's tab strip" does. The activated tab becomes the visible tab in headed mode and the receiver of keyboard focus events.

Gotcha: inactive tabs don't receive input events

Chrome serializes physical input (mouse moves, key presses) through whichever tab currently has OS focus. CDP Input.dispatchMouseEvent and Input.dispatchKeyEvent calls route through the page's render process directly, so they do reach inactive tabs — clicks, typing, and the realistic Bezier-mouse path all work without activating first.

But events that flow back through the OS layer — e.g. fullscreen requests, clipboard reads, focus-trap behaviors that read document.hasFocus() — observe the OS-level active tab. If you hit a "works in active tab, breaks in background tab" issue, the cause is almost always document.hasFocus() returning false or a feature gated on document.visibilityState.

The fix is to activate the tab before the input sequence:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.new_tab_at("https://example.com").await?;
tab.activate().await?;
let btn = tab.find().css("button#go").one().await?;
btn.click().await?;
Ok(()) }
}

You can leave any of the other tabs inactive — activate only sets the OS focus to a single tab; it doesn't affect anyone else.

Closing tabs

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.new_tab().await?;
tab.close().await?;
Ok(()) }
}

Tab::close() consumes the Tab handle and sends Target.closeTarget. The registrar removes the entry from the browser-wide registry on the resulting Target.targetDestroyed event. Existing clones of the closed tab will error on their next CDP call with [ZendriverError::SessionClosed].

Closing every tab does not close the browser. To shut down the whole subprocess, call Browser::close() (see Quickstart).

End-to-end example

This example opens three tabs at distinct URLs, prints each tab's URL + title, then closes the whole browser (which tears down every tab):

//! Open three tabs at distinct URLs, iterate the [`Browser::tabs`] registry,
//! and print every tab's URL — the canonical P4 multi-tab smoke test.
//!
//! Demonstrates:
//!   - [`Browser::new_tab_at`] (open + navigate in one step).
//!   - [`Browser::tabs`] (snapshot of every live tab the registrar tracks,
//!     including the auto-attached `main_tab`).
//!   - [`Browser::tab_count`] (cheap len read on the same registry).
//!
//! Each opened tab is its own session — closing the [`Browser`] tears them
//! all down via the shared Connection drop path.

use zendriver::Browser;

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;

    // `main_tab()` is the about:blank tab Chrome auto-opens at launch.
    // Drive it to a real URL so the printout is interesting.
    let main = browser.main_tab();
    main.goto("https://example.com").await?;
    main.wait_for_load().await?;

    // Open two more tabs at distinct origins; each call returns a fully
    // initialised Tab (Page/DOM/Runtime/Network domains enabled, stealth
    // applied, isolated world ready).
    let tab_b = browser
        .new_tab_at("data:text/html,<!doctype html><title>B</title><h1>tab B</h1>")
        .await?;
    tab_b.wait_for_load().await?;

    let tab_c = browser
        .new_tab_at("data:text/html,<!doctype html><title>C</title><h1>tab C</h1>")
        .await?;
    tab_c.wait_for_load().await?;

    // Pull the live snapshot from the registry and walk it.
    let tabs = browser.tabs().await;
    println!("tab_count = {}", browser.tab_count().await);
    for (i, tab) in tabs.iter().enumerate() {
        let url = tab.url().await?;
        let title = tab.title().await?;
        println!(
            "  [{i}] target={} url={url} title={title:?}",
            tab.target_id()
        );
    }

    browser.close().await?;
    Ok(())
}

Run it with:

cargo run --example multi_tab -p zendriver

Expected output (target IDs vary):

tab_count = 3
  [0] target=B... url=data:text/html,... title="B"
  [1] target=C... url=data:text/html,... title="C"
  [2] target=A... url=https://example.com/ title="Example Domain"

Concurrency note

Tab is Clone + Send + Sync. You can spawn one worker per tab and drive them in parallel:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let urls = ["https://example.com", "https://example.org", "https://example.net"];
let mut handles = Vec::new();
for u in urls {
    let tab = browser.new_tab().await?;
    handles.push(tokio::spawn(async move {
        tab.goto(u).await?;
        tab.wait_for_load().await?;
        let title = tab.title().await?;
        Ok::<_, zendriver::ZendriverError>(title)
    }));
}
for h in handles {
    println!("{}", h.await.unwrap()?);
}
Ok(()) }
}

The transport actor serializes CDP frames across the WebSocket, so the underlying CDP traffic is sequenced — but every await point yields, letting other tab workers make progress. The aggregate throughput is limited by CDP RTT rather than your spawn count.

Per-context isolation

A BrowserContext is a thin RAII wrapper over a Chrome BrowserContextID — the CDP-side primitive for cookie + storage isolation within a single Chrome process. Tabs opened in a BrowserContext see their own cookie jar, IndexedDB, and (optionally) their own proxy; tabs in the default context still share the browser-wide jar, exactly as before. The two APIs coexist; existing code that calls browser.new_tab() is unaffected.

When to use it. Per-request proxy bindings, parallel sessions with different logins under one Chrome process, A/B fingerprint tests where the cookie state must not bleed across runs. If you need separate user-data-dir, separate GPU caches, or process-level isolation, launch a second Browser instead.

Quick start

use zendriver::Browser;

#[tokio::main]
async fn main() -> zendriver::Result<()> {
    let browser = Browser::builder().launch().await?;

    let ctx = browser.create_browser_context().await?;
    let tab = ctx.new_tab().await?;
    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;

    // ctx dropped at end of scope -> Target.disposeBrowserContext
    // is scheduled on the runtime, tearing down cookies + tabs.
    Ok(())
}

Browser::create_browser_context rejects if the underlying connection is closed; otherwise it returns the new guard immediately — the CDP round-trip is sub-millisecond.

Per-context proxy

Use create_browser_context_with when the isolated context should route through its own upstream proxy:

#![allow(unused)]
fn main() {
use zendriver::Browser;
async fn ex(browser: &Browser) -> zendriver::Result<()> {
let ctx = browser
    .create_browser_context_with(
        Some("http://proxy.example.com:8080".into()),
        // bypass list — same shape as Chrome's --proxy-bypass-list
        Some("<-loopback>".into()),
    )
    .await?;
let tab = ctx.new_tab().await?;
tab.goto("https://api.ipify.org").await?;
Ok(()) }
}

proxy_server is forwarded to CDP Target.createBrowserContext as the proxyServer field. Chrome accepts the same URL shapes as the --proxy-server command-line flag (http://, socks5://, host:port without scheme). proxy_bypass_list defaults to none.

Note: proxyServer alone does not carry proxy authentication — Chrome will issue a 407 if the upstream requires Basic auth. For that, use the first-class builder below instead.

Per-context proxy authentication

Browser::browser_context returns a BrowserContextBuilder that binds a proxy and its credentials to the context, so every tab it opens is transparently authenticated — no per-tab handle to hold:

#![allow(unused)]
fn main() {
use zendriver::Browser;
async fn ex(browser: &Browser) -> zendriver::Result<()> {
let ctx = browser
    .browser_context()
    .proxy("http://user:pass@proxy.example.com:8080")
    .proxy_bypass("<-loopback>")
    .build()
    .await?;
let tab = ctx.new_tab().await?;
tab.goto("https://api.ipify.org").await?;
Ok(()) }
}

Userinfo embedded in .proxy() (user:pass@host:port) is auto-split into credentials and stripped from the proxyServer sent to Chrome (which would otherwise ignore it). Call .proxy_auth(user, pass) (requires the interception feature) to supply — or override — credentials explicitly:

#![allow(unused)]
fn main() {
use zendriver::Browser;
async fn ex(browser: &Browser) -> zendriver::Result<()> {
let ctx = browser
    .browser_context()
    .proxy("http://proxy.example.com:8080")
    .proxy_auth("user", "pass")
    .build()
    .await?;
let _ = ctx; Ok(()) }
}

Under the hood, build() registers the credentials in the browser's per-context registry; each tab opened in that context has a Fetch.authRequired handler auto-installed, chained into the same per-session interception actor used for tracker blocking (never two actors on one session). Without the interception feature, .proxy() and .proxy_bypass() still work (proxy routing only); embedded or explicit credentials are logged as inactive since there is no actor to install them into.

create_browser_context_with (proxy, no auth) and create_browser_context (no proxy) remain available as lighter-weight convenience constructors when authentication isn't needed.

Tabs in a context

#![allow(unused)]
fn main() {
use zendriver::{Browser, BrowserContext};
async fn ex(ctx: &BrowserContext) -> zendriver::Result<()> {
// One tab on about:blank — same defaults as `Browser::new_tab`.
let blank = ctx.new_tab().await?;

// Or start at a specific URL — saves one `goto` round-trip.
let preset = ctx.new_tab_at("https://example.com").await?;
Ok(()) }
}

Both methods thread ctx.id() into Target.createTarget so the new target is bound to this context. Cross-context tabs cannot share cookies; the test suite exercises this on every CI run.

Drop semantics

BrowserContext::drop schedules Target.disposeBrowserContext on the current Tokio runtime via tokio::spawn (the CDP call is async, but Drop is sync). Two implications:

  • Disposal is fire-and-forget. If the parent runtime is shutting down at the same instant the guard drops, the dispose may not land before the process exits. The Chrome side cleans up at process exit anyway; this only matters for long-lived browsers reused across many contexts.
  • Drop order matters for observability. Drop the Tab handles first (they hold references into the context's targets), then the BrowserContext. The Rust borrow checker enforces this for you — the example below compiles only because tab goes out of scope before ctx.
#![allow(unused)]
fn main() {
use zendriver::Browser;
async fn ex(browser: &Browser) -> zendriver::Result<()> {
let ctx = browser.create_browser_context().await?;
{
    let tab = ctx.new_tab().await?;
    tab.goto("https://example.com").await?;
    // `tab` drops here.
}
// `ctx` drops next. dispose() spawned now.
drop(ctx); Ok(())
}
}

If you need to wait for disposal to complete before continuing (e.g. before launching a second context that shares the proxy host), use the explicit BrowserContext::dispose method, which awaits the CDP call and returns its Result.

Worked example: rotating-proxy session pool

A common pattern: pool of independent sessions, each pinned to a different upstream proxy, recycled per request. Each iteration spawns a fresh BrowserContext and disposes it via Drop once the request result is captured.

use zendriver::Browser;

#[tokio::main]
async fn main() -> zendriver::Result<()> {
    let browser = Browser::builder().launch().await?;
    let proxies = [
        "http://proxy-a.example.com:8080",
        "http://proxy-b.example.com:8080",
        "http://proxy-c.example.com:8080",
    ];

    for proxy in proxies {
        let ctx = browser
            .create_browser_context_with(Some(proxy.into()), None)
            .await?;
        let tab = ctx.new_tab_at("https://httpbin.org/ip").await?;
        tab.wait_for_load().await?;
        let body = tab.find().css("pre").one().await?.inner_text().await?;
        println!("via {proxy}: {body}");
        // ctx + tab drop here — context disposed before next iter.
    }

    Ok(())
}

See examples/browser_context_isolation.rs in the source tree for a runnable variant exercising the full round-trip — including BrowserContextBuilder's auth — against a real rotating upstream proxy (set ZD_PROXY).

Limitations

  • Single Chrome process. Contexts share Chromium binaries, GPU process, GPU cache, and the user-data-dir. A compromised renderer in one context can in principle observe shared state. If process- level isolation matters, launch a second Browser.
  • Per-context auth requires interception. Browser::browser_context().proxy_auth(...) (or embedded .proxy("http://user:pass@host:port") userinfo) needs the interception feature to install the per-tab Fetch.authRequired handler; without it, proxy routing still works but credentials are inactive (a warning is logged). See Per-context proxy authentication above, or use a per-tab interception handler for finer control.
  • Extension scoping. Chrome only loads --load-extension content scripts into the default context. Tabs opened in a non-default BrowserContext will not see your extensions. Workaround: stay on the default context when extensions are required, or inject equivalent scripts via Page.addScriptToEvaluateOnNewDocument.

Frames

A page is a tree of frames: one main frame (the top-level document) plus zero or more child frames (typically <iframe> elements). zendriver-rs exposes every frame as a first-class Frame handle with its own query and JS-evaluation surface, so you can drive iframe content with the same ergonomics as the top-level page.

The frame tree

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let main = tab.main_frame().await?;     // top-level document
let all  = tab.frames().await?;         // main + every attached child
println!("{} frames total", all.len());
for f in &all {
    println!("  - id={} main={} url={:?}",
             f.id(), f.is_main(), f.url().await);
}
Ok(()) }
}

Tab::main_frame() returns the top-level frame (lazily — the first call dispatches Page.getFrameTree and caches the result). Tab::frames() returns a snapshot of the main frame plus every attached child the tab currently tracks.

The registry observes Page.frameAttached / Page.frameDetached events, so the snapshot is current as of the last event drained from the session. Just-attached frames (within the same event loop tick as the parent's load event) may not appear until the event lands — poll briefly if you depend on a specific child being present immediately.

Looking up specific frames

Two convenience lookups for common cases:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
// By URL substring — useful for "the YouTube embed somewhere on this page".
if let Some(yt) = tab.frame_by_url("youtube.com").await? {
    yt.evaluate::<()>("document.querySelector('video').play()").await?;
}

// By name attribute — useful for legacy frame layouts that name their iframes.
if let Some(content) = tab.frame_by_name("content").await? {
    let el = content.find().css("h1").one().await?;
    println!("{}", el.inner_text().await?);
}
Ok(()) }
}

Tab::frame_by_url() does a substring match against each child frame's URL (useful when you don't know the exact path / query string). Tab::frame_by_name() reads the name attribute set by the parent <iframe name="...">.

Both return Option<Frame>. Iterate tab.frames().await? yourself for anything more elaborate.

Frame-scoped queries

A Frame has its own find / find_all / evaluate / evaluate_main — all scoped to that frame's document and execution context:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let main = tab.main_frame().await?;

let h1 = main.find().css("h1").one().await?;
println!("main frame h1 = {}", h1.inner_text().await?);

// Per-frame JS evaluation. Isolated world by default — same as
// Tab::evaluate.
let title: String = main.evaluate("document.title").await?;
println!("main frame title = {title}");
Ok(()) }
}

Frame::find() and Frame::evaluate() dispatch CDP calls bound to the frame's contextId, so the query runs against that frame's DOM, not the parent's. This is the lever for driving iframe content without hunting for cross-frame DOM access workarounds.

FindBuilder::in_frame

When you'd rather start the query from the [Tab] but target a specific Frame, use FindBuilder::in_frame():

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let yt = tab.frame_by_url("youtube.com").await?
    .ok_or_else(|| zendriver::ZendriverError::Other("no YT frame".into()))?;

let play = tab
    .find()
    .in_frame(&yt)            // re-target to the YT iframe
    .css(".ytp-play-button")
    .one()
    .await?;
play.click().await?;
Ok(()) }
}

This is precedence-equivalent to yt.find().css(...).one().await?. Use whichever reads better at the call site — in_frame shines when you're composing helpers that take a [Tab] and a Frame reference and want a single fluent chain.

Cross-origin iframes (OOPIFs)

A same-origin child iframe shares the parent's render process, so its DOM is reachable via the parent's CDP session. A cross-origin iframe (Out-Of-Process IFrame — OOPIF) gets its own render process, and Chrome exposes it as a separate CDP target that auto-attaches to the same browser connection.

zendriver's tab registrar wires OOPIF targets in automatically. From your code's perspective:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
tab.goto("https://example.com").await?;
// Same call as for same-origin frames — OOPIFs show up in the same
// `tab.frames()` snapshot.
for f in tab.frames().await? {
    println!("frame {} url={:?}", f.id(), f.url().await);
}

// And the same Frame::find call works regardless of which process
// hosts the iframe.
if let Some(ad) = tab.frame_by_url("doubleclick.net").await? {
    let _ = ad.find().css(".close").one().await?.click().await;
}
Ok(()) }
}

There's no special API and no attach_to_oopif-style call to remember. This is one of the major ergonomic wins over the WebDriver-derived libraries, where you usually have to manually switch frames or attach to the OOPIF's session by hand.

End-to-end example

This example loads a page that hosts a srcdoc iframe, enumerates every frame, then runs a query inside the child to prove that Frame::find resolves against the iframe's document (not the parent's):

//! Load a page hosting a `srcdoc` iframe, enumerate every [`Frame`] the tab
//! tracks, then run a frame-scoped query inside the child to prove that
//! `Frame::find` resolves against the iframe's own document — not the
//! parent.
//!
//! Demonstrates:
//!   - [`Tab::frames`] (snapshot of main + every attached child).
//!   - [`Frame::is_main`] / [`Frame::id`] / [`Frame::url`] (frame metadata).
//!   - [`Frame::find`] scoped to the iframe's document context.
//!
//! Uses `srcdoc` so the iframe stays same-origin and routes through the
//! standard same-session frame path (no OOPIF needed for a self-contained
//! example).
//!
//! `Page.frameAttached` is delivered asynchronously after navigation
//! completes — the loop below polls the registry briefly until the child
//! frame shows up.

use std::time::{Duration, Instant};

use zendriver::Browser;

const PAGE_HTML: &str = "data:text/html,\
<!doctype html><html><body>\
<h1>parent</h1>\
<iframe id='f' srcdoc=\"<button id='b'>hello from iframe</button>\"></iframe>\
</body></html>";

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();
    tab.goto(PAGE_HTML).await?;
    tab.wait_for_load().await?;

    // Wait for the child frame to register — polled because the
    // Page.frameAttached event fires after the parent's load completes.
    let deadline = Instant::now() + Duration::from_secs(5);
    let child = loop {
        let frames = tab.frames().await?;
        println!("frames so far: {}", frames.len());
        for f in &frames {
            println!(
                "  - id={} main={} url={:?}",
                f.id(),
                f.is_main(),
                f.url().await
            );
        }
        if let Some(child) = frames.into_iter().find(|f| !f.is_main()) {
            break child;
        }
        if Instant::now() >= deadline {
            return Err(zendriver::ZendriverError::Timeout(Duration::from_secs(5)));
        }
        tokio::time::sleep(Duration::from_millis(50)).await;
    };

    // Frame-scoped query: the button only exists in the iframe's document,
    // so this resolves only because `find` runs against the child's context.
    let btn = child.find().css("#b").one().await?;
    let text = btn.inner_text().await?;
    println!("iframe button text = {text:?}");

    browser.close().await?;
    Ok(())
}

Expected output:

frames so far: 2
  - id=... main=true url=Ok("data:text/html,...")
  - id=... main=false url=Ok("about:srcdoc")
iframe button text = "hello from iframe"

The srcdoc keeps the iframe same-origin so the example stays self-contained, but the API call shape is identical for cross-origin OOPIFs.

Frame lifecycle and auto-refresh

Frames navigate independently. When a child iframe navigates, its existing execution context is destroyed and a new one is created. Any Element handles you hold from the old context become stale.

zendriver's auto-refresh handles this transparently in most cases — the next method call on a stale handle re-resolves the original query against the new context. For repeated reads against a freshly-navigated iframe, just re-run the query against the Frame handle — the Frame itself stays valid across the navigation (the frameId is stable; only the contextId rotates).

Input

Every input method on Element comes in two variants:

  • Realistic (default) — Bezier-interpolated cursor moves for the mouse, per-character delays with occasional typos for the keyboard. Tuned to defeat behavioral fingerprinters.
  • _fast — single CDP dispatch, no delays, no jitter, no typos. Skips the actionability gate. For tests and fast automation flows where deterministic timing matters more than realism.

Both flavors route through the same shared InputController on each tab, so the OS-level modifier state (Shift, Ctrl, etc.) stays consistent across realistic and fast paths.

Realistic vs _fast

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let btn = tab.find().css("button").one().await?;

// Realistic: Bezier-path cursor approach, hover, then mousedown/up.
btn.click().await?;

// Fast: single Input.dispatchMouseEvent, no actionability gate.
btn.click_fast().await?;
Ok(()) }
}
MethodCursor pathGateUse case
click()BezieractionabilityDefault. Indistinguishable.
click_fast()teleportskippedTests; trusted automation.
hover()BezieractionabilityDefault. Real cursor approach.
hover_fast()teleportskippedTests; trusted automation.
type_text(s)per-char + delaysfocus gateDefault. Sub-keystroke timing.
type_text_fast(s)per-char, no delayfocus gateTests; trusted automation.

By default, the realism comes from the active StealthProfile's InputProfile:

  • StealthProfile::spoofed() installs a realistic profile by default — Bezier control points with deterministic-but-jittered timing, per-character keyboard delays of 30-200 ms, occasional 1-2% typo + correction events.
  • StealthProfile::native() and ::off() install a zero-overhead, deterministic profile by default — even realistic methods just do the dispatch without added realism.

When realism matters but you also want determinism (e.g. snapshots inside tests), seed the profile with a fixed RNG — see the InputProfile rustdoc.

Opt-in: decoupling input timing from stealth

BrowserBuilder::input_profile() lets you pick the InputProfile explicitly, independent of StealthProfile. This is opt-in only — it does not change any default. With no .input_profile(..) call, timing still resolves to whatever the active StealthProfile implies (the same mapping described above — InputProfile::spoofed() under StealthProfile::spoofed(), InputProfile::native() under ::native() or ::off()), exactly as before this method existed.

Use it when you want to decouple timing from the stealth setting — e.g. humanized timing without also turning on stealth's surface patches (canvas/WebGL/navigator overrides), or stealth on but deterministic zero-delay input for a test:

#![allow(unused)]
fn main() {
use zendriver::stealth::{InputProfile, StealthProfile};

async fn ex() -> zendriver::Result<()> {
// Stealth off (stock Chrome launch), but keep human-paced typing and
// jittery mouse motion — previously impossible, since input timing was
// derived from the stealth profile.
let browser = zendriver::Browser::builder()
    .stealth(StealthProfile::off())
    .input_profile(InputProfile::coherent())
    .launch()
    .await?;
browser.close().await?;
Ok(()) }
}

BrowserBuilder::resolved_input_profile() returns the effective profile before launch, for tests/inspection — same pattern as resolved_persona().

ClickOptions for fine control

Both click() and click_fast() are wrappers around Element::click_with(), which takes a ClickOptions struct for full control:

#![allow(unused)]
fn main() {
use zendriver::{ClickOptions, MouseButton, KeyModifiers};

async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let row = tab.find().css("tr.contact").one().await?;

// Right-click.
row.click_with(ClickOptions {
    button: MouseButton::Right,
    ..Default::default()
}).await?;

// Ctrl+click (open in new tab).
let link = tab.find().css("a.external").one().await?;
link.click_with(ClickOptions {
    modifiers: KeyModifiers::CTRL,
    ..Default::default()
}).await?;

// Double-click.
let item = tab.find().css(".item").one().await?;
item.click_with(ClickOptions {
    click_count: 2,
    ..Default::default()
}).await?;

// Click at a specific offset inside the element's bbox.
let canvas = tab.find().css("canvas").one().await?;
canvas.click_with(ClickOptions {
    position: Some((100.0, 50.0)),
    ..Default::default()
}).await?;
Ok(()) }
}

The full ClickOptions shape:

FieldTypeDefaultMeaning
buttonMouseButtonMouseButton::LeftWhich button to dispatch.
modifiersKeyModifiersKeyModifiers::empty()Modifier bits held during dispatch.
click_countu321clickCount for the dispatch (2 = double-click).
forceboolfalseSkip the actionability gate. Mirrors Playwright.
realisticbooltrueBezier path vs teleport.
positionOption<(f64, f64)>None (bbox center)Click offset relative to bbox top-left.

Touch / tap

Element::tap() taps an element's bbox center via a real touch dispatch (Input.dispatchTouchEvent touchStarttouchEnd), not a mouse click. It mirrors click()'s scroll-into-view + actionability-gate + bbox-center path, but ends in a touch event pair instead of mousePressed/mouseReleased — for pages that branch their handling on touch vs mouse input (an ontouchstart listener, or pointerType checks).

Tab::tap(x, y) is the coordinate-level equivalent, for canvas / custom widgets with no element to target — mirrors Tab::mouse_click().

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let btn = tab.find().css("button").one().await?;
btn.tap().await?;

// Or at a raw coordinate:
tab.tap(120.0, 240.0).await?;
Ok(()) }
}

touchEnd carries an empty touchPoints array — that's the CDP contract for a lifted finger, not a bug.

Capability-emulation caveat: tap() does not call Emulation.setTouchEmulationEnabled, so it doesn't flip touch-capability signals a page might probe — 'ontouchstart' in window, navigator.maxTouchPoints, matchMedia('(pointer: coarse)'). The bare dispatchTouchEvent still fires the page's real touchstart/touchend handlers (and, on a clickable element, the browser's own synthesized click), which is what a tap needs; a page that gates its behavior on those capability signals before wiring up touch listeners won't see them flip. Full capability emulation belongs with mobile device emulation (viewport + UA + touch capability together) — a later, larger feature, not part of this tap primitive.

Scope is touch only: no pressure, pen/stylus, or tilt input yet.

Keyboard: Key, KeyModifiers, SpecialKey

For single-key dispatches (Enter, Tab, arrow keys, Ctrl+A, etc.):

#![allow(unused)]
fn main() {
use zendriver::{Key, KeyModifiers, SpecialKey};

async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let input = tab.find().css("input").one().await?;

// Press Enter (named special key).
input.press(Key::Special(SpecialKey::Enter)).await?;

// Press Tab to move focus.
input.press(Key::Special(SpecialKey::Tab)).await?;

// Ctrl+A (select all).
input.press_with(Key::Char('a'), KeyModifiers::CTRL).await?;

// Ctrl+Shift+End.
input.press_with(
    Key::Special(SpecialKey::End),
    KeyModifiers::CTRL | KeyModifiers::SHIFT,
).await?;
Ok(()) }
}

Key is one of:

  • Key::Char(char) — any typeable character.
  • Key::Special(SpecialKey) — a key with a name rather than a glyph.

SpecialKey covers Enter, Tab, Escape, Backspace, Delete, Space, all four arrows, Home, End, PageUp, PageDown, F1-F12, Insert, CapsLock, NumLock, ScrollLock, PrintScreen, Pause, ContextMenu — the whole named keyboard. Two of them still type: Space inserts a space, and Enter inserts a newline in a <textarea> or contenteditable while in a single-line <input> it inserts nothing and only submits a form that allows implicit submission — exactly as the physical keys do. The rest only run their default action. Holding Ctrl, Alt or Meta suppresses the insertion, for those two and for character keys alike, the same way a shortcut does on real hardware.

KeyModifiers is a bitflags struct:

  • KeyModifiers::ALT — Alt (Option on macOS).
  • KeyModifiers::CTRL — Control.
  • KeyModifiers::META — Meta (Command on macOS, Windows key on Windows).
  • KeyModifiers::SHIFT — Shift.

Combine with |: KeyModifiers::CTRL | KeyModifiers::SHIFT.

press vs press_with

  • Element::press(key) uses whatever modifiers are currently held by the InputController — useful when you've explicitly tracked modifier-held state (e.g. via held-key sequences).
  • Element::press_with(key, mods) passes mods straight through to the CDP dispatch for this one call, without mutating the controller's tracked state — the safer default when you want a single key event with specific modifiers.

End-to-end form fill

//! Fill out a small HTML form rendered via `data:` URL — demonstrates the
//! P3 input surface end-to-end without depending on any third-party site.
//!
//! Equivalent in spirit to the form-fill snippets scattered through the
//! Python `examples/` directory (`network_monitor.py`'s search-and-submit
//! flow, `imgur_upload_image.py`'s title-field fill). Picks `data:` over
//! a third-party form so the example stays deterministic across runs.
//!
//! Sequence:
//!   1. CSS-select the inputs and submit button.
//!   2. [`Element::type_text`] simulates per-character key events with the
//!      Bezier/jitter realism from the [`StealthProfile`]'s `InputProfile`.
//!   3. [`Element::click`] dispatches a real `mousedown` + `mouseup` via
//!      `Input.dispatchMouseEvent` after running the actionability gates.
//!   4. Read back the form's serialized state via `evaluate_main` to prove
//!      the inputs took our values.

use zendriver::Browser;

const FORM_HTML: &str = "data:text/html,\
<!doctype html><html><body>\
<form id='f' onsubmit='window.submitted=true;return false'>\
<input id='user' name='user' />\
<input id='pass' name='pass' type='password' />\
<button id='go' type='submit'>Submit</button>\
</form></body></html>";

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();
    tab.goto(FORM_HTML).await?;
    tab.wait_for_load().await?;

    let user = tab.find().css("#user").one().await?;
    user.type_text("rin").await?;

    let pass = tab.find().css("#pass").one().await?;
    pass.type_text("hunter2").await?;

    let go = tab.find().css("#go").one().await?;
    go.click().await?;

    let user_val: String = tab
        .evaluate_main("document.getElementById('user').value")
        .await?;
    let submitted: bool = tab.evaluate_main("window.submitted === true").await?;
    println!("user field = {user_val:?}, submitted = {submitted}");

    browser.close().await?;
    Ok(())
}

Expected output:

user field = "rin", submitted = true

The example demonstrates the realistic input surface end-to-end: per-character typing into two inputs, then a single click on the submit button. All three calls (type_text + type_text + click) go through the actionability gate and the realistic-cursor path.

When to use _fast variants

  • Tests where you only care that the action happened, not how it looked to a fingerprinter.
  • Trusted automation pipelines (internal admin tools, scraping flows where you already know stealth isn't being checked).
  • CI where every saved millisecond per click compounds across thousands of runs.
  • Setup steps (typing a known query into a search box before the real interaction starts). Save realism for the moments that matter.

When in doubt, stick with the realistic defaults — the per-call cost is small (typically 50-300 ms per click; a few ms per typed character) and it keeps input timing on the human-plausible path that the rest of the stealth machinery is built around.

Interception

The interception Cargo feature wraps Chrome's Fetch CDP domain in a fluent rule-based API plus a lower-level Stream of paused requests. It lets you block, redirect, synthesize, or rewrite any subresource a page asks for — useful for ad blocking, response mocking, header injection, and offline-replay tests.

Enable it in Cargo.toml:

[dependencies]
zendriver = { version = "0.1", features = ["interception"] }

The entry point is Tab::intercept, which returns an InterceptBuilder bound to that tab's session. The builder has two terminal methods: start activates a background actor with declarative rules, and subscribe returns a Stream<Item = PausedRequest> for the manual escape-hatch path.

Rule-based API

The four rule methods chain on the builder and dispatch the matching terminal action automatically when the URL pattern fires. Patterns use CDP wildcard syntax (* matches any characters; ? matches any single character).

MethodActionUse case
blockFetch.failRequest with BlockedByClientAd / tracker blocking
redirectFetch.continueRequest with new URLMove an endpoint without page changes
respondFetch.fulfillRequest with synthetic bodyMock an API for tests
modify_requestFetch.continueRequest with overridesInject headers, change method/body

Blocking ads

//! Demonstrates the P5 interception API by blocking any subresource whose
//! URL matches `*/ads/*`, then navigating to a real page.
//!
//! Sequence:
//!   1. Build a [`Browser`] in headless mode.
//!   2. Register a `block` rule on the main tab via the [`InterceptBuilder`]
//!      fluent API; `start()` spawns the per-tab actor that drives
//!      `Fetch.enable` + `Fetch.continueRequest` / `Fetch.failRequest` in
//!      the background. Bind the returned [`InterceptHandle`] — its `Drop`
//!      tears the actor down, so letting it go out of scope mid-flow would
//!      silently disable interception.
//!   3. `goto` + `wait_for_load` example.com. The page itself doesn't load
//!      anything under `/ads/`, so no rule fires; the example is here to
//!      show the *shape* of the API, not a hit count. Adapt the URL and
//!      pattern to whatever you actually want to block.
//!   4. Print the page title to prove the navigation succeeded with the
//!      actor in the loop.
//!
//! Requires the `interception` cargo feature:
//! `cargo run --example intercept_block_ads --features interception`.

use zendriver::Browser;

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();

    // `start()` returns an `InterceptHandle`; binding it keeps the actor
    // alive. Letting it drop would tear interception down.
    let _intercept = tab.intercept().block("*/ads/*")?.start();

    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;

    let title = tab.title().await?;
    println!("title = {title:?}");

    browser.close().await?;
    Ok(())
}

start returns an [InterceptHandle]. The handle owns the background actor — when you drop it, the actor receives a cancel signal, dispatches Fetch.disable, and stops processing events. Bind the handle to a variable; letting it drop immediately silently disables interception. Use let _intercept = ... (note the leading underscore — Rust would warn on a plain _, which is a different binding semantics that drops at end of statement, not end of scope).

Modifying headers

CDP semantics for headers on Fetch.continueRequest is replacement, not merge — every header you want sent must appear in the returned map. The modify_request closure receives a [RequestInfo] with the original headers; copy them forward before stamping your additions on top:

//! Demonstrates the P5 [`InterceptBuilder::modify_request`] rule, which lets
//! you mutate the outbound headers/method/body for every request whose URL
//! matches a pattern.
//!
//! The closure returns a [`RequestOverrides`] for each matched
//! [`RequestInfo`]; per CDP semantics, `headers` is *replacement*, not
//! merge — so we explicitly copy the original header map and stamp our
//! `X-Custom` header on top before handing it back.
//!
//! The example navigates to `https://httpbin.org/headers`, which echoes
//! every request header back as JSON in the response body. Print the body
//! to verify `X-Custom: zendriver-demo` made the round-trip.
//!
//! Requires the `interception` cargo feature:
//! `cargo run --example intercept_modify_headers --features interception`.

use zendriver::Browser;
use zendriver::RequestOverrides;

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();

    let _intercept = tab
        .intercept()
        .modify_request("*httpbin.org/headers*", |req| {
            // CDP replaces — not merges — the header set. Copy the originals
            // forward and stamp our custom header on top. Headers are an
            // ordered Vec to preserve duplicates / Set-Cookie semantics.
            let mut headers = req.headers.clone();
            headers.push(("X-Custom".into(), "zendriver-demo".into()));
            RequestOverrides {
                headers: Some(headers),
                ..Default::default()
            }
        })?
        .start();

    tab.goto("https://httpbin.org/headers").await?;
    tab.wait_for_load().await?;

    // httpbin renders the response as `<pre>` JSON; grab its text content.
    let body: String = tab.evaluate_main("document.body.innerText").await?;
    println!("{body}");

    browser.close().await?;
    Ok(())
}

The headers you copy forward are in a stable order, but not the browser's original one: CDP delivers request headers as an object keyed by header name, so Chrome has already merged duplicate names and the on-the-wire ordering is gone before the closure runs. Response headers (modify_response) keep both, since CDP sends those as an array.

The closure runs synchronously per-event on the actor task; it should not block. Spawn off the runtime if you need to call out to an async service before deciding the override.

Redirect and synthesize

let _intercept = tab.intercept()
    .redirect("*/old-api/*", "https://example.com/new-api/")?
    .respond(
        "*/api/health*",
        200,
        vec![("content-type".into(), "application/json".into())],
        b"{\"ok\":true}".to_vec(),
    )?
    .start();

Both redirect and respond reach an internal Fetch.continueRequest or Fetch.fulfillRequest — the actor decides per event, so a single InterceptHandle can host any mix of the four rule types.

Stream API

When rules are too restrictive — e.g. you need to inspect the upstream response body before deciding what to do, or you want to forward events to your own pipeline — call subscribe instead of start:

use futures::StreamExt;
use zendriver::{AbortReason, RequestStage};

let mut stream = Box::pin(
    tab.intercept()
        .at_response()  // pause AFTER headers come back
        .subscribe()
);

while let Some(paused) = stream.next().await {
    let body = paused.body().await?;
    if body.windows(7).any(|w| w == b"BLOCKED") {
        paused.abort(AbortReason::BlockedByClient).await?;
    } else {
        paused.continue_().await?;
    }
}

Each PausedRequest is consumed by exactly one of continue_ / abort / respond / modify_and_continue. The body method is &self and reads the upstream response body non-destructively — only useful at the Response stage; at the Request stage Chrome has no body yet.

Forgetting to release a PausedRequest deadlocks the page. Chrome holds the connection open until exactly one of the four terminal methods arrives. If your stream consumer panics mid-flow, the actor's Drop will dispatch Fetch.disable — every still-paused request fails with net::ERR_BLOCKED_BY_CLIENT, which is unpleasant but not silent.

Tracker / fingerprinter blocklist

The tracker-blocking feature builds a ready-made host-blocking rule on top of interception, so you can block third-party trackers and fingerprinters without writing patterns. Enable it on the builder and it installs on the main tab and every new tab automatically.

[dependencies]
zendriver = { version = "0.1", features = ["tracker-blocking"] }
#![allow(unused)]
fn main() {
use zendriver::Browser;

let browser = Browser::builder()
    .block_trackers(true)                        // curated bundled list
    .tracker_blocklist_add(["ads.example.com"])  // + your own hosts
    .launch().await?;
}
MethodEffect
block_trackers(true)Enable blocking with the curated bundled list (include_str!-embedded — only adds binary size when the feature is on)
tracker_blocklist_addAdd extra hosts (repeatable; implicitly enables blocking)
tracker_blocklist_fileAdd hosts from a local file (one host per line; 0.0.0.0 host hosts-file lines tolerated)
tracker_blocklist_urlAdd hosts fetched from a URL at launch (cached on disk after the first fetch)

Matching is host-based: a blocked host also blocks its subdomains. The bundle ships only our own clean list — point tracker_blocklist_url at a third-party list only if you accept that list's license.

The URL fetch runs inside launch() and is bounded: 5s to connect, 20s for the whole request, 32 MiB of body. None of those are configurable, and a source that exceeds one fails the launch. For a mirror that is slow or unusually large, pre-seed the cache or point tracker_blocklist_file at a local copy.

Those three bounds are the only things about a URL source that can fail a launch. The body is decoded as UTF-8 lossily, so a list whose licence header carries a latin-1 byte still loads: host lines are ASCII, and the replacement character only ever lands in text the parser discards.

Pattern + stage filters

By default the builder pauses on every request at the Request stage. Restrict it with builder modifiers:

use zendriver::ResourceType;

let _h = tab.intercept()
    .pattern("*/static/*")   // URL filter for next-emitted RequestPattern
    .resource(ResourceType::Image)  // only images
    .at_response()           // pause after response headers, not before request
    .block("*/static/*")?
    .start();

Each call to pattern() opens a new Fetch.RequestPattern; chained resource / at_request / at_response modifiers mutate that most-recent pattern. Without a pattern(), the builder synthesizes one matching all URLs at the rule-declared stage.

Gotchas

  • Fetch.enable serializes the network. Chrome routes every matched request through the JSON-RPC channel — round-trip per request adds latency. On heavy pages, expect 10-30% throughput loss even with a no-op continue_ handler. Scope patterns tightly; prefer resource-type filters over * patterns.
  • Each tab carries one actor. Calling tab.intercept().start() twice on the same tab without dropping the first handle leaves the second rule set inert — the first actor still has Fetch.enable and the new one can't re-enable on the same target.
  • CSP-restricted pages. Fetch.fulfillRequest synthesizes responses that may violate a page's Content-Security-Policy (especially for scripts). Combine with StealthProfile::spoofed() which sets bypass_csp = true if you're synthesizing scripts.
  • HTTPS responses can't be re-signed. respond and modify_request work for the wire payload Chrome sees, not for upstream-TLS-signed artifacts.

See also

  • InterceptBuilder rustdoc for every modifier method.
  • Expect() for the orthogonal "wait for this network event" surface — expect_request observes without holding traffic; intercept actively rewrites it.

Expect()

The expect Cargo feature ports Playwright's "pre-register then await" pattern to zendriver-rs. Instead of polling for an event after triggering it (which races against the response coming back faster than your subscription registers), you register an expectation before the action and await the returned Future afterwards. The subscriber is live by the time expect_* returns, so the event cannot slip past.

Enable it in Cargo.toml:

[dependencies]
zendriver = { version = "0.1", features = ["expect"] }

Five entry points on Tab:

MethodCDP eventReturns
expect_requestNetwork.requestWillBeSentRequestExpectationMatchedRequest
expect_responseNetwork.responseReceivedResponseExpectationMatchedResponse
expect_dialogPage.javascriptDialogOpenedDialogExpectationMatchedDialog
expect_downloadPage.downloadWillBegin + progressDownloadExpectationMatchedDownload
expect_file_chooserPage.fileChooserOpenedFileChooserExpectationMatchedFileChooser

The race-free pattern

Naive polling races the network:

// WRONG — the click can fire the request and Chrome can return the
// response before our subscriber registers. We then poll forever.
go.click().await?;
let resp = wait_for_response("*/login").await?;  // race!

The correct flow pre-registers, then triggers:

// RIGHT — the oneshot subscription is live by the time expect_response
// returns. The request cannot complete before we're listening.
let expectation = tab.expect_response("*/login");
go.click().await?;
let resp = expectation.await?;  // safe

expect_response is sync — it spawns the subscriber task internally and returns the awaitable handle synchronously, so any event Chrome emits between the spawn point and the trigger action is captured.

URL matching

expect_request and expect_response take any value that implements Into<UrlMatcher>:

  • &str / String — substring match (URL contains the needle).
  • regex::Regex — full regex via is_match.
use regex::Regex;

let exp1 = tab.expect_response("/api/users");  // substring
let exp2 = tab.expect_response(Regex::new(r"^https://.*\.example\.com/v\d+/").unwrap());

expect_dialog, expect_download, and expect_file_chooser take no matcher — they fire on the first event of their kind. If you need to filter further, inspect the matched event in your code after .await?.

Full example: login response

This example renders a tiny form via data: URL, registers a response expectation against */login, clicks submit, and asserts the URL + status:

//! Demonstrates the P5 [`Tab::expect_response`] expectation API.
//!
//! Sequence:
//!   1. Render a tiny login-style form via `data:` URL. The form's submit
//!      handler `fetch()`s `https://example.com/login` (no real backend —
//!      example.com just 404s for that path, but the response still fires
//!      `Network.responseReceived`, which is what the expectation
//!      subscribes to).
//!   2. Register `tab.expect_response("*/login")` BEFORE clicking submit.
//!      The subscriber task is spawned synchronously inside the call, so it
//!      is live before the click — the response cannot slip past us.
//!   3. Click submit; the page fires the `fetch()`.
//!   4. Await the [`ResponseExpectation`]; assert the URL matched. Print the
//!      status code (404 from example.com, demonstrating that *any* response
//!      arrival satisfies the expectation regardless of HTTP status).
//!
//! Requires the `expect` cargo feature:
//! `cargo run --example expect_login_response --features expect`.

use std::time::Duration;

use zendriver::Browser;

const FORM_HTML: &str = "data:text/html,\
<!doctype html><html><body>\
<form id='f' onsubmit=\"event.preventDefault();fetch('https://example.com/login',{method:'POST',mode:'no-cors'});\">\
<input id='user' name='user' value='rin' />\
<input id='pass' name='pass' type='password' value='hunter2' />\
<button id='go' type='submit'>Log in</button>\
</form></body></html>";

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();
    tab.goto(FORM_HTML).await?;
    tab.wait_for_load().await?;

    // Register expectation BEFORE the trigger action — the subscriber is
    // live by the time `expect_response` returns, so the response cannot
    // race past us.
    let expectation = tab
        .expect_response("*/login")
        .timeout(Duration::from_secs(10));

    let go = tab.find().css("#go").one().await?;
    go.click().await?;

    let matched = expectation.await?;
    println!(
        "matched: url={} status={} status_text={:?}",
        matched.url, matched.status, matched.status_text
    );
    assert!(
        matched.url.contains("/login"),
        "matched url should contain /login; got {}",
        matched.url
    );

    browser.close().await?;
    Ok(())
}

Three things worth noting:

  1. The expectation is constructed before the click. Reversing those two lines reintroduces the race.
  2. The .timeout(Duration::from_secs(10)) overrides the default 30 s outer timeout. Use a tighter budget when you expect a fast local response — saves you 30 s of waiting on a test that's quietly broken.
  3. expectation.await? resolves on the first match. If you need to collect every matching request over a window, use a Stream (build one via repeated expect_request calls or fall back to the interception API's subscribe).

Dialogs

Pages that call alert() / confirm() / prompt() hang waiting for user input. expect_dialog lets you handle the dialog programmatically:

let dlg = tab.expect_dialog();

// Trigger code that opens the dialog.
tab.evaluate_main::<()>("alert('hi')").await?;

let matched = dlg.await?;
println!("dialog type: {:?}, message: {}", matched.dialog_type, matched.message);
matched.accept(None).await?;  // dismiss with no prompt response

MatchedDialog::accept(Some("text")) supplies a response for prompt() dialogs. MatchedDialog::dismiss() closes without accepting.

Downloads

expect_download does extra per-tab wiring on its first call: allocates a tempdir, dispatches Browser.setDownloadBehavior { behavior: "allowAndName", downloadPath }, and starts a long-running progress subscriber. Subsequent calls reuse the same setup, so the per-expect_download cost is one CDP event subscription.

use std::path::PathBuf;

let dl = tab.expect_download().await?;

let link = tab.find().css("a[download]").one().await?;
link.click().await?;

let matched = dl.await?;

// Wait for completion then copy out of the tempdir to a stable path:
matched.save_to(PathBuf::from("/tmp/result.pdf")).await?;

The path returned by MatchedDownload::path().await points into a per-tab tempdir (named by Chrome's CDP guid) and is None until the transfer completes. The tempdir lives as long as the Tab does, so call save_to to copy the file somewhere stable before the tab drops.

File pickers

Element::upload_files dispatches DOM.setFileInputFiles straight at a direct <input type="file">'s backend node — but it can't reach a button or label that opens the picker indirectly (a JS handler that calls hiddenInput.click(), or any custom upload widget). expect_file_chooser covers that case: it intercepts Page.fileChooserOpened via Page.setInterceptFileChooserDialog, so it answers the picker regardless of what triggered it — a hidden input, a visible one, or a custom widget that ultimately opens a native <input type="file"> chooser.

let fc = tab.expect_file_chooser(&["/tmp/photo.jpg"]).await?;

let button = tab.find().css("#upload-btn").one().await?;
button.click().await?; // opens the picker via a hidden input

let matched = fc.await?;
println!("chooser mode: {:?}", matched.mode); // SelectSingle / SelectMultiple

Unlike the other four, expect_file_chooser is async: it must await Page.setInterceptFileChooserDialog { enabled: true } reaching Chrome before returning, or the trigger click could race Chrome into showing the real OS dialog instead of firing the intercept event — the same reason expect_download is async for its own Browser.setDownloadBehavior setup call. It's also the only active expectation: matching doesn't just hand back data, it dispatches DOM.setFileInputFiles with the paths you passed to expect_file_chooser(...) and disables the intercept before resolving — there's nothing left to drive on the returned MatchedFileChooser.

If the expectation is dropped before a chooser opens (timeout, early return, panic unwind), the intercept is disabled best-effort so a later real file dialog isn't silently swallowed.

Timeout semantics

All five expectations default to 30 s. Override per-call:

use std::time::Duration;

let exp = tab.expect_request("/api/")
    .timeout(Duration::from_secs(5));

On timeout the future resolves to ZendriverError::Timeout. The subscriber task is canceled before the error returns, so there's no leaked listener.

If the connection to Chrome drops, reconnects, or a broadcast subscriber falls behind while an expectation is waiting, the future instead resolves to ZendriverError::EventStreamIncomplete — distinct from Timeout because the awaited event may in fact have fired, but the delivery gap means it can no longer be confirmed either way. Treat it as "re-establish and re-wait", not as "it didn't happen".

Note: the delivery lag that triggers this is connection-wide, not scoped to the awaited event — a Lagged boundary on the shared CDP broadcast can abort an expect_* wait even when the lag was on unrelated traffic, so callers on event-heavy pages may see EventStreamIncomplete more often.

When to use which

  • expect_response — confirm an API call returned (covers status + body). The most common one in tests.
  • expect_request — assert what a page sent (headers, body, method). Useful for verifying CSRF tokens, auth bearer formats.
  • expect_dialog — automate alert / confirm flows; required whenever the page may pop a dialog or your script will hang.
  • expect_download — capture file downloads end-to-end; replaces the headless-Chrome "downloads vanish silently" footgun.
  • expect_file_chooser — feed files into a button/label-triggered picker; reach for Element::upload_files instead when the target is a direct <input type="file"> (no CDP intercept round-trip needed).

For continuous capture (every request matching a pattern, not just the first), drop into Interception's subscribe() path instead.

Network monitor & HTTP

Two complementary network features sit on the CDP Network domain:

  • Network monitortab.monitor() (feature monitor) is a long-lived Stream<NetworkEvent> of completed HTTP exchanges, WebSocket frames, and EventSource messages. Passive: it observes, never modifies.
  • Browser-context HTTPtab.request() (always available) makes an HTTP request from the browser context, inheriting the page's cookies and CORS, with an opt-in privileged bypass.

For one-shot "await the next response and assert on it" use the expect surface instead; for modifying or blocking requests use Interception (the active Fetch domain). The monitor is the persistent, read-only generalization of expect_response.

Network monitor

Enable the feature:

[dependencies]
zendriver = { version = "0.1", features = ["monitor"] }

tab.monitor() returns a MonitorBuilder; set an optional URL filter, then .start().await? spawns the correlator task and hands back a NetworkMonitor — a Stream of NetworkEvent:

use futures::StreamExt;

// Start the monitor BEFORE navigating so no events are missed.
let mut monitor = tab.monitor().url_pattern("/api/").start().await?;
tab.goto("https://example.com").await?;

while let Some(event) = monitor.next().await {
    match event {
        zendriver::NetworkEvent::Http(ex) => {
            println!("{} {} -> {:?}", ex.request.method, ex.request.url, ex.status());
            if ex.is_success() {
                let body = ex.text().await?; // lazy: fetched on demand
                println!("{body}");
            }
        }
        zendriver::NetworkEvent::WebSocketFrame { direction, payload, .. } => {
            println!("ws {direction:?}: {payload}");
        }
        _ => {}
    }
}

Event model

NetworkEvent is a tagged enum:

VariantEmitted whenPayload
Http(NetworkExchange)a request reaches loadingFinished / loadingFailedrequest + optional response + optional error
HttpDataa streamed chunk arrives (stream_bodies: true only)request_id, chunk
WebSocketOpenNetwork.webSocketCreatedrequest_id, url
WebSocketFramea frame is sent / receiveddirection, opcode, inline payload
WebSocketCloseNetwork.webSocketClosedrequest_id
EventSourceMessagean SSE message arrivesevent_name, event_id, inline data

HTTP exchanges are completed: the monitor correlates requestWillBeSentresponseReceivedloadingFinished by requestId and emits one NetworkEvent::Http per request. WebSocket and EventSource payloads are delivered inline (they arrive whole in the CDP event); HTTP bodies are lazy by default, or incrementally streamed as HttpData chunks when opted in — see Streaming bodies below.

Streaming bodies

By default HTTP bodies are whole-body-buffered (fetched on demand, above). MonitorBuilder::stream_bodies opts a monitor into incremental delivery instead:

let mut monitor = tab.monitor().stream_bodies(true).start().await?;
tab.goto("https://example.com").await?;

while let Some(event) = monitor.next().await {
    match event {
        zendriver::NetworkEvent::HttpData { request_id, chunk } => {
            println!("{request_id}: {} bytes", chunk.len());
        }
        zendriver::NetworkEvent::Http(ex) => {
            println!("{} -> {:?} (request_id {})", ex.request.url, ex.status(), ex.request_id());
        }
        _ => {}
    }
}

Mechanism: CDP Network.streamResourceContent — passive, no Fetch domain interception, no response pausing (unlike Fetch.takeResponseBodyAsStream, which was rejected for exactly that reason). The correlator enables it for each request as soon as it's observed (requestWillBeSent), before it's even sent on the wire — empirically, waiting for responseReceived leaves too little headroom for the enable call (an async CDP round-trip) to land before a fast response's loadingFinished, past which Chrome rejects the call. Filtered by MonitorBuilder::url_pattern like every other event — there is no separate filter for streaming.

NetworkEvent::HttpData is a sibling to WebSocketFrame / EventSourceMessage: emitted once per received chunk, not accumulated. Chunks for a request always arrive before the NetworkEvent::Http exchange for the same request (which only fires on loadingFinished / loadingFailed) — correlate them by request_id, which matches NetworkExchange::request_id on the eventual Http event. Any bytes Chrome already buffered before the enable call landed are emitted as the first chunk, so nothing from that window is lost.

Chrome version / graceful fallback: Network.streamResourceContent needs roughly Chrome 124+. On an older Chrome the enable call comes back "method not found" — the monitor logs one tracing::warn! (not once per request), stops issuing the call, and simply never emits HttpData for that session.

Other enable errors are treated as being about that one request rather than about the browser, and streaming stays on for the next one. The common case is a response that finished loading before the enable call landed, which a fast (especially loopback) resource can lose without saying anything about anything else; that one is logged at debug. Anything else warns once.

Either way NetworkExchange::body keeps working as the whole-body fallback the entire time, and the monitor never fails or ends over this.

Off by default — streaming every response is wasted CDP round-trips when bodies are small; opt in only when you need bytes as they arrive rather than once the whole response completes.

Lazy bodies

HTTP bodies are fetched on demand via NetworkExchange::body / NetworkExchange::text (CDP Network.getResponseBody):

if let zendriver::NetworkEvent::Http(ex) = event {
    let bytes: Vec<u8> = ex.body().await?;
}

Chrome only retains a response body for a short window after the response completes, so call body() / text() promptly after observing the exchange — a later call can fail with ZendriverError::NetworkMonitor if the body was already evicted.

URL filtering

MonitorBuilder::url_pattern takes any Into<UrlMatcher> (a &str / String substring, or a regex::Regex) — the same matcher type the expect surface uses. For HTTP the request URL is matched; for WebSocket / EventSource the connection URL observed at open time is matched. Unmatched events are dropped before they reach the stream.

Lifecycle

NetworkMonitor owns the correlator task. Dropping the monitor — or calling NetworkMonitor::stop — cancels that task; there is no leaked subscriber. The correlation map is bounded (10k in-flight requests).

Delivery-loss boundaries

Http exchanges are assembled by correlating requestWillBeSentresponseReceivedloadingFinished / loadingFailed by requestId. The correlator rides the transport's loss-accounted event stream, so instead of ever silently stitching a possibly-bogus "complete" exchange across a gap, silently evicting a stuck correlation entry, or silently skipping a payload it couldn't decode, every one of those cases surfaces as an explicit NetworkEvent::DeliveryBoundary(NetworkDeliveryBoundary) on the same stream:

VariantEmitted whenThe correlator also…
Lagged { missed, generation }this subscription fell behind the connection's accounted event busclears all in-flight correlation state — a gap means any partial exchange spanning it can't be trusted
Reconnected { previous, generation }the transport re-established a fresh WebSocketclears all in-flight correlation state — nothing from the old socket will ever complete
Disconnected { generation }the transport's WebSocket died unexpectedlyclears state, then ends the monitor task — see below
CorrelationEvicted { url }the in-flight correlation map exceeded its 10k boundevicts one entry (previously silent beyond a tracing warning)
DecodeFaileda CDP payload didn't match the shape expected for its methodskips that one event only — no raw payload is ever included
Unknowna future transport-level variant this correlator doesn't yet recognizeconservatively clears state, but keeps running

DeliveryBoundary events bypass any MonitorBuilder::url_pattern filter — they describe the monitor's own health, not a specific exchange. A consumer that ignores this variant still sees every fully-observed exchange exactly as before; it just loses the ability to tell "nothing happened" apart from "something was lost and I was never told."

match event {
    zendriver::NetworkEvent::DeliveryBoundary(b) => {
        eprintln!("network monitor gap: {b:?}");
        // Decide whether to resync, alert, or (for `Disconnected`) restart
        // the monitor.
    }
    _ => { /* handle the fully-observed variants as usual */ }
}

Disconnected ends the monitor task — fail closed. There is no automatic reconnect: once a Disconnected boundary is emitted, the correlator task returns and the NetworkMonitor stream ends (None on the next poll). A consumer that wants to keep observing across a transport blip must call tab.monitor()...start() again to spawn a fresh correlator.

Bounded response bodies

NetworkExchange::body / text are unbounded — they fetch and return the entire body every time, unchanged by this section. For a size cap, wrap the fetched bytes yourself with BoundedBody::capture:

use zendriver::BoundedBody;

if let zendriver::NetworkEvent::Http(ex) = event {
    let full = ex.body().await?;
    let bounded = BoundedBody::capture(&full, 1024 * 1024); // cap at 1 MiB
    if bounded.truncated {
        println!("body truncated: kept {} of {} bytes", bounded.bytes.len(), bounded.full_len);
    }
}

max_bytes == 0 means unbounded — every byte is kept and truncated is always false. Bounding is always computed against the raw decoded byte length, never a base64 (or other encoded) representation's length.

The MCP browser_monitor_start tool wires this in for you: its capture_body_max_bytes input (default 1 MiB, 0 for unbounded) bounds each captured body, and browser_monitor_read's http events carry body_truncated / body_full_bytes alongside body / body_base64. A body-fetch failure (e.g. Chrome already evicted the response) sets body_capture_error instead of silently degrading to an indistinguishable empty body. See mcp.md.

Full example

//! Demonstrates the network monitor API (`tab.monitor()`).
//!
//! Launches a browser, navigates to example.com, and runs a network monitor
//! that prints every HTTP exchange (method / URL / status) and WebSocket
//! frame observed while the page loads.
//!
//! The monitor is a [`futures::Stream`] over
//! [`zendriver::NetworkEvent`](zendriver::monitor::NetworkEvent) — it runs in
//! the background and delivers events as the browser fires them. Dropping the
//! monitor (or calling `.stop()`) cancels its background task.
//!
//! Requires the `monitor` cargo feature:
//! `cargo run --example network_monitor --features monitor`.

use futures::StreamExt;
use zendriver::Browser;
use zendriver::monitor::{FrameDirection, NetworkDeliveryBoundary, NetworkEvent};

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();

    // Start the monitor BEFORE navigating so no events are missed.
    // An optional URL pattern restricts events to matching URLs (substring
    // match). Omit `.url_pattern(...)` to observe all network activity.
    let mut monitor = tab.monitor().url_pattern("example.com").start().await?;

    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;

    // Drain events until the channel is empty (monitor still running but
    // nothing new is in flight). In a real application you would drive the
    // stream until a specific event arrives or a timeout fires.
    while let Ok(Some(event)) =
        tokio::time::timeout(std::time::Duration::from_millis(500), monitor.next()).await
    {
        match event {
            NetworkEvent::Http(exchange) => {
                let status = exchange.response.as_ref().map_or_else(
                    || exchange.error.clone().unwrap_or_default(),
                    |r| r.status.to_string(),
                );
                println!(
                    "[HTTP] {} {} -> {}",
                    exchange.request.method, exchange.request.url, status
                );
            }
            // Only emitted when the monitor is started with
            // `.stream_bodies(true)` (not enabled in this example) — shown
            // here for completeness since the match must be exhaustive.
            NetworkEvent::HttpData { request_id, chunk } => {
                println!("[DATA] id={request_id} {} bytes", chunk.len());
            }
            NetworkEvent::WebSocketOpen { url, request_id } => {
                println!("[WS  ] open  id={request_id} url={url}");
            }
            NetworkEvent::WebSocketFrame {
                request_id,
                direction,
                opcode,
                payload,
            } => {
                let dir = match direction {
                    FrameDirection::Sent => "sent",
                    FrameDirection::Received => "recv",
                };
                println!("[WS  ] frame {dir} id={request_id} opcode={opcode} payload={payload:?}");
            }
            NetworkEvent::WebSocketClose { request_id } => {
                println!("[WS  ] close id={request_id}");
            }
            NetworkEvent::EventSourceMessage {
                request_id,
                event_name,
                data,
                ..
            } => {
                println!("[SSE ] id={request_id} event={event_name:?} data={data:?}");
            }
            // A delivery-loss boundary — a lagged/reconnected/disconnected
            // transport, a correlation-map eviction, or an undecodable
            // payload. Ignoring this variant is fine (every fully-observed
            // exchange above still arrives); printing it here just shows
            // where a real consumer would decide whether to resync, alert,
            // or restart the monitor. `Disconnected` in particular means
            // this monitor's correlator task has already ended — see
            // `NetworkDeliveryBoundary::Disconnected`.
            NetworkEvent::DeliveryBoundary(boundary) => {
                println!("[GAP ] {boundary:?}");
                if matches!(boundary, NetworkDeliveryBoundary::Disconnected { .. }) {
                    break;
                }
            }
        }
    }

    browser.close().await?;
    Ok(())
}

Browser-context HTTP

tab.request() makes an HTTP call that inherits the browser's cookies and session. It needs no feature flag. The builder mirrors pydoll's shape:

use serde_json::json;

// GET (inherits cookies + same-origin CORS of the current page)
let resp = tab.request().get("https://example.com/api/data").send().await?;
println!("{} {}", resp.status(), resp.text()?);

// POST a JSON body
let resp = tab
    .request()
    .post("https://example.com/api/echo")
    .header("X-Trace", "1")
    .json(&json!({ "key": "value" }))?
    .send()
    .await?;
let parsed: serde_json::Value = resp.json()?;

get / post / put / delete / head / patch set the method and URL; header appends a header; body sets raw bytes; json serializes a value and sets Content-Type: application/json. send returns a Response exposing status / headers / text / json / bytes.

A non-2xx status is not an error — the Response carries the status. Only a thrown fetch (network failure, CORS block) or a failed privileged load surfaces a ZendriverError::Request.

Default path: in-page fetch

By default send() runs fetch in the page via evaluate_main, so the request behaves exactly like one the page itself made — same cookies, same CORS rules. Because of that it needs a loaded document of the right origin: navigate to the origin first, then make same-origin calls. On about:blank a cross-origin call is a null-origin request and will be CORS-blocked.

The request URL, headers, and body are embedded into the generated JS via serde_json, so arbitrary url / header / body values can't break out of the JS string — there is no injection surface. The body round-trips as base64 so binary payloads survive intact.

Opt-in: bypass_cors()

bypass_cors routes through Chrome's privileged Network.loadNetworkResource instead — it ignores page CORS and works without a same-origin document, while still inheriting session cookies. It is GET-only in this version; for other methods use the default fetch path.

// Reach a cross-origin endpoint that the in-page fetch would be blocked on.
let resp = tab
    .request()
    .get("https://other-origin.example/resource")
    .bypass_cors()
    .send()
    .await?;

Full example

//! Demonstrates the browser-context HTTP API (`tab.request()`).
//!
//! `tab.request()` runs `fetch` inside the page, so the request inherits the
//! page's cookies and same-origin CORS rules. Use `.bypass_cors()` to route
//! through Chrome's privileged `Network.loadNetworkResource` path instead
//! (GET only; bypasses CORS).
//!
//! Sequence:
//!   1. Navigate to example.com so that cookies / origin context is
//!      established.
//!   2. Issue a GET to `https://httpbin.org/get` — prints status + truncated
//!      body.
//!   3. Issue a POST with a JSON body to `https://httpbin.org/post` — prints
//!      status + truncated body.
//!   4. Close the browser.
//!
//! No special cargo features required:
//! `cargo run --example browser_request`.

use serde_json::json;
use zendriver::Browser;

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();

    // Navigate first so the page context (cookies / origin) is established.
    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;

    // ── GET ──────────────────────────────────────────────────────────────────
    let resp = tab.request().get("https://httpbin.org/get").send().await?;

    let body = resp.text()?;
    println!(
        "GET  status={} body_len={} snippet={:.80}",
        resp.status(),
        body.len(),
        body
    );

    // ── POST with JSON body ───────────────────────────────────────────────────
    let resp = tab
        .request()
        .post("https://httpbin.org/post")
        .json(&json!({"hello": "zendriver"}))?
        .send()
        .await?;

    let body = resp.text()?;
    println!(
        "POST status={} body_len={} snippet={:.80}",
        resp.status(),
        body.len(),
        body
    );

    browser.close().await?;
    Ok(())
}

Fetcher

The fetcher Cargo feature downloads a Chromium build and hands back a path you can pass to BrowserBuilder::executable. Useful in CI runners that don't ship Chrome, in containers, or whenever you want a version pinned independently of the host's Chrome install.

It fetches Google's Chrome for Testing (CFT) by default; two other distributions are available, described under Distributions.

Enable it in Cargo.toml:

[dependencies]
zendriver = { version = "0.1", features = ["fetcher"] }

Two entry points:

Entry pointWhen to use
BrowserBuilder::ensure_chromeCommon case: just download Chrome and launch. One line, no configuration.
Fetcher (builder)Pin a version / channel, customize the cache dir, register progress callbacks.

The one-liner

For the common "I just want Chrome" path:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder()
    .ensure_chrome().await?
    .launch().await?;
Ok(()) }
}

ensure_chrome resolves the latest stable CFT version for the host platform, downloads + extracts it on cache miss, and points the [BrowserBuilder] at the resulting binary.

Resolution runs on every call, before the cache is consulted — the cache is keyed by the resolved build id, so the manifest has to be fetched to know which key to look for. A cache hit skips the download and the extraction, not the manifest request, so ensure_chrome is not an offline path.

The full builder

Fetcher::new returns a builder with sensible defaults. Configure as needed, then call ensure_chrome:

//! Demonstrates the P5 [`Fetcher`] — Chrome for Testing binary downloader.
//!
//! [`Fetcher::new`] starts a builder; `.version(VersionSpec::Latest)`
//! pins the version selector to the newest Stable build in the CFT
//! manifest. `.on_progress(...)` registers a callback that fires through
//! every phase ([`FetcherPhase::Resolving`] → `Downloading` → `Extracting`
//! → `Verifying` → `Done`).
//!
//! [`Fetcher::ensure_chrome`] resolves the manifest, downloads + extracts
//! into the OS-conventional cache dir on a cache miss, and returns a
//! [`PathBuf`] to a runnable Chrome binary. The manifest is resolved on
//! every call, before the cache is probed, so a cache hit (binary already
//! extracted under `<cache>/<build-id>/`) skips the download and the
//! extraction but still needs to reach the manifest host.
//!
//! After resolving the path, you can hand it to a [`Browser`] launch:
//! `Browser::builder().executable(path).launch().await?` — or use the
//! one-line shortcut `Browser::builder().ensure_chrome().await?.launch()`,
//! which wraps this call internally.
//!
//! Requires the `fetcher` cargo feature:
//! `cargo run --example fetcher_demo --features fetcher`.

use zendriver::{Fetcher, VersionSpec};

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let path = Fetcher::new()
        .version(VersionSpec::Latest)
        .on_progress(|p| println!("{p:?}"))
        .ensure_chrome()
        .await?;

    println!("chrome binary: {}", path.display());

    Ok(())
}

Customization points:

  • .version(VersionSpec) — pin a release.
    • VersionSpec::Latest — the newest entry in the manifest (default).
    • VersionSpec::Stable — alias for Latest today; will diverge if / when CFT exposes a stable-channel JSON.
    • VersionSpec::Channel(Channel::Stable | Channel::Beta | Channel::Dev | Channel::Canary)Stable resolves through the same flat manifest as Latest; Beta / Dev / Canary resolve through Chrome for Testing's separate per-channel last-known-good-versions-with-downloads.json endpoint.
    • VersionSpec::Explicit("126.0.6478.182".into()) — exact version string from the manifest.
  • .platform(Platform) — override Platform::auto_detect. Useful for cross-compiling docker images for a different host arch.
  • .cache_dir(path) — override the default cache root. Point at a shared CI volume so multiple jobs share one download.
  • .on_progress(cb) — receive a FetcherProgress snapshot on every phase transition + per-chunk during download.
  • .distribution(Distribution) — choose which Chromium build to fetch. Defaults to ChromeForTesting; see below.

Distributions

Distribution::default() is ChromeForTesting, so everything above describes what you get without touching this knob. Only resolution varies between distributions — download, integrity check, unpacking and the atomic cache are one shared path.

DistributionIndexKeyed byPackaging
ChromeForTestingone JSON manifestversionzip (all platforms)
UngoogledChromiumthree GitHub repos, one per OSversion (tag prefix)zip / AppImage / dmg
ChromiumSnapshota GCS bucket per platformrevisionzip (all platforms)
#![allow(unused)]
fn main() {
async fn ex() -> Result<(), zendriver::FetcherError> {
use zendriver::{Distribution, Fetcher, VersionSpec};

let chromium = Fetcher::new()
    .distribution(Distribution::UngoogledChromium)
    .version(VersionSpec::Explicit("151.0.7922.71".into()))
    .ensure_chrome()
    .await?;
let _ = chromium; Ok(()) }
}

ungoogled-chromium

Chromium with Google integration stripped out. There is no single manifest: binaries come from three independent per-OS repos under the ungoogled-software org, each releasing on its own cadence.

  • Tags carry a packaging suffix (151.0.7922.71-1.1), so VersionSpec::Explicit("151.0.7922.71") matches on the version prefix rather than by equality.
  • Availability differs per platform, and that is not a bug. On 2026-08-06 Windows and Linux were on 151.0.7922.71 while macOS was still on 150.0.7871.46. list_builds answers the question for the platform you actually asked about.
  • Packaging differs too: Windows a zip, Linux an AppImage (taken over the .tar.xz so no xz decompressor enters the dependency graph), and macOS a .dmg. Disk images are unpacked with hdiutil, so ungoogled-on-macOS can only be fetched from a macOS host; every other combination is cross-platform.
  • Release lookups hit api.github.com, which allows 60 requests per hour unauthenticated. Set GITHUB_TOKEN to raise that to 5000; the rate-limit error names both.

Chromium snapshots

Per-commit continuous builds from Google's GCS bucket, laid out <platform>/<revision>/chrome-<os>.zip.

Snapshots are keyed by revision, not by version, and the bucket publishes no version index — so there is nothing to look 151.0.7922.76 up in. VersionSpec::Explicit is therefore refused here rather than quietly resolved to the newest snapshot, since handing back a different browser than the one requested is worse than an error. Pin one with VersionSpec::Revision(1674890), or take the tip with VersionSpec::Latest.

Snapshots also use their own platform spelling (Mac_Arm, Win_x64, Linux_x64) rather than CFT's (mac-arm64, win64, linux64); the translation is internal, and you keep using the CFT names.

Listing what is available

#![allow(unused)]
fn main() {
async fn ex() -> Result<(), zendriver::FetcherError> {
use zendriver::{Distribution, Platform, list_builds};

for build in list_builds(Distribution::UngoogledChromium, Platform::MacArm64).await? {
    println!("{}", build.label);
}
Ok(()) }
}

Snapshots return a single entry — the tip named by LAST_CHANGE. Older snapshots remain reachable, but only by revision.

Cache layout

Downloads land in the OS-conventional cache dir under zendriver/chrome:

  • Linux${XDG_CACHE_HOME:-$HOME/.cache}/zendriver/chrome/
  • macOS~/Library/Caches/zendriver/chrome/
  • Windows%LOCALAPPDATA%\zendriver\chrome\

Inside, each version gets its own subdirectory matching the CFT zip layout verbatim:

<cache_dir>/
  126.0.6478.182/
    chrome-linux64/
      chrome                                            (Linux)
    chrome-win64/
      chrome.exe                                        (Windows)
    chrome-mac-arm64/
      Google Chrome for Testing.app/Contents/MacOS/...  (macOS Apple Silicon)

The other distributions namespace themselves one level deeper:

<cache_dir>/
  ungoogled/151.0.7922.71/...
  snapshot/r1674890/...

CFT stays un-prefixed so caches populated by earlier versions of the crate keep hitting. The others are prefixed because a Chrome version number is not unique across distributions — CFT 151.0.7922.71 and ungoogled 151.0.7922.71 are different binaries, and a shared directory would serve whichever landed first.

Writes are atomic. The fetcher downloads + unpacks into a <build>.tmp/ sibling, then a single rename promotes it to <build>/. Crashing mid-download leaves a .tmp/ that the next run detects, deletes, and retries — no half-extracted binaries ever appear under the canonical name.

The zendriver-fetch CLI

The same resolver, as a standalone binary:

cargo install zendriver-fetcher --features cli

The cli feature is off by default — this is a library first, and clap has no business in the dependency graph of a crate that only downloads a browser.

Fully specified, it never prompts and exits non-zero on failure, which is what CI needs:

zendriver-fetch --distribution cft --version 146.0.7680.153 \
                --platform mac-arm64 --out ./chrome

It prints the resolved binary path on stdout and progress on stderr, so CHROME=$(zendriver-fetch ... ) works. --quiet drops the progress.

Run it with a distribution or version missing and it turns interactive: it asks which distribution, lists the builds that distribution really publishes for the resolved platform (newest first, paged), and confirms before downloading.

If stdin is not a terminal, it refuses to prompt and prints the flags it needed instead. A CLI that blocks on stdin inside CI does not fail — it hangs until the job times out.

FlagMeaning
-d, --distributioncft, ungoogled, or snapshot
--versionBrowser version, or latest
--revisionChromium revision (snapshots only; conflicts with --version)
-p, --platformlinux64, mac-x64, mac-arm64, win32, win64; defaults to this host
-o, --outCache directory; defaults to the OS cache dir
-q, --quietSuppress progress output

Progress callbacks

FetcherProgress carries:

The callback runs on Tokio worker threads. Render to a TUI / progress bar inside it; heavier work (e.g. logging via I/O) should spawn_blocking itself off the runtime to avoid stalling the download task.

use indicatif::{ProgressBar, ProgressStyle};
use zendriver::{Fetcher, FetcherPhase};

let bar = ProgressBar::new(0);
let path = Fetcher::new()
    .on_progress(move |p| {
        if p.phase == FetcherPhase::Downloading {
            if let Some(t) = p.total { bar.set_length(t); }
            bar.set_position(p.downloaded);
        }
    })
    .ensure_chrome()
    .await?;

CI use case

The motivating workflow: GitHub Actions / GitLab / etc runners that don't have Chrome installed. Skipping Chrome from the system image and letting the fetcher download Chrome inside the job has three wins:

  1. Reproducibility. Pin VersionSpec::Explicit(...) so the same Chrome runs everywhere. No surprises when the runner image bumps.
  2. Smaller base images. Don't bake Chrome into a hot container image if only a fraction of jobs need it.
  3. Parallel cache. Point the fetcher at a runner-side volume (CFT binaries are ~150 MB compressed; one download serves every job).

A minimal .github/workflows/test.yml snippet:

- uses: actions/cache@v4
  with:
    path: ~/.cache/zendriver/chrome
    key: zendriver-chrome-${{ runner.os }}-126.0.6478.182
- run: cargo test --features fetcher

actions/cache rehydrates the cache dir; the fetcher resolves the manifest, detects the cache hit, and skips the download. First run takes ~30 s on GitHub's free runners; cached runs take <1 s in ensure_chrome — the manifest request, not the archive transfer. The runner still needs egress to the manifest host either way.

When NOT to use it

  • You already have Chrome on the host and don't care about version-pinning — the built-in PATH discovery is faster.
  • Network-restricted environments that can't reach https://googlechromelabs.github.io or the CFT CDN. Pre-populating the cache dir is not enough — the manifest is resolved before the cache is probed, so ensure_chrome still errors. Point [BrowserBuilder::executable] at the binary directly, or ship a Docker image with Chrome baked in.
  • You need Chrome stable on Linux ARM64 — CFT doesn't ship a linux-arm64 build today; Platform::auto_detect returns None on that host and ensure_chrome errors out. (ungoogled-chromium's portablelinux repo does publish arm64 AppImages, but Platform has no LinuxArm64 variant yet, so they aren't selectable.)
  • You want ungoogled-chromium for macOS from a Linux or Windows box — it ships as a .dmg, and unpacking one needs macOS's hdiutil.

Cloudflare

The cloudflare Cargo feature ships a driver that bypasses Cloudflare's interactive Turnstile challenge — the "verify you are human" checkbox page that gates many sites behind a CDN. It is not a generic anti-Cloudflare solution; it specifically automates clicking the visible Turnstile checkbox iframe and waiting for the resulting clearance token.

Enable it in Cargo.toml:

[dependencies]
zendriver = { version = "0.1", features = ["cloudflare"] }

The entry point is Tab::cloudflare, which constructs a CloudflareBypass driver scoped to that tab's session. Call wait_for_clearance with a timeout to run the full detect-click-poll flow.

Usage

//! Demonstrates the P5 Cloudflare Turnstile bypass driver.
//!
//! Sequence:
//!   1. Launch a headless browser and navigate to a Cloudflare-protected
//!      URL. Set this to whatever endpoint you actually want to clear.
//!   2. Call [`Tab::cloudflare`] to construct a [`CloudflareBypass`] bound
//!      to the tab's session.
//!   3. Call [`CloudflareBypass::wait_for_clearance`] with a 30s budget.
//!      The driver runs a single CDP poll loop that, per tick, looks for
//!      the clearance token, the Turnstile challenge iframe's bounding box
//!      (shadow-DOM aware), and any challenge marker on the page. When the
//!      interactive iframe is present, the driver scrolls it into view,
//!      re-measures it, and dispatches a raw left-click at the canonical
//!      (15% x, 50% y) offset (Turnstile checkbox), retrying up to three
//!      times in case the widget swallowed the first one. Resolves the
//!      first tick a token is observed, or the challenge stops being
//!      actionable.
//!   4. Print the [`ClearanceOutcome`] enum variant + the page title.
//!
//! Which markers to look for and how to click are caller data —
//! `.selectors(TurnstileSelectors { .. })`,
//! `.click_policy(ClickPolicy { .. })` and `.on_click(..)` — so a change on
//! Cloudflare's side is a change here, not a release of the crate. This
//! example takes the defaults.
//!
//! Outcomes:
//!   - `TokenAcquired(_)` — Turnstile yielded a `cf-turnstile-response`
//!     token, either after clicking the checkbox or directly (invisible
//!     Turnstile, where the iframe never mounts).
//!   - `ChallengeGone` — the challenge stopped being actionable without a
//!     token (e.g. clearance-cookie shortcut): the clicked iframe is no
//!     longer a valid click target, or every marker seen earlier is gone.
//!     A widget that is merely hidden or zero-size is also not a valid
//!     click target, so this is not by itself proof the gate was passed.
//!   - `TimedOut { saw_challenge: false }` — the full timeout window
//!     elapsed without any challenge markers ever being observed (no
//!     container, no hidden input, no iframe). The page likely has no
//!     Cloudflare gate; not a failure.
//!   - `TimedOut { saw_challenge: true }` — 30s elapsed with markers
//!     present but neither success state observed.
//!
//! Requires the `cloudflare` cargo feature:
//! `cargo run --example cloudflare_bypass --features cloudflare`.

use std::time::Duration;

use zendriver::{Browser, ClearanceOutcome};

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let browser = Browser::builder().headless(true).launch().await?;
    let tab = browser.main_tab();

    tab.goto("https://nopecha.com/demo/cloudflare").await?;
    tab.wait_for_load().await?;

    match tab
        .cloudflare()
        .wait_for_clearance(Duration::from_secs(30))
        .await
    {
        Ok(ClearanceOutcome::TimedOut {
            saw_challenge: false,
        }) => {
            println!("no challenge detected (page already cleared or no CF gate present)");
        }
        Ok(ClearanceOutcome::TimedOut {
            saw_challenge: true,
        }) => {
            println!("clearance timed out within 30s");
        }
        Ok(outcome) => println!("cleared: {outcome:?}"),
        Err(e) => return Err(e.into()),
    }

    let title = tab.title().await?;
    println!("title = {title:?}");

    browser.close().await?;
    Ok(())
}

The driver returns a ClearanceOutcome on success. All three are ordinary terminals, the last one included:

  • TokenAcquired(token) — the cf-turnstile-response input picked up a non-empty value. The page can now proceed; the token is also forwarded to Cloudflare server-side on the next request.
  • ChallengeGone — the challenge stopped being actionable without yielding a token, typically because Cloudflare honored a clearance cookie and short-circuited the gate. Either the iframe the driver clicked is no longer a valid click target, or every challenge marker seen on an earlier tick has vanished. The first case is weaker than it sounds: a widget that is still mounted but hidden or zero-sized is also not a valid click target, so once a click has landed this outcome can arrive on a tick where the widget simply is not clickable at that moment. That case says the driver ran out of things to click, not that the gate is behind you; confirm the page before trusting what you scrape next.
  • TimedOut { saw_challenge } — the deadline elapsed. saw_challenge separates the two cases worth telling apart: true means a real challenge sat on the page and never resolved, false means no Cloudflare marker ever appeared and the bypass was probably called on a page with no gate on it.

A deadline is not an error here, so CloudflareError is left to carry genuine faults, of which there are two:

  • Call — the underlying CDP call failed, usually a dead tab or a closed connection.
  • JsError — the in-page evaluator raised, or handed back a payload the driver could not decode.

The enum is #[non_exhaustive], so a match on it needs a wildcard arm. There is no NoChallenge or ClearanceTimeout variant — if you are looking for those, the first is TimedOut { saw_challenge: false } and the second is TimedOut { saw_challenge: true }.

How it works

The driver runs a single poll loop, and each tick costs one CDP round-trip. A shadow-DOM-aware walk of the page's main world reports three things at once: the cf-turnstile-response token if one is present, the challenge iframe's box if that iframe is a valid click target, and whether any Cloudflare marker at all (container, hidden input, or live iframe) is on the page. The tick then resolves in this order:

  1. A token wins outright. A non-empty token returns TokenAcquired immediately. This is also the invisible-Turnstile path, where no iframe mounts and Cloudflare's loader script fills the field in without anything to click.
  2. Scroll, re-measure, click. Raw mouse events carry viewport coordinates, so a widget below the fold has to be scrolled in and re-measured before a click can land on it. The click goes to (bbox.x + bbox.width * 0.15, bbox.y + bbox.height * 0.5) — by default the 15%-from-left, 50%-from-top position of the checkbox inside the iframe. No Bezier-path motion; Cloudflare wants a real click on a real checkbox. A widget that is mounted but hidden or zero-sized is never clicked, since there is no meaningful point to click.
  3. Retry, up to a cap. Cloudflare drops clicks that land while the widget is still booting, so one run spends up to three of them, each at least four poll ticks after the last (about two seconds at the default interval). A swallowed first click no longer strands the run until the deadline.
  4. Otherwise keep polling — every 500 ms by default, override via poll_interval — until a terminal fires or the deadline passes.

After ten consecutive ticks with no progress the driver logs a warning asking whether BrowserBuilder::stealth is on, which is the answer most of the time. See Pairing with stealth below. A run configured never to click (max_attempts: 0, below) is exempt — nothing is stalled there, and stealth would be the wrong thing to go change.

Every number and every selector in that description is a default, not a constant — the next section is how to change them.

Markers and clicks are yours

Cloudflare owns the class names, the hidden input's name, the iframe host and where the checkbox sits inside the widget. They change all of it on their own schedule and with no notice. A crate that writes those into its source needs a release every time they move, and everyone using it is stuck on the old literals until that release lands.

So they aren't in the source. TurnstileSelectors is what the injected evaluators are built from, and ClickPolicy decides whether and where to click:

use zendriver::{ClickPolicy, TurnstileSelectors};

tab.cloudflare()
    .selectors(TurnstileSelectors {
        // Cloudflare moved the widget behind a new host.
        iframe_src_contains: "challenges.example-cdn.com".into(),
        ..TurnstileSelectors::default()
    })
    .click_policy(ClickPolicy {
        max_attempts: 5,
        ..ClickPolicy::default()
    })
    .wait_for_clearance(Duration::from_secs(30))
    .await?;

Both are plain structs with public fields and no closures in them, which is what lets browser_solve_turnstile take them over MCP too — an agent that finds the defaults no longer matching a page can hand the solver the new markers itself.

The defaults:

FieldDefault
TurnstileSelectors::iframe_src_contains"challenges.cloudflare.com"
TurnstileSelectors::container".cf-turnstile, .turnstile, [data-sitekey]"
TurnstileSelectors::token_inputs["[name=\"cf-turnstile-response\"]", "[name=\"cf_challenge_response\"]"]
ClickPolicy::max_attempts3 (0 watches without ever clicking)
ClickPolicy::retry_ticks4
ClickPolicy::x_fraction0.15
ClickPolicy::y_fraction0.5

token_inputs is tried in order, so a page carrying both the modern and the legacy input is read the modern way. An empty value turns a marker off rather than widening it: an empty container drops the container signal, an empty iframe_src_contains matches no iframe (rather than every iframe, which is what a bare substring test would otherwise do), and an empty token_inputs leaves only the marker-vanished and deadline terminals.

Two things stay fixed, because they are the library's judgement rather than Cloudflare's markup. What counts as clickable — a widget needs non-zero size and must not be hidden by visibility, display or opacity — and how it is brought into view, a centred instant scroll.

Replacing the click entirely

The click is the part most likely to stop working, and a policy struct can only describe clicks that look like the built-in one. on_click hands the whole step over: it is called once per attempt with the session and the widget's post-scroll box, and what happens next is up to you — a human-ish pointer path, a solver service, a click through a higher level of the library.

tab.cloudflare()
    .on_click(|session, target| async move {
        my_humanized_click(&session, target.x, target.y).await
    })
    .wait_for_clearance(Duration::from_secs(30))
    .await?;

A handler counts against max_attempts exactly as the built-in click does, and an error from it fails the run rather than being retried.

It replaces the click, not the decision to click. The driver still applies its own clickability rule first, so a handler installed specifically to reach a hidden or zero-size widget never runs — that widget needs a different approach entirely.

Limitations

The driver clicks the visible interactive Turnstile checkbox, and picks up the token on the invisible path when Cloudflare's own script produces one. It does not solve:

  • Silent / invisible Turnstile. There is no UI element to click and the verdict comes from passive fingerprinting. The loop returns TokenAcquired the moment the field is populated, but nothing here makes Cloudflare populate it — that is stealth's job. Pair StealthProfile::spoofed() with a clean residential IP.
  • Cloudflare's full Pro / Enterprise managed challenge (which can escalate to image puzzles or even hCaptcha).
  • Bot Fight Mode soft blocks that issue 403s without a UI.
  • Rate-limit blocks (1015 errors) that don't expose a challenge UI at all.

If the bypass times out, switch to a real browser session, manually inspect the page, and confirm whether the gate is the interactive checkbox flow. If it's not, this driver can't help and you'll need a different strategy (better stealth, rotating residential proxies, or giving up on that target).

Pairing with stealth

Cloudflare's challenge logic checks several signals before deciding whether to show the visible checkbox or escalate to the silent flow: TLS JA3 fingerprint, User-Agent, header order, navigator.webdriver, etc. Out-of-the-box headless Chrome trips most of those, so it tends to get the harder challenge path — sometimes one this driver can't pass.

Pair the bypass with StealthProfile::spoofed() for the best results:

use zendriver::{Browser, StealthProfile};

let browser = Browser::builder()
    .stealth(StealthProfile::spoofed())  // patches navigator.webdriver etc.
    .launch()
    .await?;
let tab = browser.main_tab();
tab.goto("https://target.example.com").await?;
tab.wait_for_load().await?;

tab.cloudflare()
    .wait_for_clearance(std::time::Duration::from_secs(30))
    .await?;

spoofed patches the Navigator-prototype tells that Cloudflare also checks during the protocol-level challenge — together they pass most consumer-site Cloudflare gates. See Stealth for the profile tradeoffs.

When to call it

Call wait_for_clearance after the navigation completes but before any post-challenge code that depends on being past the gate. The typical sequence:

tab.goto(url).await?;
tab.wait_for_load().await?;

match tab.cloudflare()
    .wait_for_clearance(Duration::from_secs(30))
    .await?
{
    ClearanceOutcome::TokenAcquired(_) | ClearanceOutcome::ChallengeGone => {
        // Past the gate.
    }
    ClearanceOutcome::TimedOut { saw_challenge: false } => {
        // No Cloudflare marker ever appeared — this page has no gate.
        // Usually fine to continue.
    }
    ClearanceOutcome::TimedOut { saw_challenge: true } => {
        // A real challenge that never resolved. Worth failing the job.
        return Err("cloudflare challenge did not clear".into());
    }
}

// Now your normal scraping / interaction code.
let data = tab.find().css(".product-grid").one().await?;

The ? propagates the two real faults, Call and JsError. Everything else is a terminal you decide about, and the saw_challenge split is the one that changes what you do: "no gate on this page" and "a gate we could not pass" want opposite handling.

Tuning

  • .poll_interval(Duration::from_millis(200)) — tighter polling burns more CPU but reacts faster to clearance. Defaults to 500 ms which balances responsiveness against load.
  • .selectors(..) / .click_policy(..) / .on_click(..) — the markers and the click, covered in Markers and clicks are yours.
  • Pass a generous wait_for_clearance timeout (30-60 s) for the first challenge; subsequent navigations on the same user_data_dir are usually cookie-shortcut clears and resolve in <1 s via ChallengeGone.

Imperva WAF / Incapsula

The imperva cargo feature (sub-crate: zendriver-imperva) provides a passive bypass driver for sites protected by Imperva WAF / Incapsula. It detects which Imperva surface is active (modern reese84 bot management, legacy Incapsula ___utmvc flow, or a CAPTCHA escalation), then polls the page until both clearance signals — reese84 cookie set AND body markers cleared — are observed.

Stealth required. Imperva's reese84 sensor is itself a browser fingerprint check. Run with BrowserBuilder::stealth enabled or the bypass will fail on nearly all real Imperva-protected sites.

Limitations

This crate observes the page; it does not modify Imperva's response. A TokenAcquired result means the JS challenge completed and the reese84 cookie landed — it does not guarantee the next request will be accepted. Imperva runs a second validation pass on every request that ships the token; if its scoring of the fingerprint collected during the challenge falls below threshold, the next request returns a 403 challenge page with edet=15 / B15(x,y,z) (general bot protection). Root causes for that are upstream of this crate:

  • Browser stealth gaps. Missing fingerprint shim — canvas, audio, WebGL, client hints — leaks "automation" even when the challenge JS itself runs to completion.
  • IP reputation. Residential / datacenter pools flagged at the edge return B15 before the JS challenge runs at all.
  • UA-vs-binary drift. Claiming Chrome/146 from a Chromium 148 binary leaks JS-API behavior inconsistent with the claimed version.

If wait_for_clearance returns TokenAcquired but subsequent requests still hit edet=15, look upstream — the fingerprint the browser emitted is the problem, not the clearance detection.

Quick start

use std::time::Duration;
use zendriver::stealth::StealthProfile;
use zendriver::{Browser, ImpervaClearanceOutcome};

#[tokio::main]
async fn main() -> zendriver::Result<()> {
    let browser = Browser::builder()
        .stealth(StealthProfile::spoofed())
        .launch()
        .await?;
    let tab = browser.main_tab();
    tab.goto("https://protected.example.com").await?;
    tab.wait_for_load().await?;

    let outcome = tab
        .imperva()
        .timeout(Duration::from_secs(60))
        .wait_for_clearance()
        .await?;

    match outcome {
        ImpervaClearanceOutcome::TokenAcquired { reese84, .. } => {
            println!("got token: {reese84}")
        }
        ImpervaClearanceOutcome::ChallengeGone => println!("legacy cleared"),
        ImpervaClearanceOutcome::AlreadyClear => println!("no challenge"),
    }

    browser.close().await?;
    Ok(())
}

Surface variants

VariantWhat it isHow it's detected
Reese84Modern Imperva ABP bot management. Invisible JS challenge → reese84 sensor token.reese84 cookie name OR Reese.js body marker.
LegacyOlder Incapsula ___utmvc / incap_ses_* flow.___utmvc / incap_ses_* / visid_incap_* cookies, or /_Incapsula_Resource body marker.
Captcha(kind)Escalation to hCaptcha, reCAPTCHA, or Imperva's native CAPTCHA.iframe src patterns + g-recaptcha / h-captcha DOM markers.
NoneNo Imperva surface present.Default — fast AlreadyClear path.

Detection precedence: Captcha > Reese84 > Legacy > None.

Clearance signal (S3 hybrid AND)

TokenAcquired requires both:

  1. A non-empty reese84 cookie scoped to the current site.
  2. The page body no longer contains Imperva challenge markers.

This avoids false positives (cookie set during pre-clearance redirect) and false negatives (cookie evicted by site CSP). Legacy flows that never set reese84 resolve to ChallengeGone once body markers clear.

CAPTCHA handling

A CAPTCHA surface escalates only when no usable reese84 token is in hand. Without an on_captcha callback that case returns ImpervaError::CaptchaRequired { kind } immediately (no waiting). Plug in your own solver:

#![allow(unused)]
fn main() {
use zendriver_imperva::{CaptchaSolution, ImpervaBypass};
async fn ex(tab: &zendriver_transport::SessionHandle) -> Result<(), zendriver_imperva::ImpervaError> {
let _ = ImpervaBypass::new(tab)
    .on_captcha(|challenge| async move {
        // Call 2captcha / anticaptcha / your own service here.
        Ok(CaptchaSolution {
            token: "...".into(),
            form_field: "h-captcha-response".into(),
        })
    })
    .wait_for_clearance()
    .await?;
Ok(()) }
}

CaptchaChallenge carries kind, site_key (when extractable), and url. CaptchaSolution is the token + form field name your solver returns.

Two things worth knowing before you wire up a paid service. A snapshot that already carries a reese84 never escalates, whatever the surface says — the site's own post-clearance form routinely mounts an ordinary reCAPTCHA, and a token in hand outranks it — so a registered solver can go uncalled for an entire run. And the solver is invoked at most once per wait_for_clearance, raced against that call's timeout: injecting the response does not remove the widget, so the surface stays Captcha afterwards, and an unlatched loop would buy a fresh solve every tick.

Fetch-domain fast path

with_interception() spawns a Fetch subscription that signals on first 2xx response to Reese.js or _Incapsula_Resource*. Polling continues in parallel; first signal wins. Useful on sites where the token cookie is set faster than the default 250ms poll cadence.

#![allow(unused)]
fn main() {
use zendriver_imperva::ImpervaBypass;
async fn ex(tab: &zendriver_transport::SessionHandle) -> Result<(), zendriver_imperva::ImpervaError> {
let _ = ImpervaBypass::new(tab)
    .with_interception()
    .wait_for_clearance()
    .await?;
Ok(()) }
}

Active sensor synthesis — out of scope

Reverse-engineering Imperva's obfuscated reese84 sensor JS and computing tokens in pure Rust is not in scope for this crate. The maintenance burden (Imperva ships new obfuscated builds frequently) and the lack of CAPTCHA fallback in a pure-HTTP design make it a poor fit alongside a browser-automation library. If you need pure-HTTP Imperva bypass for high-throughput scraping, build that as a separate crate.

DataDome

The datadome cargo feature (sub-crate: zendriver-datadome) provides a passive bypass driver for sites protected by DataDome. It detects the active DataDome surface (device_check, captcha, or block), then polls the page until the datadome clearance cookie lands, optionally escalating a CAPTCHA surface to a caller-supplied async solver.

Stealth strongly recommended. DataDome's dominant surface is an invisible device-check that scores the browser fingerprint. Without BrowserBuilder::stealth the device-check will not clear on the vast majority of real DataDome-protected sites. The stealth() profile now includes the Surface::Webgpu coherence patch (issue #20) which aligns navigator.gpu adapter info with the spoofed WebGL renderer — a DataDome signal previously unmasked.

Enabling the feature

[dependencies]
zendriver = { version = "*", features = ["datadome"] }

To run the integration test suite against a real Chrome:

cargo test -p zendriver --features datadome-tests --test datadome_v0 -- --ignored

Quick start

use std::time::Duration;
use zendriver::stealth::StealthProfile;
use zendriver::{Browser, DataDomeClearanceOutcome};

#[tokio::main]
async fn main() -> zendriver::Result<()> {
    let browser = Browser::builder()
        .stealth(StealthProfile::spoofed())
        .launch()
        .await?;
    let tab = browser.main_tab();
    tab.goto("https://protected.example.com").await?;
    tab.wait_for_load().await?;

    let outcome = tab
        .datadome()
        .timeout(Duration::from_secs(60))
        .wait_for_clearance()
        .await?;

    match outcome {
        DataDomeClearanceOutcome::Cleared { datadome } => {
            println!("cleared — datadome cookie: {datadome}")
        }
        DataDomeClearanceOutcome::AlreadyClear => println!("no DataDome surface"),
        DataDomeClearanceOutcome::Blocked => println!("IP banned — change your proxy"),
        DataDomeClearanceOutcome::TimedOut { .. } => println!("timed out"),
        DataDomeClearanceOutcome::ChallengeGone => println!("challenge cleared without cookie"),
    }

    browser.close().await?;
    Ok(())
}

tab.datadome() builder

MethodDefaultDescription
.timeout(Duration)30 sMaximum total wait for a terminal outcome.
.poll_interval(Duration)250 msHow often to re-probe the page during the poll loop.
.with_interception()offEnable Fetch-domain fast-path: signals on first 2xx response from captcha-delivery.com or any datadome* URL.
.on_captcha(solver)noneRegister an async CAPTCHA solver. Without it, a CAPTCHA surface returns DataDomeError::CaptchaRequired.

Call .wait_for_clearance().await to start the drive.

Surface variants

VariantWhat it isHow it's detected
DeviceCheckInvisible JS interrogation (window.dd.t == 'fe'). Scores the browser fingerprint.window.dd present + no captcha-delivery iframe.
CaptchaSlider / puzzle / press-hold via captcha-delivery.com iframe.captcha-delivery.com iframe src present.
BlockIP banned (window.dd.t == 'bv'). Nothing in-browser clears this.window.dd.t == 'bv'.
NoneNo DataDome surface. Fast AlreadyClear path.Default — window.dd absent + no iframe.

Detection precedence: Block > Captcha > DeviceCheck > None.

Clearance signal

Cleared requires both:

  1. The datadome cookie is present and non-empty.
  2. window.dd is absent and no captcha-delivery.com iframe is present (body_clean).

ChallengeGone fires when body markers clear but no datadome cookie is observed (rare / legacy path). Legacy flows that never set the cookie use this path.

CAPTCHA handling

Without an on_captcha callback, a CAPTCHA surface returns DataDomeError::CaptchaRequired immediately (no waiting). Plug in your solver:

#![allow(unused)]
fn main() {
use zendriver_datadome::{DataDomeSolution, DataDomeBypass};
async fn ex(tab: &zendriver_transport::SessionHandle) -> Result<(), zendriver_datadome::DataDomeError> {
let _ = DataDomeBypass::new(tab)
    .on_captcha(|challenge| async move {
        // challenge.captcha_url — the captcha-delivery.com iframe URL.
        // challenge.user_agent — must match the page UA (solver requirement).
        // Wire to 2captcha / capsolver / your own service:
        let cookie = call_my_service(&challenge.captcha_url, &challenge.user_agent).await?;
        Ok(DataDomeSolution { datadome_cookie: cookie })
    })
    .wait_for_clearance()
    .await?;
Ok(()) }
async fn call_my_service(_: &str, _: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { Ok(String::new()) }
}

The solver returns the datadome COOKIE value — DataDome whitelists the browser by setting this cookie (not a form-field token like hCaptcha/reCAPTCHA). The driver applies it via Network.setCookie scoped to the registrable domain and reloads the page.

The solver runs at most once per wait_for_clearance, and within that call's timeout. The reload is acked before the new document loads, so the next poll still sees the CAPTCHA — an unlatched loop would bill you a solve every 250ms until the page settled. After the one attempt the driver goes back to polling until the surface clears or the deadline passes; call wait_for_clearance again if you want a second solve.

DataDomeChallenge carries: captcha_url, site_url, user_agent, cid (DataDome challenge ID), and hash. DataDomeSolution holds datadome_cookie.

Blocked / TimedOut outcomes

  • Blockedwindow.dd.t == 'bv' means DataDome has banned the IP at the edge. Nothing the browser does will clear this. Change your proxy to a residential IP, or wait out the ban.
  • TimedOut { last_surface } — the deadline elapsed without reaching a terminal state. last_surface records the most recent surface the poll loop observed. Common causes: fingerprint scoring failure (see stealth note), IP reputation, or a CAPTCHA with no solver registered.

Fetch-domain fast path

.with_interception() spawns a Fetch subscription that signals on first 2xx response to captcha-delivery.com or any datadome* URL. Polling continues in parallel; the first signal wins. Useful on sites where the cookie is set faster than the default 250 ms poll cadence.

#![allow(unused)]
fn main() {
use zendriver_datadome::DataDomeBypass;
async fn ex(tab: &zendriver_transport::SessionHandle) -> Result<(), zendriver_datadome::DataDomeError> {
let _ = DataDomeBypass::new(tab)
    .with_interception()
    .wait_for_clearance()
    .await?;
Ok(()) }
}

WebGPU / issue #20 stealth note

DataDome's device-check probes navigator.gpu.requestAdapter() and compares the reported GPUAdapterInfo.vendor + architecture against its device dataset. Before the Surface::Webgpu patch (issue #20), Chrome running under zendriver leaked the platform's real GPU adapter info even when the WebGL renderer was spoofed — the inconsistency read as a bot signal.

Surface::Webgpu (shipped with zendriver-stealth) derives a coherent GPUAdapterInfo from the spoofed WebGL renderer string and decorates the real adapter's reported .info so both surfaces report the same hardware. This patch is included in BrowserBuilder::stealth(StealthProfile::spoofed()) with no extra configuration required.

Containers / CI: WebGPU requires a real GPU. In GPU-less containers, requestAdapter() returns null both before and after the patch — which is itself coherent (no GPU present) and passes DataDome's own consistency check (both null). The patch does not fabricate adapters in GPU-less environments by default. If a GPU-less environment specifically needs requestAdapter() to resolve a non-null adapter (e.g. to match a device profile DataDome expects), opt into WebgpuSpec::fabricate_when_absent with explicit vendor + limits — see the WebGPU section of the fingerprint chapter. It's an explicit, caller-supplied override, not automatic: wrong values are more detectable than the honest null this container caveat already describes.

Active sensor reverse-engineering — out of scope

Computing DataDome's invisible device-check score in pure Rust (outside of a real browser) is not in scope for this crate. DataDome updates its obfuscated JS sensor frequently; maintaining a pure-HTTP solver alongside a browser-automation library is a poor fit. If you need pure-HTTP DataDome bypass for high-throughput scraping, build that as a separate crate.

MCP server (zendriver-mcp)

zendriver-mcp is a Model Context Protocol server that exposes zendriver-rs through 72 MCP tools (73 with the optional fingerprints feature), so any MCP-compatible client (Claude Desktop, Claude Code, custom agents) can drive a real, stealth-by-default Chrome browser.

Using Claude Code? The zendriver plugin installs this server plus scraping skills, commands, and a subagent in two commands — see the plugin chapter.

Install

cargo install zendriver-mcp

The default build enables interception, expect, cloudflare, imperva, datadome, monitor, fetcher, and tracker-blocking. The fingerprints and geo features are opt-in (add --features fingerprints,geo). For a lean build:

cargo install zendriver-mcp --no-default-features

Claude Desktop

{
  "mcpServers": {
    "zendriver": {
      "command": "zendriver-mcp"
    }
  }
}

HTTP mode

zendriver-mcp --http 127.0.0.1:8765

Bind localhost-only by default. It is the operator's responsibility to expose the endpoint via a reverse proxy + mTLS / network policy for remote access.

CLI flags

zendriver-mcp [OPTIONS]

OPTIONS:
    --http <ADDR>                  Run streamable HTTP transport on ADDR
                                   (e.g. 127.0.0.1:8765). Default: stdio.
    --stealth-profile <KIND>       Default stealth profile.
                                   [auto|native|spoof_macos|spoof_linux|spoof_windows]
                                   Default: auto
    --log <FILTER>                 Tracing log filter (EnvFilter syntax).
                                   Default: info
    -h, --help
    -V, --version

Tool surface

72 tools across these categories (73 with the optional fingerprints feature):

CategoryToolsCount
Lifecyclebrowser_open / _close / _status3
Navigationbrowser_goto / _back / _forward / _reload / _wait_for_idle / _wait_for_load / _bypass_insecure_warning7
Scroll / Windowbrowser_scroll / _get_window / _set_window3
Tabsbrowser_tab_list / _new / _switch / _close / _activate5
Findbrowser_find / _find_all2
Actionsbrowser_click / _hover / _tap / _type / _press / _key_sequence / _mouse / _set_value / _clear / _focus / _scroll_into_view / _upload12
Readsbrowser_element_state / _get_links / _search_resources3
Snapshots / Exportbrowser_html / _screenshot / _pdf / _save_mhtml4
Evalbrowser_evaluate / _evaluate_main2
Networkbrowser_request1
Cookiesbrowser_cookies_get / _set / _delete / _clear / _persist5
Storagebrowser_storage_get / _set / _delete / _clear4
Downloadsbrowser_download / _set_download_path2
Framesbrowser_frame_list / _frame_goto2
Stealthbrowser_set_stealth_profile / _set_user_agent2
Interception (gated)browser_intercept_add_rule / _remove_rule / _list_rules / _clear_rules4
Expect (gated)browser_expect_register / _await / _cancel3
Cloudflare (gated)browser_solve_turnstile1
GPU cataloguebrowser_gpu_devices1
Imperva (gated)browser_solve_imperva1
DataDome (gated)browser_solve_datadome1
Fetcher (gated)browser_install_chrome1
Monitor (gated)browser_monitor_start / _read / _stop3
Fingerprint (gated)browser_fingerprint_generate1

browser_gpu_devices lists the measured GPU device catalogue so an agent can claim a specific real GPU. Its renderer field goes straight into a persona's webgl.unmasked_renderer and selects the capability tier, WebGPU adapter and vendor by itself — see the fingerprint chapter.

All find / action tools share a Selector arg — one-of css | xpath | text | text_exact | text_regex | role, or bs4-like predicate mode (tag

  • attrs: [{ name, op, value, case_insensitive }], with op one of eq | contains | starts_with | ends_with | has | regex), plus modifiers nth / visible_only / timeout_ms / frame_id. In predicate mode, text / text_exact double as containing_text / text_equals post-filters; text_case_insensitive: bool makes either comparison case-insensitive. AttrPredicate.case_insensitive does the same for eq | contains | starts_with | ends_with (rejected for has/regex, which have no value to fold case on / are already case-insensitive via an inline (?i) pattern flag). State-changing tools accept return_snapshot: bool for one-call action + observe. browser_pdf / _save_mhtml / _download return a binary-output shape ({ byte_len, saved_path? , base64? }): bytes go to save_path on the MCP host when given, else base64-inline (capped at 5 MiB). The default build enables interception / expect / cloudflare / imperva / datadome / monitor / fetcher / tracker-blocking; fingerprints and geo are opt-in.

Full JSON Schema for every tool's input + output is captured in crates/zendriver-mcp/tests/snapshots/ and changes there require an explicit cargo insta accept — the wire shape is reviewed.

browser_open options

browser_open accepts opt-in third-party tracker / fingerprinter blocking:

  • block_trackers: bool — enable blocking with the curated bundled list.
  • tracker_blocklist — one of { "url": "..." }, { "path": "..." }, or { "domains": ["..."] } to add custom hosts (implicitly enables blocking). Requires the default tracker-blocking feature.

The stealth-profile override accepts geo_country (ISO 3166-1 alpha-2, e.g. "DE") to derive a coherent locale + Accept-Language. The field is always present in the schema but only takes effect when the geo feature is enabled.

browser_open also accepts:

  • proxy: string — route the browser through an upstream proxy (scheme://[user:pass@]host:port); userinfo is auto-split into proxy-auth credentials (requires the default interception feature to actually answer the Fetch.authRequired challenge). Always present in the schema.
  • geo_auto: bool — auto-derive locale/languages from the exit IP's country via a proxied probe to ip-api.com (mirrors proxy above), instead of naming a country explicitly via geo_country. Makes at most one outbound request, only at launch, only when true. An explicit persona locale wins and skips the probe. Requires the opt-in geo feature; always present in the schema (ignored with a logged warning on a non-geo build).
  • geo_endpoint: string — override the probe endpoint (default http://ip-api.com/json); only meaningful with geo_auto: true. Note this bypasses proxy mirroring — only the bundled default endpoint routes through proxy.
  • input_profile: "native" | "coherent" — select input-timing realism (keyboard/mouse), independent of stealth_profile. When unset, the default follows the resolved stealth profile: a spoofed stealth profile (spoof_macos/spoof_linux/spoof_windows) implies "coherent" (human-paced typing, jittery mouse motion), while auto/native stealth implies "native" (zero-overhead, deterministic timing) — mirroring BrowserBuilder::resolved_input_profile(). Pass "native" or "coherent" explicitly to pin the input timing regardless of the stealth setting. Wraps zendriver::stealth::InputProfile via BrowserBuilder::input_profile; the output input_profile field always echoes the resolved value, not the raw request.
  • gpu_backend: "disabled" | "swift_shader" | "native" — select the GPU backend Chrome renders WebGL/WebGPU with (default: "disabled", today's historical flags). "swift_shader" forces ANGLE's software rasterizer explicitly. "native" drops --disable-gpu and renders on the host's real GPU — a fully coherent WebGL/WebGPU surface, but it reports the host's GPU (no identity control) and requires a usable GPU with no automatic fallback: the launch is validated against what Chrome actually initialized, and a host without hardware-accelerated WebGL fails outright rather than silently serving software rendering. See the GPU backend chapter for the measured comparison and the --disable-gpu-alone hang this option exists to avoid.

browser_monitor_* options

browser_monitor_start accepts capture_body_max_bytes: integer (default 1048576, i.e. 1 MiB; 0 means unbounded) alongside capture_bodies: bool — it bounds how much of each HTTP response body is captured per event. A body over the cap is truncated to a prefix; browser_monitor_read's http events report the truncation via body_truncated: bool and body_full_bytes: integer (the full pre-truncation length, regardless of how much was kept). A body-fetch failure (e.g. Chrome already evicted the response) sets body_capture_error: string instead of silently omitting body / body_base64 with no explanation.

browser_monitor_start also accepts stream_bodies: bool (default false) to opt in to incremental body delivery: browser_monitor_read then emits http_data chunk events (request_id + base64 chunk_base64) as each response streams in, ahead of the completed http event for the same request — correlate them by request_id, which http events now also carry. Uses passive CDP Network.streamResourceContent (no request interception, no response pausing) and filters by url_pattern like every other event (no separate filter for streaming). Falls back gracefully to the whole-body path on Chrome versions that don't support it (roughly pre-124) — the monitor never errors out over this. See Network monitor for the full mechanism.

browser_monitor_read can also return a delivery_boundary event: a lagged/reconnected/disconnected transport, a correlation-map eviction, or an undecodable payload on the underlying event stream, surfaced explicitly instead of silently dropped. Its boundary field is one of "lagged" | "reconnected" | "disconnected" | "correlation_evicted" | "decode_failed" | "unknown", with generation / missed / previous / url populated depending on which. A "disconnected" boundary means the underlying monitor's correlator task has ended — no further events will ever be buffered for that handle; call browser_monitor_start again for a fresh one. See Network monitor for the full semantics.

Stealth

Stealth is on by default (matching the zendriver library). Configure the default fingerprint via --stealth-profile at server start; switch live via browser_set_stealth_profile (takes effect on the next browser_open).

Troubleshooting

  • Logs go to stderr in stdio mode — stdout is reserved for MCP JSON-RPC. Use --log debug for verbose CDP-call logging.
  • Errors include _meta.suggested_next hints when applicable (e.g. ElementNotFound suggests reconnaissance via browser_html or a fresh browser_find_all snapshot).
  • HTTP smoke test binds 127.0.0.1:18765 by convention — if your environment has that port taken, set a different port via --http.
  • Real-Chrome integration tests are gated behind cargo feature integration-tests and #[ignore] markers: run via cargo test -p zendriver-mcp --features integration-tests -- --ignored.

Claude Code plugin

The zendriver Claude Code plugin bundles the MCP server with skills, commands, and a subagent so Claude can drive zendriver effectively out of the box.

Install

claude plugin marketplace add TurtIeSocks/zendriver-rs
claude plugin install zendriver@zendriver-rs
/zendriver:setup

/zendriver:setup provisions the zendriver-mcp binary — choose:

  • prebuilt — download the matching binary from the latest zendriver-mcp-v* GitHub release (checksum-verified). No Rust toolchain.
  • sourcecargo install zendriver-mcp (compile the public source yourself).
  • link — reuse a zendriver-mcp already on your PATH.

Restart the session afterward. Chrome is fetched automatically on first use.

What's included

KindItems
MCP serverthe full zendriver browser tool surface
Skillsscraping (canonical playbook), bypass (anti-bot walls), advanced (interception/monitor/capture/sessions)
Commands/zendriver:setup, /zendriver:scrape <url> [goal], /zendriver:extract <url> <schema>
Subagentzendriver-scraper

How it fits together

The scraping skill is the single source of truth for "how to scrape well"; the commands and the subagent both follow it. The subagent runs jobs in its own context so big extractions don't fill the main thread.

Responsible use

Authorized access only — your own sites, permitted content, authorized testing, research. Respect rate limits and site terms.

Migration from Playwright

zendriver-rs's surface borrows heavily from Playwright (locator-based queries, pre-register expectations, fluent builders) so most flows port straightforwardly. This page is a crosswalk for the common operations, plus a note on the structural differences that don't have a 1:1 mapping.

The Playwright side is shown in JavaScript/TypeScript; the Python binding is structurally identical (snake_case methods, otherwise the same shape).

Crosswalk table

OperationPlaywrightzendriver-rs
Launch browserawait chromium.launch()Browser::builder().launch().await?
Launch headedchromium.launch({ headless: false })Browser::builder().headless(false).launch().await?
Open a tab / pageawait context.newPage()browser.new_tab().await?
Reuse first tab(await context.pages())[0]browser.main_tab()
Navigateawait page.goto(url)tab.goto(url).await?
Wait for loadimplicit on gototab.wait_for_load().await?
Wait for network idleawait page.waitForLoadState("networkidle")tab.wait_for_idle().await?
Find one element (CSS)page.locator("button").click()tab.find().css("button").one().await?.click().await?
Find by textpage.getByText("Submit")tab.find().text("Submit").one().await?
Find by ARIA rolepage.getByRole("button", { name: "Go" })tab.find().role(AriaRole::Button).name("Go").one().await?
Find by XPathpage.locator("xpath=//button")tab.find().xpath("//button").one().await?
Find allawait page.locator("li").all()tab.find().css("li").many().await?
Get nth matchpage.locator("li").nth(2)tab.find().css("li").nth(2).one().await?
Clickawait locator.click()el.click().await?
Type textawait locator.fill("hello")el.set_value("hello").await? (instant)
Type with key eventsawait locator.pressSequentially("hi")el.type_text("hi").await?
Press keyawait locator.press("Enter")el.press(Key::Special(SpecialKey::Enter)).await?
Read textawait locator.innerText()el.inner_text().await?
Read attributeawait locator.getAttribute("href")el.attr("href").await?
Check visibilityawait locator.isVisible()el.is_visible().await?
Eval JS (page world)await page.evaluate(() => document.title)tab.evaluate_main::<String>("document.title").await?
Eval JS (isolated)n/a (always main world)tab.evaluate::<String>("...").await?
Wait for responseawait page.waitForResponse("**/api/*")tab.expect_response("/api/").await?
Wait for requestawait page.waitForRequest("**/auth")tab.expect_request("/auth").await?
Wait for downloadpage.waitForEvent("download")tab.expect_download().await?.await?
Handle dialogpage.on("dialog", d => d.accept())let d = tab.expect_dialog(); ...; d.await?.accept(None).await?
Intercept / blockroute.abort() in page.routetab.intercept().block("*/ads/*")?.start()
Modify requestroute.continue({headers})tab.intercept().modify_request("...", |req| {...})?.start()
Screenshotawait page.screenshot()tab.screenshot().await?
Cookies (get all)await context.cookies()browser.cookies().all().await?
LocalStorageawait page.evaluate("...") (no helper)tab.local_storage().get("k").await?
Closeawait browser.close()browser.close().await?

Structural differences

Async runtime is Tokio, not the JS event loop

Every await is a Tokio await. Every method that does I/O takes &self and returns a Future. You drive it with tokio::main:

#[tokio::main]
async fn main() -> zendriver::Result<()> {
    let browser = zendriver::Browser::builder().launch().await?;
    // ...
    Ok(())
}

There is no implicit "current page" — every operation takes an explicit Tab handle. Multiple tabs run in parallel by cloning the Tab and spawning Tokio tasks.

Builder pattern instead of object-config

Playwright uses option-bag objects:

await page.click("button", { force: true, timeout: 5000 });

zendriver-rs uses fluent builders with terminal methods:

use std::time::Duration;
use zendriver::ClickOptions;

let el = tab.find().css("button")
    .timeout(Duration::from_secs(5))
    .one()
    .await?;
el.click_with(ClickOptions { force: true, ..Default::default() }).await?;

Builders are checked at compile time — there's no { tymeout: ... } typo that silently uses the default.

Browser / BrowserContext split is opt-in

Playwright separates Browser (the process) from BrowserContext (an isolated cookie/storage scope). zendriver-rs supports both — which one a Tab lives in is up to the caller:

  • browser.new_tab().await? opens a tab in the default context. Default-context tabs share the browser-wide cookie jar (this matches the pre-BrowserContext behavior — existing code is unaffected).
  • browser.create_browser_context().await? returns an isolated BrowserContext whose new_tab().await? lives in its own cookie + storage scope. The context is disposed on drop via Target.disposeBrowserContext.

For per-context proxy + bypass-list isolation, use browser.create_browser_context_with(Some(proxy_url), None).await?. See the Per-context isolation chapter for the full surface, including the Drop-on-async caveat and a worked proxy-rotation example.

Multi-context isolation via a second Browser process is still an option when total isolation (separate user-data-dir, separate process) matters — BrowserContext shares the parent Chrome process, so heavy isolation needs (e.g. profile-level GPU caches) still warrant a fresh Browser.

Find returns one or many, explicitly

Playwright's locator() lazily evaluates to "zero or more matches" until you call a terminal action. zendriver-rs forces the choice at query time:

TerminalSemantic
.one()Exactly one match — errors with ElementNotUnique otherwise.
.first()First match — errors with ElementNotFound if zero.
.many()All matches — errors with ElementNotFound if zero.
.many_or_empty()All matches; returns Vec::new() if zero.
.count()Just the count.
.exists()Boolean.

Force-picking .first() over .one() is a documented choice when the page may legitimately have multiple matching elements. Playwright's implicit "first-of-many" can mask bugs.

Element handles auto-refresh on stale

Playwright re-resolves locators on every call by design. zendriver-rs caches the CDP RemoteObjectId per Element for speed; if the page re-renders and invalidates the handle, the next method call replays the original query, gets a fresh handle, and retries silently. Handles returned from raw evaluate calls (without an underlying selector) error with NotRefreshable instead.

This means let el = tab.find().css("...").one().await?; el.click().await?; is just as safe across navigations as Playwright; you don't need to re-find before every action.

Isolated-world vs main-world JS

Playwright always evaluates user JS in the main world. zendriver-rs defaults to an isolated world (sandbox) via evaluate(), which means your JS can't see page globals — useful for stealth (the page can't detect your eval), risky if you actually need document.title etc. evaluate_main() is the main-world escape hatch.

let title: String = tab.evaluate_main("document.title").await?;     // page globals work
let n: i32 = tab.evaluate("[1,2,3].length").await?;                  // sandboxed; no DOM

Stealth is on by default

Playwright launches with the headless-Chrome HeadlessChrome UA, the navigator.webdriver = true tell, and no anti-detection patches. zendriver-rs defaults to StealthProfile::native() — the UA scrub plus launch-flag set passes most consumer-site detectors. For active fingerprint detection (sannysoft, etc), opt into StealthProfile::spoofed(). See Stealth.

What's not ported

  • Trace viewer / video recording — out of scope; integrate with tab.screenshot() plus your own ffmpeg pipeline if needed.
  • Test runner (@playwright/test) — use cargo test plus the zendriver-rs surface; the expect feature covers the pre-register pattern that powers Playwright's expect(locator) assertions.
  • Codegen (playwright codegen) — not implemented.
  • Mobile emulation (devices) — set the User-Agent + viewport manually via StealthProfile.

See Architecture for why these are out of scope rather than not-yet-built.

Migration from zendriver (Python)

zendriver-rs deliberately mirrors the Python zendriver package's surface shape — locator-style queries, fluent builders, and a thin wrapper over CDP. Most scripts port across with mechanical translation: keep the control flow, swap the await zd.start() for Browser::builder().launch(), and let the Rust compiler tell you about the type-level differences (Result vs exceptions, &str vs str). The biggest shift is ergonomic, not architectural: every async call is .await?, handles are cheap Arc-clones, and features that are always-on in Python live behind Cargo features here so binary size scales with what you use.

Crosswalk table

The Python side is shown with the conventional import zendriver as zd alias.

OperationPython zendriverzendriver-rs
Launch browserbrowser = await zd.start()let browser = Browser::builder().launch().await?;
Launch headlessawait zd.start(headless=True)Browser::builder().headless(true).launch().await?
No-sandboxawait zd.start(sandbox=False)Browser::builder().arg("--no-sandbox").launch().await?
Persistent profileawait zd.start(user_data_dir="path")Browser::builder().user_data_dir("path").launch().await?
Navigate (first tab)tab = await browser.get(url)let tab = browser.main_tab(); tab.goto(url).await?;
Open new tabtab = await browser.get(url, new_tab=True)let tab = browser.new_tab_at(url).await?;
Find by textawait tab.find("Submit", best_match=True)tab.find().text("Submit").one().await?
Find by CSS (one)await tab.select("button.go")tab.find().css("button.go").one().await?
Find by CSS (many)await tab.select_all("li")tab.find_all().css("li").many().await?
Find by XPathawait tab.xpath("//button")tab.find().xpath("//button").one().await?
Clickawait element.click()element.click().await?
Type textawait element.send_keys("hi")element.type_text("hi").await?
Read textelement.textelement.inner_text().await?
Read attributeelement.attrs["href"]element.attr("href").await?
Run JSawait tab.evaluate("document.title")tab.evaluate_main::<String>("document.title").await?
List tabsbrowser.tabsbrowser.tabs().await
Wait for responseawait tab.expect_request(url)tab.expect_request(url).await? (feature expect)
Block requeststab.add_handler(zd.cdp.fetch.RequestPaused, h)tab.intercept().block("...")?.start() (feature interception)
Solve Cloudflareawait tab.verify_cf()tab.cloudflare().wait_for_clearance(d).await? (feature cloudflare)
Get cookiesawait browser.cookies.get_all()browser.cookies().all().await?
Set cookieawait browser.cookies.set_all([...])browser.cookies().set_many(vec![...]).await?
Screenshotawait tab.save_screenshot(path)let png = tab.screenshot().await?; std::fs::write(path, png)?;
Closeawait browser.stop()browser.close().await?

Behavioral differences worth knowing

Errors are Result, not exceptions

Every fallible call returns Result<T, ZendriverError>. You propagate with ? and pattern-match on the enum to recover. There is no try / except zendriver.NoSuchElementErrorElementNotFound arrives as an Err(ZendriverError::ElementNotFound { selector }) that you handle inline:

match tab.find().css(".banner").one().await {
    Ok(el) => el.click().await?,
    Err(ZendriverError::ElementNotFound { .. }) => {
        // soft-fail: banner wasn't on this page variant
    }
    Err(e) => return Err(e),
}

See the Error Reference for every variant.

Tab and Browser are cheap Arc-clones

In Python you mostly hold one Browser and one Tab reference per script. In Rust both types are Clone + Send + Sync — internally an Arc over the connection plus a session id. You can clone them freely to pass into helper functions or tokio::spawn blocks; every clone points at the same underlying CDP session, so a tab.clone().goto(...) in one task is visible to the original tab in another.

let tab = browser.main_tab();
let probe = tab.clone();
tokio::spawn(async move {
    let _ = probe.expect_response("/api/").await;
});
tab.goto("https://example.com").await?;

Pre-register-then-await replaces handler callbacks

Python zendriver exposes raw CDP event handlers (tab.add_handler(zd.cdp.fetch.RequestPaused, callback)) for both network observation and modification. zendriver-rs splits those two intents:

  • Observationexpect_request / expect_response / expect_dialog / expect_download from the expect feature. Pre-register before triggering the action; await the returned handle after. The subscriber is live from the moment expect_* returns, so no race with fast responses.
  • ModificationTab::intercept from the interception feature. A declarative rule builder (block / redirect / respond / modify_request) or a subscribe() stream for callback-style control.

Direct CDP-event handlers are still available via Tab::session().subscribe::<E>() if you need them — but most flows lift cleanly into one of the two helpers above.

Builder methods replace keyword arguments

Python's start(headless=True, sandbox=False, user_data_dir=...) becomes Browser::builder().headless(true).arg("--no-sandbox").user_data_dir(...).launch().await?. Same for query options (tab.find("x", best_match=True, timeout=10)tab.find().text("x").timeout(Duration::from_secs(10)).one().await?). The compiler catches typos that would silently default in Python.

Isolated-world JS is the default

tab.evaluate("document.title") in Python runs in the page's main world. In Rust tab.evaluate::<T>("...") runs in an isolated world (sandboxed; no access to page globals like document or window.appConfig). Use tab.evaluate_main::<T>("...") for main-world access. The isolated default is what lets stealth keep the page from detecting your evaluator script; see Architecture.

let n: i32 = tab.evaluate("[1,2,3].length").await?;             // sandbox; no DOM
let title: String = tab.evaluate_main("document.title").await?; // page globals

The turbofish (::<String>) drives JSON deserialization via serde, so you can return any serde::de::DeserializeOwned type — String, i32, your own #[derive(Deserialize)] struct, serde_json::Value for dynamic payloads, etc.

Find terminals are explicit

Python's tab.find() and tab.select() return "the match" (raising on zero, silent on multiple). zendriver-rs forces the choice at query time:

TerminalSemantic
.one()Exactly one match — errors with ElementNotFound if zero, ElementNotUnique if more.
.one_or_none()Returns Option<Element>.
.many()All matches — errors with ElementNotFound if zero.
.many_or_empty()All matches; returns Vec::new() if zero.

This makes "zero matches" an explicit code path in the source rather than a runtime surprise.

Cargo features

Python zendriver is one PyPI package — every feature ships in the default install. zendriver-rs splits optional surface behind Cargo features so binary size and compile time scale with what you use.

Python capabilityRust Cargo featureWhat it gates
tab.add_handler(zd.cdp.fetch.RequestPaused, ...)interceptionTab::intercept(), the Fetch.*-based rule builder, and the subscribe() stream.
tab.expect_request(...), dialogs, downloadsexpectAll four expect_* methods on Tab.
tab.verify_cf()cloudflareTab::cloudflare() and the CloudflareBypass driver. Pulls in interception.
Chrome auto-downloadfetcherzendriver_fetcher re-exports; downloads Chrome for Testing on demand.
Stealthalways onzendriver-stealth is a non-optional dep; profiles via StealthProfile::native() / spoofed() / off().

Enable in your Cargo.toml:

[dependencies]
zendriver = { version = "0.1", features = ["interception", "expect", "cloudflare"] }

If you're not sure which to enable, start with expect (most scripts end up wanting expect_response for network assertions) and add interception / cloudflare if you hit those needs.

Known gaps in v0.1.0

Canvas / WebGL / font / audio fingerprint spoofing and a browserforge equivalent have shipped since this section was first written — see the Fingerprint spoofing chapter, including pool + generative sources for the zendriver-fingerprints crate (real-device persona dataset / Bayesian-network sampler) that plays the same role as Python's optional browserforge dependency.

Capabilities the Python zendriver ships that are not yet in the Rust port:

  • OCR helpers. Python's bundled OCR wrappers (tesseract / easyocr) for text-in-image extraction aren't ported. Pair Rust's tab.screenshot() with the tesseract-rs or leptess crate.
  • Widevine / DRM playback. Python supports loading the Widevine CDM for protected video. The Rust port launches a vanilla Chrome / CfT binary that doesn't ship the CDM. Track upstream Chromium for a pluggable CDM story.
  • browser.get(url) shorthand. Python returns a tab from browser.get. In Rust use browser.main_tab(); tab.goto(url).await? (or browser.new_tab_at(url).await? for the equivalent of new_tab=True). The split is intentional — main_tab() is sync, so binding it doesn't add a turn to your code.
  • page.find(text, best_match=True) fuzzy matching. Rust's .text(...) is a substring match. For "closest match" semantics, use text_regex(...) with a permissive regex.

If you hit a gap that blocks your migration, please file an issue at https://github.com/TurtIeSocks/zendriver-rs/issues — pre-1.0 prioritization is largely driven by reported migration friction.

See also

  • Quickstart — the minimal Rust launch / navigate / find / read flow, walked line by line.
  • Expect() — full coverage of the pre-register-then-await pattern that replaces Python's CDP event handlers for observation.
  • Interception — the rule builder + stream API that replaces the handler-based rewriting flow.
  • Architecture — the Rust-specific design choices (single-actor CDP transport, isolated-world default, auto-refresh on stale handles) that shape the public surface.

Migration from nodriver (Python)

If you're coming from nodriver (the original Python CDP wrapper that zendriver-py was forked from), you'll find zendriver-rs's shape familiar: same locator-style queries, same per-tab handle, same isolated-world JS evaluation as a sandbox layer. The Rust port closes a few rough edges nodriver carried — explicit Frame types instead of flatten-mode juggling, a dedicated Cloudflare driver instead of the inline verify_cf helper, and named API surface for the things nodriver did through Python's dunder methods. The translation is mostly mechanical: swap await for .await?, learn the four query terminals, opt into Cargo features for the optional surface.

Crosswalk table

The Python side uses the conventional import nodriver as nd alias.

OperationPython nodriverzendriver-rs
Launch browserbrowser = await nd.start()let browser = Browser::builder().launch().await?;
Launch headlessawait nd.start(headless=True)Browser::builder().headless(true).launch().await?
No-sandboxawait nd.start(sandbox=False)Browser::builder().arg("--no-sandbox").launch().await?
Navigate (first tab)tab = await browser.get(url)let tab = browser.main_tab(); tab.goto(url).await?;
Open new tabtab = await browser.get(url, new_tab=True)let tab = browser.new_tab_at(url).await?;
Find by textawait tab.find("Submit")tab.find().text("Submit").one().await?
Find by CSS (one)await tab.select("button.go")tab.find().css("button.go").one().await?
Find by CSS (many)await tab.select_all("li")tab.find_all().css("li").many().await?
Nth element(await tab.select_all("li"))[2]tab.find().css("li").nth(2).one().await?
Clickawait element.click()element.click().await?
Type textawait element.send_keys("hi")element.type_text("hi").await?
Read textelement.textelement.inner_text().await?
Read attributeelement.attrs["href"]element.attr("href").await?
Eval JSawait tab.evaluate("document.title")tab.evaluate_main::<String>("document.title").await?
Eval JS, await promisetab.evaluate("p()", await_promise=True)tab.evaluate_main::<T>("await p()").await?
Iterate tabsbrowser.tabsbrowser.tabs().await
Cookiesawait browser.cookies.get_all()browser.cookies().all().await?
Screenshotawait tab.save_screenshot(path)let png = tab.screenshot().await?; std::fs::write(path, png)?;
Solve Cloudflareawait tab.verify_cf()tab.cloudflare().wait_for_clearance(d).await? (feature cloudflare)
Closeawait browser.stop()browser.close().await?

Behavioral differences worth knowing

Iframes get a first-class Frame type

nodriver inherited Chromium's "flatten mode" for nested frames — every node from a same-origin iframe appeared in the parent document's tree, and you switched into out-of-process iframes (OOPIFs) by attaching to the iframe's CDP target manually. zendriver-rs makes Frame a first-class type with its own SessionHandle, find / find_all / evaluate / evaluate_main, and the same auto-refresh semantics as top-level elements:

let main = tab.main_frame().await?;
let h1 = main.find().css("h1").one().await?;

// OOPIFs work the same — no manual attach.
if let Some(yt) = tab.frame_by_url("youtube.com").await? {
    yt.evaluate::<()>("document.querySelector('video').play()").await?;
}

You can also start from the Tab and re-target the query at a Frame via FindBuilder::in_frame. See Frames.

Cloudflare bypass is a dedicated crate

nodriver ships a tab.verify_cf() helper that walks the shadow DOM to find Turnstile's iframe and dispatches a click at the checkbox's expected offset. zendriver-rs lifts that flow into the zendriver-cloudflare crate (Cargo feature cloudflare), exposed via Tab::cloudflareCloudflareBypass::wait_for_clearance:

use std::time::Duration;
use zendriver::CloudflareError;

match tab.cloudflare()
    .wait_for_clearance(Duration::from_secs(30))
    .await
{
    Ok(_) => { /* cleared (token acquired or challenge gone) */ }
    Err(CloudflareError::NoChallenge) => { /* already clear */ }
    Err(e) => return Err(e.into()),
}

The driver uses the same shadow-DOM walk approach as nodriver, runs a 15%-from-left / 50%-from-top click at the iframe offset, and polls the cf-turnstile-response input for a non-empty value. Unlike nodriver, none of those are baked in — the markers and the click are caller data, and the click itself can be replaced outright. Pair with StealthProfile::spoofed() for the best bypass rate. See Cloudflare.

No magic methods — explicit .await and .nth()

nodriver leans on Python dunders to make the API feel imperative:

  • await tab__await__ waits for the page to be ready.
  • tab[2]__getitem__ returns the 3rd element of the last query.
  • for el in elements: — implicit element iteration after a find_all.

Rust has no equivalent to these — every operation is a named method call. The translations:

Python idiomRust replacement
await tabtab.wait_for_load().await?
result = await tab.find_all("li"); result[2]tab.find().css("li").nth(2).one().await?
for el in await tab.select_all("li"):for el in tab.find_all().css("li").many().await? { ... }
tab[2] (last result indexing)not supported — capture the Vec<Element> to a let and index it

The verbosity is a one-time tax for code that's easier to grep, easier to refactor, and lets rust-analyzer see every callsite.

evaluate returns deserialized JSON, not a CDP RemoteObject

nodriver's tab.evaluate(js, await_promise=False) returns Chromium-specific cdp.runtime.RemoteObject wrappers — you fish out .value or .description, type-check what you got, and handle the "object reference" case manually for non-serializable returns. zendriver-rs returns a typed Rust value via serde:

// Primitives.
let n: i32 = tab.evaluate_main("[1,2,3].length").await?;

// Strings.
let title: String = tab.evaluate_main("document.title").await?;

// Dynamic JSON.
let json: serde_json::Value = tab.evaluate_main("({a: 1, b: [2,3]})").await?;

// Strongly typed (define your own struct).
#[derive(serde::Deserialize)]
struct Meta { name: String, count: i32 }

let m: Meta = tab.evaluate_main("({name: 'x', count: 5})").await?;

For promise return values, await the promise inside the JS string:

let result: serde_json::Value = tab
    .evaluate_main("await fetch('/api/me').then(r => r.json())")
    .await?;

Non-serializable returns (DOM nodes, functions) error with ZendriverError::JsException — for DOM access prefer tab.find(), which returns an Element handle that exposes inner_text, attr, click, etc.

Errors are Result, not exceptions

Every fallible call returns Result<T, ZendriverError>. nodriver raises Python exceptions (NoSuchElementError, TimeoutError, plus a few wrappers around chromiumoxide errors). The Rust port flattens them all into one ZendriverError enum with #[from] conversions for the sub-crate errors. See the Error Reference for every variant.

Tab / Browser are cheap Arc-clones

Tab and Browser are Clone + Send + Sync — they're thin Arc-wrappers over the underlying CDP session. Clone freely to pass into helpers or tokio::spawn blocks. Every clone references the same session, so an action on one clone is visible to all.

let tab = browser.main_tab();
let probe = tab.clone();
let handle = tokio::spawn(async move {
    probe.expect_response("/api/data").await
});
tab.goto("https://example.com").await?;
let _matched = handle.await??;

Isolated-world is the default eval target

tab.evaluate() runs in an isolated world (sandboxed; no access to page globals like document or window.appConfig). The escape hatch is tab.evaluate_main() which runs in the page's default context — the equivalent of nodriver's tab.evaluate(...). The isolated default keeps the page from detecting your evaluator via Function.prototype.toString drift. See Architecture.

let n: i32 = tab.evaluate("[1,2,3].length").await?;             // sandbox
let title: String = tab.evaluate_main("document.title").await?; // page globals

Cargo features

nodriver is one PyPI package with everything in the box. zendriver-rs splits optional capabilities behind Cargo features so you pay only for what you use.

nodriver capabilityRust Cargo featureWhat it gates
tab.add_handler(nd.cdp.fetch.RequestPaused, ...) rewritinginterceptionTab::intercept() plus the rule builder (block / redirect / respond / modify_request) and the subscribe() stream.
await tab.expect_request(...) (where supported)expectThe expect_request / expect_response / expect_dialog / expect_download methods on Tab.
await tab.verify_cf()cloudflareTab::cloudflare() plus the CloudflareBypass driver. Pulls in interception.
Chrome auto-download (separate nodriver extras)fetcherzendriver_fetcher for downloading Chrome for Testing binaries on demand.
Stealthalways onProfiles via StealthProfile::native() (default recommendation), spoofed(), or off().

Enable in your Cargo.toml:

[dependencies]
zendriver = { version = "0.1", features = ["interception", "expect", "cloudflare"] }

If you're not sure where to start, enable expect (the pre-register-then-await pattern saves you from event-handler race conditions) and add the rest as you hit them.

Known gaps in v0.1.0

Canvas / WebGL / audio / font fingerprint spoofing and a browserforge equivalent have shipped since this section was first written — see the Fingerprint spoofing chapter, including pool + generative sources for the zendriver-fingerprints crate (real-device persona dataset / Bayesian-network sampler) that plays the same role as nodriver's pairing with the browserforge library.

Things nodriver supports that zendriver-rs doesn't yet:

  • OCR helpers. nodriver bundles easyocr / tesseract wrappers for text-in-image extraction. Not ported; pair tab.screenshot() with the tesseract-rs or leptess crate.
  • Widevine / DRM playback. nodriver supports loading the Widevine CDM for protected video; zendriver-rs launches a vanilla Chrome / CfT binary that doesn't ship the CDM.
  • __await__ on tab and flow_to_finish. nodriver overloads await tab for "wait for the page to be ready". Rust call sites are always explicit: tab.wait_for_load().await? or tab.wait_for_idle().await?.
  • __getitem__ on element collections. No tab[2] shortcut — call tab.find().css(...).nth(2).one().await? or capture a Vec<Element> from .many() and index it via [2].
  • Element.children walks. nodriver exposes parent / child / sibling traversal on the element handle. zendriver-rs has limited traversal (see Element::children and friends in the docs); deeper DOM walks may need a JS evaluate call returning the structured shape you need.
  • tab.send_dom_event — direct DOM event synthesis isn't a first-class helper. Use tab.evaluate_main with the corresponding JS (el.dispatchEvent(new Event('change'))).

If you hit a gap that blocks your migration, please file an issue at https://github.com/TurtIeSocks/zendriver-rs/issues — pre-1.0 prioritization is largely driven by reported migration friction.

See also

  • Migration from zendriver (Python) — the zendriver Python package is a downstream fork of nodriver, so most differences from nodriver also apply to it.
  • Quickstart — the minimal Rust launch / navigate / find / read flow, walked line by line.
  • Frames — covers Frame semantics and the OOPIF auto-attach behavior in detail.
  • Cloudflare — full CloudflareBypass documentation including the four internal stages, limitations, and stealth pairing.
  • Architecture — the design choices behind the isolated-world default, auto-refresh on stale handles, and the single-actor CDP transport.

Architecture

This chapter sketches the layered design that zendriver-rs sits on top of. The goal is to give you enough mental model to debug surprises ("why did my evaluate fail mid-navigation?") and to reason about performance ("is interception serializing my requests?").

The big picture

                            ┌─────────────────────────────────┐
                            │  Your code: Browser/Tab/Element │
                            │      query / actions / eval     │
                            └────────────────┬────────────────┘
                                             │
              ┌──────────────────────────────┼─────────────────────────────┐
              │                              │                             │
              ▼                              ▼                             ▼
   ┌────────────────────┐       ┌────────────────────────┐    ┌─────────────────────┐
   │  Stealth (boot JS) │       │  Element auto-refresh  │    │  Isolated-world eval│
   │  + protocol patch  │       │  + actionability gate  │    │  (sandbox per Tab)  │
   └─────────┬──────────┘       └───────────┬────────────┘    └──────────┬──────────┘
             │                              │                            │
             └──────────────────────────────┴────────────────────────────┘
                                            │
                                            ▼
                            ┌─────────────────────────────────┐
                            │   CDP Actor (single Tokio task) │
                            │   – cmd/response routing        │
                            │   – event fan-out + observers   │
                            └────────────────┬────────────────┘
                                             │   JSON-RPC
                                             ▼
                            ┌─────────────────────────────────┐
                            │     Chrome (subprocess)         │
                            │     CDP over WebSocket          │
                            └─────────────────────────────────┘

Every public type above the actor is a cheap handle (Arc clone + session-id) — the actor is the single source of truth for outbound commands and inbound events.

The CDP transport actor

zendriver-transport runs a single Tokio task that owns the WebSocket connection to Chrome. All command sends go through a mpsc::UnboundedSender; every public handle holds a Connection clone that wraps that sender. The actor task:

  1. Reads commands from the channel, attaches a monotonically-increasing id, and writes them to the socket.
  2. Reads frames from the socket, decodes them into CdpInbound (either a response to a command or an event), and routes them.
  3. For responses: looks up the pending oneshot::Sender in a HashMap<id, oneshot::Sender<Result<Value>>> and resolves it.
  4. For events: fans them out via a tokio::sync::broadcast so every subscriber gets a copy without blocking the actor loop.
  5. For Target.attachedToTarget: invokes each registered TargetObserver (stealth installs JS bootstrap here) before releasing the debugger pause, so observers run during the gap.

The actor model gives you exactly-one-reader/writer per socket without explicit locking, while keeping the public surface cloneable (Tab, Element are Clone + Send + Sync). All concurrency happens in user-space Futures handed back from connection.call(...).

Observer pattern

Most CDP usage thinks of events as "fire and forget — subscribe if you care". zendriver-rs has two layers:

  • Broadcast subscribersTab clones a per-target receiver from the broadcast channel. Event helpers (expect_request, etc) drop into this layer, filter on type + payload, and resolve a oneshot when the first match arrives.
  • Synchronous observersTargetObserver runs on Target.attachedToTarget before the new target's debugger pause is released. Stealth depends on this: the auto-attach observer dispatches Page.addScriptToEvaluateOnNewDocument while the page is still paused, so the bootstrap script lands before any page script. No race; no need for the script to detect its own arrival timing.

The same observer chain re-applies stealth on every new tab — that's why Browser::new_tab() gives you a fully stealth-patched tab without extra code.

Auto-refresh on stale handles

CDP returns a RemoteObjectId (per-context handle) for every queried element. Those handles invalidate when the page re-renders or navigates — Chrome will return Cannot find object with given id on the next call, which Playwright papers over by re-resolving the locator on every action.

zendriver-rs takes a different bet: cache the RemoteObjectId on the Element for speed, but transparently re-run the original query and retry the action when the handle goes stale. The trigger:

                      Element::click()
                            │
                            ▼
            ┌─── Runtime.callFunctionOn ───┐
            │   on cached RemoteObjectId   │
            └──────────────┬───────────────┘
                           │
                  ┌────────┴────────┐
                  │ success? ──► return │
                  └────────┬────────┘
                           │ stale
                           ▼
              re-run cached query origin
                  (find().css("..."))
                           │
                           ▼
              new RemoteObjectId; retry once
                           │
                  ┌────────┴────────┐
                  │ success? ──► return │
                  └────────┬────────┘
                           │ stale again
                           ▼
                  Err(ZendriverError::ElementStale)

Handles returned from raw evaluate() calls (no underlying query) can't be replayed and surface ZendriverError::NotRefreshable on stale. The borrow checker tracks the query scope for you — there's no way to use an element across browser teardown.

Isolated-world evaluation

tab.evaluate(...) runs JS in a sandboxed isolated world per tab: a V8 context that shares the DOM with the main world but has its own global scope. This means:

  • The page can't detect your eval via Function.prototype.toString drift, window-global mutation, or scope-leak tells.
  • Your JS can't see page globals (window.appConfig, jQuery, etc).

For the main-world escape hatch, use evaluate_main(...). It dispatches the same Runtime.evaluate but targets the page's default context.

The isolated world is allocated lazily on first evaluate() and cached per tab. After navigation Chrome invalidates the context; the next evaluate() call re-allocates transparently. Frames each have their own isolated world (allocated per frame contextId).

Why these choices

  • CDP-direct (no WebDriver shim). WebDriver's JSON wire serializes every command to disk-style protocol overhead — milliseconds per call on localhost, plus needing a separate chromedriver process. CDP is millisecond-roundtrip over a single socket and exposes the full protocol surface (interception, fetch, target tree, etc). Anti-detection also requires protocol-level control: chromedriver injects its own automation tells that we'd then have to scrub back out.
  • Single actor task. Easier reasoning than a connection pool; no command-ordering ambiguity. The actor does no parsing past JSON-RPC framing, so it's not a CPU bottleneck even under interception load.
  • Tokio runtime. Browser automation is I/O-heavy (every action costs at least one round-trip to Chrome); pinning ourselves to Tokio gives us the mature tokio::time, tokio::sync, tokio::select surface plus the ecosystem (reqwest, etc).
  • Auto-refresh by default. Two-thirds of "flaky test" reports we triaged during P3 development were stale-handle races. Making it silent + a single retry covers >95% of cases without inviting the Playwright-style "every action re-finds, eating round-trips" cost.

Crate split

CratePurposePublic?
zendriverHigh-level Browser/Tab/Element + traitsyes
zendriver-transportActor + WebSocket + observersyes, but SEMVER looser
zendriver-stealthFingerprint composition + bootstrap JSyes
zendriver-interceptionFetch.* actor + rule + stream APIyes, gated interception
zendriver-cloudflareTurnstile bypassyes, gated cloudflare
zendriver-fetcherChrome-for-Testing downloaderyes, gated fetcher

The split lets you take a dep on only what you need (the zendriver-transport crate is the heaviest; the optional sub-crates each pull a small additional surface). It also lets future runtime backends (e.g. embedding in WASM or smol) replace zendriver-transport without touching the high-level types — the actor's public API is the seam.

See also

FAQ

Common questions about zendriver-rs. Each entry links into the relevant chapter for the long-form answer.

How do I run headed (with a visible window)?

Pass .headless(false) to the builder:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder()
    .headless(false)
    .launch()
    .await?;
Ok(()) }
}

Useful while debugging — you can watch what the script does. Switch back to headless(true) for production or CI. There is no "slow-mo" or "keep open" flag; if you need the window to stick around after the script exits, comment out browser.close().await? and Ctrl+C the process.

Why am I getting NotActionable?

ZendriverError::NotActionable fires when an element didn't pass the actionability checks within the gate timeout. The checks are: visible, enabled, stable (not animating), and hit-tested (no overlay blocking clicks). The error message includes which check failed.

Common causes:

  • Visibility — element has display: none, visibility: hidden, or zero bounding box. Use tab.find().css("...").visible_only() to skip these during the query.
  • Hit-test failure — a modal overlay sits above the element. Close the overlay first, or pass ClickOptions { force: true, ..default() } to bypass the check.
  • Animation — the element is still moving. Wait for tab.wait_for_idle().await? before clicking; the gate retries a few frames automatically but won't wait through a 2-second CSS transition.

If you genuinely want to click an invisible element (e.g. testing keyboard nav), use el.click_fast() instead of el.click() — the _fast variant skips the realism gate.

Does this work on Apple Silicon / M1+?

Yes. Chrome ships native arm64 binaries; zendriver-rs picks them up via the standard PATH discovery. The Fetcher also has a Platform::MacArm64 variant and downloads the matching CFT zip on Apple Silicon hosts.

Does this work on Linux ARM64 / aarch64?

The library itself builds cleanly. The Fetcher does not download Chrome on linux-aarch64 because Chrome for Testing doesn't ship a linux-arm64 build. Install Chrome through your distro's package manager, then let the standard PATH discovery find it.

Can I use a custom Chrome binary?

Yes — .executable(path) on the builder:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder()
    .executable("/opt/chrome/126/chrome")
    .launch()
    .await?;
Ok(()) }
}

Useful for pinning a specific Chrome version, running Chromium / Edge, or running a custom-built debug Chrome. The binary needs to support --remote-debugging-port=0 and emit the standard DevTools listening on ws://... line — every recent stable Chrome / Chromium / Edge does.

Why is my evaluate() not seeing window.foo?

tab.evaluate() runs in an isolated world by default — a sandbox that shares the DOM with the page but has its own globals. Use tab.evaluate_main() for page-global access:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
let title: String = tab.evaluate_main("document.title").await?;
let app_state: serde_json::Value = tab.evaluate_main("JSON.stringify(window.appState)").await?;
Ok(()) }
}

The isolated default is a stealth feature — page scripts can't detect your eval the way they could if you wrote into the main world. See Architecture.

How do I detect bot-detection?

There's no built-in detector. The pragmatic test:

  1. Run your target site headed with StealthProfile::off() first, then native(), then spoofed(). Compare behavior — if a feature works off but not native, the issue is in your stealth setup, not the anti-bot.
  2. Hit bot.sannysoft.com and arh.antoinevastel.com to see what generic detectors find.
  3. For Cloudflare specifically, check whether the gate is the visible Turnstile checkbox (cloudflare feature can pass it) or the silent challenge (which requires better stealth, not bypass tooling).
  4. If the site blocks you even with spoofed(), the issue is usually not headless detection but: TLS JA3 fingerprint (use a real Chrome build, not chromiumoxide's), datacenter IP (rotate to residential), or rate-limit thresholds.

What's the difference between native and spoofed stealth?

  • StealthProfile::native() — patches only what fingerprinters see at the protocol level: UA scrub, launch flags, Emulation overrides. No JS bootstrap, so there's nothing for a Function.prototype.toString check to catch — it's cheap and safe against that specific technique by construction. Passes most consumer sites.
  • StealthProfile::spoofed()native() plus Navigator-prototype JS patches injected via Page.addScriptToEvaluateOnNewDocument. Restores navigator.webdriver to undefined, fixes navigator.plugins / chrome runtime / WebGL vendor, etc. Required to pass sannysoft and other active detectors. Pays a small per-navigation cost (script runs on every new document).

Full table in Stealth.

My Chrome subprocess didn't clean up on Ctrl+C

Drop on the last Browser clone sends SIGTERM; the subprocess exits within a second on a graceful shutdown. If your process panics without unwinding (or aborts), the subprocess may linger. Two fixes:

  • Use browser.close().await? explicitly at the end of your script — close waits for the subprocess to exit and surfaces any failure via the Result. Drop is a fallback, not the primary path.
  • Run zendriver-rs inside tokio::select! with a ctrl_c arm so panics still trigger drop:
tokio::select! {
    res = your_main(&browser) => res?,
    _ = tokio::signal::ctrl_c() => {
        browser.close().await?;
    }
}

How do I share login state across runs?

Pass .user_data_dir(path) to the builder. Chrome stores cookies, localStorage, IndexedDB, etc under that path; second-and-onwards launches inherit the state.

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder()
    .user_data_dir("/home/me/.zendriver-state")
    .launch()
    .await?;
Ok(()) }
}

Caveat: Chrome locks the directory while running. Two simultaneous launches against the same user_data_dir will error out. Either coordinate access (mutex) or use separate dirs per worker.

Can I run multiple browsers in parallel?

Yes. Browser clones are cheap (Arc underneath) and Send + Sync, so you can stash them in any worker pool. Run multiple independent Chrome subprocesses by calling Browser::builder().launch() more than once — each call spawns a separate Chrome. RAM-bound: each Chrome instance is ~150-300 MB headless.

For multi-tab orchestration within one Chrome (cheaper), see Multi-tab.

How do I capture network traffic?

Two paths:

  • Observe only — use expect_request / expect_response for individual events, or stash a tab.intercept().subscribe() stream that auto-continue_()s and logs each PausedRequest.
  • Modify — use Interception's rule API (block / redirect / respond / modify_request).

There's no Playwright-style "trace viewer" output; assemble the data you want from those streams.

Why is the first launch slow on macOS?

The chromedriver framework's notarization check runs the first time the OS sees a Chrome binary. Subsequent launches reuse the cached result and start in <500 ms. On a fresh CFT download via the Fetcher this is more visible because the binary is new to the OS.

What's the MSRV?

Rust 1.85 (required for edition 2024). We don't aim to track stable bleeding-edge; MSRV bumps follow the same SemVer policy as API changes (see SEMVER.md).

I'm getting ZendriverError::Cdp with code -32000. What now?

Code -32000 ("Cannot find context") usually means the page navigated out from under your call. zendriver-rs maps this specifically to ZendriverError::Navigation rather than the raw Cdp variant — if you're seeing the raw Cdp form, you're on a CDP method we haven't special-cased. Wait for wait_for_load() / wait_for_idle() before the call, or use expect_response to pin the wait to the specific event you care about.

Tab::evaluate and Frame::evaluate already recover from this on their own: they drop the dead execution context, create a fresh isolated world and retry once. Seeing the error surface from either of those means the context died twice in a row, which normally points at a page navigating in a loop rather than at a one-off race.

wait_for_idle never returns on a page with a long-poll, SSE, or analytics-beacon request

By default wait_for_idle waits for every in-flight request to terminate, so a single never-completing request (a Server-Sent Events stream, a long-poll, a hung analytics beacon) keeps the tab non-idle until timeout. Use Tab::wait_for_idle_opts with an IdleOptions::max_inflight_age — any request older than that age is treated as background, letting idle resolve while it stays open:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
use std::time::Duration;
use zendriver::IdleOptions;

tab.wait_for_idle_opts(IdleOptions {
    max_inflight_age: Some(Duration::from_secs(5)),
    ..Default::default()
})
.await?;
Ok(()) }
}

max_inflight_age defaults to None (the historical wait-for-everything behavior).

Does wait_for_idle count a response's headers as "done"?

No — only Network.loadingFinished / Network.loadingFailed clear a request from the in-flight set. Network.responseReceived (the response's headers arriving) is deliberately not treated as completion, since the body can still be streaming for an arbitrarily long time afterward (a large download, chunked transfer encoding, a slow origin).

wait_for_idle is best-effort by default: if the underlying CDP event stream loses delivery continuity mid-wait (a lagging subscriber, a Browser::reconnect, or the WebSocket dying), it silently tolerates the gap and still resolves on what it did observe — IdleOptions::loss_policy: IdleLossPolicy::Lenient, matching every prior release's behavior. Opt into IdleLossPolicy::Strict to fail loudly with ZendriverError::EventStreamIncomplete instead of risking a possibly-wrong idle:

#![allow(unused)]
fn main() {
async fn ex() -> zendriver::Result<()> {
let browser = zendriver::Browser::builder().launch().await?;
let tab = browser.main_tab();
use zendriver::{IdleLossPolicy, IdleOptions};

tab.wait_for_idle_opts(IdleOptions {
    loss_policy: IdleLossPolicy::Strict,
    ..Default::default()
})
.await?;
Ok(()) }
}

Where do I find the full list of errors?

Error Reference — every public variant of ZendriverError plus the sub-crate errors that flow into it.

Error Reference

Every fallible API in zendriver returns Result<T>, an alias for std::result::Result<T, ZendriverError>. This page lists every public variant of ZendriverError plus the sub-crate errors that flow into it via #[from], with the common cause + the fix.

The enum is #[non_exhaustive] — pattern matches should include a _ arm. Per SEMVER, new variants may land in minor releases.

Top-level ZendriverError variants

VariantCommon causeFix
Browser(BrowserError)Chrome launch / discovery failed.See BrowserError below.
Transport(TransportError)WebSocket failure (Chrome crashed, socket reset).Retry the operation; if recurring, check Chrome's stderr for crash dumps.
DisconnectedThe connection to Chrome dropped unexpectedly mid-call — no caller-requested close() — distinct from the clean-shutdown Transport case.Reconnect via Browser::reconnect, then re-acquire any Tab/Frame/Element handles (a reconnect invalidates them) via Browser::main_tab / Browser::tabs.
Cdp { code, message, data }Chrome returned a CDP RPC error.Inspect messageInvalid params usually means a stale RemoteObjectId or wrong type signature.
ElementNotFound { selector }Query selector matched zero elements within the timeout.Confirm the page actually rendered the element (tab.wait_for_load() / wait_for_idle()); check the selector with tab.find().css(...).count().
ConflictingSelectorsA query mixed a single-selector method (.css/.xpath/.text/.role) with bs4-like predicate methods (.tag/.attr*/.containing_text/…).Use one selector style per query — either a single selector or combinable predicates, not both.
Timeout(Duration)Generic operation timeout.Increase the timeout via the builder, or address the underlying slow operation.
CdpTimeout { method, budget }One CDP call ran out its per-call budget — either Chrome accepted it and never answered, or it never left the send queue. Unlike Timeout(Duration) it names the stuck method.Raise the budget with Connection::set_call_timeout (whole connection) or call_raw_with_timeout (one call). A repeat offender on the same method usually means a wedged renderer — restart the browser.
EventStreamIncompletetab.wait_for_idle_opts(IdleOptions { loss_policy: IdleLossPolicy::Strict, .. }) observed a delivery gap (a lagging subscriber, a reconnect, or the WebSocket dying) mid-wait.Re-issue the wait — after Browser::reconnect first if the connection itself dropped. Switch to IdleLossPolicy::Lenient (the default) to tolerate gaps instead.
Navigation(String)Page navigation failed (DNS, refused, crashed) or an in-flight call lost its context to a navigation.Check the URL / DNS / network; for the context-lost case, sequence the action after tab.wait_for_load().
JsException(String)A JS expression in evaluate() raised an exception.Wrap the JS in try { ... } catch (e) { return null; } if you want a soft failure; otherwise fix the JS.
ElementStaleAn element's CDP handle invalidated and the auto-refresh path failed.Re-issue the original query manually.
NotRefreshableAn element returned from raw evaluate() went stale and can't be replayed (no underlying selector).Use tab.find() instead of evaluate when you need an element you'll hold across DOM mutations.
NotActionable(Duration, reason)Element wasn't visible/enabled/stable/hit-testable within the gate timeout.See FAQ entry. Use click_fast to skip the gate when intentional.
FrameNotFound(String)tab.frame_by_url/name/id(...) matched no frame.Confirm the iframe is loaded; tab.frames().await? to inspect what frames Chrome sees.
TabNotFound(String)Tab registry lookup failed (auto-attach observer crashed or new-tab race window exceeded).Restart the browser; report as a bug if it happens with a reliable repro.
Cookie(String)Cookie operation refused by Chrome (malformed domain, mixed origin, etc.).Read the message — most often a domain / path mismatch.
Storage(String)DOM storage operation refused (origin mismatch).Confirm tab.url() matches the storage origin before the call.
HistoryNavigation(String)back() / forward() with no entry to go to.Check tab.history_length().await? first.
Serde(serde_json::Error)JSON serialization at the CDP boundary failed.Almost always indicates a type-mismatch bug in zendriver — please file an issue with the failing call.
Io(std::io::Error)File I/O failure (screenshot write, upload read).Standard std::io::Error handling; check permissions.
Stealth(StealthError)Fingerprint resolution failed at launch.See StealthError below.
Interception(InterceptionError)Interception-layer error (gated interception).See InterceptionError below.
Cloudflare(CloudflareError)Cloudflare bypass error (gated cloudflare).See CloudflareError below.
Fetcher(FetcherError)Chrome download error (gated fetcher).See FetcherError below.

BrowserError variants

Sub-error returned wrapped in ZendriverError::Browser.

VariantCommon causeFix
ExecutableNotFound { searched }No Chrome on $PATH or in conventional install locations.Install Chrome / Chromium, or pass .executable(path) to the builder. Use the fetcher feature to auto-download.
SpawnFailed(io::Error)OS refused to spawn the binary (permissions, missing libs).Check the wrapped io::Error; Permission denied → chmod, No such file → bad path.
EarlyExit(ExitStatus)Chrome exited before printing DevTools listening on. Typical: user_data_dir locked by another Chrome, missing GPU sandbox on Linux.Free the user-data-dir (kill stale Chrome processes), or pass .arg("--no-sandbox") if running in a container.
WsTimeoutChrome printed nothing within the WS-endpoint wait window.Try headed mode (headless(false)) to see Chrome's window — usually reveals a missing dependency.
GpuBackendUnavailableA launch that requested GpuBackend::Native could not get a hardware GPU: either Chrome never advertised its WS endpoint (no GPU present, a crashed GPU process, a missing GPU sandbox), or Chrome started and the launch-time check found its WebGL pipeline was not hardware-accelerated.See GPU backend. Catch this error and retry with GpuBackend::SwiftShader or GpuBackend::Disabled; zendriver does not fall back automatically.
DevtoolsParseStderr line matched expected pattern but URL didn't parse.Should not happen with stable Chrome; file a bug.
Cleanup(io::Error)tempfile cleanup of the user_data_dir failed.Usually harmless; check filesystem permissions if persistent.

TransportError variants

Re-exported from zendriver-transport. Surfaced via ZendriverError::Transport.

VariantCommon causeFix
DisconnectedChrome closed the WebSocket without a Close frame. Typically Chrome crashed.Restart the browser; check syslog / dmesg for OOM kills.
Ws(tungstenite::Error)Underlying WebSocket error.Inspect the wrapped error — ConnectionClosed is benign during shutdown.
Frame(serde_json::Error)JSON framing failed on a CDP message.Indicates a Chrome protocol drift; file an issue.
ShutdownActor was told to shut down; pending calls drain with this.Expected during graceful browser.close(); only an error if it surprises you.
ResponseDropped { id }Actor replied but the caller's oneshot receiver had dropped.Should not happen in normal use; indicates a panic somewhere up-stack.
Io(io::Error)I/O error inside tungstenite.Standard I/O handling.

CallError is the transport's per-call result type; it folds into ZendriverError automatically via the From impl — you won't see it directly in the public surface.

StealthError variants

Sub-error returned wrapped in ZendriverError::Stealth.

VariantCommon causeFix
PatchFailed { patch, source }A specific stealth patch CDP call failed.Read the source CallError; usually means the target page navigated mid-patch. Retry the launch.
ChromeVersionDetect(String)The Chrome version probe failed — reading the binary's PE version resource on Windows, or running chrome --version on Unix. Non-fatal on its own: the probe falls back to a baked-in version.Confirm the binary path; pass .chrome_version(N) to the stealth profile to skip the probe.
SystemInfo(String)sysinfo couldn't read RAM / CPU count.Pass .memory_gb(N).cpu_count(N) overrides to skip the auto-detect.
InvalidOverride(String)A fingerprint override value was outside the validated range (e.g. memory_gb = 0).Read the message; fix the override.

InterceptionError variants

Gated interception. Sub-error returned wrapped in ZendriverError::Interception.

VariantCommon causeFix
Call(CallError)Underlying CDP call failed.Inspect inner error.
InvalidPattern(String)URL pattern didn't parse as CDP wildcard syntax.Patterns use * / ? (not regex). Quote literal * characters.
AlreadyStartedstart() called twice on the same builder.Builders are one-shot; create a new one if you need another actor.
NotStartedOperation requires an active actor that hasn't started yet.Call start() first.
SubscriptionClosedThe subscribe() stream's actor was torn down.Stream ends naturally on InterceptHandle drop; expected during shutdown.
InvalidResponse(String)A CDP response didn't carry the expected field (e.g. Fetch.getResponseBody returned no body).Should not happen with stable Chrome; file a bug.

CloudflareError variants

Gated cloudflare. Sub-error returned wrapped in ZendriverError::Cloudflare.

VariantCommon causeFix
NoChallengeNo Turnstile iframe was detected at call time.Treat as success — the page was already cleared (cookie shortcut) or had no CF gate.
ClearanceTimeoutDeadline elapsed without resolution.The challenge may be silent / escalated; pair with StealthProfile::spoofed, or switch to a residential proxy.
Call(CallError)Underlying CDP call failed (typically the JS detection probe).Inspect inner error.
JsError(String)The detection / clearance JS raised an exception.The page may be CSP-strict; ensure stealth bypass_csp(true) (the default for spoofed).

FetcherError variants

Gated fetcher. Sub-error returned wrapped in ZendriverError::Fetcher.

VariantCommon causeFix
Http(reqwest::Error)Network call to the CFT manifest / CDN failed.Check connectivity; CFT URLs need outbound HTTPS to googlechromelabs.github.io and the CDN.
Io(io::Error)Local FS write failed (cache, extract).Check cache-dir permissions / free space.
Manifest(serde_json::Error)Manifest JSON didn't parse.Should not happen with the canonical URL; means the CFT side changed format — file an issue.
VersionNotFound(version)VersionSpec::Explicit("...") string not present in manifest.Drop a version (CFT only keeps the last N); use VersionSpec::Latest or a known version from the manifest.
UnsupportedPlatformPlatform::auto_detect returned None, or the resolved manifest/channel has no download for the requested platform.Currently no fix for unsupported platforms (Linux arm64, BSDs); install Chrome out-of-band.
IntegrityFailed { expected, actual }SHA256 of the downloaded zip doesn't match the manifest.Delete the partial download under the cache dir; retry.
Extraction(String)Zip extraction failed.Free disk space; check for filesystem corruption.

Pattern-matching tips

  • Use matches! for boolean checks on a single variant:
    if matches!(err, ZendriverError::ElementNotFound { .. }) {
        // soft-fail path
    }
  • Use _ always to handle future variants gracefully — #[non_exhaustive] requires it.
  • Sub-errors flatten via #[from] — your ? operator works across the boundary (e.g. let body = paused.body().await?; returns InterceptionError but converts to ZendriverError::Interception inside a function returning zendriver::Result).