viewpoint_cdp/connection/discovery/
mod.rs1use std::collections::HashMap;
8use std::time::Duration;
9
10use serde::Deserialize;
11use tracing::{debug, info, instrument};
12use url::Url;
13
14use crate::error::CdpError;
15
16const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
18
19#[derive(Debug, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct BrowserVersion {
23 pub browser: Option<String>,
25 pub protocol_version: Option<String>,
27 pub user_agent: Option<String>,
29 #[serde(rename = "V8-Version")]
31 pub v8_version: Option<String>,
32 pub webkit_version: Option<String>,
34 #[serde(rename = "webSocketDebuggerUrl")]
36 pub web_socket_debugger_url: Option<String>,
37}
38
39#[derive(Debug, Clone, Default)]
41pub struct CdpConnectionOptions {
42 pub timeout: Option<Duration>,
44 pub headers: HashMap<String, String>,
46}
47
48impl CdpConnectionOptions {
49 #[must_use]
51 pub fn new() -> Self {
52 Self::default()
53 }
54
55 #[must_use]
57 pub fn timeout(mut self, timeout: Duration) -> Self {
58 self.timeout = Some(timeout);
59 self
60 }
61
62 #[must_use]
64 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
65 self.headers.insert(name.into(), value.into());
66 self
67 }
68
69 #[must_use]
71 pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
72 self.headers.extend(headers);
73 self
74 }
75}
76
77#[instrument(level = "info", skip(options))]
94pub async fn discover_websocket_url(
95 endpoint_url: &str,
96 options: &CdpConnectionOptions,
97) -> Result<String, CdpError> {
98 let base_url = Url::parse(endpoint_url)
100 .map_err(|e| CdpError::InvalidEndpointUrl(format!("{endpoint_url}: {e}")))?;
101
102 if base_url.scheme() == "ws" || base_url.scheme() == "wss" {
104 debug!("URL is already a WebSocket URL, returning as-is");
105 return Ok(endpoint_url.to_string());
106 }
107
108 if base_url.scheme() != "http" && base_url.scheme() != "https" {
110 return Err(CdpError::InvalidEndpointUrl(format!(
111 "expected http, https, ws, or wss scheme, got: {}",
112 base_url.scheme()
113 )));
114 }
115
116 let version_url = base_url
118 .join("/json/version")
119 .map_err(|e| CdpError::InvalidEndpointUrl(format!("failed to build version URL: {e}")))?;
120
121 info!(url = %version_url, "Discovering WebSocket URL from HTTP endpoint");
122
123 let timeout = options.timeout.unwrap_or(DEFAULT_DISCOVERY_TIMEOUT);
125 let client = reqwest::Client::builder()
126 .timeout(timeout)
127 .build()
128 .map_err(|e| CdpError::HttpRequestFailed(e.to_string()))?;
129
130 let mut request = client.get(version_url.as_str());
132 for (name, value) in &options.headers {
133 request = request.header(name, value);
134 }
135
136 let response = request.send().await.map_err(|e| {
138 if e.is_timeout() {
139 CdpError::ConnectionTimeout(timeout)
140 } else if e.is_connect() {
141 CdpError::ConnectionFailed(format!("failed to connect to {endpoint_url}: {e}"))
142 } else {
143 CdpError::HttpRequestFailed(e.to_string())
144 }
145 })?;
146
147 if !response.status().is_success() {
149 return Err(CdpError::EndpointDiscoveryFailed {
150 url: endpoint_url.to_string(),
151 reason: format!("HTTP status {}", response.status()),
152 });
153 }
154
155 let version: BrowserVersion =
157 response
158 .json()
159 .await
160 .map_err(|e| CdpError::EndpointDiscoveryFailed {
161 url: endpoint_url.to_string(),
162 reason: format!("failed to parse response: {e}"),
163 })?;
164
165 let ws_url =
167 version
168 .web_socket_debugger_url
169 .ok_or_else(|| CdpError::EndpointDiscoveryFailed {
170 url: endpoint_url.to_string(),
171 reason: "response missing webSocketDebuggerUrl field".to_string(),
172 })?;
173
174 info!(ws_url = %ws_url, browser = ?version.browser, "Discovered WebSocket URL");
175
176 Ok(ws_url)
177}
178
179#[cfg(test)]
180mod tests;