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::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
26/// [`EngineAdapter`] backed by a Node.js Playwright or Puppeteer subprocess.
27pub 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    /// Close the browser subprocess. Dropping the adapter also terminates it.
147    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}