Skip to main content

agent_first_http/sdk/
endpoint.rs

1//! Endpoint URL parsing.
2//!
3//! `afhttp` connects to a host over one of:
4//!   - `ws://host:port` / `wss://host:port` — CDP over WebSocket (primary)
5//!   - `http://host:port` / `https://host:port` — for `/health`,
6//!     `/capabilities`, and the takeover panel
7//!   - `unix:/path/to.sock` — local-machine listener (Linux/macOS only)
8//!
9//! The SDK accepts any of those forms and derives the others when needed
10//! (e.g. the HTTP base URL from a WS endpoint).
11
12use std::str::FromStr;
13
14use crate::shared::error::{Error, ErrorCode};
15
16/// Parsed endpoint. Round-trip-safe: any input that parses also formats
17/// back to the canonical string with [`Endpoint::as_str`].
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Endpoint {
20    Ws {
21        host: String,
22        port: u16,
23        secure: bool,
24    },
25    Http {
26        host: String,
27        port: u16,
28        secure: bool,
29    },
30    #[cfg(unix)]
31    Unix { path: std::path::PathBuf },
32}
33
34impl Endpoint {
35    pub fn parse(input: &str) -> Result<Self, Error> {
36        if let Some(rest) = input.strip_prefix("ws://") {
37            let (host, port) = split_host_port(rest, 80)?;
38            Ok(Self::Ws {
39                host,
40                port,
41                secure: false,
42            })
43        } else if let Some(rest) = input.strip_prefix("wss://") {
44            let (host, port) = split_host_port(rest, 443)?;
45            Ok(Self::Ws {
46                host,
47                port,
48                secure: true,
49            })
50        } else if let Some(rest) = input.strip_prefix("http://") {
51            let (host, port) = split_host_port(rest, 80)?;
52            Ok(Self::Http {
53                host,
54                port,
55                secure: false,
56            })
57        } else if let Some(rest) = input.strip_prefix("https://") {
58            let (host, port) = split_host_port(rest, 443)?;
59            Ok(Self::Http {
60                host,
61                port,
62                secure: true,
63            })
64        } else if let Some(path) = input.strip_prefix("unix:") {
65            #[cfg(unix)]
66            {
67                Ok(Self::Unix {
68                    path: std::path::PathBuf::from(path),
69                })
70            }
71            #[cfg(not(unix))]
72            {
73                let _ = path;
74                Err(Error::new(
75                    ErrorCode::InvalidEndpoint,
76                    "unix: endpoints are not supported on this platform; use tcp:127.0.0.1:<port>",
77                ))
78            }
79        } else {
80            Err(Error::new(
81                ErrorCode::InvalidEndpoint,
82                format!(
83                    "endpoint must start with ws://, wss://, http://, https://, or unix:; got {input:?}"
84                ),
85            ))
86        }
87    }
88
89    /// HTTP base URL for `/health`, `/capabilities`, takeover panel. WS endpoints
90    /// derive an HTTP twin on the same host:port; Unix endpoints derive
91    /// `http://unix-socket` and use a unix transport at request time.
92    #[must_use]
93    pub fn http_base(&self) -> String {
94        match self {
95            Self::Http { host, port, secure } | Self::Ws { host, port, secure } => {
96                let scheme = if *secure { "https" } else { "http" };
97                format!("{scheme}://{host}:{port}")
98            }
99            #[cfg(unix)]
100            Self::Unix { .. } => "http://unix-socket".to_string(),
101        }
102    }
103
104    /// WS URL with path `/cdp` appended; the host routes that to the CDP
105    /// proxy.
106    #[must_use]
107    pub fn cdp_ws_url(&self) -> String {
108        match self {
109            Self::Ws { host, port, secure } | Self::Http { host, port, secure } => {
110                let scheme = if *secure { "wss" } else { "ws" };
111                format!("{scheme}://{host}:{port}/cdp")
112            }
113            #[cfg(unix)]
114            Self::Unix { .. } => "ws://unix-socket/cdp".to_string(),
115        }
116    }
117}
118
119impl FromStr for Endpoint {
120    type Err = Error;
121    fn from_str(s: &str) -> Result<Self, Self::Err> {
122        Self::parse(s)
123    }
124}
125
126fn split_host_port(rest: &str, default_port: u16) -> Result<(String, u16), Error> {
127    let rest = rest.split('/').next().unwrap_or(rest);
128    if rest.is_empty() {
129        return Err(Error::new(
130            ErrorCode::InvalidEndpoint,
131            "endpoint missing host",
132        ));
133    }
134    if let Some((host, port)) = rest.rsplit_once(':') {
135        let port: u16 = port.parse().map_err(|_| {
136            Error::new(
137                ErrorCode::InvalidEndpoint,
138                format!("endpoint port not a u16: {port:?}"),
139            )
140        })?;
141        Ok((host.to_string(), port))
142    } else {
143        Ok((rest.to_string(), default_port))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn parses_ws_with_explicit_port() {
153        let e = Endpoint::parse("ws://127.0.0.1:9222").unwrap();
154        assert_eq!(
155            e,
156            Endpoint::Ws {
157                host: "127.0.0.1".into(),
158                port: 9222,
159                secure: false
160            }
161        );
162    }
163
164    #[test]
165    fn parses_wss_with_default_port() {
166        let e = Endpoint::parse("wss://host.example").unwrap();
167        assert_eq!(
168            e,
169            Endpoint::Ws {
170                host: "host.example".into(),
171                port: 443,
172                secure: true
173            }
174        );
175    }
176
177    #[test]
178    fn parses_http_endpoints() {
179        let e = Endpoint::parse("http://localhost:8080").unwrap();
180        assert!(matches!(e, Endpoint::Http { port: 8080, .. }));
181    }
182
183    #[cfg(unix)]
184    #[test]
185    fn parses_unix_endpoint() {
186        let e = Endpoint::parse("unix:/run/afhttp/work.sock").unwrap();
187        assert!(matches!(e, Endpoint::Unix { .. }));
188    }
189
190    #[test]
191    fn http_base_strips_path() {
192        let e = Endpoint::parse("ws://example:9222").unwrap();
193        assert_eq!(e.http_base(), "http://example:9222");
194    }
195
196    #[test]
197    fn cdp_ws_url_uses_secure_scheme_when_endpoint_is_secure() {
198        let e = Endpoint::parse("https://example:443").unwrap();
199        assert_eq!(e.cdp_ws_url(), "wss://example:443/cdp");
200    }
201
202    #[test]
203    fn rejects_unknown_scheme() {
204        let err = Endpoint::parse("ftp://example").err();
205        assert_eq!(err.map(|e| e.error_code), Some(ErrorCode::InvalidEndpoint),);
206    }
207
208    #[test]
209    fn rejects_bad_port() {
210        let err = Endpoint::parse("ws://host:notnum").err();
211        assert_eq!(err.map(|e| e.error_code), Some(ErrorCode::InvalidEndpoint),);
212    }
213}