viewpoint_cdp/connection/discovery/
mod.rs

1//! CDP endpoint discovery via HTTP.
2//!
3//! Chrome DevTools Protocol exposes an HTTP endpoint that returns browser metadata
4//! including the WebSocket URL. This module handles discovering the WebSocket URL
5//! from an HTTP endpoint.
6
7use 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
16/// Default timeout for HTTP endpoint discovery.
17const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(30);
18
19/// Response from the `/json/version` endpoint.
20#[derive(Debug, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct BrowserVersion {
23    /// Browser name and version.
24    pub browser: Option<String>,
25    /// Protocol version.
26    pub protocol_version: Option<String>,
27    /// User agent string.
28    pub user_agent: Option<String>,
29    /// V8 version.
30    #[serde(rename = "V8-Version")]
31    pub v8_version: Option<String>,
32    /// WebKit version.
33    pub webkit_version: Option<String>,
34    /// The WebSocket URL for browser-level CDP connection.
35    #[serde(rename = "webSocketDebuggerUrl")]
36    pub web_socket_debugger_url: Option<String>,
37}
38
39/// Options for CDP connection.
40#[derive(Debug, Clone, Default)]
41pub struct CdpConnectionOptions {
42    /// Timeout for the connection attempt.
43    pub timeout: Option<Duration>,
44    /// Custom headers to include in the WebSocket upgrade request.
45    pub headers: HashMap<String, String>,
46}
47
48impl CdpConnectionOptions {
49    /// Create new connection options with default values.
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    /// Set the connection timeout.
56    #[must_use]
57    pub fn timeout(mut self, timeout: Duration) -> Self {
58        self.timeout = Some(timeout);
59        self
60    }
61
62    /// Add a custom header.
63    #[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    /// Add multiple custom headers.
70    #[must_use]
71    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
72        self.headers.extend(headers);
73        self
74    }
75}
76
77/// Discover the WebSocket URL from an HTTP endpoint.
78///
79/// Given a URL like `http://localhost:9222`, this function fetches `/json/version`
80/// to get the `webSocketDebuggerUrl`.
81///
82/// # Arguments
83///
84/// * `endpoint_url` - The HTTP endpoint URL (e.g., `http://localhost:9222`)
85/// * `options` - Connection options including timeout and headers
86///
87/// # Errors
88///
89/// Returns an error if:
90/// - The URL is invalid
91/// - The HTTP request fails
92/// - The response doesn't contain a WebSocket URL
93#[instrument(level = "info", skip(options))]
94pub async fn discover_websocket_url(
95    endpoint_url: &str,
96    options: &CdpConnectionOptions,
97) -> Result<String, CdpError> {
98    // Parse and validate the URL
99    let base_url = Url::parse(endpoint_url)
100        .map_err(|e| CdpError::InvalidEndpointUrl(format!("{endpoint_url}: {e}")))?;
101
102    // Check if it's already a WebSocket URL
103    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    // Ensure it's an HTTP URL
109    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    // Build the /json/version URL
117    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    // Build the HTTP client with timeout
124    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    // Build the request with custom headers
131    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    // Send the request
137    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    // Check response status
148    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    // Parse the response
156    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    // Extract the WebSocket URL
166    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;