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(
113            "launch",
114            json!({
115                "engine": engine.to_string(),
116                "userDataDir": path_to_string(&user_data_dir),
117                "headless": options.headless,
118                "slowMo": options.slow_mo,
119                "verbose": options.verbose,
120                "args": options.all_chrome_args(),
121                "colorScheme": options.color_scheme.as_ref().map(|cs| cs.as_str()),
122                "sandbox": options.sandbox,
123            }),
124        )
125        .await
126        .map_err(|err| anyhow::anyhow!("{}", err))?;
127
128        Ok(page)
129    }
130
131    /// Close the browser subprocess. Dropping the adapter also terminates it.
132    pub async fn close(&self) -> Result<(), EngineError> {
133        let close_result = self.request("close", json!({})).await;
134        let mut inner = self.inner.lock().await;
135        let _ = inner.child.start_kill();
136        if let Some(task) = self.stderr_task.lock().await.take() {
137            task.abort();
138        }
139        close_result.map(|_| ())
140    }
141
142    async fn request(&self, method: &str, params: Value) -> Result<Value, EngineError> {
143        let mut inner = self.inner.lock().await;
144        inner.next_id = inner.next_id.saturating_add(1);
145        let id = inner.next_id;
146        let request = json!({
147            "id": id,
148            "method": method,
149            "params": params,
150        });
151
152        let mut encoded = serde_json::to_vec(&request).map_err(|err| {
153            EngineError::Browser(format!("bridge request encoding failed: {err}"))
154        })?;
155        encoded.push(b'\n');
156
157        inner
158            .stdin
159            .write_all(&encoded)
160            .await
161            .map_err(|err| EngineError::Browser(format!("bridge write failed: {err}")))?;
162        inner
163            .stdin
164            .flush()
165            .await
166            .map_err(|err| EngineError::Browser(format!("bridge flush failed: {err}")))?;
167
168        let mut line = String::new();
169        loop {
170            line.clear();
171            let bytes = inner
172                .stdout
173                .read_line(&mut line)
174                .await
175                .map_err(|err| EngineError::Browser(format!("bridge read failed: {err}")))?;
176            if bytes == 0 {
177                return Err(EngineError::Browser(
178                    "Node.js bridge exited before responding".to_string(),
179                ));
180            }
181
182            let response: BridgeResponse =
183                serde_json::from_str(line.trim_end()).map_err(|err| {
184                    EngineError::Browser(format!(
185                        "bridge returned invalid JSON: {err}; line={}",
186                        line.trim_end()
187                    ))
188                })?;
189
190            if response.id != id {
191                tracing::debug!(
192                    expected_id = id,
193                    actual_id = response.id,
194                    "ignoring out-of-order bridge response"
195                );
196                continue;
197            }
198
199            if response.ok {
200                return Ok(response.result);
201            }
202
203            return Err(EngineError::Browser(
204                response
205                    .error
206                    .unwrap_or_else(|| "Node.js bridge command failed".to_string()),
207            ));
208        }
209    }
210
211    async fn string_request(&self, method: &str, params: Value) -> Result<String, EngineError> {
212        let value = self.request(method, params).await?;
213        Ok(value.as_str().unwrap_or_default().to_string())
214    }
215
216    async fn bool_request(&self, method: &str, params: Value) -> Result<bool, EngineError> {
217        let value = self.request(method, params).await?;
218        Ok(value.as_bool().unwrap_or(false))
219    }
220}
221
222impl std::fmt::Debug for NodeBridgePage {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        f.debug_struct("NodeBridgePage")
225            .field("engine", &self.engine)
226            .finish()
227    }
228}
229
230impl Drop for NodeBridgePage {
231    fn drop(&mut self) {
232        if let Ok(mut inner) = self.inner.try_lock() {
233            let _ = inner.child.start_kill();
234        }
235        if let Ok(mut task) = self.stderr_task.try_lock() {
236            if let Some(handle) = task.take() {
237                handle.abort();
238            }
239        }
240    }
241}
242
243#[async_trait]
244impl EngineAdapter for NodeBridgePage {
245    fn engine_type(&self) -> EngineType {
246        self.engine
247    }
248
249    async fn url(&self) -> Result<String, EngineError> {
250        self.string_request("url", json!({})).await
251    }
252
253    async fn goto(&self, url: &str) -> Result<(), EngineError> {
254        self.request("goto", json!({ "url": url })).await?;
255        Ok(())
256    }
257
258    async fn query_selector(&self, selector: &str) -> Result<Option<ElementInfo>, EngineError> {
259        let value = self
260            .request("querySelector", json!({ "selector": selector }))
261            .await?;
262        element_info_from_value(&value)
263    }
264
265    async fn query_selector_all(&self, selector: &str) -> Result<Vec<ElementInfo>, EngineError> {
266        let value = self
267            .request("querySelectorAll", json!({ "selector": selector }))
268            .await?;
269        let Some(items) = value.as_array() else {
270            return Ok(Vec::new());
271        };
272        let mut infos = Vec::with_capacity(items.len());
273        for item in items {
274            if let Some(info) = element_info_from_value(item)? {
275                infos.push(info);
276            }
277        }
278        Ok(infos)
279    }
280
281    async fn count(&self, selector: &str) -> Result<usize, EngineError> {
282        let value = self
283            .request("count", json!({ "selector": selector }))
284            .await?;
285        Ok(value.as_u64().unwrap_or(0) as usize)
286    }
287
288    async fn click(&self, selector: &str) -> Result<(), EngineError> {
289        self.request("click", json!({ "selector": selector }))
290            .await?;
291        Ok(())
292    }
293
294    async fn fill(&self, selector: &str, text: &str) -> Result<(), EngineError> {
295        self.request("fill", json!({ "selector": selector, "text": text }))
296            .await?;
297        Ok(())
298    }
299
300    async fn type_text(&self, selector: &str, text: &str) -> Result<(), EngineError> {
301        self.request("typeText", json!({ "selector": selector, "text": text }))
302            .await?;
303        Ok(())
304    }
305
306    async fn text_content(&self, selector: &str) -> Result<Option<String>, EngineError> {
307        let value = self
308            .request("textContent", json!({ "selector": selector }))
309            .await?;
310        Ok(value.as_str().map(ToString::to_string))
311    }
312
313    async fn input_value(&self, selector: &str) -> Result<Option<String>, EngineError> {
314        let value = self
315            .request("inputValue", json!({ "selector": selector }))
316            .await?;
317        Ok(value.as_str().map(ToString::to_string))
318    }
319
320    async fn get_attribute(
321        &self,
322        selector: &str,
323        attribute: &str,
324    ) -> Result<Option<String>, EngineError> {
325        let value = self
326            .request(
327                "getAttribute",
328                json!({ "selector": selector, "attribute": attribute }),
329            )
330            .await?;
331        Ok(value.as_str().map(ToString::to_string))
332    }
333
334    async fn is_visible(&self, selector: &str) -> Result<bool, EngineError> {
335        self.bool_request("isVisible", json!({ "selector": selector }))
336            .await
337    }
338
339    async fn is_enabled(&self, selector: &str) -> Result<bool, EngineError> {
340        self.bool_request("isEnabled", json!({ "selector": selector }))
341            .await
342    }
343
344    async fn wait_for_selector(&self, selector: &str, timeout_ms: u64) -> Result<(), EngineError> {
345        self.request(
346            "waitForSelector",
347            json!({ "selector": selector, "timeoutMs": timeout_ms }),
348        )
349        .await?;
350        Ok(())
351    }
352
353    async fn scroll_into_view(&self, selector: &str) -> Result<(), EngineError> {
354        self.request("scrollIntoView", json!({ "selector": selector }))
355            .await?;
356        Ok(())
357    }
358
359    async fn evaluate(&self, script: &str) -> Result<Value, EngineError> {
360        self.request("evaluate", json!({ "script": script })).await
361    }
362
363    async fn screenshot(&self) -> Result<Vec<u8>, EngineError> {
364        decode_base64(self.string_request("screenshot", json!({})).await?)
365    }
366
367    async fn pdf(&self, options: PdfOptions) -> Result<Vec<u8>, EngineError> {
368        let pdf_options = json!({
369            "format": options.format,
370            "printBackground": options.print_background,
371            "margin": {
372                "top": options.margin_top,
373                "right": options.margin_right,
374                "bottom": options.margin_bottom,
375                "left": options.margin_left,
376            },
377            "scale": options.scale,
378            "path": options.path,
379        });
380        let encoded = self
381            .request("pdf", pdf_options)
382            .await?
383            .as_str()
384            .unwrap_or_default()
385            .to_string();
386        decode_base64(encoded)
387    }
388
389    async fn bring_to_front(&self) -> Result<(), EngineError> {
390        self.request("bringToFront", json!({})).await?;
391        Ok(())
392    }
393
394    async fn wait_for_navigation(&self, timeout_ms: u64) -> Result<(), EngineError> {
395        self.request("waitForNavigation", json!({ "timeoutMs": timeout_ms }))
396            .await?;
397        Ok(())
398    }
399
400    async fn keyboard_press(&self, key: &str) -> Result<(), EngineError> {
401        self.request("keyboardPress", json!({ "key": key })).await?;
402        Ok(())
403    }
404
405    async fn keyboard_type(&self, text: &str) -> Result<(), EngineError> {
406        self.request("keyboardType", json!({ "text": text }))
407            .await?;
408        Ok(())
409    }
410
411    async fn keyboard_down(&self, key: &str) -> Result<(), EngineError> {
412        self.request("keyboardDown", json!({ "key": key })).await?;
413        Ok(())
414    }
415
416    async fn keyboard_up(&self, key: &str) -> Result<(), EngineError> {
417        self.request("keyboardUp", json!({ "key": key })).await?;
418        Ok(())
419    }
420}
421
422fn path_to_string(path: &Path) -> String {
423    path.to_string_lossy().into_owned()
424}
425
426fn decode_base64(value: impl AsRef<str>) -> Result<Vec<u8>, EngineError> {
427    base64::engine::general_purpose::STANDARD
428        .decode(value.as_ref())
429        .map_err(|err| EngineError::Browser(format!("bridge returned invalid base64: {err}")))
430}
431
432fn element_info_from_value(value: &Value) -> Result<Option<ElementInfo>, EngineError> {
433    if value.is_null() {
434        return Ok(None);
435    }
436
437    let bounding_box = value.get("boundingBox").and_then(|box_value| {
438        let items = box_value.as_array()?;
439        if items.len() != 4 {
440            return None;
441        }
442        Some((
443            items[0].as_f64().unwrap_or_default(),
444            items[1].as_f64().unwrap_or_default(),
445            items[2].as_f64().unwrap_or_default(),
446            items[3].as_f64().unwrap_or_default(),
447        ))
448    });
449
450    Ok(Some(ElementInfo {
451        tag_name: value
452            .get("tagName")
453            .and_then(Value::as_str)
454            .unwrap_or("UNKNOWN")
455            .to_string(),
456        text_content: value
457            .get("textContent")
458            .and_then(Value::as_str)
459            .map(ToString::to_string),
460        is_visible: value
461            .get("isVisible")
462            .and_then(Value::as_bool)
463            .unwrap_or(false),
464        is_enabled: value
465            .get("isEnabled")
466            .and_then(Value::as_bool)
467            .unwrap_or(true),
468        bounding_box,
469    }))
470}