use crate::cdp::session::CdpSession;
use crate::cdp::CdpEvent;
use crate::error::Result;
use crate::network::NetworkStore;
use crate::request::Request;
use crate::types::Headers;
use base64::Engine;
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Weak};
use tokio::sync::broadcast;
pub(crate) type RouteHandler =
Arc<dyn Fn(Route) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
#[derive(Clone)]
pub(crate) struct RouteEntry {
pub pattern: String,
pub handler: RouteHandler,
}
pub(crate) async fn route_listener(
mut rx: broadcast::Receiver<CdpEvent>,
session: Arc<CdpSession>,
store: Arc<NetworkStore>,
handlers: Arc<Mutex<Vec<RouteEntry>>>,
) {
loop {
match rx.recv().await {
Ok(ev) if ev.method == "Fetch.requestPaused" => {
let url = ev
.params
.get("request")
.and_then(|r| r.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let fetch_id = ev
.params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let handler = {
let hs = handlers.lock();
hs.iter()
.find(|e| pattern_matches(&e.pattern, &url))
.map(|e| Arc::clone(&e.handler))
};
match (handler, request_from_paused(&ev.params, Arc::downgrade(&store))) {
(Some(h), Some(req)) => {
let route = Route::new(req, fetch_id, Arc::clone(&session));
tokio::spawn(async move {
(h)(route).await;
});
}
_ => {
let _ = session
.send("Fetch.continueRequest", json!({ "requestId": fetch_id }))
.await;
}
}
}
Ok(_) => {}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
}
}
}
pub(crate) fn pattern_matches(pattern: &str, url: &str) -> bool {
if pattern == url {
return true;
}
if let Some(result) = legacy_simple_wildcard(pattern, url) {
return result;
}
match glob_to_regex(pattern) {
Some(re) => re.is_match(url),
None => url.contains(pattern),
}
}
fn legacy_simple_wildcard(pattern: &str, url: &str) -> Option<bool> {
let has_leading = pattern.starts_with('*');
let has_trailing = pattern.ends_with('*') && pattern.len() > 1;
let inner_start = if has_leading { 1 } else { 0 };
let inner_end = if has_trailing {
pattern.len() - 1
} else {
pattern.len()
};
if inner_start > inner_end {
return None;
}
let inner = &pattern[inner_start..inner_end];
if inner.contains('*') || inner.contains('?') || inner.contains('[') {
return None;
}
match (has_leading, has_trailing) {
(true, true) => Some(url.contains(inner)),
(true, false) => Some(url.ends_with(inner)),
(false, true) => Some(url.starts_with(inner)),
(false, false) => {
Some(url.contains(pattern))
}
}
}
fn glob_to_regex(pattern: &str) -> Option<regex::Regex> {
let mut out = String::with_capacity(pattern.len() * 2);
out.push('^');
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'*' => {
if i + 1 < chars.len() && chars[i + 1] == '*' {
out.push_str(".*");
i += 2;
while i < chars.len() && chars[i] == '*' {
i += 1;
}
} else {
out.push_str("[^/]*");
i += 1;
}
}
'?' => {
out.push_str("[^/]");
i += 1;
}
'[' => {
let start = i + 1;
let mut j = start;
if j < chars.len() && (chars[j] == '!' || chars[j] == '^') {
j += 1;
}
if j < chars.len() && chars[j] == ']' {
j += 1;
}
while j < chars.len() && chars[j] != ']' {
j += 1;
}
if j >= chars.len() {
out.push_str("\\[");
i += 1;
} else {
out.push('[');
let mut k = start;
if k < chars.len() && chars[k] == '!' {
out.push('^');
k += 1;
}
while k < j {
let ch = chars[k];
if "\\.+*?(){}|^$".contains(ch) {
out.push('\\');
}
out.push(ch);
k += 1;
}
out.push(']');
i = j + 1;
}
}
_ => {
out.push_str(®ex::escape(&c.to_string()));
i += 1;
}
}
}
out.push('$');
regex::Regex::new(&out).ok()
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RouteContinueOptions {
pub url: Option<String>,
pub method: Option<String>,
pub headers: Option<Headers>,
pub post_data: Option<String>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RouteFulfillOptions {
pub status: Option<u16>,
pub headers: Option<Headers>,
pub content_type: Option<String>,
pub body: Option<String>,
}
impl RouteFulfillOptions {
pub fn new() -> Self {
Self::default()
}
pub fn status(mut self, v: u16) -> Self {
self.status = Some(v);
self
}
pub fn body(mut self, v: impl Into<String>) -> Self {
self.body = Some(v.into());
self
}
pub fn content_type(mut self, v: impl Into<String>) -> Self {
self.content_type = Some(v.into());
self
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct RouteFetchOptions {
pub url: Option<String>,
pub method: Option<String>,
pub headers: Option<Headers>,
pub post_data: Option<String>,
pub post_data_bytes: Option<Vec<u8>>,
pub timeout: Option<u64>,
pub max_redirects: Option<u32>,
}
impl RouteFetchOptions {
pub fn new() -> Self {
Self::default()
}
pub fn url(mut self, v: impl Into<String>) -> Self {
self.url = Some(v.into());
self
}
pub fn method(mut self, v: impl Into<String>) -> Self {
self.method = Some(v.into());
self
}
pub fn headers(mut self, v: Headers) -> Self {
self.headers = Some(v);
self
}
pub fn post_data(mut self, v: impl Into<String>) -> Self {
self.post_data = Some(v.into());
self
}
pub fn post_data_bytes(mut self, v: Vec<u8>) -> Self {
self.post_data_bytes = Some(v);
self
}
pub fn timeout(mut self, v: u64) -> Self {
self.timeout = Some(v);
self
}
pub fn max_redirects(mut self, v: u32) -> Self {
self.max_redirects = Some(v);
self
}
}
#[derive(Debug, Clone)]
pub struct RouteFetchResponse {
url: String,
status: u16,
status_text: String,
headers: Headers,
body: Vec<u8>,
}
impl RouteFetchResponse {
pub fn url(&self) -> &str {
&self.url
}
pub fn status(&self) -> u16 {
self.status
}
pub fn status_text(&self) -> &str {
&self.status_text
}
pub fn headers(&self) -> &Headers {
&self.headers
}
pub fn body(&self) -> &[u8] {
&self.body
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).into_owned()
}
}
#[derive(Clone)]
pub struct Route {
inner: Arc<RouteInner>,
}
struct RouteInner {
request: Request,
fetch_request_id: String,
session: Arc<CdpSession>,
handled: AtomicBool,
}
impl Route {
pub(crate) fn new(
request: Request,
fetch_request_id: String,
session: Arc<CdpSession>,
) -> Self {
Self {
inner: Arc::new(RouteInner {
request,
fetch_request_id,
session,
handled: AtomicBool::new(false),
}),
}
}
pub fn request(&self) -> Request {
self.inner.request.clone()
}
fn claim(&self) -> bool {
!self.inner.handled.swap(true, Ordering::SeqCst)
}
pub async fn continue_(&self, options: Option<RouteContinueOptions>) -> Result<()> {
if !self.claim() {
return Ok(());
}
let opts = options.unwrap_or_default();
let mut params = json!({ "requestId": self.inner.fetch_request_id });
if let Some(u) = opts.url {
params["url"] = json!(u);
}
if let Some(m) = opts.method {
params["method"] = json!(m);
}
if let Some(h) = opts.headers {
params["headers"] = json!(headers_to_array(&h));
}
if let Some(p) = opts.post_data {
params["postData"] = json!(base64::engine::general_purpose::STANDARD.encode(p));
}
self.inner
.session
.send("Fetch.continueRequest", params)
.await
.map(|_: Value| ())
}
pub async fn fallback(&self) -> Result<()> {
self.continue_(None).await
}
pub async fn fulfill(&self, options: RouteFulfillOptions) -> Result<()> {
if !self.claim() {
return Ok(());
}
let status = options.status.unwrap_or(200);
let mut headers = options.headers.unwrap_or_default();
if let Some(ct) = options.content_type {
headers.entry("content-type".into()).or_insert(ct);
}
let body_b64 = options
.body
.map(|b| base64::engine::general_purpose::STANDARD.encode(b))
.unwrap_or_default();
self.inner
.session
.send(
"Fetch.fulfillRequest",
json!({
"requestId": self.inner.fetch_request_id,
"responseCode": status,
"responseHeaders": headers_to_array(&headers),
"body": body_b64,
}),
)
.await
.map(|_: Value| ())
}
pub async fn abort(&self, error_code: Option<&str>) -> Result<()> {
if !self.claim() {
return Ok(());
}
let reason = error_code.unwrap_or("Failed");
self.inner
.session
.send(
"Fetch.failRequest",
json!({ "requestId": self.inner.fetch_request_id, "errorReason": reason }),
)
.await
.map(|_: Value| ())
}
pub async fn fetch(&self, options: Option<RouteFetchOptions>) -> Result<RouteFetchResponse> {
let opts = options.unwrap_or_default();
let request = self.request();
let url = opts
.url
.clone()
.unwrap_or_else(|| request.url().to_string());
let method_str = opts
.method
.clone()
.unwrap_or_else(|| request.method().to_string());
let method = reqwest::Method::from_bytes(method_str.as_bytes())
.map_err(|e| crate::error::Error::InvalidArgument(format!("invalid method: {e}")))?;
let mut headers = request.headers().clone();
if let Some(extra) = opts.headers.as_ref() {
for (k, v) in extra {
headers.insert(k.clone(), v.clone());
}
}
let body: Vec<u8> = if let Some(b) = opts.post_data_bytes.clone() {
b
} else if let Some(s) = opts.post_data.clone() {
s.into_bytes()
} else {
request.post_data_buffer().unwrap_or_default()
};
let mut client_builder = reqwest::Client::builder();
if let Some(n) = opts.max_redirects {
client_builder = client_builder.redirect(reqwest::redirect::Policy::limited(
n.max(1) as usize,
));
}
let client = client_builder
.build()
.map_err(|e| crate::error::Error::Http(format!("failed to build client: {e}")))?;
let mut builder = client.request(method, &url);
const SKIP: &[&str] = &[
"host",
"connection",
"content-length",
"transfer-encoding",
"accept-encoding",
"content-encoding",
"keep-alive",
"proxy-connection",
"upgrade",
];
for (k, v) in &headers {
if SKIP.contains(&k.as_str()) {
continue;
}
if let (Ok(name), Ok(val)) = (
reqwest::header::HeaderName::from_bytes(k.as_bytes()),
reqwest::header::HeaderValue::from_str(v),
) {
builder = builder.header(name, val);
}
}
let is_bodyless =
method_str.eq_ignore_ascii_case("GET") || method_str.eq_ignore_ascii_case("HEAD");
if !body.is_empty() && !is_bodyless {
builder = builder.body(body);
}
let timeout_ms = opts.timeout.unwrap_or(30_000);
builder = builder.timeout(std::time::Duration::from_millis(timeout_ms));
let resp = builder.send().await.map_err(|e| {
let mut s = format!("{e}");
let mut src = std::error::Error::source(&e);
while let Some(c) = src {
s.push_str(" :: ");
s.push_str(&c.to_string());
src = c.source();
}
crate::error::Error::Http(format!("request failed: {s}"))
})?;
let final_url = resp.url().to_string();
let status = resp.status().as_u16();
let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
let mut resp_headers: Headers = Headers::with_capacity(resp.headers().len());
for (name, value) in resp.headers().iter() {
let key = name.as_str().to_ascii_lowercase();
let val = value.to_str().map(String::from).unwrap_or_else(|_| {
String::from_utf8_lossy(value.as_bytes()).into_owned()
});
resp_headers.insert(key, val);
}
let body = resp
.bytes()
.await
.map_err(|e| crate::error::Error::Http(format!("failed to read body: {e}")))?;
Ok(RouteFetchResponse {
url: final_url,
status,
status_text,
headers: resp_headers,
body: body.to_vec(),
})
}
}
pub(crate) fn request_from_paused(params: &Value, store: Weak<NetworkStore>) -> Option<Request> {
let req = params.get("request")?;
let request_id = params.get("requestId").and_then(|v| v.as_str())?.to_string();
let url = req.get("url").and_then(|v| v.as_str())?.to_string();
let method = req
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("GET")
.to_string();
let headers = headers_from_object(req.get("headers"));
let resource_type = params
.get("resourceType")
.and_then(|v| v.as_str())
.unwrap_or("Other")
.to_string();
Some(Request::new(
url,
method,
headers,
resource_type.clone(),
request_id,
resource_type == "Document",
store,
))
}
fn headers_to_array(h: &Headers) -> Vec<Value> {
h.iter().map(|(k, v)| json!({ "name": k, "value": v })).collect()
}
fn headers_from_object(v: Option<&Value>) -> Headers {
let mut out = Headers::new();
if let Some(obj) = v.and_then(|v| v.as_object()) {
for (k, val) in obj {
if let Some(s) = val.as_str() {
out.insert(k.to_lowercase(), s.to_string());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_exact_match() {
assert!(pattern_matches("http://x/y", "http://x/y"));
assert!(!pattern_matches("http://x/y", "http://x/z"));
}
#[test]
fn legacy_leading_star_is_suffix() {
assert!(pattern_matches("*mocked", "http://host/mock-api-mocked"));
assert!(!pattern_matches("*mocked", "http://host/api"));
}
#[test]
fn legacy_trailing_star_is_prefix() {
assert!(pattern_matches("http://x*", "http://x/api"));
assert!(!pattern_matches("http://x*", "http://y/api"));
}
#[test]
fn legacy_both_stars_is_substring() {
assert!(pattern_matches("*mocked*", "http://host/mock-api-mocked/path"));
assert!(pattern_matches("*blocked*", "http://host/blocked"));
assert!(!pattern_matches("*mocked*", "http://host/api"));
}
#[test]
fn legacy_no_wildcard_is_substring() {
assert!(pattern_matches("api/v1", "http://host/api/v1/users"));
assert!(!pattern_matches("api/v1", "http://host/api/v2"));
}
#[test]
fn double_star_matches_anything_including_path() {
assert!(pattern_matches("**", "http://x/"));
assert!(pattern_matches("**", "http://x/a/b/c"));
assert!(pattern_matches("**", "https://example.com/api/v1?x=1#frag"));
assert!(pattern_matches("**", ""));
}
#[test]
fn double_star_slash_star_matches_any() {
assert!(pattern_matches("**/*", "http://x/api/y"));
assert!(pattern_matches("**/*", "http://x/"));
}
#[test]
fn bare_single_star_matches_any() {
assert!(pattern_matches("*", "http://x/a/b"));
}
#[test]
fn single_star_does_not_cross_slash() {
assert!(pattern_matches("*/api/*", "http://x/api/y"));
assert!(pattern_matches("*/api/*", "http://x/a/b/api/c"));
assert!(!pattern_matches("*/api/*", "http://x/users"));
}
#[test]
fn question_mark_matches_single_char() {
assert!(pattern_matches("http://x/a?c", "http://x/abc"));
assert!(pattern_matches("http://x/a?c", "http://x/aZc"));
assert!(!pattern_matches("http://x/a?c", "http://x/a/c"));
}
#[test]
fn char_class_positive() {
assert!(pattern_matches("http://x/v[12]", "http://x/v1"));
assert!(pattern_matches("http://x/v[12]", "http://x/v2"));
assert!(!pattern_matches("http://x/v[12]", "http://x/v3"));
}
#[test]
fn char_class_negation() {
assert!(pattern_matches("http://x/v[!12]", "http://x/v3"));
assert!(!pattern_matches("http://x/v[!12]", "http://x/v1"));
}
#[test]
fn real_world_typical_pattern() {
let pattern = "**/api/users/*";
assert!(pattern_matches(pattern, "https://app.example.com/api/users/42"));
assert!(pattern_matches(pattern, "https://app.example.com/v2/api/users/abc"));
assert!(pattern_matches(pattern, "https://app.example.com/api/users/"));
assert!(!pattern_matches(pattern, "https://app.example.com/api/groups/1"));
assert!(!pattern_matches(pattern, "https://other.example.com/feed"));
}
#[test]
fn regex_metacharacters_in_pattern_are_literal() {
assert!(pattern_matches("http://x/.png", "http://x/.png"));
assert!(!pattern_matches("http://x/.png", "http://x/xpng"));
}
}