browser_commander/browser/
connector.rs1use 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#[derive(Debug, Clone)]
19pub struct ConnectOptions {
20 pub engine: EngineType,
22 pub cdp_endpoint: Option<String>,
24 pub ws_endpoint: Option<String>,
26 pub slow_mo: u64,
28 pub timeout: Option<Duration>,
30 pub protocol_timeout: Option<Duration>,
32 pub seed_cookies: Vec<Value>,
34 pub verbose: bool,
36 pub node_executable: Option<PathBuf>,
38 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 pub fn chromiumoxide() -> Self {
62 Self::default()
63 }
64
65 pub fn playwright() -> Self {
67 Self {
68 engine: EngineType::Playwright,
69 ..Self::default()
70 }
71 }
72
73 pub fn puppeteer() -> Self {
75 Self {
76 engine: EngineType::Puppeteer,
77 ..Self::default()
78 }
79 }
80
81 pub fn cdp_endpoint(mut self, endpoint: impl Into<String>) -> Self {
83 self.cdp_endpoint = Some(endpoint.into());
84 self
85 }
86
87 pub fn ws_endpoint(mut self, endpoint: impl Into<String>) -> Self {
89 self.ws_endpoint = Some(endpoint.into());
90 self
91 }
92
93 pub fn slow_mo(mut self, milliseconds: u64) -> Self {
95 self.slow_mo = milliseconds;
96 self
97 }
98
99 pub fn timeout(mut self, timeout: Duration) -> Self {
101 self.timeout = Some(timeout);
102 self
103 }
104
105 pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
107 self.protocol_timeout = Some(timeout);
108 self
109 }
110
111 pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
113 self.seed_cookies = cookies;
114 self
115 }
116
117 pub fn verbose(mut self, verbose: bool) -> Self {
119 self.verbose = verbose;
120 self
121 }
122
123 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
125 self.node_executable = Some(executable.into());
126 self
127 }
128
129 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
145pub 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}