api: add request/response interception API to Page (#306)
Expose the interception infrastructure that already existed internally but was
private, so embedders can intercept, observe, mock, or block JS fetch()/XHR
without forking or unsafe casts.
On obscura::Page:
- add_preload_script: inject JS before any page <script> (CDP
Page.addScriptToEvaluateOnNewDocument). The field already ran the scripts;
this only adds the method.
- enable_interception: returns a receiver for every JS fetch()/XHR request;
resolve each through its resolver with InterceptResolution::{Continue,
Fulfill, Fail} to pass, mock, or block (CDP Fetch.enable equivalent).
- on_request / on_response: passive callbacks. on_response is the main path for
capturing API response payloads from SPAs.
on_request/on_response previously fired only for navigation; op_fetch_url now
invokes them for JS fetch()/XHR too (both reqwest and stealth paths), gated on
a non-empty callback vector so the common no-callback case pays nothing.
Re-export InterceptedRequest, InterceptResolution, RequestInfo, ResourceType,
RequestCallback, ResponseCallback, Response from the obscura crate.
Integration test tests/interception.rs exercises all three against a local echo
server.
This commit is contained in:
1 parent
12b927f476
commit
1f5066fc30
8 files changed
+261
-7
No files matched your search
@@ -20,3 +20,6 @@ thiserror = "1"
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
|
||||
@@ -26,3 +26,7 @@ pub use config::BrowserConfig;
|
||||
pub use cookie::{Cookie, CookieStore};
|
||||
pub use error::Error;
|
||||
pub use page::Page;
|
||||
|
||||
// Request/response interception types (issue #306).
|
||||
pub use obscura_browser::{InterceptedRequest, InterceptResolution};
|
||||
pub use obscura_net::{RequestCallback, RequestInfo, ResourceType, Response, ResponseCallback};
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use obscura_browser::lifecycle::WaitUntil;
|
||||
use obscura_browser::Page as InnerPage;
|
||||
use obscura_browser::{InterceptedRequest, Page as InnerPage};
|
||||
use obscura_net::{RequestCallback, ResponseCallback};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::Error;
|
||||
@@ -91,6 +92,39 @@ impl Page {
|
||||
pub async fn settle(&mut self, max_ms: u64) {
|
||||
self.inner.settle(max_ms).await
|
||||
}
|
||||
|
||||
/// Register a script that runs before any of the page's own `<script>` tags,
|
||||
/// equivalent to CDP `Page.addScriptToEvaluateOnNewDocument`. Runs on the next
|
||||
/// `goto()` / navigation. Use it to install a fetch()/XHR interceptor or any
|
||||
/// other page-init logic before the page's bootstrap runs.
|
||||
pub fn add_preload_script(&mut self, script: &str) {
|
||||
self.inner.add_preload_script(script);
|
||||
}
|
||||
|
||||
/// Enable CDP-Fetch-style interception of every JS `fetch()`/XHR. Returns a
|
||||
/// receiver yielding each request; resolve it through its `resolver` with
|
||||
/// [`obscura::InterceptResolution`] (`Continue`, `Fulfill`, `Fail`) to pass,
|
||||
/// mock, or block it. Works in stealth and non-stealth.
|
||||
pub fn enable_interception(
|
||||
&mut self,
|
||||
) -> tokio::sync::mpsc::UnboundedReceiver<InterceptedRequest> {
|
||||
self.inner.enable_interception()
|
||||
}
|
||||
|
||||
/// Register a passive callback fired for every request the page makes
|
||||
/// (navigation and JS `fetch()`/XHR), once its method/headers/body are known
|
||||
/// and before it is sent. Non-blocking; use `enable_interception` to mutate
|
||||
/// or block.
|
||||
pub fn on_request(&mut self, cb: RequestCallback) {
|
||||
self.inner.on_request(cb);
|
||||
}
|
||||
|
||||
/// Register a passive callback fired with every response the page receives
|
||||
/// (navigation and JS `fetch()`/XHR), including its body. Non-blocking. The
|
||||
/// main path for capturing API response payloads from SPAs.
|
||||
pub fn on_response(&mut self, cb: ResponseCallback) {
|
||||
self.inner.on_response(cb);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to a DOM element.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! End-to-end check for the request/response interception API on `obscura::Page`
|
||||
//! (issue #306): preload scripts, the interception channel, and the passive
|
||||
//! on_request/on_response callbacks all work for JS-initiated fetch()/XHR.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use obscura::{Browser, InterceptResolution, ResourceType};
|
||||
|
||||
/// Minimal HTTP/1.1 server: `/` returns HTML that fires `fetch('/api')`; `/api`
|
||||
/// returns JSON. Enough to exercise JS fetch() interception + the callbacks.
|
||||
fn spawn_echo_server() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for incoming in listener.incoming() {
|
||||
let mut s = match incoming {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = s.read(&mut buf);
|
||||
let req = std::str::from_utf8(&buf).unwrap_or("");
|
||||
let path = req.split_whitespace().nth(1).unwrap_or("/");
|
||||
let (ct, body) = if path.starts_with("/api") {
|
||||
("application/json", "{\"hello\":\"world\"}".to_string())
|
||||
} else {
|
||||
("text/html", "<script>fetch('/api');</script>".to_string())
|
||||
};
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n{}",
|
||||
ct,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = s.write_all(resp.as_bytes());
|
||||
let _ = s.shutdown(std::net::Shutdown::Both);
|
||||
}
|
||||
});
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn page_intercepts_and_observes_js_fetch() {
|
||||
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
|
||||
let base = spawn_echo_server();
|
||||
|
||||
let browser = Browser::new().unwrap();
|
||||
let mut page = browser.new_page().await.unwrap();
|
||||
|
||||
// Passive on_request counter (fires for navigation + the JS fetch).
|
||||
let req_count = Arc::new(AtomicU32::new(0));
|
||||
let rc = req_count.clone();
|
||||
page.on_request(Arc::new(move |_info| {
|
||||
rc.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
|
||||
// Passive on_response: capture the /api (Fetch) body.
|
||||
let captured = Arc::new(Mutex::new(String::new()));
|
||||
let cap = captured.clone();
|
||||
page.on_response(Arc::new(move |info, resp| {
|
||||
if info.resource_type == ResourceType::Fetch {
|
||||
*cap.lock().unwrap() = String::from_utf8_lossy(&resp.body).into_owned();
|
||||
}
|
||||
}));
|
||||
|
||||
// Channel-based interception: resolve every request Continue so the fetch
|
||||
// is not blocked.
|
||||
let mut rx = page.enable_interception();
|
||||
tokio::spawn(async move {
|
||||
while let Some(req) = rx.recv().await {
|
||||
if req
|
||||
.resolver
|
||||
.send(InterceptResolution::Continue {
|
||||
url: None,
|
||||
method: None,
|
||||
headers: None,
|
||||
body: None,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
page.goto(&base).await.unwrap();
|
||||
// Pump the event loop so the inline fetch('/api') resolves.
|
||||
for _ in 0..20 {
|
||||
page.settle(500).await;
|
||||
if captured.lock().unwrap().contains("hello") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
req_count.load(Ordering::SeqCst) >= 1,
|
||||
"on_request never fired for navigation or fetch"
|
||||
);
|
||||
let body = captured.lock().unwrap().clone();
|
||||
assert!(
|
||||
body.contains("hello"),
|
||||
"on_response did not capture the fetch response body: {:?}",
|
||||
body
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user