Skip to main content

browser_commander/browser/
node_bridge.rs

1//! Node.js CLI bridge for Playwright and Puppeteer engines.
2//!
3//! Rust does not have official Playwright or Puppeteer bindings. This adapter
4//! keeps those engine names available by delegating browser operations to the
5//! official Node.js packages over a line-delimited JSON protocol.
6
7use 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
28/// [`EngineAdapter`] backed by a Node.js Playwright or Puppeteer subprocess.
29pub 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    /// Launch the browser through the Node engine
54    /// ([`LaunchMode::Engine`](crate::browser::launcher::LaunchMode::Engine)).
55    ///
56    /// `args` is the resolved command line. `env` is added to the browser
57    /// process's environment only: neither this process's environment nor the
58    /// bridge's own is changed.
59    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    /// Attach the Node engine to a running browser. `color_scheme` is then
80    /// emulated on the page the bridge picked, best-effort.
81    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        // The bridge stays on tokio::process rather than command-stream (the
114        // route the one-shot credential subprocesses take): it is a
115        // long-lived, interactive child that answers one JSON request per
116        // line on its stdin, and every request has to wait for the matching
117        // line on stdout while the stderr log is drained concurrently.
118        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    /// Close the browser subprocess. Dropping the adapter also terminates it.
167    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        // Parity exclusions come first, the caller's follow.
590        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}