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, undetectable browser-automation library for Rust. 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 batteries out of the box.

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. Cloudflare Turnstile, PerimeterX, and DataDome challenges pass without manual headers.
  • 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.
  • Undetectable 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. Off-by-default for 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
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

PlatformSupportedNotes
Linux (x86_64)yesTested in CI on Ubuntu 22.04. Recommended for headless scraping.
Linux (aarch64)yesBuilds + passes; CI coverage is x86_64-only.
macOS (x86_64)yesTested in CI on macOS 14.
macOS (Apple Si)yesBuilds + passes locally; CI coverage is x86_64-only.
WindowsyesTested in CI on windows-latest. 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).
  • .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.

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.

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 (returns "Google Inc. (Intel)" / "ANGLE (Intel, Mesa Intel(R) UHD Graphics, OpenGL 4.6)" by default).
  • 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 + --lang flag
    .timezone("America/New_York");

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

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.

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.

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.

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.

The realism comes from the active StealthProfile's InputProfile:

  • StealthProfile::native() and ::spoofed() install 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::off() installs a no-op profile — even realistic methods just do the dispatch without 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.

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.

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) — named non-character key.

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 full non-character keyboard.

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 you on the "indistinguishable from a real user" 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 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.

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"] }

Four entry points on Tab:

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

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 and expect_download 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.

Timeout semantics

All four 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.

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.

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

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 `cf-turnstile-response` token, the Turnstile challenge
//!      iframe's bounding box (shadow-DOM aware), and any challenge marker
//!      on the page. When the interactive iframe is present and we have
//!      not yet clicked it, the driver dispatches a single raw left-click
//!      at the canonical (15% x, 50% y) offset (Turnstile checkbox).
//!      Resolves the first tick a token is observed or the iframe is gone
//!      after a click.
//!   4. Print the [`ClearanceOutcome`] enum variant + the page title.
//!
//! 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 interactive iframe was clicked and the
//!     challenge container then disappeared without a token (e.g.
//!     clearance-cookie shortcut).
//!   - `Err(NoChallenge)` — 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.
//!   - `Err(ClearanceTimeout)` — 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, CloudflareError};

#[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(outcome) => println!("cleared: {outcome:?}"),
        Err(CloudflareError::NoChallenge) => {
            println!("no challenge detected (page already cleared or no CF gate present)");
        }
        Err(CloudflareError::ClearanceTimeout) => {
            println!("clearance timed out within 30s");
        }
        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:

  • 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 container disappeared without yielding a token, typically because Cloudflare honored a clearance cookie and short-circuited the gate.

Errors are typed via CloudflareError:

  • NoChallenge — no Turnstile iframe was detected at call time. Often means the page already cleared you (an existing cookie) or there's no CF gate present. Treat as a no-op, not a failure.
  • ClearanceTimeout — the deadline elapsed without resolution. Usually means Cloudflare escalated to the deeper anti-bot path that this driver doesn't handle.

How it works

The driver runs four stages internally:

  1. Detect. A shadow-DOM-aware walk of the page's main world looks for the Turnstile iframe (<iframe> whose src matches Cloudflare's Turnstile widget origin). It surfaces the bounding box.
  2. Click. A raw mousedown / mouseup is dispatched at offset (bbox.x + bbox.width * 0.15, bbox.y + bbox.height * 0.5) — the canonical 15%-from-left, 50%-from-top position of the Turnstile checkbox inside the iframe. No Bezier-path motion; Cloudflare wants a real click on a real checkbox.
  3. Poll. Every 500 ms (override via poll_interval), the driver checks both cf-turnstile-response (for a non-empty token) and the challenge container (for removal from the DOM).
  4. Return. First condition to fire wins; deadline elapsed → ClearanceTimeout.

Limitations

This driver only handles the visible interactive Turnstile checkbox. It does not solve:

  • Silent / invisible Turnstile (no UI element to click — relies on passive fingerprinting). For those, stealth alone is your only defense; 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
{
    Ok(_) => { /* cleared */ }
    Err(CloudflareError::NoChallenge) => { /* already cleared, fine */ }
    Err(e) => return Err(e.into()),
}

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

NoChallenge is informational, not an error — code should treat it as success. The other variants of CloudflareError should propagate.

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.
  • 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.

Fetcher

The fetcher Cargo feature downloads a Chrome binary from Google's Chrome for Testing (CFT) distribution 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.

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. On a cache hit the call returns in milliseconds and skips the network.

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. On a cache hit (binary already
//! extracted under `<cache>/<version>/`), it skips the network entirely
//! and returns the cached path.
//!
//! 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) — only Stable is fully wired in 0.1; Beta / Dev / Canary return FetcherError::UnsupportedPlatform (the CFT endpoint is separate).
    • 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.

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)

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

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 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.

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-populate the cache out-of-band 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.

MCP server (zendriver-mcp)

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

Install

cargo install zendriver-mcp

The default build enables all gated features (interception, expect, cloudflare, fetcher). 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

49 tools across these categories:

CategoryToolsCount
Lifecyclebrowser_open / _close / _status3
Navigationbrowser_goto / _back / _forward / _reload / _wait_for_idle5
Tabsbrowser_tab_list / _new / _switch / _close / _activate5
Findbrowser_find / _find_all2
Actionsbrowser_click / _hover / _type / _press / _set_value / _clear / _focus / _scroll_into_view / _upload9
Readsbrowser_element_state1
Snapshotsbrowser_html / _screenshot2
Evalbrowser_evaluate / _evaluate_main2
Cookiesbrowser_cookies_get / _set / _delete / _clear / _persist5
Storagebrowser_storage_get / _set / _delete / _clear4
Framesbrowser_frame_list1
Stealthbrowser_set_stealth_profile1
Interception (gated)browser_intercept_add_rule / _remove_rule / _list_rules / _clear_rules4
Expect (gated)browser_expect_register / _await / _cancel3
Cloudflare (gated)browser_solve_turnstile1
Fetcher (gated)browser_install_chrome1

All find / action tools share a Selector arg — one-of css | xpath | text | text_exact | text_regex | role, with modifiers nth / visible_only / timeout_ms / frame_id. State-changing tools accept return_snapshot: bool for one-call action + observe.

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.

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.

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.

No global Browser / BrowserContext split

Playwright separates Browser (the process) from BrowserContext (an isolated cookie/storage scope). zendriver-rs has only Browser; every Tab shares the browser-scope cookie jar and is the equivalent of one Playwright page inside the default context.

If you need multi-context isolation, launch a second Browser. The overhead is one extra Chrome subprocess — heavier than a context, but the isolation is total (separate user-data-dir, separate cookies, separate process). Most testing flows can avoid it.

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

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

  • Canvas / WebGL / font / audio fingerprint spoofing. Python's stealth layer randomizes these per-launch via JS bootstrap injection; the Rust port only ships the protocol-level patches (UA scrub, webdriver removal, hardware overrides) plus the optional spoofed() profile that patches the Navigator prototype. Active canvas-noise injection is on the post-v0.1 roadmap.
  • browserforge integration. Python's optional dep for pre-canned realistic fingerprints isn't ported. Build a StealthProfile from explicit UserAgentMetadata fields instead.
  • 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 the canonical 15%-from-left / 50%-from-top click at the iframe offset, and polls the cf-turnstile-response input for a non-empty value. 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

Things nodriver supports that zendriver-rs doesn't yet:

  • Canvas / WebGL / audio / font fingerprint spoofing. nodriver's bootstrap injects JS randomizers for each of these per launch. The Rust port currently ships protocol-level stealth (UA scrub, webdriver flag, hardware overrides) plus the spoofed() profile that patches the Navigator prototype — but not canvas-noise injection. On the post-v0.1 roadmap.
  • browserforge fingerprint generation. nodriver pairs with the browserforge library for pre-canned realistic fingerprints. Not ported; build a StealthProfile from explicit UserAgentMetadata fields by hand.
  • 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. Cheap, undetectable via Function.prototype.toString drift. 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.

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.
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().
Timeout(Duration)Generic operation timeout.Increase the timeout via the builder, or address the underlying slow operation.
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.
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)Probe of chrome --version failed.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 a non-Stable channel was requested.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).