Network monitor & HTTP
Two complementary network features sit on the CDP Network domain:
tab.monitor()(featuremonitor) — a long-livedStream<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:
| Variant | Emitted when | Payload |
|---|---|---|
Http(NetworkExchange) | a request reaches loadingFinished / loadingFailed | request + optional response + optional error |
WebSocketOpen | Network.webSocketCreated | request_id, url |
WebSocketFrame | a frame is sent / received | direction, opcode, inline payload |
WebSocketClose | Network.webSocketClosed | request_id |
EventSourceMessage | an SSE message arrives | event_name, event_id, inline data |
HTTP exchanges are completed: the monitor correlates
requestWillBeSent → responseReceived → loadingFinished 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(()) }