browser_automation_cli/native/cdp/
discovery.rs1#![allow(missing_docs)]
3use std::time::Duration;
4
5use chromiumoxide::browser::Browser;
6use futures::StreamExt;
7
8use super::types::BrowserVersionInfo;
9
10const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
12
13pub async fn discover_cdp_url(
23 host: &str,
24 port: u16,
25 query: Option<&str>,
26) -> Result<String, String> {
27 discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
28}
29
30pub async fn discover_cdp_url_with_timeout(
32 host: &str,
33 port: u16,
34 query: Option<&str>,
35 timeout: Duration,
36) -> Result<String, String> {
37 let cfg = crate::retry::RetryConfig::cdp();
39 crate::retry::retry_async(cfg, || async {
40 discover_cdp_url_once(host, port, query, timeout).await
41 })
42 .await
43}
44
45pub(crate) async fn discover_cdp_url_once(
47 host: &str,
48 port: u16,
49 query: Option<&str>,
50 timeout: Duration,
51) -> Result<String, String> {
52 let version_err = match fetch_cdp_info(host, port, timeout).await {
54 Ok(info) => {
55 if let Some(ws_url) = info.web_socket_debugger_url {
56 return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
57 }
58 format!(
59 "No webSocketDebuggerUrl in /json/version at {}:{}",
60 host, port
61 )
62 }
63 Err(e) => e,
64 };
65
66 let list_err = match fetch_cdp_list(host, port, timeout).await {
68 Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
69 Err(e) => e,
70 };
71
72 match discover_cdp_ws(host, port, timeout).await {
76 Ok(ws_url) => Ok(append_query(&ws_url, query)),
77 Err(ws_err) => Err(format!(
78 "All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
79 host, port, version_err, list_err, ws_err
80 )),
81 }
82}
83
84fn bracket_ipv6(host: &str) -> String {
86 if host.contains(':') && !host.starts_with('[') {
87 format!("[{}]", host)
88 } else {
89 host.to_string()
90 }
91}
92
93async fn fetch_cdp_info(
95 host: &str,
96 port: u16,
97 timeout: Duration,
98) -> Result<BrowserVersionInfo, String> {
99 let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
100
101 let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
102 .await
103 .map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
104 .map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
105
106 serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
107}
108
109fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
114 if let Ok(mut parsed) = url::Url::parse(ws_url) {
115 let _ = parsed.set_host(Some(&bracket_ipv6(host)));
116 let _ = parsed.set_port(Some(port));
117 parsed.to_string()
118 } else {
119 ws_url.to_string()
120 }
121}
122
123fn append_query(url: &str, query: Option<&str>) -> String {
125 match query {
126 Some(q) if !q.is_empty() => {
127 if let Ok(mut parsed) = url::Url::parse(url) {
128 {
129 let mut pairs = parsed.query_pairs_mut();
130 pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
131 }
132 parsed.to_string()
133 } else {
134 if url.contains('?') {
136 format!("{}&{}", url, q)
137 } else {
138 format!("{}?{}", url, q)
139 }
140 }
141 }
142 _ => url.to_string(),
143 }
144}
145
146async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
149 let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
150
151 let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
152 .await
153 .map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
154 .map_err(|e| {
155 format!(
156 "Failed to connect to /json/list at {}:{}: {}",
157 host, port, e
158 )
159 })?;
160
161 let targets: Vec<serde_json::Value> =
162 serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
163
164 let browser_target = targets
166 .iter()
167 .find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
168
169 let target = browser_target.or_else(|| targets.first());
170
171 target
172 .and_then(|t| t.get("webSocketDebuggerUrl"))
173 .and_then(|v| v.as_str())
174 .map(|s| s.to_string())
175 .ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
176}
177
178async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
182 let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
183
184 tokio::time::timeout(timeout, async {
185 let (mut browser, mut handler) = Browser::connect(&ws_url)
186 .await
187 .map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
188
189 let handle = tokio::spawn(async move {
190 while let Some(h) = handler.next().await {
191 if h.is_err() {
192 break;
193 }
194 }
195 });
196
197 let version = browser
198 .version()
199 .await
200 .map_err(|e| format!("Browser.getVersion failed: {e}"))?;
201 let _ = version.product;
202 let _ = browser.close().await;
203 let _ = handle.await;
204 Ok::<(), String>(())
205 })
206 .await
207 .map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
208 .map(|()| ws_url)
209}
210
211async fn reqwest_get_string(url: &str) -> Result<String, String> {
212 let resp = match crate::robots::shared_http_client() {
214 Ok(c) => c.get(url).send().await.map_err(|e| e.to_string())?,
215 Err(_) => reqwest::get(url).await.map_err(|e| e.to_string())?,
216 };
217 resp.text().await.map_err(|e| e.to_string())
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use tokio::io::{AsyncReadExt, AsyncWriteExt};
224 use tokio::net::TcpListener;
225
226 const HTTP_404: &str =
227 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
228
229 fn http_200(body: &str) -> String {
230 format!(
231 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
232 body.len(), body
233 )
234 }
235
236 async fn accept_http(listener: &TcpListener, response: &str) {
237 let (mut s, _) = listener.accept().await.unwrap();
238 let mut buf = [0u8; 1024];
239 let _ = s.read(&mut buf).await;
240 s.write_all(response.as_bytes()).await.unwrap();
241 }
242
243 #[tokio::test]
244 async fn discovers_ws_url_from_json_version() {
245 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
246 let port = listener.local_addr().unwrap().port();
247 let server = tokio::spawn(async move {
248 accept_http(
249 &listener,
250 &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
251 )
252 .await;
253 });
254
255 let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
256 assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
257 server.await.unwrap();
258 }
259
260 #[tokio::test]
261 async fn returns_error_when_version_returns_invalid_json() {
262 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
263 let port = listener.local_addr().unwrap().port();
264 let server = tokio::spawn(async move {
265 accept_http(&listener, &http_200("not-json")).await;
266 });
268
269 let err = discover_cdp_url_once("127.0.0.1", port, None, Duration::from_secs(2))
271 .await
272 .unwrap_err();
273 assert!(
274 err.contains("Invalid /json/version response") || err.contains("/json/version"),
275 "unexpected discovery error: {err}"
276 );
277 server.await.unwrap();
278 }
279
280 #[tokio::test]
281 async fn falls_back_to_json_list_on_version_404() {
282 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
283 let port = listener.local_addr().unwrap().port();
284 let server = tokio::spawn(async move {
285 accept_http(&listener, HTTP_404).await;
286 accept_http(
287 &listener,
288 &http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
289 ).await;
290 });
291
292 let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
293 assert!(ws_url.contains("/devtools/browser/abc"));
294 assert!(ws_url.contains(&port.to_string()));
295 server.await.unwrap();
296 }
297
298 #[tokio::test]
299 async fn all_discovery_methods_fail_when_http_404_and_no_browser() {
300 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
301 let port = listener.local_addr().unwrap().port();
302 let server = tokio::spawn(async move {
303 accept_http(&listener, HTTP_404).await;
305 accept_http(&listener, HTTP_404).await;
306 });
307
308 let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
309 assert!(
310 err.contains("All CDP discovery methods failed"),
311 "unexpected error: {err}"
312 );
313 let _ = server.await;
314 }
315
316 #[test]
317 fn rewrite_ws_host_replaces_host_and_port() {
318 let original = "ws://127.0.0.1:9222/devtools/browser/abc";
319 let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
320 assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
321 }
322
323 #[test]
324 fn rewrite_ws_host_handles_ipv6() {
325 let original = "ws://127.0.0.1:9222/devtools/browser/abc";
326 let rewritten = rewrite_ws_host(original, "::1", 9222);
327 assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
328 }
329
330 #[test]
331 fn append_query_adds_params_to_url_without_query() {
332 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
333 let result = append_query(url, Some("mode=Hello"));
334 assert_eq!(
335 result,
336 "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
337 );
338 }
339
340 #[test]
341 fn append_query_merges_with_existing_query() {
342 let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
343 let result = append_query(url, Some("mode=Hello"));
344 assert_eq!(
345 result,
346 "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
347 );
348 }
349
350 #[test]
351 fn append_query_noop_for_none() {
352 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
353 let result = append_query(url, None);
354 assert_eq!(result, url);
355 }
356
357 #[test]
358 fn append_query_noop_for_empty() {
359 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
360 let result = append_query(url, Some(""));
361 assert_eq!(result, url);
362 }
363
364 #[test]
365 fn append_query_handles_multiple_params() {
366 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
367 let result = append_query(url, Some("mode=Hello&token=abc"));
368 assert_eq!(
369 result,
370 "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
371 );
372 }
373
374 #[tokio::test]
375 async fn discover_preserves_query_params() {
376 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
377 let port = listener.local_addr().unwrap().port();
378 let server = tokio::spawn(async move {
379 accept_http(
380 &listener,
381 &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
382 )
383 .await;
384 });
385
386 let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
387 .await
388 .unwrap();
389 assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
390 server.await.unwrap();
391 }
392}