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