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
spoofedstealth profile patchesnavigator.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.
- Clone
- Drop-in replacement for
chromiumoxidecallers who want stealth, multi-tab, and an ergonomicfind().css("...").one()query surface instead of hand-rollingPage.querySelectorcalls.
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::nativeis the suggested starting point — UA scrub plus Emulation overrides, no JS bootstrap, no prototype patching.StealthProfile::spoofedadds Navigator-prototype patches that pass sannysoft + areyouheadless. Off-by-default forStealthProfile::offwhen you want a vanilla browser for reproduction. - Async-first. Built on Tokio. Every call returns a
Future. No blocking, noblock_on, notokio::task::spawn_blocking. Browser / Tab / Element handles areClone + Send + Syncso they cross.awaitboundaries and task spawns without ceremony. - Rust-native. Errors are typed via
thiserrorand surfaced throughResult. Selectors are checked at call time, not parsed at startup. Resources clean up viaDrop(Chrome subprocess getsSIGTERMwhen the lastBrowserclone drops). The borrow checker tracks query scopes for you.
Comparison
| feature | zendriver-rs | chromiumoxide | thirtyfour | fantoccini |
|---|---|---|---|---|
| Transport | CDP-direct | CDP-direct | WebDriver | WebDriver |
| Stealth out of the box | yes | no | no | no |
| Builder-style queries | yes | partial | no | no |
| Cross-origin iframes | yes | partial | yes | yes |
| Send+Sync handles | yes | yes | yes | yes |
| Async runtime | Tokio | async-std/Tokio | Tokio | Tokio |
| Network interception | yes | yes | limited | limited |
| Multi-tab orchestration | yes | manual | manual | manual |
Comparisons against Playwright + Selenium are covered in Migration from Playwright.
How this book is organized
- Setup chapters — Install and Quickstart cover the minimum needed to write your first script.
- Core API chapters — Stealth, Multi-tab, Frames, and Input cover the always-on Browser/Tab/Element surface.
- Optional-feature chapters — Interception, Expect(), Cloudflare, and Fetcher cover the gated Cargo features.
- Reference chapters — Architecture, Migration from Playwright, FAQ, and Error Reference round out the long tail.
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
| Feature | Pulls in | Use case | Extra deps |
|---|---|---|---|
| (default) | zendriver-transport, -stealth | Navigation, queries, input, cookies, storage, screenshots, multi-tab | none |
interception | zendriver-interception | Block/modify/serve requests via the Fetch CDP domain | none |
expect | (in-tree module) | Playwright-style expect_request / expect_response / expect_dialog | none |
cloudflare | zendriver-cloudflare, interception | Auto-solve Cloudflare Turnstile challenges | none |
fetcher | zendriver-fetcher | Download Chrome for Testing on-demand via the official JSON API | reqwest, 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
| Platform | Supported | Notes |
|---|---|---|
| Linux (x86_64) | yes | Tested in CI on Ubuntu 22.04. Recommended for headless scraping. |
| Linux (aarch64) | yes | Builds + passes; CI coverage is x86_64-only. |
| macOS (x86_64) | yes | Tested in CI on macOS 14. |
| macOS (Apple Si) | yes | Builds + passes locally; CI coverage is x86_64-only. |
| Windows | yes | Tested 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$PATHlookup 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()— skipdisplay: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-tab —
Browser::new_tab/Browser::new_tab_at, tab iteration, activate. - Frames — querying inside cross-origin iframes, the
FindBuilder::in_framemodifier.
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
| Profile | Launch flags | UA scrub | Emulation overrides | JS bootstrap | Bypass CSP | Use case |
|---|---|---|---|---|---|---|
off() | none | no | no | none | no | Reproducing issues in vanilla Chrome. |
native() | yes | yes | yes | none | no | Most sites. Default recommendation. |
spoofed() | yes | yes | yes | Navigator JS | yes (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
HeadlessChromesegment from the User-Agent string and the Sec-CH-UA brand list. - Emulation overrides —
Emulation.setUserAgentOverride/Emulation.setHardwareConcurrencyOverride/Emulation.setDeviceMetricsOverrideset 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 navigatorisfalse).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 thecloudflareCargo feature when you specifically need Turnstile bypass — see Cloudflare. - Sites with strict CSP —
spoofed()defaults tobypass_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.toStringlooking for[native code]mismatches —native()rather thanspoofed(), becausespoofed()'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:
- Page/DOM/Runtime/Network CDP domains enabled.
- Stealth bootstrap re-applied via the auto-attach observer chain.
- 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(()) } }
| Method | Cursor path | Gate | Use case |
|---|---|---|---|
click() | Bezier | actionability | Default. Indistinguishable. |
click_fast() | teleport | skipped | Tests; trusted automation. |
hover() | Bezier | actionability | Default. Real cursor approach. |
hover_fast() | teleport | skipped | Tests; trusted automation. |
type_text(s) | per-char + delays | focus gate | Default. Sub-keystroke timing. |
type_text_fast(s) | per-char, no delay | focus gate | Tests; 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:
| Field | Type | Default | Meaning |
|---|---|---|---|
button | MouseButton | MouseButton::Left | Which button to dispatch. |
modifiers | KeyModifiers | KeyModifiers::empty() | Modifier bits held during dispatch. |
click_count | u32 | 1 | clickCount for the dispatch (2 = double-click). |
force | bool | false | Skip the actionability gate. Mirrors Playwright. |
realistic | bool | true | Bezier path vs teleport. |
position | Option<(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 theInputController— useful when you've explicitly tracked modifier-held state (e.g. via held-key sequences).Element::press_with(key, mods)passesmodsstraight 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).
| Method | Action | Use case |
|---|---|---|
block | Fetch.failRequest with BlockedByClient | Ad / tracker blocking |
redirect | Fetch.continueRequest with new URL | Move an endpoint without page changes |
respond | Fetch.fulfillRequest with synthetic body | Mock an API for tests |
modify_request | Fetch.continueRequest with overrides | Inject 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.enableserializes 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-opcontinue_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 hasFetch.enableand the new one can't re-enable on the same target. - CSP-restricted pages.
Fetch.fulfillRequestsynthesizes responses that may violate a page'sContent-Security-Policy(especially for scripts). Combine withStealthProfile::spoofed()which setsbypass_csp = trueif you're synthesizing scripts. - HTTPS responses can't be re-signed.
respondandmodify_requestwork for the wire payload Chrome sees, not for upstream-TLS-signed artifacts.
See also
InterceptBuilderrustdoc for every modifier method.Expect()for the orthogonal "wait for this network event" surface —expect_requestobserves without holding traffic;interceptactively 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:
| Method | CDP event | Returns |
|---|---|---|
expect_request | Network.requestWillBeSent | RequestExpectation → MatchedRequest |
expect_response | Network.responseReceived | ResponseExpectation → MatchedResponse |
expect_dialog | Page.javascriptDialogOpened | DialogExpectation → MatchedDialog |
expect_download | Page.downloadWillBegin + progress | DownloadExpectation → MatchedDownload |
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 viais_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:
- The expectation is constructed before the click. Reversing those two lines reintroduces the race.
- 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. expectation.await?resolves on the first match. If you need to collect every matching request over a window, use aStream(build one via repeatedexpect_requestcalls or fall back to the interception API'ssubscribe).
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— automatealert/confirmflows; 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)— thecf-turnstile-responseinput 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:
- Detect. A shadow-DOM-aware walk of the page's main world looks
for the Turnstile iframe (
<iframe>whosesrcmatches Cloudflare's Turnstile widget origin). It surfaces the bounding box. - Click. A raw
mousedown/mouseupis 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. - Poll. Every 500 ms (override via
poll_interval), the driver checks bothcf-turnstile-response(for a non-empty token) and the challenge container (for removal from the DOM). - 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_clearancetimeout (30-60 s) for the first challenge; subsequent navigations on the sameuser_data_dirare usually cookie-shortcut clears and resolve in <1 s viaChallengeGone.
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 point | When to use |
|---|---|
BrowserBuilder::ensure_chrome | Common 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 forLatesttoday; will diverge if / when CFT exposes a stable-channel JSON.VersionSpec::Channel(Channel::Stable)— onlyStableis fully wired in 0.1;Beta/Dev/CanaryreturnFetcherError::UnsupportedPlatform(the CFT endpoint is separate).VersionSpec::Explicit("126.0.6478.182".into())— exact version string from the manifest.
.platform(Platform)— overridePlatform::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 aFetcherProgresssnapshot 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:
phase— one ofResolving/Downloading/Extracting/Verifying/Done.downloaded/total: Option<u64>— bytes for the current phase, withtotalpopulated duringDownloadingfrom theContent-Lengthheader.
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:
- Reproducibility. Pin
VersionSpec::Explicit(...)so the same Chrome runs everywhere. No surprises when the runner image bumps. - Smaller base images. Don't bake Chrome into a hot container image if only a fraction of jobs need it.
- 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.ioor 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-arm64build today;Platform::auto_detectreturnsNoneon that host andensure_chromeerrors 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:
| Category | Tools | Count |
|---|---|---|
| Lifecycle | browser_open / _close / _status | 3 |
| Navigation | browser_goto / _back / _forward / _reload / _wait_for_idle | 5 |
| Tabs | browser_tab_list / _new / _switch / _close / _activate | 5 |
| Find | browser_find / _find_all | 2 |
| Actions | browser_click / _hover / _type / _press / _set_value / _clear / _focus / _scroll_into_view / _upload | 9 |
| Reads | browser_element_state | 1 |
| Snapshots | browser_html / _screenshot | 2 |
| Eval | browser_evaluate / _evaluate_main | 2 |
| Cookies | browser_cookies_get / _set / _delete / _clear / _persist | 5 |
| Storage | browser_storage_get / _set / _delete / _clear | 4 |
| Frames | browser_frame_list | 1 |
| Stealth | browser_set_stealth_profile | 1 |
| Interception (gated) | browser_intercept_add_rule / _remove_rule / _list_rules / _clear_rules | 4 |
| Expect (gated) | browser_expect_register / _await / _cancel | 3 |
| Cloudflare (gated) | browser_solve_turnstile | 1 |
| Fetcher (gated) | browser_install_chrome | 1 |
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 debugfor verbose CDP-call logging. - Errors include
_meta.suggested_nexthints when applicable (e.g.ElementNotFoundsuggests reconnaissance viabrowser_htmlor a freshbrowser_find_allsnapshot). - HTTP smoke test binds
127.0.0.1:18765by convention — if your environment has that port taken, set a different port via--http. - Real-Chrome integration tests are gated behind cargo feature
integration-testsand#[ignore]markers: run viacargo 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
| Operation | Playwright | zendriver-rs |
|---|---|---|
| Launch browser | await chromium.launch() | Browser::builder().launch().await? |
| Launch headed | chromium.launch({ headless: false }) | Browser::builder().headless(false).launch().await? |
| Open a tab / page | await context.newPage() | browser.new_tab().await? |
| Reuse first tab | (await context.pages())[0] | browser.main_tab() |
| Navigate | await page.goto(url) | tab.goto(url).await? |
| Wait for load | implicit on goto | tab.wait_for_load().await? |
| Wait for network idle | await 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 text | page.getByText("Submit") | tab.find().text("Submit").one().await? |
| Find by ARIA role | page.getByRole("button", { name: "Go" }) | tab.find().role(AriaRole::Button).name("Go").one().await? |
| Find by XPath | page.locator("xpath=//button") | tab.find().xpath("//button").one().await? |
| Find all | await page.locator("li").all() | tab.find().css("li").many().await? |
| Get nth match | page.locator("li").nth(2) | tab.find().css("li").nth(2).one().await? |
| Click | await locator.click() | el.click().await? |
| Type text | await locator.fill("hello") | el.set_value("hello").await? (instant) |
| Type with key events | await locator.pressSequentially("hi") | el.type_text("hi").await? |
| Press key | await locator.press("Enter") | el.press(Key::Special(SpecialKey::Enter)).await? |
| Read text | await locator.innerText() | el.inner_text().await? |
| Read attribute | await locator.getAttribute("href") | el.attr("href").await? |
| Check visibility | await 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 response | await page.waitForResponse("**/api/*") | tab.expect_response("/api/").await? |
| Wait for request | await page.waitForRequest("**/auth") | tab.expect_request("/auth").await? |
| Wait for download | page.waitForEvent("download") | tab.expect_download().await?.await? |
| Handle dialog | page.on("dialog", d => d.accept()) | let d = tab.expect_dialog(); ...; d.await?.accept(None).await? |
| Intercept / block | route.abort() in page.route | tab.intercept().block("*/ads/*")?.start() |
| Modify request | route.continue({headers}) | tab.intercept().modify_request("...", |req| {...})?.start() |
| Screenshot | await page.screenshot() | tab.screenshot().await? |
| Cookies (get all) | await context.cookies() | browser.cookies().all().await? |
| LocalStorage | await page.evaluate("...") (no helper) | tab.local_storage().get("k").await? |
| Close | await 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:
| Terminal | Semantic |
|---|---|
.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) — usecargo testplus the zendriver-rs surface; theexpectfeature covers the pre-register pattern that powers Playwright'sexpect(locator)assertions. - Codegen (
playwright codegen) — not implemented. - Mobile emulation (
devices) — set the User-Agent + viewport manually viaStealthProfile.
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.
| Operation | Python zendriver | zendriver-rs |
|---|---|---|
| Launch browser | browser = await zd.start() | let browser = Browser::builder().launch().await?; |
| Launch headless | await zd.start(headless=True) | Browser::builder().headless(true).launch().await? |
| No-sandbox | await zd.start(sandbox=False) | Browser::builder().arg("--no-sandbox").launch().await? |
| Persistent profile | await 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 tab | tab = await browser.get(url, new_tab=True) | let tab = browser.new_tab_at(url).await?; |
| Find by text | await 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 XPath | await tab.xpath("//button") | tab.find().xpath("//button").one().await? |
| Click | await element.click() | element.click().await? |
| Type text | await element.send_keys("hi") | element.type_text("hi").await? |
| Read text | element.text | element.inner_text().await? |
| Read attribute | element.attrs["href"] | element.attr("href").await? |
| Run JS | await tab.evaluate("document.title") | tab.evaluate_main::<String>("document.title").await? |
| List tabs | browser.tabs | browser.tabs().await |
| Wait for response | await tab.expect_request(url) | tab.expect_request(url).await? (feature expect) |
| Block requests | tab.add_handler(zd.cdp.fetch.RequestPaused, h) | tab.intercept().block("...")?.start() (feature interception) |
| Solve Cloudflare | await tab.verify_cf() | tab.cloudflare().wait_for_clearance(d).await? (feature cloudflare) |
| Get cookies | await browser.cookies.get_all() | browser.cookies().all().await? |
| Set cookie | await browser.cookies.set_all([...]) | browser.cookies().set_many(vec![...]).await? |
| Screenshot | await tab.save_screenshot(path) | let png = tab.screenshot().await?; std::fs::write(path, png)?; |
| Close | await 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.NoSuchElementError — ElementNotFound
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:
- Observation →
expect_request/expect_response/expect_dialog/expect_downloadfrom theexpectfeature. Pre-register before triggering the action; await the returned handle after. The subscriber is live from the momentexpect_*returns, so no race with fast responses. - Modification →
Tab::interceptfrom theinterceptionfeature. A declarative rule builder (block/redirect/respond/modify_request) or asubscribe()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:
| Terminal | Semantic |
|---|---|
.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 capability | Rust Cargo feature | What it gates |
|---|---|---|
tab.add_handler(zd.cdp.fetch.RequestPaused, ...) | interception | Tab::intercept(), the Fetch.*-based rule builder, and the subscribe() stream. |
tab.expect_request(...), dialogs, downloads | expect | All four expect_* methods on Tab. |
tab.verify_cf() | cloudflare | Tab::cloudflare() and the CloudflareBypass driver. Pulls in interception. |
| Chrome auto-download | fetcher | zendriver_fetcher re-exports; downloads Chrome for Testing on demand. |
| Stealth | always on | zendriver-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,
webdriverremoval, hardware overrides) plus the optionalspoofed()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
StealthProfilefrom explicitUserAgentMetadatafields instead. - OCR helpers. Python's bundled OCR wrappers (
tesseract/easyocr) for text-in-image extraction aren't ported. Pair Rust'stab.screenshot()with thetesseract-rsorleptesscrate. - 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 frombrowser.get. In Rust usebrowser.main_tab(); tab.goto(url).await?(orbrowser.new_tab_at(url).await?for the equivalent ofnew_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, usetext_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.
| Operation | Python nodriver | zendriver-rs |
|---|---|---|
| Launch browser | browser = await nd.start() | let browser = Browser::builder().launch().await?; |
| Launch headless | await nd.start(headless=True) | Browser::builder().headless(true).launch().await? |
| No-sandbox | await 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 tab | tab = await browser.get(url, new_tab=True) | let tab = browser.new_tab_at(url).await?; |
| Find by text | await 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? |
| Click | await element.click() | element.click().await? |
| Type text | await element.send_keys("hi") | element.type_text("hi").await? |
| Read text | element.text | element.inner_text().await? |
| Read attribute | element.attrs["href"] | element.attr("href").await? |
| Eval JS | await tab.evaluate("document.title") | tab.evaluate_main::<String>("document.title").await? |
| Eval JS, await promise | tab.evaluate("p()", await_promise=True) | tab.evaluate_main::<T>("await p()").await? |
| Iterate tabs | browser.tabs | browser.tabs().await |
| Cookies | await browser.cookies.get_all() | browser.cookies().all().await? |
| Screenshot | await tab.save_screenshot(path) | let png = tab.screenshot().await?; std::fs::write(path, png)?; |
| Solve Cloudflare | await tab.verify_cf() | tab.cloudflare().wait_for_clearance(d).await? (feature cloudflare) |
| Close | await 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::cloudflare → CloudflareBypass::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 afind_all.
Rust has no equivalent to these — every operation is a named method call. The translations:
| Python idiom | Rust replacement |
|---|---|
await tab | tab.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 capability | Rust Cargo feature | What it gates |
|---|---|---|
tab.add_handler(nd.cdp.fetch.RequestPaused, ...) rewriting | interception | Tab::intercept() plus the rule builder (block / redirect / respond / modify_request) and the subscribe() stream. |
await tab.expect_request(...) (where supported) | expect | The expect_request / expect_response / expect_dialog / expect_download methods on Tab. |
await tab.verify_cf() | cloudflare | Tab::cloudflare() plus the CloudflareBypass driver. Pulls in interception. |
Chrome auto-download (separate nodriver extras) | fetcher | zendriver_fetcher for downloading Chrome for Testing binaries on demand. |
| Stealth | always on | Profiles 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,
webdriverflag, hardware overrides) plus thespoofed()profile that patches the Navigator prototype — but not canvas-noise injection. On the post-v0.1 roadmap. - browserforge fingerprint generation. nodriver pairs with the
browserforgelibrary for pre-canned realistic fingerprints. Not ported; build aStealthProfilefrom explicitUserAgentMetadatafields by hand. - OCR helpers. nodriver bundles
easyocr/tesseractwrappers for text-in-image extraction. Not ported; pairtab.screenshot()with thetesseract-rsorleptesscrate. - 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__ontabandflow_to_finish. nodriver overloadsawait tabfor "wait for the page to be ready". Rust call sites are always explicit:tab.wait_for_load().await?ortab.wait_for_idle().await?.__getitem__on element collections. Notab[2]shortcut — calltab.find().css(...).nth(2).one().await?or capture aVec<Element>from.many()and index it via[2].Element.childrenwalks. nodriver exposes parent / child / sibling traversal on the element handle. zendriver-rs has limited traversal (seeElement::childrenand friends in the docs); deeper DOM walks may need a JSevaluatecall returning the structured shape you need.tab.send_dom_event— direct DOM event synthesis isn't a first-class helper. Usetab.evaluate_mainwith 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
Framesemantics and the OOPIF auto-attach behavior in detail. - Cloudflare — full
CloudflareBypassdocumentation 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:
- Reads commands from the channel, attaches a monotonically-increasing
id, and writes them to the socket. - Reads frames from the socket, decodes them into
CdpInbound(either a response to a command or an event), and routes them. - For responses: looks up the pending
oneshot::Senderin aHashMap<id, oneshot::Sender<Result<Value>>>and resolves it. - For events: fans them out via a
tokio::sync::broadcastso every subscriber gets a copy without blocking the actor loop. - For
Target.attachedToTarget: invokes each registeredTargetObserver(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 subscribers —
Tabclones 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 observers —
TargetObserverruns onTarget.attachedToTargetbefore the new target's debugger pause is released. Stealth depends on this: the auto-attach observer dispatchesPage.addScriptToEvaluateOnNewDocumentwhile 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.toStringdrift,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
chromedriverprocess. 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:chromedriverinjects 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::selectsurface 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
| Crate | Purpose | Public? |
|---|---|---|
zendriver | High-level Browser/Tab/Element + traits | yes |
zendriver-transport | Actor + WebSocket + observers | yes, but SEMVER looser |
zendriver-stealth | Fingerprint composition + bootstrap JS | yes |
zendriver-interception | Fetch.* actor + rule + stream API | yes, gated interception |
zendriver-cloudflare | Turnstile bypass | yes, gated cloudflare |
zendriver-fetcher | Chrome-for-Testing downloader | yes, 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
zendriver-transportrustdoc — wire types, observer trait, theMockConnectiontest harness.- Stealth — what the bootstrap JS actually patches.
- Interception — how the
Fetch.*actor sits on top of the same transport.
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. Usetab.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:
- Run your target site headed with
StealthProfile::off()first, thennative(), thenspoofed(). Compare behavior — if a feature works off but not native, the issue is in your stealth setup, not the anti-bot. - Hit bot.sannysoft.com and arh.antoinevastel.com to see what generic detectors find.
- For Cloudflare specifically, check whether the gate is the visible
Turnstile checkbox (
cloudflarefeature can pass it) or the silent challenge (which requires better stealth, not bypass tooling). - 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 viaFunction.prototype.toStringdrift. Passes most consumer sites.StealthProfile::spoofed()—native()plus Navigator-prototype JS patches injected viaPage.addScriptToEvaluateOnNewDocument. Restoresnavigator.webdriverto undefined, fixesnavigator.plugins/chromeruntime / WebGL vendor, etc. Required to passsannysoftand 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 —closewaits for the subprocess to exit and surfaces any failure via theResult.Dropis a fallback, not the primary path. - Run zendriver-rs inside
tokio::select!with actrl_carm 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_responsefor individual events, or stash atab.intercept().subscribe()stream that auto-continue_()s and logs eachPausedRequest. - 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
| Variant | Common cause | Fix |
|---|---|---|
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 message — Invalid 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. |
ElementStale | An element's CDP handle invalidated and the auto-refresh path failed. | Re-issue the original query manually. |
NotRefreshable | An 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.
| Variant | Common cause | Fix |
|---|---|---|
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. |
WsTimeout | Chrome printed nothing within the WS-endpoint wait window. | Try headed mode (headless(false)) to see Chrome's window — usually reveals a missing dependency. |
DevtoolsParse | Stderr 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.
| Variant | Common cause | Fix |
|---|---|---|
Disconnected | Chrome 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. |
Shutdown | Actor 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.
| Variant | Common cause | Fix |
|---|---|---|
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.
| Variant | Common cause | Fix |
|---|---|---|
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. |
AlreadyStarted | start() called twice on the same builder. | Builders are one-shot; create a new one if you need another actor. |
NotStarted | Operation requires an active actor that hasn't started yet. | Call start() first. |
SubscriptionClosed | The 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.
| Variant | Common cause | Fix |
|---|---|---|
NoChallenge | No Turnstile iframe was detected at call time. | Treat as success — the page was already cleared (cookie shortcut) or had no CF gate. |
ClearanceTimeout | Deadline 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.
| Variant | Common cause | Fix |
|---|---|---|
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. |
UnsupportedPlatform | Platform::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?;returnsInterceptionErrorbut converts toZendriverError::Interceptioninside a function returningzendriver::Result).