Skip to main content

browser_automation_cli/native/cdp/
discovery.rs

1#![allow(missing_docs)]
2use std::time::Duration;
3
4use chromiumoxide::browser::Browser;
5use futures::StreamExt;
6
7use super::types::BrowserVersionInfo;
8
9/// Default timeout for CDP discovery HTTP requests.
10const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
11
12/// Discover the CDP WebSocket URL for the given host and port.
13///
14/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
15/// WebSocket connection to `/devtools/browser`. The returned URL has its
16/// host/port rewritten to match the requested target.
17///
18/// An optional `query` string (without the leading `?`) is appended to the
19/// final WebSocket URL so that user-supplied URL parameters (e.g.
20/// `?mode=Hello`) are forwarded to the remote endpoint.
21pub async fn discover_cdp_url(
22    host: &str,
23    port: u16,
24    query: Option<&str>,
25) -> Result<String, String> {
26    discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
27}
28
29/// Like [`discover_cdp_url`] but with a custom request timeout.
30pub async fn discover_cdp_url_with_timeout(
31    host: &str,
32    port: u16,
33    query: Option<&str>,
34    timeout: Duration,
35) -> Result<String, String> {
36    // Primary: /json/version (standard path)
37    let version_err = match fetch_cdp_info(host, port, timeout).await {
38        Ok(info) => {
39            if let Some(ws_url) = info.web_socket_debugger_url {
40                return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
41            }
42            format!(
43                "No webSocketDebuggerUrl in /json/version at {}:{}",
44                host, port
45            )
46        }
47        Err(e) => e,
48    };
49
50    // Fallback: /json/list (returns target list; look for the browser target)
51    let list_err = match fetch_cdp_list(host, port, timeout).await {
52        Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
53        Err(e) => e,
54    };
55
56    // Final fallback: direct WebSocket at /devtools/browser.
57    // Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
58    // CDP over WebSocket but does not serve HTTP discovery endpoints.
59    match discover_cdp_ws(host, port, timeout).await {
60        Ok(ws_url) => Ok(append_query(&ws_url, query)),
61        Err(ws_err) => Err(format!(
62            "All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
63            host, port, version_err, list_err, ws_err
64        )),
65    }
66}
67
68/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
69fn bracket_ipv6(host: &str) -> String {
70    if host.contains(':') && !host.starts_with('[') {
71        format!("[{}]", host)
72    } else {
73        host.to_string()
74    }
75}
76
77/// Fetch `/json/version` from the given host:port and parse the response.
78async fn fetch_cdp_info(
79    host: &str,
80    port: u16,
81    timeout: Duration,
82) -> Result<BrowserVersionInfo, String> {
83    let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
84
85    let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
86        .await
87        .map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
88        .map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
89
90    serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
91}
92
93/// Rewrite the host and port in a WebSocket URL to match the target we
94/// actually connected to. Chrome's `/json/version` always returns
95/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
96/// browser is on a remote machine or behind a port-forward.
97fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
98    if let Ok(mut parsed) = url::Url::parse(ws_url) {
99        let _ = parsed.set_host(Some(&bracket_ipv6(host)));
100        let _ = parsed.set_port(Some(port));
101        parsed.to_string()
102    } else {
103        ws_url.to_string()
104    }
105}
106
107/// Append a query string to a URL, preserving any existing query parameters.
108fn append_query(url: &str, query: Option<&str>) -> String {
109    match query {
110        Some(q) if !q.is_empty() => {
111            if let Ok(mut parsed) = url::Url::parse(url) {
112                {
113                    let mut pairs = parsed.query_pairs_mut();
114                    pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
115                }
116                parsed.to_string()
117            } else {
118                // Fallback: raw string append
119                if url.contains('?') {
120                    format!("{}&{}", url, q)
121                } else {
122                    format!("{}?{}", url, q)
123                }
124            }
125        }
126        _ => url.to_string(),
127    }
128}
129
130/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
131/// target with `type == "browser"`, or the first target if none has that type.
132async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
133    let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
134
135    let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
136        .await
137        .map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
138        .map_err(|e| {
139            format!(
140                "Failed to connect to /json/list at {}:{}: {}",
141                host, port, e
142            )
143        })?;
144
145    let targets: Vec<serde_json::Value> =
146        serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
147
148    // Prefer targets with type "browser", fall back to first target with a ws URL
149    let browser_target = targets
150        .iter()
151        .find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
152
153    let target = browser_target.or_else(|| targets.first());
154
155    target
156        .and_then(|t| t.get("webSocketDebuggerUrl"))
157        .and_then(|v| v.as_str())
158        .map(|s| s.to_string())
159        .ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
160}
161
162/// Discover a CDP endpoint by connecting via chromiumoxide `Browser::connect`
163/// and verifying `Browser.getVersion` / version() succeeds.
164/// Returns the WebSocket URL on success.
165async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
166    let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
167
168    tokio::time::timeout(timeout, async {
169        let (mut browser, mut handler) = Browser::connect(&ws_url)
170            .await
171            .map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
172
173        let handle = tokio::spawn(async move {
174            while let Some(h) = handler.next().await {
175                if h.is_err() {
176                    break;
177                }
178            }
179        });
180
181        let version = browser
182            .version()
183            .await
184            .map_err(|e| format!("Browser.getVersion failed: {e}"))?;
185        let _ = version.product;
186        let _ = browser.close().await;
187        let _ = handle.await;
188        Ok::<(), String>(())
189    })
190    .await
191    .map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
192    .map(|()| ws_url)
193}
194
195async fn reqwest_get_string(url: &str) -> Result<String, String> {
196    let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
197    resp.text().await.map_err(|e| e.to_string())
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use tokio::io::{AsyncReadExt, AsyncWriteExt};
204    use tokio::net::TcpListener;
205
206    const HTTP_404: &str =
207        "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
208
209    fn http_200(body: &str) -> String {
210        format!(
211            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
212            body.len(), body
213        )
214    }
215
216    async fn accept_http(listener: &TcpListener, response: &str) {
217        let (mut s, _) = listener.accept().await.unwrap();
218        let mut buf = [0u8; 1024];
219        let _ = s.read(&mut buf).await;
220        s.write_all(response.as_bytes()).await.unwrap();
221    }
222
223    #[tokio::test]
224    async fn discovers_ws_url_from_json_version() {
225        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
226        let port = listener.local_addr().unwrap().port();
227        let server = tokio::spawn(async move {
228            accept_http(
229                &listener,
230                &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
231            )
232            .await;
233        });
234
235        let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
236        assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
237        server.await.unwrap();
238    }
239
240    #[tokio::test]
241    async fn returns_error_when_version_returns_invalid_json() {
242        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
243        let port = listener.local_addr().unwrap().port();
244        let server = tokio::spawn(async move {
245            accept_http(&listener, &http_200("not-json")).await;
246            // /json/list and ws fallback both fail (server closes)
247        });
248
249        let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
250        assert!(err.contains("Invalid /json/version response"));
251        server.await.unwrap();
252    }
253
254    #[tokio::test]
255    async fn falls_back_to_json_list_on_version_404() {
256        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
257        let port = listener.local_addr().unwrap().port();
258        let server = tokio::spawn(async move {
259            accept_http(&listener, HTTP_404).await;
260            accept_http(
261                &listener,
262                &http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
263            ).await;
264        });
265
266        let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
267        assert!(ws_url.contains("/devtools/browser/abc"));
268        assert!(ws_url.contains(&port.to_string()));
269        server.await.unwrap();
270    }
271
272    #[tokio::test]
273    async fn all_discovery_methods_fail_when_http_404_and_no_browser() {
274        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
275        let port = listener.local_addr().unwrap().port();
276        let server = tokio::spawn(async move {
277            // /json/version -> 404, /json/list -> 404; WS connect has no CDP browser
278            accept_http(&listener, HTTP_404).await;
279            accept_http(&listener, HTTP_404).await;
280        });
281
282        let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
283        assert!(
284            err.contains("All CDP discovery methods failed"),
285            "unexpected error: {err}"
286        );
287        let _ = server.await;
288    }
289
290    #[test]
291    fn rewrite_ws_host_replaces_host_and_port() {
292        let original = "ws://127.0.0.1:9222/devtools/browser/abc";
293        let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
294        assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
295    }
296
297    #[test]
298    fn rewrite_ws_host_handles_ipv6() {
299        let original = "ws://127.0.0.1:9222/devtools/browser/abc";
300        let rewritten = rewrite_ws_host(original, "::1", 9222);
301        assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
302    }
303
304    #[test]
305    fn append_query_adds_params_to_url_without_query() {
306        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
307        let result = append_query(url, Some("mode=Hello"));
308        assert_eq!(
309            result,
310            "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
311        );
312    }
313
314    #[test]
315    fn append_query_merges_with_existing_query() {
316        let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
317        let result = append_query(url, Some("mode=Hello"));
318        assert_eq!(
319            result,
320            "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
321        );
322    }
323
324    #[test]
325    fn append_query_noop_for_none() {
326        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
327        let result = append_query(url, None);
328        assert_eq!(result, url);
329    }
330
331    #[test]
332    fn append_query_noop_for_empty() {
333        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
334        let result = append_query(url, Some(""));
335        assert_eq!(result, url);
336    }
337
338    #[test]
339    fn append_query_handles_multiple_params() {
340        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
341        let result = append_query(url, Some("mode=Hello&token=abc"));
342        assert_eq!(
343            result,
344            "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
345        );
346    }
347
348    #[tokio::test]
349    async fn discover_preserves_query_params() {
350        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
351        let port = listener.local_addr().unwrap().port();
352        let server = tokio::spawn(async move {
353            accept_http(
354                &listener,
355                &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
356            )
357            .await;
358        });
359
360        let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
361            .await
362            .unwrap();
363        assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
364        server.await.unwrap();
365    }
366}