Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Interception

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

Enable it in Cargo.toml:

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

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

Rule-based API

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

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

Blocking ads

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

use zendriver::Browser;

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

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

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

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

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

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

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

Modifying headers

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

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

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

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

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

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

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

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

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

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

Redirect and synthesize

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

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

Stream API

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

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

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

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

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

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

Pattern + stage filters

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

use zendriver::ResourceType;

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

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

Gotchas

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

See also

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