Skip to main content

browser_commander/browser/
connector.rs

1//! Connect to an already-running Chromium-family browser over CDP.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use chromiumoxide::browser::Browser as CdpBrowser;
8use chromiumoxide::cdp::browser_protocol::network::CookieParam;
9use futures::StreamExt;
10use serde_json::Value;
11
12use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
13use crate::browser::launcher::{Browser, LaunchResult};
14use crate::browser::node_bridge::NodeBridgePage;
15use crate::core::engine::EngineType;
16
17/// Options for attaching to a running browser over CDP.
18#[derive(Debug, Clone)]
19pub struct ConnectOptions {
20    /// Browser automation engine used for the connection.
21    pub engine: EngineType,
22    /// HTTP DevTools endpoint, for example `http://127.0.0.1:9222`.
23    pub cdp_endpoint: Option<String>,
24    /// DevTools browser WebSocket endpoint.
25    pub ws_endpoint: Option<String>,
26    /// Slow down Playwright/Puppeteer operations by this many milliseconds.
27    pub slow_mo: u64,
28    /// Optional connection timeout.
29    pub timeout: Option<Duration>,
30    /// Optional Puppeteer timeout for individual CDP calls.
31    pub protocol_timeout: Option<Duration>,
32    /// Cookies to seed after attaching, in CDP/Playwright cookie format.
33    pub seed_cookies: Vec<Value>,
34    /// Enable verbose bridge logging.
35    pub verbose: bool,
36    /// Node.js executable for Playwright/Puppeteer bridge engines.
37    pub node_executable: Option<PathBuf>,
38    /// Directory where Node resolves the Playwright/Puppeteer package.
39    pub node_working_dir: Option<PathBuf>,
40}
41
42impl Default for ConnectOptions {
43    fn default() -> Self {
44        Self {
45            engine: EngineType::Chromiumoxide,
46            cdp_endpoint: None,
47            ws_endpoint: None,
48            slow_mo: 0,
49            timeout: None,
50            protocol_timeout: None,
51            seed_cookies: Vec::new(),
52            verbose: false,
53            node_executable: None,
54            node_working_dir: None,
55        }
56    }
57}
58
59impl ConnectOptions {
60    /// Create Chromiumoxide connection options.
61    pub fn chromiumoxide() -> Self {
62        Self::default()
63    }
64
65    /// Create Playwright bridge connection options.
66    pub fn playwright() -> Self {
67        Self {
68            engine: EngineType::Playwright,
69            ..Self::default()
70        }
71    }
72
73    /// Create Puppeteer bridge connection options.
74    pub fn puppeteer() -> Self {
75        Self {
76            engine: EngineType::Puppeteer,
77            ..Self::default()
78        }
79    }
80
81    /// Select an HTTP DevTools endpoint.
82    pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
83        self.cdp_endpoint = Some(endpoint.into());
84        self
85    }
86
87    /// Select a DevTools browser WebSocket endpoint.
88    pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
89        self.ws_endpoint = Some(endpoint.into());
90        self
91    }
92
93    /// Set the engine operation delay.
94    pub fn slow_mo(mut self, milliseconds: u64) -> Self {
95        self.slow_mo = milliseconds;
96        self
97    }
98
99    /// Set the connection timeout.
100    pub fn timeout(mut self, timeout: Duration) -> Self {
101        self.timeout = Some(timeout);
102        self
103    }
104
105    /// Set Puppeteer's timeout for individual CDP calls.
106    pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
107        self.protocol_timeout = Some(timeout);
108        self
109    }
110
111    /// Seed cookies immediately after the connection is established.
112    pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
113        self.seed_cookies = cookies;
114        self
115    }
116
117    /// Enable verbose connection logging.
118    pub fn verbose(mut self, verbose: bool) -> Self {
119        self.verbose = verbose;
120        self
121    }
122
123    /// Override the Node.js executable for bridge engines.
124    pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
125        self.node_executable = Some(executable.into());
126        self
127    }
128
129    /// Set the directory where Node resolves Playwright or Puppeteer.
130    pub fn node_working_dir(mut self, directory: impl Into<PathBuf>) -> Self {
131        self.node_working_dir = Some(directory.into());
132        self
133    }
134
135    pub(crate) fn endpoint(&self) -> Result<&str, anyhow::Error> {
136        match (&self.cdp_endpoint, &self.ws_endpoint) {
137            (Some(endpoint), None) | (None, Some(endpoint)) if !endpoint.is_empty() => Ok(endpoint),
138            _ => Err(anyhow::anyhow!(
139                "connect_browser requires exactly one of cdp_endpoint or ws_endpoint"
140            )),
141        }
142    }
143}
144
145/// Attach to a running Chromium-family browser over CDP.
146///
147/// Chromiumoxide connects natively. Playwright and Puppeteer use the same
148/// official Node.js packages as [`launch_browser`](super::launcher::launch_browser).
149/// The returned page implements the crate's shared [`EngineAdapter`](crate::core::EngineAdapter)
150/// API. The browser's profile and process remain externally managed.
151pub async fn connect_browser(options: ConnectOptions) -> Result<LaunchResult, anyhow::Error> {
152    let endpoint = options.endpoint()?.to_string();
153    if options.verbose {
154        tracing::info!(engine = %options.engine, %endpoint, "connecting to browser");
155    }
156
157    match options.engine {
158        EngineType::Chromiumoxide => connect_chromiumoxide(options, endpoint).await,
159        EngineType::Playwright | EngineType::Puppeteer => {
160            let engine = options.engine;
161            let timeout = options.timeout;
162            let connection = NodeBridgePage::connect(options);
163            let page = if let Some(timeout) = timeout {
164                tokio::time::timeout(timeout, connection)
165                    .await
166                    .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
167            } else {
168                connection.await?
169            };
170            Ok(LaunchResult {
171                browser: Browser {
172                    engine,
173                    user_data_dir: PathBuf::new(),
174                    headless: false,
175                },
176                page: Arc::new(page),
177            })
178        }
179        EngineType::Fantoccini => Err(anyhow::anyhow!(
180            "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
181        )),
182    }
183}
184
185async fn connect_chromiumoxide(
186    options: ConnectOptions,
187    endpoint: String,
188) -> Result<LaunchResult, anyhow::Error> {
189    let connection = CdpBrowser::connect(endpoint);
190    let (browser, mut handler) = if let Some(timeout) = options.timeout {
191        tokio::time::timeout(timeout, connection)
192            .await
193            .map_err(|_| anyhow::anyhow!("timed out connecting to browser"))??
194    } else {
195        connection.await?
196    };
197
198    let handler_task = tokio::spawn(async move {
199        while let Some(event) = handler.next().await {
200            if let Err(error) = event {
201                tracing::debug!(%error, "chromiumoxide handler event error");
202            }
203        }
204    });
205
206    if !options.seed_cookies.is_empty() {
207        let cookies = options
208            .seed_cookies
209            .iter()
210            .cloned()
211            .map(serde_json::from_value::<CookieParam>)
212            .collect::<Result<Vec<_>, _>>()
213            .map_err(|error| anyhow::anyhow!("invalid seed cookie: {error}"))?;
214        browser.set_cookies(cookies).await?;
215    }
216
217    let page = match browser.pages().await?.into_iter().next() {
218        Some(page) => page,
219        None => browser.new_page("about:blank").await?,
220    };
221    let engine = options.engine;
222    let adapter = ChromiumoxidePage::new(page, browser, handler_task, PathBuf::new());
223
224    Ok(LaunchResult {
225        browser: Browser {
226            engine,
227            user_data_dir: PathBuf::new(),
228            headless: false,
229        },
230        page: Arc::new(adapter),
231    })
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use serde_json::json;
238
239    #[test]
240    fn connect_options_builders_preserve_endpoints_and_cookies() {
241        let cookies = vec![json!({"name": "SID", "value": "saved", "domain": ".example.com"})];
242        let options = ConnectOptions::playwright()
243            .cdp_endpoint("http://127.0.0.1:9222")
244            .slow_mo(25)
245            .seed_cookies(cookies.clone())
246            .node_working_dir("../js");
247
248        assert_eq!(options.engine, EngineType::Playwright);
249        assert_eq!(options.endpoint().unwrap(), "http://127.0.0.1:9222");
250        assert_eq!(options.slow_mo, 25);
251        assert_eq!(options.seed_cookies, cookies);
252        assert_eq!(options.node_working_dir, Some(PathBuf::from("../js")));
253    }
254
255    #[test]
256    fn connect_options_require_exactly_one_endpoint() {
257        assert!(ConnectOptions::default().endpoint().is_err());
258        assert!(ConnectOptions::puppeteer()
259            .cdp_endpoint("http://127.0.0.1:9222")
260            .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id")
261            .endpoint()
262            .is_err());
263    }
264}