Skip to main content

cdp_client/
connect.rs

1#[derive(Debug, Clone, PartialEq, Eq)]
2pub enum Start {
3    Direct(String),
4    Discover(String),
5}
6
7pub fn choose_start(cdp: Option<&str>, env: Option<&str>) -> Result<Start, String> {
8    if let Some(url) = nonempty(cdp) {
9        return classify(url);
10    }
11    if let Some(url) = nonempty(env) {
12        return classify(url);
13    }
14    Ok(Start::Discover(
15        "http://127.0.0.1:9222/json/version".to_string(),
16    ))
17}
18
19fn nonempty(value: Option<&str>) -> Option<&str> {
20    value.map(str::trim).filter(|s| !s.is_empty())
21}
22
23pub const DEFAULT_PORT: u16 = 9222;
24
25fn classify(raw: &str) -> Result<Start, String> {
26    let Some(url) = with_scheme(raw) else {
27        return Err(format!(
28            "unsupported CDP URL \"{raw}\" (expected a port, host:port, ws://, wss://, http://, or https://)\n"
29        ));
30    };
31    let lower = url.to_ascii_lowercase();
32    if let Some(http) = pathless_socket(&url) {
33        Ok(Start::Discover(discovery_url(&http)))
34    } else if lower.starts_with("ws://") || lower.starts_with("wss://") {
35        Ok(Start::Direct(url))
36    } else if lower.starts_with("http://") || lower.starts_with("https://") {
37        Ok(Start::Discover(discovery_url(&url)))
38    } else {
39        Err(format!(
40            "unsupported CDP URL \"{raw}\" (expected a port, host:port, ws://, wss://, http://, or https://)\n"
41        ))
42    }
43}
44
45pub fn with_scheme(raw: &str) -> Option<String> {
46    let raw = raw.trim();
47    if raw.is_empty() {
48        return None;
49    }
50    if raw.contains("://") {
51        return Some(raw.to_string());
52    }
53    if raw.bytes().all(|byte| byte.is_ascii_digit()) {
54        let port: u16 = raw.parse().ok().filter(|port| *port != 0)?;
55        return Some(format!("http://localhost:{port}"));
56    }
57    if raw.parse::<std::net::Ipv6Addr>().is_ok() {
58        return Some(format!("http://[{raw}]:{DEFAULT_PORT}"));
59    }
60    let cut = raw.find(['/', '?', '#']).unwrap_or(raw.len());
61    let (authority, rest) = raw.split_at(cut);
62    let (host, port) = if let Some(inner) = authority.strip_prefix('[') {
63        let (host, after) = inner.split_once(']')?;
64        host.parse::<std::net::Ipv6Addr>().ok()?;
65        let port = match after {
66            "" => None,
67            text => Some(text.strip_prefix(':')?),
68        };
69        (format!("[{host}]"), port)
70    } else {
71        match authority.split_once(':') {
72            Some((host, port)) => (host.to_string(), Some(port)),
73            None => (authority.to_string(), None),
74        }
75    };
76    if host.is_empty() || host.contains('@') {
77        return None;
78    }
79    let port = match port {
80        Some(text) => text.parse::<u16>().ok().filter(|port| *port != 0)?,
81        None => DEFAULT_PORT,
82    };
83    Some(format!("http://{host}:{port}{rest}"))
84}
85
86fn pathless_socket(url: &str) -> Option<String> {
87    let (scheme, rest) = url.split_once("://")?;
88    let http = match scheme.to_ascii_lowercase().as_str() {
89        "ws" => "http",
90        "wss" => "https",
91        _ => return None,
92    };
93    let authority = rest.strip_suffix('/').unwrap_or(rest);
94    if authority.is_empty() || authority.contains(['/', '?', '#']) {
95        return None;
96    }
97    Some(format!("{http}://{authority}"))
98}
99
100pub fn discovery_url(http_url: &str) -> String {
101    if http_url.contains("/json/version") {
102        return http_url.to_string();
103    }
104    let trimmed = http_url.trim_end_matches('/');
105    format!("{trimmed}/json/version")
106}
107
108pub fn append_runtime(url: &str, runtime: Option<&str>) -> String {
109    let Some(runtime) = nonempty(runtime) else {
110        return url.to_string();
111    };
112    let enc = percent_encode(runtime);
113    if url.contains('?') {
114        format!("{url}&runtime={enc}")
115    } else {
116        format!("{url}?runtime={enc}")
117    }
118}
119
120pub fn format_http_error(status: u16, body: &[u8]) -> String {
121    let text = String::from_utf8_lossy(body);
122    let text = text.trim_end_matches('\0');
123    if text.is_empty() {
124        format!("HTTP {status}\n")
125    } else if text.ends_with('\n') {
126        format!("HTTP {status}\n{text}")
127    } else {
128        format!("HTTP {status}\n{text}\n")
129    }
130}
131
132pub fn websocket_url_from_version(body: &str) -> Result<String, String> {
133    let value: serde_json::Value =
134        serde_json::from_str(body).map_err(|err| format!("json/version: {err}\n"))?;
135    match value.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
136        Some(url) if !url.is_empty() => Ok(url.to_string()),
137        _ => Err("json/version returned no webSocketDebuggerUrl\n".to_string()),
138    }
139}
140
141fn percent_encode(value: &str) -> String {
142    let mut out = String::new();
143    for byte in value.bytes() {
144        match byte {
145            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
146                out.push(byte as char);
147            }
148            _ => out.push_str(&format!("%{byte:02X}")),
149        }
150    }
151    out
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn flag_wins_over_env_and_discovery() {
160        let flag = "ws://127.0.0.1:9333/devtools/browser/from-flag";
161        let env = "ws://127.0.0.1:9222/devtools/browser/from-env";
162        assert_eq!(
163            choose_start(Some(flag), Some(env)).unwrap(),
164            Start::Direct(flag.to_string())
165        );
166    }
167
168    #[test]
169    fn env_url_is_opaque() {
170        let env = "ws://127.0.0.1:9222/devtools/browser/6f1c0a2e-1b4d-4e2a-9c77-0a1b2c3d4e5f";
171        assert_eq!(
172            choose_start(None, Some(env)).unwrap(),
173            Start::Direct(env.to_string())
174        );
175    }
176
177    #[test]
178    fn unset_uses_json_version_discovery() {
179        assert_eq!(
180            choose_start(None, None).unwrap(),
181            Start::Discover("http://127.0.0.1:9222/json/version".to_string())
182        );
183    }
184
185    #[test]
186    fn http_flag_is_discovery() {
187        assert_eq!(
188            choose_start(Some("http://127.0.0.1:9444"), None).unwrap(),
189            Start::Discover("http://127.0.0.1:9444/json/version".to_string())
190        );
191    }
192
193    #[test]
194    fn does_not_invent_a_debugger_path() {
195        let start = choose_start(None, None).unwrap();
196        let text = format!("{start:?}");
197        assert!(!text.contains("cdp.slicc.internal"));
198        assert!(!text.contains("cdp.kernel.localhost"));
199        assert!(!text.contains("/devtools/browser"));
200    }
201
202    #[test]
203    fn runtime_query_appends() {
204        assert_eq!(
205            append_runtime("ws://127.0.0.1:9222/devtools/browser/abc", Some("follower")),
206            "ws://127.0.0.1:9222/devtools/browser/abc?runtime=follower"
207        );
208        assert_eq!(
209            append_runtime("ws://127.0.0.1:9222/devtools/browser/abc?x=1", Some("a b")),
210            "ws://127.0.0.1:9222/devtools/browser/abc?x=1&runtime=a%20b"
211        );
212        assert_eq!(
213            append_runtime("ws://127.0.0.1:9222/p", None),
214            "ws://127.0.0.1:9222/p"
215        );
216    }
217
218    #[test]
219    fn every_spelling_of_localhost_is_a_cdp_endpoint() {
220        let discover = |url: &str| Start::Discover(url.to_string());
221        for (raw, want) in [
222            ("9222", "http://localhost:9222/json/version"),
223            ("9444", "http://localhost:9444/json/version"),
224            ("localhost", "http://localhost:9222/json/version"),
225            ("localhost:9444", "http://localhost:9444/json/version"),
226            ("127.0.0.1:9444", "http://127.0.0.1:9444/json/version"),
227            ("0.0.0.0:9444", "http://0.0.0.0:9444/json/version"),
228            ("::1", "http://[::1]:9222/json/version"),
229            ("[::1]", "http://[::1]:9222/json/version"),
230            ("[::1]:9444", "http://[::1]:9444/json/version"),
231            ("localhost:9444/json/version", "http://localhost:9444/json/version"),
232            ("http://localhost:9444", "http://localhost:9444/json/version"),
233            ("ws://localhost:9444", "http://localhost:9444/json/version"),
234            ("ws://[::1]:9444/", "http://[::1]:9444/json/version"),
235            ("wss://example.test:9444", "https://example.test:9444/json/version"),
236        ] {
237            assert_eq!(choose_start(Some(raw), None).unwrap(), discover(want), "{raw}");
238        }
239        assert_eq!(
240            choose_start(Some("ws://localhost:9444/devtools/browser/x"), None).unwrap(),
241            Start::Direct("ws://localhost:9444/devtools/browser/x".to_string())
242        );
243        assert_eq!(
244            choose_start(Some("ws://localhost:9444?x=1"), None).unwrap(),
245            Start::Direct("ws://localhost:9444?x=1".to_string())
246        );
247        for bad in ["0", "70000", "localhost:x", "[::1", "[nope]:9444", "[::1]x", "[::1]:", "a@b:9444", ":9444", "ftp://x"] {
248            assert!(choose_start(Some(bad), None).is_err(), "{bad}");
249        }
250        assert_eq!(with_scheme("  "), None);
251        assert_eq!(pathless_socket("http://x"), None);
252        assert_eq!(pathless_socket("ws://"), None);
253        assert_eq!(pathless_socket("nothing"), None);
254    }
255
256    #[test]
257    fn http_error_prints_status_and_body() {
258        assert_eq!(format_http_error(503, b"no host"), "HTTP 503\nno host\n");
259        assert_eq!(format_http_error(404, b""), "HTTP 404\n");
260        assert_eq!(format_http_error(404, b"missing\n"), "HTTP 404\nmissing\n");
261    }
262
263    #[test]
264    fn version_body_uses_returned_socket_only() {
265        let body = r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/uuid"}"#;
266        assert_eq!(
267            websocket_url_from_version(body).unwrap(),
268            "ws://127.0.0.1:9222/devtools/browser/uuid"
269        );
270        assert!(websocket_url_from_version("{}").is_err());
271    }
272}