1use 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
25pub 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 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}