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
23fn classify(url: &str) -> Result<Start, String> {
24    let lower = url.to_ascii_lowercase();
25    if lower.starts_with("ws://") || lower.starts_with("wss://") {
26        Ok(Start::Direct(url.to_string()))
27    } else if lower.starts_with("http://") || lower.starts_with("https://") {
28        Ok(Start::Discover(discovery_url(url)))
29    } else {
30        Err(format!(
31            "unsupported CDP URL \"{url}\" (expected ws://, wss://, http://, or https://)\n"
32        ))
33    }
34}
35
36pub fn discovery_url(http_url: &str) -> String {
37    if http_url.contains("/json/version") {
38        return http_url.to_string();
39    }
40    let trimmed = http_url.trim_end_matches('/');
41    format!("{trimmed}/json/version")
42}
43
44pub fn append_runtime(url: &str, runtime: Option<&str>) -> String {
45    let Some(runtime) = nonempty(runtime) else {
46        return url.to_string();
47    };
48    let enc = percent_encode(runtime);
49    if url.contains('?') {
50        format!("{url}&runtime={enc}")
51    } else {
52        format!("{url}?runtime={enc}")
53    }
54}
55
56pub fn format_http_error(status: u16, body: &[u8]) -> String {
57    let text = String::from_utf8_lossy(body);
58    let text = text.trim_end_matches('\0');
59    if text.is_empty() {
60        format!("HTTP {status}\n")
61    } else if text.ends_with('\n') {
62        format!("HTTP {status}\n{text}")
63    } else {
64        format!("HTTP {status}\n{text}\n")
65    }
66}
67
68pub fn websocket_url_from_version(body: &str) -> Result<String, String> {
69    let value: serde_json::Value =
70        serde_json::from_str(body).map_err(|err| format!("json/version: {err}\n"))?;
71    match value.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
72        Some(url) if !url.is_empty() => Ok(url.to_string()),
73        _ => Err("json/version returned no webSocketDebuggerUrl\n".to_string()),
74    }
75}
76
77fn percent_encode(value: &str) -> String {
78    let mut out = String::new();
79    for byte in value.bytes() {
80        match byte {
81            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
82                out.push(byte as char);
83            }
84            _ => out.push_str(&format!("%{byte:02X}")),
85        }
86    }
87    out
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn flag_wins_over_env_and_discovery() {
96        let flag = "ws://127.0.0.1:9333/devtools/browser/from-flag";
97        let env = "ws://127.0.0.1:9222/devtools/browser/from-env";
98        assert_eq!(
99            choose_start(Some(flag), Some(env)).unwrap(),
100            Start::Direct(flag.to_string())
101        );
102    }
103
104    #[test]
105    fn env_url_is_opaque() {
106        let env = "ws://127.0.0.1:9222/devtools/browser/6f1c0a2e-1b4d-4e2a-9c77-0a1b2c3d4e5f";
107        assert_eq!(
108            choose_start(None, Some(env)).unwrap(),
109            Start::Direct(env.to_string())
110        );
111    }
112
113    #[test]
114    fn unset_uses_json_version_discovery() {
115        assert_eq!(
116            choose_start(None, None).unwrap(),
117            Start::Discover("http://127.0.0.1:9222/json/version".to_string())
118        );
119    }
120
121    #[test]
122    fn http_flag_is_discovery() {
123        assert_eq!(
124            choose_start(Some("http://127.0.0.1:9444"), None).unwrap(),
125            Start::Discover("http://127.0.0.1:9444/json/version".to_string())
126        );
127    }
128
129    #[test]
130    fn does_not_invent_a_debugger_path() {
131        let start = choose_start(None, None).unwrap();
132        let text = format!("{start:?}");
133        assert!(!text.contains("cdp.slicc.internal"));
134        assert!(!text.contains("cdp.kernel.localhost"));
135        assert!(!text.contains("/devtools/browser"));
136    }
137
138    #[test]
139    fn runtime_query_appends() {
140        assert_eq!(
141            append_runtime("ws://127.0.0.1:9222/devtools/browser/abc", Some("follower")),
142            "ws://127.0.0.1:9222/devtools/browser/abc?runtime=follower"
143        );
144        assert_eq!(
145            append_runtime("ws://127.0.0.1:9222/devtools/browser/abc?x=1", Some("a b")),
146            "ws://127.0.0.1:9222/devtools/browser/abc?x=1&runtime=a%20b"
147        );
148        assert_eq!(
149            append_runtime("ws://127.0.0.1:9222/p", None),
150            "ws://127.0.0.1:9222/p"
151        );
152    }
153
154    #[test]
155    fn http_error_prints_status_and_body() {
156        assert_eq!(format_http_error(503, b"no host"), "HTTP 503\nno host\n");
157        assert_eq!(format_http_error(404, b""), "HTTP 404\n");
158        assert_eq!(format_http_error(404, b"missing\n"), "HTTP 404\nmissing\n");
159    }
160
161    #[test]
162    fn version_body_uses_returned_socket_only() {
163        let body = r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/devtools/browser/uuid"}"#;
164        assert_eq!(
165            websocket_url_from_version(body).unwrap(),
166            "ws://127.0.0.1:9222/devtools/browser/uuid"
167        );
168        assert!(websocket_url_from_version("{}").is_err());
169    }
170}