Skip to main content

playwright_cdp/
network.rs

1//! Network capture: stores HTTP requests/responses observed via the CDP
2//! `Network` domain and drives `Page::on_request` / `on_response` handlers.
3//!
4//! Beyond the basic `Request` / `Response` wrappers, the store also keeps a
5//! side table of extra CDP-captured detail per `requestId` (post data, redirect
6//! chain, failure text, timing/sizes, security details, remote address, …)
7//! which the wrappers surface through their accessor methods.
8
9use crate::cdp::session::CdpSession;
10use crate::cdp::CdpEvent;
11use crate::request::Request;
12use crate::response::Response;
13use crate::types::Headers;
14use parking_lot::Mutex;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17use std::collections::HashMap;
18use std::future::Future;
19use std::pin::Pin;
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::{Arc, Weak};
22use tokio::sync::broadcast;
23
24pub(crate) type RequestHandler =
25    Arc<dyn Fn(Request) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
26pub(crate) type ResponseHandler =
27    Arc<dyn Fn(Response) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
28
29// ---------------------------------------------------------------------------
30// Detail value types. These live here (in the store) so both `Request` and
31// `Response` can reuse them without a cross-dependency.
32// ---------------------------------------------------------------------------
33
34/// Timing information for a request/response, derived from the CDP
35/// `Network.Response.timing` object. All times are Unix epoch milliseconds
36/// where applicable; durations are milliseconds.
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct Timing {
39    /// Request start time (ms since epoch).
40    pub request_time: Option<f64>,
41    /// Time spent in proxy handling (ms).
42    pub proxy_start: Option<f64>,
43    /// Time spent until proxy finished (ms, relative offset from `proxy_start`).
44    pub proxy_end: Option<f64>,
45    /// DNS resolution duration (ms).
46    pub dns_start: Option<f64>,
47    pub dns_end: Option<f64>,
48    /// Connection duration (ms).
49    pub connect_start: Option<f64>,
50    pub connect_end: Option<f64>,
51    /// TLS/SSL duration (ms).
52    pub ssl_start: Option<f64>,
53    pub ssl_end: Option<f64>,
54    /// Worker start (ms).
55    pub worker_start: Option<f64>,
56    pub worker_ready: Option<f64>,
57    pub worker_fetch_start: Option<f64>,
58    /// Time until response headers received (ms).
59    pub send_start: Option<f64>,
60    pub send_end: Option<f64>,
61    /// Time until the server started responding (ms).
62    pub receive_headers_end: Option<f64>,
63}
64
65impl Timing {
66    fn from_value(v: Option<&Value>) -> Option<Self> {
67        let t = v?;
68        Some(Timing {
69            request_time: t.get("requestTime").and_then(|v| v.as_f64()),
70            proxy_start: t.get("proxyStart").and_then(|v| v.as_f64()),
71            proxy_end: t.get("proxyEnd").and_then(|v| v.as_f64()),
72            dns_start: t.get("dnsStart").and_then(|v| v.as_f64()),
73            dns_end: t.get("dnsEnd").and_then(|v| v.as_f64()),
74            connect_start: t.get("connectStart").and_then(|v| v.as_f64()),
75            connect_end: t.get("connectEnd").and_then(|v| v.as_f64()),
76            ssl_start: t.get("sslStart").and_then(|v| v.as_f64()),
77            ssl_end: t.get("sslEnd").and_then(|v| v.as_f64()),
78            worker_start: t.get("workerStart").and_then(|v| v.as_f64()),
79            worker_ready: t.get("workerReady").and_then(|v| v.as_f64()),
80            worker_fetch_start: t.get("workerFetchStart").and_then(|v| v.as_f64()),
81            send_start: t.get("sendStart").and_then(|v| v.as_f64()),
82            send_end: t.get("sendEnd").and_then(|v| v.as_f64()),
83            receive_headers_end: t
84                .get("receiveHeadersEnd")
85                .and_then(|v| v.as_f64()),
86        })
87    }
88}
89
90/// Size breakdown for a transferred resource, derived from CDP
91/// `Network.loadingFinished` (`encodedDataLength`) and the response headers.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct Sizes {
94    /// Total bytes transferred on the wire (encoded, as reported by CDP
95    /// `Network.loadingFinished.encodedDataLength`).
96    pub encoded_data_length: Option<u64>,
97}
98
99/// TLS/SSL certificate details, derived from the CDP
100/// `Network.Response.securityDetails` object.
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct SecurityDetails {
103    /// TLS protocol name (e.g. "TLS 1.3").
104    pub protocol: Option<String>,
105    /// Certificate subject (CN).
106    pub subject_name: Option<String>,
107    /// Certificate issuer (CN).
108    pub issuer: Option<String>,
109    /// Certificate validity start (Unix epoch seconds).
110    pub valid_from: Option<f64>,
111    /// Certificate validity end (Unix epoch seconds).
112    pub valid_to: Option<f64>,
113}
114
115impl SecurityDetails {
116    fn from_value(v: Option<&Value>) -> Option<Self> {
117        let s = v?;
118        Some(SecurityDetails {
119            protocol: s.get("protocol").and_then(|v| v.as_str()).map(String::from),
120            subject_name: s
121                .get("subjectName")
122                .and_then(|v| v.as_str())
123                .map(String::from),
124            issuer: s.get("issuer").and_then(|v| v.as_str()).map(String::from),
125            valid_from: s.get("validFrom").and_then(|v| v.as_f64()),
126            valid_to: s.get("validTo").and_then(|v| v.as_f64()),
127        })
128    }
129}
130
131/// The remote server address a response came from.
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct ServerAddr {
134    /// The IP literal (e.g. "93.184.216.34").
135    pub ip_address: Option<String>,
136    /// The remote port.
137    pub port: Option<u16>,
138}
139
140impl ServerAddr {
141    fn from_value(v: Option<&Value>) -> Option<Self> {
142        let r = v?;
143        let ip_address = r.get("remoteIPAddress").and_then(|v| v.as_str()).map(String::from);
144        let port = r
145            .get("remotePort")
146            .and_then(|v| v.as_i64())
147            .map(|n| n as u16);
148        // Only report when at least the address was present.
149        if ip_address.is_none() && port.is_none() {
150            None
151        } else {
152            Some(ServerAddr { ip_address, port })
153        }
154    }
155}
156
157/// The full set of extra detail captured for a single `requestId`.
158#[derive(Debug, Clone, Default)]
159struct RequestDetail {
160    // --- request side ---
161    /// Raw request body (`Network.requestWillBeSent.postData`).
162    post_data: Option<String>,
163    /// CDP `requestId` of the request this one redirected from
164    /// (`Network.requestWillBeSent.redirectSource`), if any.
165    redirected_from: Option<String>,
166    /// CDP `requestId` of the request this one redirected to
167    /// (the inverse link, populated when a redirect is observed).
168    redirected_to: Option<String>,
169    /// Failure text (`Network.loadingFailed.errorText`), if it failed.
170    failure: Option<String>,
171    // --- response / loading side ---
172    /// Response timing (`Network.Response.timing`).
173    timing: Option<Timing>,
174    /// Wire size (`Network.loadingFinished.encodedDataLength`).
175    sizes: Option<Sizes>,
176    /// HTTP/protocol version (`Network.Response.protocol`), e.g. "http/1.1".
177    http_version: Option<String>,
178    /// Security details (`Network.Response.securityDetails`).
179    security_details: Option<SecurityDetails>,
180    /// Remote server address (`Network.Response.remoteIPAddress/Port`).
181    server_addr: Option<ServerAddr>,
182    /// Raw response headers (original case, from `Network.Response.headers`).
183    raw_response_headers: Option<Headers>,
184    /// Raw request headers (original case, from the request object).
185    raw_request_headers: Option<Headers>,
186    /// `true` once `Network.loadingFinished` was observed.
187    finished: bool,
188}
189
190/// Per-page store of observed requests/responses, keyed by CDP `requestId`.
191pub(crate) struct NetworkStore {
192    self_ref: Mutex<Weak<NetworkStore>>,
193    requests: Mutex<HashMap<String, Request>>,
194    responses: Mutex<HashMap<String, Response>>,
195    /// Extra captured detail per `requestId`.
196    details: Mutex<HashMap<String, RequestDetail>>,
197    /// Document-request `loaderId` → `requestId` (for correlating navigations).
198    loaders: Mutex<HashMap<String, String>>,
199    /// Count of requests sent but not yet finished (for `networkidle`).
200    inflight: AtomicU64,
201}
202
203impl NetworkStore {
204    pub(crate) fn new() -> Arc<Self> {
205        let s = Arc::new(Self {
206            self_ref: Mutex::new(Weak::new()),
207            requests: Mutex::new(HashMap::new()),
208            responses: Mutex::new(HashMap::new()),
209            details: Mutex::new(HashMap::new()),
210            loaders: Mutex::new(HashMap::new()),
211            inflight: AtomicU64::new(0),
212        });
213        *s.self_ref.lock() = Arc::downgrade(&s);
214        s
215    }
216
217    /// Current in-flight request count.
218    pub(crate) fn inflight(&self) -> u64 {
219        self.inflight.load(Ordering::Relaxed)
220    }
221
222    fn weak(&self) -> Weak<NetworkStore> {
223        self.self_ref.lock().clone()
224    }
225
226    pub(crate) fn get_request(&self, request_id: &str) -> Option<Request> {
227        self.requests.lock().get(request_id).cloned()
228    }
229
230    pub(crate) fn get_response(&self, request_id: &str) -> Option<Response> {
231        self.responses.lock().get(request_id).cloned()
232    }
233
234    // --- detail accessors used by Request / Response ---
235
236    pub(crate) fn post_data(&self, request_id: &str) -> Option<String> {
237        self.details
238            .lock()
239            .get(request_id)
240            .and_then(|d| d.post_data.clone())
241    }
242
243    pub(crate) fn redirect_source(&self, request_id: &str) -> Option<String> {
244        self.details
245            .lock()
246            .get(request_id)
247            .and_then(|d| d.redirected_from.clone())
248    }
249
250    pub(crate) fn redirect_target(&self, request_id: &str) -> Option<String> {
251        self.details
252            .lock()
253            .get(request_id)
254            .and_then(|d| d.redirected_to.clone())
255    }
256
257    pub(crate) fn failure(&self, request_id: &str) -> Option<String> {
258        self.details
259            .lock()
260            .get(request_id)
261            .and_then(|d| d.failure.clone())
262    }
263
264    pub(crate) fn timing(&self, request_id: &str) -> Option<Timing> {
265        self.details
266            .lock()
267            .get(request_id)
268            .and_then(|d| d.timing.clone())
269    }
270
271    pub(crate) fn sizes(&self, request_id: &str) -> Option<Sizes> {
272        self.details
273            .lock()
274            .get(request_id)
275            .and_then(|d| d.sizes.clone())
276    }
277
278    pub(crate) fn http_version(&self, request_id: &str) -> Option<String> {
279        self.details
280            .lock()
281            .get(request_id)
282            .and_then(|d| d.http_version.clone())
283    }
284
285    pub(crate) fn security_details(&self, request_id: &str) -> Option<SecurityDetails> {
286        self.details
287            .lock()
288            .get(request_id)
289            .and_then(|d| d.security_details.clone())
290    }
291
292    pub(crate) fn server_addr(&self, request_id: &str) -> Option<ServerAddr> {
293        self.details
294            .lock()
295            .get(request_id)
296            .and_then(|d| d.server_addr.clone())
297    }
298
299    pub(crate) fn raw_response_headers(&self, request_id: &str) -> Option<Headers> {
300        self.details
301            .lock()
302            .get(request_id)
303            .and_then(|d| d.raw_response_headers.clone())
304    }
305
306    pub(crate) fn raw_request_headers(&self, request_id: &str) -> Option<Headers> {
307        self.details
308            .lock()
309            .get(request_id)
310            .and_then(|d| d.raw_request_headers.clone())
311    }
312
313    pub(crate) fn finished(&self, request_id: &str) -> bool {
314        self.details
315            .lock()
316            .get(request_id)
317            .map(|d| d.finished)
318            .unwrap_or(false)
319    }
320
321    /// The document response for a navigation `loaderId` (for `goto`).
322    pub(crate) fn response_for_loader(&self, loader_id: &str) -> Option<Response> {
323        let req_id = self.loaders.lock().get(loader_id).cloned()?;
324        self.get_response(&req_id)
325    }
326
327    /// Handle `Network.requestWillBeSent`. Stores + returns the request.
328    pub(crate) fn on_request_will_be_sent(&self, params: &Value) -> Option<Request> {
329        let req = params.get("request")?;
330        let request_id = params.get("requestId").and_then(|v| v.as_str())?.to_string();
331        let url = req.get("url").and_then(|v| v.as_str())?.to_string();
332        let method = req
333            .get("method")
334            .and_then(|v| v.as_str())
335            .unwrap_or("GET")
336            .to_string();
337        let headers = headers_from_value(req.get("headers"));
338        let resource_type = params
339            .get("type")
340            .and_then(|v| v.as_str())
341            .unwrap_or("Other")
342            .to_string();
343        let is_navigation = resource_type == "Document";
344        let loader_id = params.get("loaderId").and_then(|v| v.as_str()).map(String::from);
345
346        let request = Request::new(
347            url,
348            method,
349            headers,
350            resource_type,
351            request_id.clone(),
352            is_navigation,
353            self.weak(),
354        );
355        if is_navigation {
356            if let Some(lid) = &loader_id {
357                self.loaders.lock().insert(lid.clone(), request_id.clone());
358            }
359        }
360        self.requests.lock().insert(request_id.clone(), request.clone());
361        self.inflight.fetch_add(1, Ordering::Relaxed);
362
363        // Capture extra request detail.
364        let post_data = req
365            .get("postData")
366            .and_then(|v| v.as_str())
367            .map(String::from);
368        let raw_request_headers = headers_raw_from_value(req.get("headers"));
369        // `redirectSource` carries the CDP requestId of the request that
370        // redirected into this one.
371        let redirected_from = params
372            .get("redirectSource")
373            .and_then(|v| v.as_str())
374            .map(String::from);
375
376        let mut details = self.details.lock();
377        let entry = details.entry(request_id.clone()).or_default();
378        entry.post_data = post_data;
379        entry.raw_request_headers = raw_request_headers;
380        if let Some(src) = &redirected_from {
381            entry.redirected_from = Some(src.clone());
382            // Back-link: the source redirected into this request.
383            let src_entry = details.entry(src.clone()).or_default();
384            src_entry.redirected_to = Some(request_id.clone());
385        }
386
387        Some(request)
388    }
389
390    /// Handle `Network.loadingFinished`: record encoded size + finished flag,
391    /// decrement inflight.
392    pub(crate) fn on_loading_finished(&self, params: &Value) {
393        let request_id = match params.get("requestId").and_then(|v| v.as_str()) {
394            Some(id) => id.to_string(),
395            None => return,
396        };
397        let encoded = params
398            .get("encodedDataLength")
399            .and_then(|v| v.as_u64())
400            .or_else(|| params.get("encodedDataLength").and_then(|v| v.as_f64()).map(|n| n as u64));
401        let mut details = self.details.lock();
402        let entry = details.entry(request_id).or_default();
403        entry.finished = true;
404        if let Some(enc) = encoded {
405            // Merge into existing sizes if any.
406            entry.sizes = Some(Sizes {
407                encoded_data_length: Some(enc),
408            });
409        }
410        drop(details);
411        // inflight bookkeeping shared with the failed path.
412        self.decrement_inflight();
413    }
414
415    /// Handle `Network.loadingFailed`: record failure text + decrement inflight.
416    pub(crate) fn on_loading_failed(&self, params: &Value) {
417        let request_id = match params.get("requestId").and_then(|v| v.as_str()) {
418            Some(id) => id.to_string(),
419            None => return,
420        };
421        let error_text = params
422            .get("errorText")
423            .and_then(|v| v.as_str())
424            .map(String::from);
425        let mut details = self.details.lock();
426        let entry = details.entry(request_id).or_default();
427        if let Some(t) = error_text {
428            entry.failure = Some(t);
429        }
430        drop(details);
431        self.decrement_inflight();
432    }
433
434    /// Shared inflight decrement with an underflow guard.
435    fn decrement_inflight(&self) {
436        let prev = self.inflight.fetch_sub(1, Ordering::Relaxed);
437        if prev == 0 {
438            // Correct an accidental underflow (event without matching request).
439            self.inflight.store(0, Ordering::Relaxed);
440        }
441    }
442
443    /// Handle `Network.responseReceived`. Stores + returns the response.
444    pub(crate) fn on_response_received(
445        &self,
446        params: &Value,
447        session: Arc<CdpSession>,
448    ) -> Option<Response> {
449        let resp = params.get("response")?;
450        let request_id = params.get("requestId").and_then(|v| v.as_str())?.to_string();
451        let request = self.get_request(&request_id)?;
452        let url = resp.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
453        let status = resp.get("status").and_then(|v| v.as_u64()).unwrap_or(0) as u16;
454        let status_text = resp
455            .get("statusText")
456            .and_then(|v| v.as_str())
457            .unwrap_or("")
458            .to_string();
459        let headers = headers_from_value(resp.get("headers"));
460        let from_disk = resp.get("fromDiskCache").and_then(|v| v.as_bool()).unwrap_or(false);
461        let from_prefetch = resp
462            .get("fromPrefetchCache")
463            .and_then(|v| v.as_bool())
464            .unwrap_or(false);
465        let from_service_worker = resp
466            .get("fromServiceWorker")
467            .and_then(|v| v.as_bool())
468            .unwrap_or(false);
469
470        let response = Response::new(
471            url,
472            status,
473            status_text,
474            headers,
475            request_id.clone(),
476            from_disk || from_prefetch,
477            from_service_worker,
478            session,
479            request,
480        );
481        self.responses.lock().insert(request_id.clone(), response.clone());
482
483        // Capture extra response detail.
484        let timing = Timing::from_value(resp.get("timing"));
485        let security_details = SecurityDetails::from_value(resp.get("securityDetails"));
486        let server_addr = ServerAddr::from_value(Some(resp));
487        let http_version = resp
488            .get("protocol")
489            .and_then(|v| v.as_str())
490            .map(String::from);
491        let raw_response_headers = headers_raw_from_value(resp.get("headers"));
492
493        let mut details = self.details.lock();
494        let entry = details.entry(request_id).or_default();
495        if let Some(t) = timing {
496            entry.timing = Some(t);
497        }
498        if let Some(s) = security_details {
499            entry.security_details = Some(s);
500        }
501        if let Some(a) = server_addr {
502            entry.server_addr = Some(a);
503        }
504        if let Some(v) = http_version {
505            entry.http_version = Some(v);
506        }
507        if let Some(h) = raw_response_headers {
508            entry.raw_response_headers = Some(h);
509        }
510
511        Some(response)
512    }
513}
514
515/// Background task: subscribe to session events, update the store, fire handlers.
516pub(crate) async fn network_tracker(
517    mut rx: broadcast::Receiver<CdpEvent>,
518    session: Arc<CdpSession>,
519    store: Arc<NetworkStore>,
520    request_handlers: Arc<Mutex<Vec<RequestHandler>>>,
521    response_handlers: Arc<Mutex<Vec<ResponseHandler>>>,
522    failed_handlers: Arc<Mutex<Vec<RequestHandler>>>,
523) {
524    loop {
525        match rx.recv().await {
526            Ok(ev) => match ev.method.as_str() {
527                "Network.requestWillBeSent" => {
528                    if let Some(req) = store.on_request_will_be_sent(&ev.params) {
529                        fire(&request_handlers, req);
530                    }
531                }
532                "Network.responseReceived" => {
533                    if let Some(resp) =
534                        store.on_response_received(&ev.params, Arc::clone(&session))
535                    {
536                        fire(&response_handlers, resp);
537                    }
538                }
539                "Network.loadingFinished" => {
540                    store.on_loading_finished(&ev.params);
541                }
542                "Network.loadingFailed" => {
543                    store.on_loading_failed(&ev.params);
544                    if let Some(rid) = ev.params.get("requestId").and_then(|v| v.as_str()) {
545                        if let Some(req) = store.get_request(rid) {
546                            fire(&failed_handlers, req);
547                        }
548                    }
549                }
550                _ => {}
551            },
552            Err(broadcast::error::RecvError::Closed) => break,
553            Err(broadcast::error::RecvError::Lagged(_)) => continue,
554        }
555    }
556}
557
558fn fire<T: Clone + Send + 'static>(
559    handlers: &Arc<Mutex<Vec<Arc<dyn Fn(T) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>>>>,
560    value: T,
561) {
562    let list = handlers.lock().clone();
563    for h in list {
564        let v = value.clone();
565        tokio::spawn(async move {
566            (h)(v).await;
567        });
568    }
569}
570
571/// Lowercased header map (used for the existing `headers()` accessors).
572fn headers_from_value(v: Option<&Value>) -> Headers {
573    let mut out = Headers::new();
574    if let Some(obj) = v.and_then(|v| v.as_object()) {
575        for (k, val) in obj {
576            if let Some(s) = val.as_str() {
577                out.insert(k.to_lowercase(), s.to_string());
578            }
579        }
580    }
581    out
582}
583
584/// Original-case header map (for `all_headers()`).
585fn headers_raw_from_value(v: Option<&Value>) -> Option<Headers> {
586    let obj = v.and_then(|v| v.as_object())?;
587    let mut out = Headers::new();
588    for (k, val) in obj {
589        if let Some(s) = val.as_str() {
590            out.insert(k.clone(), s.to_string());
591        }
592    }
593    Some(out)
594}