use crate::utils::net;
use futures_util::{SinkExt, StreamExt};
use serde_json::{Value, json};
use std::{
collections::HashMap,
sync::{
Arc, Mutex as StdMutex,
atomic::{AtomicBool, AtomicU64, Ordering},
},
time::Duration,
};
use tokio::sync::{
mpsc::{self, UnboundedSender},
oneshot,
};
use tokio_tungstenite::tungstenite::Message;
type Pending = Arc<StdMutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoint {
pub browser: String,
pub ws_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageTarget {
pub target_id: String,
pub title: String,
pub url: String,
}
pub async fn discover(host: &str, port: u16, timeout: Duration) -> Result<Endpoint, String> {
if !net::is_loopback_host(host) {
return Err(format!(
"browser: refusing to connect to non-loopback host `{host}`; the CDP endpoint has no authentication"
));
}
let url = format!("http://{host}:{port}/json/version");
let client = reqwest::Client::builder()
.timeout(timeout)
.no_proxy()
.build()
.map_err(|e| format!("browser: cannot build an HTTP client: {e}"))?;
let response = client.get(&url).send().await.map_err(|e| {
format!(
"browser: cannot reach the browser debug endpoint at {url} ({e}). \
Start Chrome/Chromium/Edge/Brave with --remote-debugging-port={port} and try again."
)
})?;
let status = response.status();
if !status.is_success() {
return Err(format!(
"browser: {url} returned HTTP {status}; is this a browser DevTools endpoint?"
));
}
let value: Value = response
.json()
.await
.map_err(|e| format!("browser: invalid response from {url}: {e}"))?;
let ws_url = value
.get("webSocketDebuggerUrl")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if ws_url.is_empty() {
return Err(format!(
"browser: {url} has no webSocketDebuggerUrl; is this a browser DevTools endpoint?"
));
}
if !net::url_is_loopback(&ws_url) {
return Err(format!(
"browser: refusing to connect to non-loopback WebSocket `{ws_url}`"
));
}
Ok(Endpoint {
browser: value
.get("Browser")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
ws_url,
})
}
#[derive(Default)]
struct Sessions {
attached: HashMap<String, String>,
active: Option<PageTarget>,
}
pub struct Cdp {
tx: UnboundedSender<Message>,
pending: Pending,
next_id: AtomicU64,
endpoint: Endpoint,
timeout: Duration,
sessions: StdMutex<Sessions>,
closed: Arc<AtomicBool>,
}
impl Cdp {
pub async fn connect(endpoint: Endpoint, timeout: Duration) -> Result<Self, String> {
let (stream, _) = tokio_tungstenite::connect_async(endpoint.ws_url.as_str())
.await
.map_err(|e| {
format!(
"browser: WebSocket handshake with {} failed: {e}",
endpoint.ws_url
)
})?;
let (mut sink, mut source) = stream.split();
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
let closed = Arc::new(AtomicBool::new(false));
tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if sink.send(message).await.is_err() {
break;
}
}
_ = sink.close().await;
});
let reader_pending = Arc::clone(&pending);
let reader_closed = Arc::clone(&closed);
tokio::spawn(async move {
while let Some(message) = source.next().await {
match message {
Ok(Message::Text(text)) => route(&reader_pending, &text),
Ok(Message::Binary(data)) => {
if let Ok(text) = std::str::from_utf8(&data) {
route(&reader_pending, text);
}
}
Ok(Message::Close(_)) | Err(_) => break,
Ok(_) => {}
}
}
reader_closed.store(true, Ordering::Relaxed);
fail_all(&reader_pending, "the CDP connection was closed");
});
Ok(Self {
tx,
pending,
next_id: AtomicU64::new(1),
endpoint,
timeout,
sessions: StdMutex::new(Sessions::default()),
closed,
})
}
pub fn is_closed(&self) -> bool {
self.closed.load(Ordering::Relaxed)
}
pub fn browser(&self) -> &str {
&self.endpoint.browser
}
pub fn ws_url(&self) -> &str {
&self.endpoint.ws_url
}
pub async fn send(&self, method: &str, params: Value) -> Result<Value, String> {
self.send_with(None, method, params).await
}
pub async fn send_session(
&self,
session_id: &str,
method: &str,
params: Value,
) -> Result<Value, String> {
self.send_with(Some(session_id), method, params).await
}
async fn send_with(
&self,
session_id: Option<&str>,
method: &str,
params: Value,
) -> Result<Value, String> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id, sender);
let mut message = json!({ "id": id, "method": method, "params": params });
if let Some(session_id) = session_id {
message["sessionId"] = json!(session_id);
}
if self.tx.send(Message::text(message.to_string())).is_err() {
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
return Err("browser: the CDP connection is closed".to_string());
}
match tokio::time::timeout(self.timeout, receiver).await {
Ok(Ok(Ok(value))) => Ok(value),
Ok(Ok(Err(error))) => {
self.drop_stale_session(session_id, &error);
Err(error)
}
Ok(Err(_)) => {
Err("browser: the CDP connection closed before the response arrived".to_string())
}
Err(_) => {
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
Err(format!(
"browser: `{method}` timed out after {}s",
self.timeout.as_secs()
))
}
}
}
fn drop_stale_session(&self, session_id: Option<&str>, error: &str) {
let Some(session_id) = session_id else {
return;
};
if !is_stale_session_error(error) {
return;
}
let mut sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
sessions.attached.retain(|_, value| value != session_id);
let stale_active = sessions
.active
.as_ref()
.is_some_and(|target| !sessions.attached.contains_key(&target.target_id));
if stale_active {
sessions.active = None;
}
}
pub async fn pages(&self) -> Result<Vec<PageTarget>, String> {
let result = self.send("Target.getTargets", json!({})).await?;
let infos = result
.get("targetInfos")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Ok(infos
.iter()
.filter(|info| info.get("type").and_then(Value::as_str) == Some("page"))
.filter(|info| {
!info
.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.starts_with("devtools://")
})
.map(page_target_from)
.collect())
}
pub async fn create_target(&self, url: &str) -> Result<PageTarget, String> {
let result = self
.send("Target.createTarget", json!({ "url": url }))
.await?;
let target_id = result
.get("targetId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if target_id.is_empty() {
return Err("browser: Target.createTarget returned no targetId".to_string());
}
let info = self
.send("Target.getTargetInfo", json!({ "targetId": target_id }))
.await
.unwrap_or(Value::Null);
match info.get("targetInfo") {
Some(target) => Ok(page_target_from(target)),
None => Ok(PageTarget {
target_id,
title: String::new(),
url: url.to_string(),
}),
}
}
pub async fn attach(&self, target: &PageTarget, activate: bool) -> Result<String, String> {
if activate {
self.send(
"Target.activateTarget",
json!({ "targetId": target.target_id }),
)
.await?;
}
let cached = self
.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.attached
.get(&target.target_id)
.cloned();
let session_id = match cached {
Some(session_id) => session_id,
None => {
let result = self
.send(
"Target.attachToTarget",
json!({ "targetId": target.target_id, "flatten": true }),
)
.await?;
let session_id = result
.get("sessionId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if session_id.is_empty() {
return Err(format!(
"browser: Target.attachToTarget returned no sessionId for {}",
target.target_id
));
}
self.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.attached
.insert(target.target_id.clone(), session_id.clone());
session_id
}
};
self.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.active = Some(target.clone());
Ok(session_id)
}
pub fn active(&self) -> Option<PageTarget> {
self.sessions
.lock()
.unwrap_or_else(|e| e.into_inner())
.active
.clone()
}
pub fn active_session(&self) -> Option<String> {
let sessions = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
let target = sessions.active.as_ref()?;
sessions.attached.get(&target.target_id).cloned()
}
pub async fn evaluate(
&self,
session_id: &str,
expression: &str,
return_by_value: bool,
) -> Result<Value, String> {
self.evaluate_with(session_id, expression, return_by_value, false)
.await
}
pub async fn evaluate_with(
&self,
session_id: &str,
expression: &str,
return_by_value: bool,
await_promise: bool,
) -> Result<Value, String> {
let result = self
.send_session(
session_id,
"Runtime.evaluate",
json!({
"expression": expression,
"returnByValue": return_by_value,
"awaitPromise": await_promise,
}),
)
.await?;
if let Some(details) = result.get("exceptionDetails") {
return Err(format!(
"browser: the page threw: {}",
exception_text(details)
));
}
Ok(result.get("result").cloned().unwrap_or(Value::Null))
}
}
fn page_target_from(info: &Value) -> PageTarget {
PageTarget {
target_id: info
.get("targetId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
title: info
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
url: info
.get("url")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
}
}
fn is_stale_session_error(error: &str) -> bool {
error.to_ascii_lowercase().contains("session with given id")
}
fn exception_text(details: &Value) -> String {
if let Some(description) = details
.get("exception")
.and_then(|e| e.get("description"))
.and_then(Value::as_str)
{
return description.to_string();
}
if let Some(text) = details.get("text").and_then(Value::as_str) {
return text.to_string();
}
details.to_string()
}
fn route(pending: &Pending, text: &str) {
let Ok(value) = serde_json::from_str::<Value>(text) else {
return;
};
let Some(id) = value.get("id").and_then(Value::as_u64) else {
return;
};
let Some(sender) = pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id)
else {
return;
};
let outcome = match value.get("error") {
Some(error) => Err(format!(
"browser: CDP error {}: {}",
error.get("code").and_then(Value::as_i64).unwrap_or(0),
error
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown error")
)),
None => Ok(value.get("result").cloned().unwrap_or(Value::Null)),
};
_ = sender.send(outcome);
}
fn fail_all(pending: &Pending, reason: &str) {
let mut table = pending.lock().unwrap_or_else(|e| e.into_inner());
for (_, sender) in table.drain() {
_ = sender.send(Err(format!("browser: {reason}")));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn discover_rejects_non_loopback_host() {
let error = discover("example.com", 9222, Duration::from_secs(1))
.await
.unwrap_err();
assert!(error.contains("non-loopback"), "{error}");
}
#[test]
fn route_dispatches_results_and_errors() {
let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
let (ok_sender, ok_receiver) = oneshot::channel();
pending.lock().unwrap().insert(1, ok_sender);
route(&pending, r#"{"id":1,"result":{"sessionId":"S1"}}"#);
assert_eq!(
ok_receiver.blocking_recv().unwrap().unwrap(),
json!({ "sessionId": "S1" })
);
let (err_sender, err_receiver) = oneshot::channel();
pending.lock().unwrap().insert(2, err_sender);
route(
&pending,
r#"{"id":2,"error":{"code":-32601,"message":"method not found"}}"#,
);
let error = err_receiver.blocking_recv().unwrap().unwrap_err();
assert!(error.contains("-32601") && error.contains("method not found"));
route(&pending, r#"{"method":"Page.loadEventFired"}"#);
route(&pending, r#"{"id":99,"result":{}}"#);
assert!(pending.lock().unwrap().is_empty());
}
#[test]
fn fail_all_releases_every_pending_call() {
let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
let (sender, receiver) = oneshot::channel();
pending.lock().unwrap().insert(7, sender);
fail_all(&pending, "the CDP connection was closed");
let error = receiver.blocking_recv().unwrap().unwrap_err();
assert!(error.contains("connection was closed"), "{error}");
}
#[test]
fn stale_session_errors_are_recognised() {
assert!(is_stale_session_error(
"browser: CDP error -32001: Session with given id not found."
));
assert!(!is_stale_session_error(
"browser: CDP error -32000: Cannot navigate to invalid URL"
));
}
}