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

Network monitor & HTTP

Two complementary network features sit on the CDP Network domain:

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

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

Network monitor

Enable the feature:

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

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

use futures::StreamExt;

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

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

Event model

NetworkEvent is a tagged enum:

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

HTTP exchanges are completed: the monitor correlates requestWillBeSentresponseReceivedloadingFinished by requestId and emits one NetworkEvent::Http per request. WebSocket and EventSource payloads are delivered inline (they arrive whole in the CDP event); only HTTP bodies are lazy.

Lazy bodies

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

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

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

URL filtering

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

Lifecycle

NetworkMonitor owns the correlator task. Dropping the monitor — or calling NetworkMonitor::stop — cancels that task; there is no leaked subscriber. The correlation map is bounded (10k in-flight requests); a pathological page that opens requests it never finishes evicts old entries with a tracing warning rather than growing without limit.

Full example

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

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

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

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

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

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

    // Drain events until the channel is empty (monitor still running but
    // nothing new is in flight). In a real application you would drive the
    // stream until a specific event arrives or a timeout fires.
    while let Ok(Some(event)) =
        tokio::time::timeout(std::time::Duration::from_millis(500), monitor.next()).await
    {
        match event {
            NetworkEvent::Http(exchange) => {
                let status = exchange.response.as_ref().map_or_else(
                    || exchange.error.clone().unwrap_or_default(),
                    |r| r.status.to_string(),
                );
                println!(
                    "[HTTP] {} {} -> {}",
                    exchange.request.method, exchange.request.url, status
                );
            }
            NetworkEvent::WebSocketOpen { url, request_id } => {
                println!("[WS  ] open  id={request_id} url={url}");
            }
            NetworkEvent::WebSocketFrame {
                request_id,
                direction,
                opcode,
                payload,
            } => {
                let dir = match direction {
                    FrameDirection::Sent => "sent",
                    FrameDirection::Received => "recv",
                };
                println!("[WS  ] frame {dir} id={request_id} opcode={opcode} payload={payload:?}");
            }
            NetworkEvent::WebSocketClose { request_id } => {
                println!("[WS  ] close id={request_id}");
            }
            NetworkEvent::EventSourceMessage {
                request_id,
                event_name,
                data,
                ..
            } => {
                println!("[SSE ] id={request_id} event={event_name:?} data={data:?}");
            }
        }
    }

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

Browser-context HTTP

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

use serde_json::json;

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

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

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

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

Default path: in-page fetch

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

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

Opt-in: bypass_cors()

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

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

Full example

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

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

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

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

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

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

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

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

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

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