Skip to main content

playwright_cdp/
route.rs

1//! `Route` — network interception via the CDP `Fetch` domain.
2//!
3//! `Page::route(pattern, handler)` enables request interception; the handler
4//! receives a [`Route`] and chooses to [`continue_`](Route::continue_),
5//! [`fulfill`](Route::fulfill), or [`abort`](Route::abort) it.
6
7use crate::cdp::session::CdpSession;
8use crate::cdp::CdpEvent;
9use crate::error::Result;
10use crate::network::NetworkStore;
11use crate::request::Request;
12use crate::types::Headers;
13use base64::Engine;
14use parking_lot::Mutex;
15use serde_json::{json, Value};
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, Weak};
20use tokio::sync::broadcast;
21
22/// A handler invoked for each intercepted request matching a route pattern.
23pub(crate) type RouteHandler =
24    Arc<dyn Fn(Route) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
25
26/// One registered route: a URL glob plus its handler.
27#[derive(Clone)]
28pub(crate) struct RouteEntry {
29    pub pattern: String,
30    pub handler: RouteHandler,
31}
32
33/// Background task: dispatch `Fetch.requestPaused` events to matching handlers.
34pub(crate) async fn route_listener(
35    mut rx: broadcast::Receiver<CdpEvent>,
36    session: Arc<CdpSession>,
37    store: Arc<NetworkStore>,
38    handlers: Arc<Mutex<Vec<RouteEntry>>>,
39) {
40    loop {
41        match rx.recv().await {
42            Ok(ev) if ev.method == "Fetch.requestPaused" => {
43                let url = ev
44                    .params
45                    .get("request")
46                    .and_then(|r| r.get("url"))
47                    .and_then(|v| v.as_str())
48                    .unwrap_or("")
49                    .to_string();
50                let fetch_id = ev
51                    .params
52                    .get("requestId")
53                    .and_then(|v| v.as_str())
54                    .unwrap_or("")
55                    .to_string();
56                let handler = {
57                    let hs = handlers.lock();
58                    hs.iter()
59                        .find(|e| pattern_matches(&e.pattern, &url))
60                        .map(|e| Arc::clone(&e.handler))
61                };
62                match (handler, request_from_paused(&ev.params, Arc::downgrade(&store))) {
63                    (Some(h), Some(req)) => {
64                        let route = Route::new(req, fetch_id, Arc::clone(&session));
65                        tokio::spawn(async move {
66                            (h)(route).await;
67                        });
68                    }
69                    _ => {
70                        // No handler / malformed: continue so the request isn't stalled.
71                        let _ = session
72                            .send("Fetch.continueRequest", json!({ "requestId": fetch_id }))
73                            .await;
74                    }
75                }
76            }
77            Ok(_) => {}
78            Err(broadcast::error::RecvError::Closed) => break,
79            Err(broadcast::error::RecvError::Lagged(_)) => continue,
80        }
81    }
82}
83
84/// Glob match supporting leading/trailing/both `*` (substring) and exact.
85pub(crate) fn pattern_matches(pattern: &str, url: &str) -> bool {
86    if pattern == url {
87        return true;
88    }
89    if pattern.len() >= 2 && pattern.starts_with('*') && pattern.ends_with('*') {
90        return url.contains(&pattern[1..pattern.len() - 1]);
91    }
92    if let Some(rest) = pattern.strip_prefix('*') {
93        return url.ends_with(rest);
94    }
95    if let Some(rest) = pattern.strip_suffix('*') {
96        return url.starts_with(rest);
97    }
98    url.contains(pattern)
99}
100
101/// Options for [`Route::continue_`] (optional request overrides).
102#[derive(Debug, Clone, Default)]
103#[non_exhaustive]
104pub struct RouteContinueOptions {
105    pub url: Option<String>,
106    pub method: Option<String>,
107    pub headers: Option<Headers>,
108    pub post_data: Option<String>,
109}
110
111/// Options for [`Route::fulfill`] (serve a synthetic response).
112#[derive(Debug, Clone, Default)]
113#[non_exhaustive]
114pub struct RouteFulfillOptions {
115    pub status: Option<u16>,
116    pub headers: Option<Headers>,
117    pub content_type: Option<String>,
118    pub body: Option<String>,
119}
120
121impl RouteFulfillOptions {
122    pub fn new() -> Self {
123        Self::default()
124    }
125    pub fn status(mut self, v: u16) -> Self {
126        self.status = Some(v);
127        self
128    }
129    pub fn body(mut self, v: impl Into<String>) -> Self {
130        self.body = Some(v.into());
131        self
132    }
133    pub fn content_type(mut self, v: impl Into<String>) -> Self {
134        self.content_type = Some(v.into());
135        self
136    }
137}
138
139/// An intercepted network request, paused in the `Fetch` domain.
140#[derive(Clone)]
141pub struct Route {
142    inner: Arc<RouteInner>,
143}
144
145struct RouteInner {
146    request: Request,
147    fetch_request_id: String,
148    session: Arc<CdpSession>,
149    handled: AtomicBool,
150}
151
152impl Route {
153    pub(crate) fn new(
154        request: Request,
155        fetch_request_id: String,
156        session: Arc<CdpSession>,
157    ) -> Self {
158        Self {
159            inner: Arc::new(RouteInner {
160                request,
161                fetch_request_id,
162                session,
163                handled: AtomicBool::new(false),
164            }),
165        }
166    }
167
168    /// The intercepted request.
169    pub fn request(&self) -> Request {
170        self.inner.request.clone()
171    }
172
173    fn claim(&self) -> bool {
174        !self.inner.handled.swap(true, Ordering::SeqCst)
175    }
176
177    /// Let the request proceed (optionally overriding url/method/headers/body).
178    pub async fn continue_(&self, options: Option<RouteContinueOptions>) -> Result<()> {
179        if !self.claim() {
180            return Ok(());
181        }
182        let opts = options.unwrap_or_default();
183        let mut params = json!({ "requestId": self.inner.fetch_request_id });
184        if let Some(u) = opts.url {
185            params["url"] = json!(u);
186        }
187        if let Some(m) = opts.method {
188            params["method"] = json!(m);
189        }
190        if let Some(h) = opts.headers {
191            params["headers"] = json!(headers_to_array(&h));
192        }
193        if let Some(p) = opts.post_data {
194            params["postData"] = json!(base64::engine::general_purpose::STANDARD.encode(p));
195        }
196        self.inner
197            .session
198            .send("Fetch.continueRequest", params)
199            .await
200            .map(|_: Value| ())
201    }
202
203    /// Let the request proceed to the next matching route (no-op if only one).
204    pub async fn fallback(&self) -> Result<()> {
205        self.continue_(None).await
206    }
207
208    /// Serve a synthetic response.
209    pub async fn fulfill(&self, options: RouteFulfillOptions) -> Result<()> {
210        if !self.claim() {
211            return Ok(());
212        }
213        let status = options.status.unwrap_or(200);
214        let mut headers = options.headers.unwrap_or_default();
215        if let Some(ct) = options.content_type {
216            headers.entry("content-type".into()).or_insert(ct);
217        }
218        let body_b64 = options
219            .body
220            .map(|b| base64::engine::general_purpose::STANDARD.encode(b))
221            .unwrap_or_default();
222        self.inner
223            .session
224            .send(
225                "Fetch.fulfillRequest",
226                json!({
227                    "requestId": self.inner.fetch_request_id,
228                    "responseCode": status,
229                    "responseHeaders": headers_to_array(&headers),
230                    "body": body_b64,
231                }),
232            )
233            .await
234            .map(|_: Value| ())
235    }
236
237    /// Abort the request with a CDP `ErrorReason` (default `"Failed"`).
238    pub async fn abort(&self, error_code: Option<&str>) -> Result<()> {
239        if !self.claim() {
240            return Ok(());
241        }
242        let reason = error_code.unwrap_or("Failed");
243        self.inner
244            .session
245            .send(
246                "Fetch.failRequest",
247                json!({ "requestId": self.inner.fetch_request_id, "errorReason": reason }),
248            )
249            .await
250            .map(|_: Value| ())
251    }
252}
253
254/// Build a `Request` from a `Fetch.requestPaused` event payload.
255pub(crate) fn request_from_paused(params: &Value, store: Weak<NetworkStore>) -> Option<Request> {
256    let req = params.get("request")?;
257    let request_id = params.get("requestId").and_then(|v| v.as_str())?.to_string();
258    let url = req.get("url").and_then(|v| v.as_str())?.to_string();
259    let method = req
260        .get("method")
261        .and_then(|v| v.as_str())
262        .unwrap_or("GET")
263        .to_string();
264    let headers = headers_from_object(req.get("headers"));
265    let resource_type = params
266        .get("resourceType")
267        .and_then(|v| v.as_str())
268        .unwrap_or("Other")
269        .to_string();
270    Some(Request::new(
271        url,
272        method,
273        headers,
274        resource_type.clone(),
275        request_id,
276        resource_type == "Document",
277        store,
278    ))
279}
280
281fn headers_to_array(h: &Headers) -> Vec<Value> {
282    h.iter().map(|(k, v)| json!({ "name": k, "value": v })).collect()
283}
284
285fn headers_from_object(v: Option<&Value>) -> Headers {
286    let mut out = Headers::new();
287    if let Some(obj) = v.and_then(|v| v.as_object()) {
288        for (k, val) in obj {
289            if let Some(s) = val.as_str() {
290                out.insert(k.to_lowercase(), s.to_string());
291            }
292        }
293    }
294    out
295}