1use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use base64::Engine as _;
13use serde::Deserialize;
14use serde_json::{json, Value};
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::process::{Child, ChildStdin, ChildStdout, Command};
17use tokio::sync::Mutex;
18use tokio::task::JoinHandle;
19
20use crate::browser::connector::ConnectOptions;
21use crate::browser::launcher::LaunchOptions;
22use crate::core::engine::{ElementInfo, EngineAdapter, EngineError, EngineType, PdfOptions};
23
24const BRIDGE_SCRIPT: &str = include_str!("node_engine_bridge.js");
25
26pub struct NodeBridgePage {
28 engine: EngineType,
29 inner: Arc<Mutex<NodeBridgeProcess>>,
30 stderr_task: Arc<Mutex<Option<JoinHandle<()>>>>,
31}
32
33struct NodeBridgeProcess {
34 child: Child,
35 stdin: ChildStdin,
36 stdout: BufReader<ChildStdout>,
37 next_id: u64,
38}
39
40#[derive(Debug, Deserialize)]
41struct BridgeResponse {
42 id: u64,
43 ok: bool,
44 #[serde(default)]
45 result: Value,
46 #[serde(default)]
47 error: Option<String>,
48}
49
50impl NodeBridgePage {
51 pub(crate) async fn launch(
52 options: LaunchOptions,
53 user_data_dir: PathBuf,
54 ) -> Result<Self, anyhow::Error> {
55 let page = Self::start(
56 options.engine,
57 options.node_executable.as_deref(),
58 options.node_working_dir.as_deref(),
59 )
60 .await?;
61
62 page.request("launch", launch_params(&options, &user_data_dir))
63 .await
64 .map_err(|err| anyhow::anyhow!("{}", err))?;
65
66 Ok(page)
67 }
68
69 pub(crate) async fn connect(options: ConnectOptions) -> Result<Self, anyhow::Error> {
70 let page = Self::start(
71 options.engine,
72 options.node_executable.as_deref(),
73 options.node_working_dir.as_deref(),
74 )
75 .await?;
76
77 page.request("connect", connect_params(&options))
78 .await
79 .map_err(|err| anyhow::anyhow!("{}", err))?;
80
81 Ok(page)
82 }
83
84 async fn start(
85 engine: EngineType,
86 node_executable: Option<&Path>,
87 node_working_dir: Option<&Path>,
88 ) -> Result<Self, anyhow::Error> {
89 if !matches!(engine, EngineType::Playwright | EngineType::Puppeteer) {
90 return Err(anyhow::anyhow!(
91 "Node bridge only supports playwright and puppeteer engines"
92 ));
93 }
94
95 let node = node_executable
96 .map(Path::to_path_buf)
97 .unwrap_or_else(|| PathBuf::from("node"));
98 let mut command = Command::new(node);
99 command
100 .arg("--input-type=module")
101 .arg("-e")
102 .arg(BRIDGE_SCRIPT)
103 .stdin(Stdio::piped())
104 .stdout(Stdio::piped())
105 .stderr(Stdio::piped());
106
107 if let Some(working_dir) = node_working_dir {
108 command.current_dir(working_dir);
109 }
110
111 let mut child = command
112 .spawn()
113 .map_err(|err| anyhow::anyhow!("failed to start Node.js bridge: {}", err))?;
114
115 let stdin = child
116 .stdin
117 .take()
118 .ok_or_else(|| anyhow::anyhow!("Node.js bridge stdin was not captured"))?;
119 let stdout = child
120 .stdout
121 .take()
122 .ok_or_else(|| anyhow::anyhow!("Node.js bridge stdout was not captured"))?;
123 let stderr = child.stderr.take();
124
125 let stderr_task = stderr.map(|stderr| {
126 tokio::spawn(async move {
127 let mut lines = BufReader::new(stderr).lines();
128 while let Ok(Some(line)) = lines.next_line().await {
129 tracing::debug!(target: "browser_commander::node_bridge", "{line}");
130 }
131 })
132 });
133
134 Ok(Self {
135 engine,
136 inner: Arc::new(Mutex::new(NodeBridgeProcess {
137 child,
138 stdin,
139 stdout: BufReader::new(stdout),
140 next_id: 0,
141 })),
142 stderr_task: Arc::new(Mutex::new(stderr_task)),
143 })
144 }
145
146 pub async fn close(&self) -> Result<(), EngineError> {
148 let close_result = self.request("close", json!({})).await;
149 let mut inner = self.inner.lock().await;
150 let _ = inner.child.start_kill();
151 if let Some(task) = self.stderr_task.lock().await.take() {
152 task.abort();
153 }
154 close_result.map(|_| ())
155 }
156
157 async fn request(&self, method: &str, params: Value) -> Result<Value, EngineError> {
158 let mut inner = self.inner.lock().await;
159 inner.next_id = inner.next_id.saturating_add(1);
160 let id = inner.next_id;
161 let request = json!({
162 "id": id,
163 "method": method,
164 "params": params,
165 });
166
167 let mut encoded = serde_json::to_vec(&request).map_err(|err| {
168 EngineError::Browser(format!("bridge request encoding failed: {err}"))
169 })?;
170 encoded.push(b'\n');
171
172 inner
173 .stdin
174 .write_all(&encoded)
175 .await
176 .map_err(|err| EngineError::Browser(format!("bridge write failed: {err}")))?;
177 inner
178 .stdin
179 .flush()
180 .await
181 .map_err(|err| EngineError::Browser(format!("bridge flush failed: {err}")))?;
182
183 let mut line = String::new();
184 loop {
185 line.clear();
186 let bytes = inner
187 .stdout
188 .read_line(&mut line)
189 .await
190 .map_err(|err| EngineError::Browser(format!("bridge read failed: {err}")))?;
191 if bytes == 0 {
192 return Err(EngineError::Browser(
193 "Node.js bridge exited before responding".to_string(),
194 ));
195 }
196
197 let response: BridgeResponse =
198 serde_json::from_str(line.trim_end()).map_err(|err| {
199 EngineError::Browser(format!(
200 "bridge returned invalid JSON: {err}; line={}",
201 line.trim_end()
202 ))
203 })?;
204
205 if response.id != id {
206 tracing::debug!(
207 expected_id = id,
208 actual_id = response.id,
209 "ignoring out-of-order bridge response"
210 );
211 continue;
212 }
213
214 if response.ok {
215 return Ok(response.result);
216 }
217
218 return Err(EngineError::Browser(
219 response
220 .error
221 .unwrap_or_else(|| "Node.js bridge command failed".to_string()),
222 ));
223 }
224 }
225
226 async fn string_request(&self, method: &str, params: Value) -> Result<String, EngineError> {
227 let value = self.request(method, params).await?;
228 Ok(value.as_str().unwrap_or_default().to_string())
229 }
230
231 async fn bool_request(&self, method: &str, params: Value) -> Result<bool, EngineError> {
232 let value = self.request(method, params).await?;
233 Ok(value.as_bool().unwrap_or(false))
234 }
235}
236
237fn launch_params(options: &LaunchOptions, user_data_dir: &Path) -> Value {
238 json!({
239 "engine": options.engine.to_string(),
240 "userDataDir": path_to_string(user_data_dir),
241 "headless": options.headless,
242 "slowMo": options.slow_mo,
243 "verbose": options.verbose,
244 "args": options.all_chrome_args(),
245 "ignoreDefaultArgs": if options.ignore_all_default_args {
246 Value::Bool(true)
247 } else {
248 json!(options.ignore_default_args)
249 },
250 "colorScheme": options.color_scheme.as_ref().map(|cs| cs.as_str()),
251 "sandbox": options.sandbox,
252 "channel": options.channel,
253 "executablePath": options.executable_path.as_deref().map(path_to_string),
254 })
255}
256
257fn connect_params(options: &ConnectOptions) -> Value {
258 json!({
259 "engine": options.engine.to_string(),
260 "cdpEndpoint": options.cdp_endpoint,
261 "wsEndpoint": options.ws_endpoint,
262 "slowMo": options.slow_mo,
263 "timeout": duration_millis(options.timeout),
264 "protocolTimeout": duration_millis(options.protocol_timeout),
265 "seedCookies": options.seed_cookies,
266 "verbose": options.verbose,
267 })
268}
269
270fn duration_millis(duration: Option<std::time::Duration>) -> Option<u64> {
271 duration.map(|value| value.as_millis().min(u64::MAX as u128) as u64)
272}
273
274impl std::fmt::Debug for NodeBridgePage {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 f.debug_struct("NodeBridgePage")
277 .field("engine", &self.engine)
278 .finish()
279 }
280}
281
282impl Drop for NodeBridgePage {
283 fn drop(&mut self) {
284 if let Ok(mut inner) = self.inner.try_lock() {
285 let _ = inner.child.start_kill();
286 }
287 if let Ok(mut task) = self.stderr_task.try_lock() {
288 if let Some(handle) = task.take() {
289 handle.abort();
290 }
291 }
292 }
293}
294
295#[async_trait]
296impl EngineAdapter for NodeBridgePage {
297 fn engine_type(&self) -> EngineType {
298 self.engine
299 }
300
301 async fn url(&self) -> Result<String, EngineError> {
302 self.string_request("url", json!({})).await
303 }
304
305 async fn goto(&self, url: &str) -> Result<(), EngineError> {
306 self.request("goto", json!({ "url": url })).await?;
307 Ok(())
308 }
309
310 async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError> {
311 let value = self
312 .request("querySelector", json!({ "selector": selector }))
313 .await?;
314 element_info_from_value(&value)
315 }
316
317 async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError> {
318 let value = self
319 .request("querySelectorAll", json!({ "selector": selector }))
320 .await?;
321 let Some(items) = value.as_array() else {
322 return Ok(Vec::new());
323 };
324 let mut infos = Vec::with_capacity(items.len());
325 for item in items {
326 if let Some(info) = element_info_from_value(item)? {
327 infos.push(info);
328 }
329 }
330 Ok(infos)
331 }
332
333 async fn count(&self, selector: &str) -> Result<usize, EngineError> {
334 let value = self
335 .request("count", json!({ "selector": selector }))
336 .await?;
337 Ok(value.as_u64().unwrap_or(0) as usize)
338 }
339
340 async fn click(&self, selector: &str) -> Result<(), EngineError> {
341 self.request("click", json!({ "selector": selector }))
342 .await?;
343 Ok(())
344 }
345
346 async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError> {
347 self.request("fill", json!({ "selector": selector, "text": text }))
348 .await?;
349 Ok(())
350 }
351
352 async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError> {
353 self.request("typeText", json!({ "selector": selector, "text": text }))
354 .await?;
355 Ok(())
356 }
357
358 async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError> {
359 let value = self
360 .request("textContent", json!({ "selector": selector }))
361 .await?;
362 Ok(value.as_str().map(ToString::to_string))
363 }
364
365 async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError> {
366 let value = self
367 .request("inputValue", json!({ "selector": selector }))
368 .await?;
369 Ok(value.as_str().map(ToString::to_string))
370 }
371
372 async fn get_attribute(
373 &self,
374 selector: &str,
375 attribute: &str,
376 ) -> Result<Option<String>, EngineError> {
377 let value = self
378 .request(
379 "getAttribute",
380 json!({ "selector": selector, "attribute": attribute }),
381 )
382 .await?;
383 Ok(value.as_str().map(ToString::to_string))
384 }
385
386 async fn is_visible(&self, selector: &str) -> Result<bool, EngineError> {
387 self.bool_request("isVisible", json!({ "selector": selector }))
388 .await
389 }
390
391 async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError> {
392 self.bool_request("isEnabled", json!({ "selector": selector }))
393 .await
394 }
395
396 async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError> {
397 self.request(
398 "waitForSelector",
399 json!({ "selector": selector, "timeoutMs": timeout_ms }),
400 )
401 .await?;
402 Ok(())
403 }
404
405 async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError> {
406 self.request("scrollIntoView", json!({ "selector": selector }))
407 .await?;
408 Ok(())
409 }
410
411 async fn evaluate(&self, script: &str) -> Result<Value, EngineError> {
412 self.request("evaluate", json!({ "script": script })).await
413 }
414
415 async fn screenshot(&self) -> Result<Vec<u8>, EngineError> {
416 decode_base64(self.string_request("screenshot", json!({})).await?)
417 }
418
419 async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError> {
420 let pdf_options = json!({
421 "format": options.format,
422 "printBackground": options.print_background,
423 "margin": {
424 "top": options.margin_top,
425 "right": options.margin_right,
426 "bottom": options.margin_bottom,
427 "left": options.margin_left,
428 },
429 "scale": options.scale,
430 "path": options.path,
431 });
432 let encoded = self
433 .request("pdf", pdf_options)
434 .await?
435 .as_str()
436 .unwrap_or_default()
437 .to_string();
438 decode_base64(encoded)
439 }
440
441 async fn bring_to_front(&self) -> Result<(), EngineError> {
442 self.request("bringToFront", json!({})).await?;
443 Ok(())
444 }
445
446 async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError> {
447 self.request("waitForNavigation", json!({ "timeoutMs": timeout_ms }))
448 .await?;
449 Ok(())
450 }
451
452 async fn keyboard_press(&self, key: &str) -> Result<(), EngineError> {
453 self.request("keyboardPress", json!({ "key": key })).await?;
454 Ok(())
455 }
456
457 async fn keyboard_type(&self, text: &str) -> Result<(), EngineError> {
458 self.request("keyboardType", json!({ "text": text }))
459 .await?;
460 Ok(())
461 }
462
463 async fn keyboard_down(&self, key: &str) -> Result<(), EngineError> {
464 self.request("keyboardDown", json!({ "key": key })).await?;
465 Ok(())
466 }
467
468 async fn keyboard_up(&self, key: &str) -> Result<(), EngineError> {
469 self.request("keyboardUp", json!({ "key": key })).await?;
470 Ok(())
471 }
472}
473
474fn path_to_string(path: &Path) -> String {
475 path.to_string_lossy().into_owned()
476}
477
478fn decode_base64(value: impl AsRef<str>) -> Result<Vec<u8>, EngineError> {
479 base64::engine::general_purpose::STANDARD
480 .decode(value.as_ref())
481 .map_err(|err| EngineError::Browser(format!("bridge returned invalid base64: {err}")))
482}
483
484fn element_info_from_value(value: &Value) -> Result<Option<ElementInfo>, EngineError> {
485 if value.is_null() {
486 return Ok(None);
487 }
488
489 let bounding_box = value.get("boundingBox").and_then(|box_value| {
490 let items = box_value.as_array()?;
491 if items.len() != 4 {
492 return None;
493 }
494 Some((
495 items[0].as_f64().unwrap_or_default(),
496 items[1].as_f64().unwrap_or_default(),
497 items[2].as_f64().unwrap_or_default(),
498 items[3].as_f64().unwrap_or_default(),
499 ))
500 });
501
502 Ok(Some(ElementInfo {
503 tag_name: value
504 .get("tagName")
505 .and_then(Value::as_str)
506 .unwrap_or("UNKNOWN")
507 .to_string(),
508 text_content: value
509 .get("textContent")
510 .and_then(Value::as_str)
511 .map(ToString::to_string),
512 is_visible: value
513 .get("isVisible")
514 .and_then(Value::as_bool)
515 .unwrap_or(false),
516 is_enabled: value
517 .get("isEnabled")
518 .and_then(Value::as_bool)
519 .unwrap_or(true),
520 bounding_box,
521 }))
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn launch_params_forward_browser_selection() {
530 let options = LaunchOptions::playwright()
531 .channel("chrome-beta")
532 .executable_path("/opt/google/chrome-beta");
533
534 let params = launch_params(&options, Path::new("/tmp/browser-data"));
535
536 assert_eq!(params["channel"], "chrome-beta");
537 assert_eq!(params["executablePath"], "/opt/google/chrome-beta");
538 }
539
540 #[test]
541 fn launch_params_default_browser_selection_is_null() {
542 let params = launch_params(&LaunchOptions::puppeteer(), Path::new("/tmp/browser-data"));
543
544 assert!(params["channel"].is_null());
545 assert!(params["executablePath"].is_null());
546 }
547
548 #[test]
549 fn launch_params_forward_ignored_defaults() {
550 let options =
551 LaunchOptions::playwright().ignore_default_args(vec!["--no-first-run".to_string()]);
552
553 let params = launch_params(&options, Path::new("/tmp/browser-data"));
554
555 assert_eq!(params["ignoreDefaultArgs"][0], "--no-first-run");
556 assert!(!params["args"]
557 .as_array()
558 .unwrap()
559 .contains(&json!("--no-first-run")));
560 }
561
562 #[test]
563 fn connect_params_forward_endpoint_timeouts_and_cookies() {
564 let options = ConnectOptions::puppeteer()
565 .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id")
566 .timeout(std::time::Duration::from_millis(1_500))
567 .protocol_timeout(std::time::Duration::from_secs(2))
568 .seed_cookies(vec![json!({"name": "SID", "value": "saved"})]);
569
570 let params = connect_params(&options);
571
572 assert_eq!(params["wsEndpoint"], options.ws_endpoint.unwrap());
573 assert_eq!(params["timeout"], 1_500);
574 assert_eq!(params["protocolTimeout"], 2_000);
575 assert_eq!(params["seedCookies"][0]["name"], "SID");
576 }
577}