Skip to main content

browser_automation_cli/native/cdp/
discovery.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![allow(missing_docs)]
3use std::time::Duration;
4
5use chromiumoxide::browser::Browser;
6use futures::StreamExt;
7
8use super::types::BrowserVersionInfo;
9
10/// Default timeout for CDP discovery HTTP requests.
11const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
12
13/// Discover the CDP WebSocket URL for the given host and port.
14///
15/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
16/// WebSocket connection to `/devtools/browser`. The returned URL has its
17/// host/port rewritten to match the requested target.
18///
19/// An optional `query` string (without the leading `?`) is appended to the
20/// final WebSocket URL so that user-supplied URL parameters (e.g.
21/// `?mode=Hello`) are forwarded to the remote endpoint.
22pub 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
30/// Like [`discover_cdp_url`] but with a custom request timeout.
31pub 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    // GAP-013: retry the full discovery cascade on transient network/timeouts.
38    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
45/// Single-pass discovery cascade (no retry). Used by tests and by the retry wrapper.
46pub(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    // Primary: /json/version (standard path)
53    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    // Fallback: /json/list (returns target list; look for the browser target)
67    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    // Final fallback: direct WebSocket at /devtools/browser.
73    // Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
74    // CDP over WebSocket but does not serve HTTP discovery endpoints.
75    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
84/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
85fn bracket_ipv6(host: &str) -> String {
86    if host.contains(':') && !host.starts_with('[') {
87        format!("[{}]", host)
88    } else {
89        host.to_string()
90    }
91}
92
93/// Fetch `/json/version` from the given host:port and parse the response.
94async 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
109/// Rewrite the host and port in a WebSocket URL to match the target we
110/// actually connected to. Chrome's `/json/version` always returns
111/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
112/// browser is on a remote machine or behind a port-forward.
113fn 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
123/// Append a query string to a URL, preserving any existing query parameters.
124fn 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                // Fallback: raw string append
135                if url.contains('?') {
136                    format!("{}&{}", url, q)
137                } else {
138                    format!("{}?{}", url, q)
139                }
140            }
141        }
142        _ => url.to_string(),
143    }
144}
145
146/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
147/// target with `type == "browser"`, or the first target if none has that type.
148async 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    // Prefer targets with type "browser", fall back to first target with a ws URL
165    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
178/// Discover a CDP endpoint by connecting via chromiumoxide `Browser::connect`
179/// and verifying `Browser.getVersion` / version() succeeds.
180/// Returns the WebSocket URL on success.
181async 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    // Prefer process-wide client (keep-alive); fall back to one-shot get.
213    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            // /json/list and ws fallback both fail (server closes)
267        });
268
269        // Use single-pass discovery: outer retry would re-hit a one-shot mock server.
270        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            // /json/version -> 404, /json/list -> 404; WS connect has no CDP browser
304            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}