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 read_browser_version_page(&self) -> Result<Value, EngineError> {
443        self.request(
444            "readBrowserVersionPage",
445            json!({ "script": crate::parity::VERSION_EXPRESSION }),
446        )
447        .await
448    }
449
450    async fn restore_storage_state(&self, state: Value) -> Result<(), EngineError> {
451        self.request("restoreStorageState", json!({ "state": state }))
452            .await?;
453        Ok(())
454    }
455
456    async fn export_storage_state(&self) -> Result<Value, EngineError> {
457        self.request("exportStorageState", json!({})).await
458    }
459
460    async fn screenshot(&self) -> Result<Vec<u8>, EngineError> {
461        decode_base64(self.string_request("screenshot", json!({})).await?)
462    }
463
464    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError> {
465        let pdf_options = json!({
466            "format": options.format,
467            "printBackground": options.print_background,
468            "margin": {
469                "top": options.margin_top,
470                "right": options.margin_right,
471                "bottom": options.margin_bottom,
472                "left": options.margin_left,
473            },
474            "scale": options.scale,
475            "path": options.path,
476        });
477        let encoded = self
478            .request("pdf", pdf_options)
479            .await?
480            .as_str()
481            .unwrap_or_default()
482            .to_string();
483        decode_base64(encoded)
484    }
485
486    async fn bring_to_front(&self) -> Result<(), EngineError> {
487        self.request("bringToFront", json!({})).await?;
488        Ok(())
489    }
490
491    async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError> {
492        self.request("waitForNavigation", json!({ "timeoutMs": timeout_ms }))
493            .await?;
494        Ok(())
495    }
496
497    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError> {
498        self.request("keyboardPress", json!({ "key": key })).await?;
499        Ok(())
500    }
501
502    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError> {
503        self.request("keyboardType", json!({ "text": text }))
504            .await?;
505        Ok(())
506    }
507
508    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError> {
509        self.request("keyboardDown", json!({ "key": key })).await?;
510        Ok(())
511    }
512
513    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError> {
514        self.request("keyboardUp", json!({ "key": key })).await?;
515        Ok(())
516    }
517}
518
519fn path_to_string(path: &Path) -> String {
520    path.to_string_lossy().into_owned()
521}
522
523fn decode_base64(value: impl AsRef<str>) -> Result<Vec<u8>, EngineError> {
524    base64::engine::general_purpose::STANDARD
525        .decode(value.as_ref())
526        .map_err(|err| EngineError::Browser(format!("bridge returned invalid base64: {err}")))
527}
528
529fn element_info_from_value(value: &Value) -> Result<Option<ElementInfo>, EngineError> {
530    if value.is_null() {
531        return Ok(None);
532    }
533
534    let bounding_box = value.get("boundingBox").and_then(|box_value| {
535        let items = box_value.as_array()?;
536        if items.len() != 4 {
537            return None;
538        }
539        Some((
540            items[0].as_f64().unwrap_or_default(),
541            items[1].as_f64().unwrap_or_default(),
542            items[2].as_f64().unwrap_or_default(),
543            items[3].as_f64().unwrap_or_default(),
544        ))
545    });
546
547    Ok(Some(ElementInfo {
548        tag_name: value
549            .get("tagName")
550            .and_then(Value::as_str)
551            .unwrap_or("UNKNOWN")
552            .to_string(),
553        text_content: value
554            .get("textContent")
555            .and_then(Value::as_str)
556            .map(ToString::to_string),
557        is_visible: value
558            .get("isVisible")
559            .and_then(Value::as_bool)
560            .unwrap_or(false),
561        is_enabled: value
562            .get("isEnabled")
563            .and_then(Value::as_bool)
564            .unwrap_or(true),
565        bounding_box,
566    }))
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn params(options: &LaunchOptions) -> Value {
574        let args = options.all_chrome_args().unwrap();
575        let env = options.browser_env().unwrap();
576        launch_params(options, &args, env.as_ref(), Path::new("/tmp/browser-data"))
577    }
578
579    #[test]
580    fn launch_params_forward_browser_selection() {
581        let options = LaunchOptions::playwright()
582            .channel("chrome-beta")
583            .executable_path("/opt/google/chrome-beta");
584
585        let params = params(&options);
586
587        assert_eq!(params["channel"], "chrome-beta");
588        assert_eq!(params["executablePath"], "/opt/google/chrome-beta");
589    }
590
591    #[test]
592    fn launch_params_default_browser_selection_is_null() {
593        let params = params(&LaunchOptions::puppeteer());
594
595        assert!(params["channel"].is_null());
596        assert!(params["executablePath"].is_null());
597        assert!(params["env"].is_null());
598    }
599
600    #[test]
601    fn launch_params_forward_ignored_defaults() {
602        let options =
603            LaunchOptions::playwright().ignore_default_args(vec!["--no-first-run".to_string()]);
604
605        let params = params(&options);
606
607        // Parity exclusions come first, the caller's follow.
608        assert_eq!(params["ignoreDefaultArgs"][0], "--enable-automation");
609        assert_eq!(
610            params["ignoreDefaultArgs"][1],
611            "--enable-unsafe-swiftshader"
612        );
613        assert_eq!(params["ignoreDefaultArgs"][2], "--no-first-run");
614        assert!(!params["args"]
615            .as_array()
616            .unwrap()
617            .contains(&json!("--no-first-run")));
618    }
619
620    #[test]
621    fn launch_params_forward_only_the_caller_exclusions_when_parity_is_off() {
622        let options = LaunchOptions::playwright()
623            .automation_parity(false)
624            .ignore_default_args(vec!["--no-first-run".to_string()]);
625
626        let params = params(&options);
627
628        assert_eq!(params["ignoreDefaultArgs"], json!(["--no-first-run"]));
629    }
630
631    #[test]
632    fn launch_params_add_only_the_automation_controlled_off_switch() {
633        let params = params(&LaunchOptions::playwright());
634
635        assert_eq!(
636            params["args"],
637            json!(["--disable-blink-features=AutomationControlled"])
638        );
639    }
640
641    #[test]
642    fn launch_params_forward_restrictions_and_the_browser_env() {
643        let options = LaunchOptions::puppeteer()
644            .restrictions(["no-google-services", "basic-password-store"])
645            .env(HashMap::from([("TZ".to_string(), "UTC".to_string())]));
646
647        let params = params(&options);
648
649        assert!(params["args"]
650            .as_array()
651            .unwrap()
652            .contains(&json!("--password-store=basic")));
653        assert_eq!(params["env"]["GOOGLE_API_KEY"], "no");
654        assert_eq!(params["env"]["TZ"], "UTC");
655    }
656
657    #[test]
658    fn connect_params_forward_endpoint_timeouts_and_cookies() {
659        let options = ConnectOptions::puppeteer()
660            .ws_endpoint("ws://127.0.0.1:9222/devtools/browser/id")
661            .timeout(std::time::Duration::from_millis(1_500))
662            .protocol_timeout(std::time::Duration::from_secs(2))
663            .seed_cookies(vec![json!({"name": "SID", "value": "saved"})]);
664
665        let params = connect_params(&options, None);
666
667        assert_eq!(params["wsEndpoint"], options.ws_endpoint.unwrap());
668        assert_eq!(params["timeout"], 1_500);
669        assert_eq!(params["protocolTimeout"], 2_000);
670        assert_eq!(params["seedCookies"][0]["name"], "SID");
671        assert!(params["colorScheme"].is_null());
672    }
673
674    #[test]
675    fn connect_params_forward_the_color_scheme() {
676        let params = connect_params(&ConnectOptions::playwright(), Some(&ColorScheme::Dark));
677
678        assert_eq!(params["colorScheme"], "dark");
679    }
680}