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.