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