Skip to main content

browser_control/sidecar/
mod.rs

1//! Playwright sidecar — spawn and talk to a Node script that wraps
2//! `playwright-core`.
3//!
4//! This module owns the lifecycle of the Node child process and the
5//! NDJSON-over-stdio JSON-RPC channel to it. Tools in the MCP layer call
6//! [`Sidecar::call`] with `(method, params)` and get back the parsed
7//! result (or an error).
8//!
9//! Lifecycle (per `Sidecar` instance):
10//!
11//! 1. [`Sidecar::start`] — picks a launcher (`bun` preferred, then
12//!    `node`), prepares the cache directory containing the bundled
13//!    `sidecar.mjs` + `package.json`, runs `bun install` /
14//!    `npm install` if the deps aren't already there, then spawns the
15//!    child with stdin/stdout piped.
16//! 2. [`Sidecar::connect`] — sends a `connect` RPC carrying the CDP
17//!    endpoint URL so the sidecar holds a Playwright `Browser` for
18//!    the duration.
19//! 3. [`Sidecar::call`] — send a request, receive the response. Many
20//!    requests can be in flight (each carries a unique id); responses
21//!    are routed by id.
22//! 4. Drop — closes stdin, the child exits.
23//!
24//! Errors that originate inside the sidecar arrive as JSON-RPC error
25//! responses; we surface them as `anyhow` errors. Connection-level
26//! failures (sidecar process gone, stdio closed) surface as `SidecarGone`
27//! so the caller can decide whether to restart.
28
29use anyhow::{anyhow, Context, Result};
30use serde_json::{json, Value};
31use std::collections::HashMap;
32use std::path::PathBuf;
33use std::process::Stdio;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::sync::Arc;
36use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
37use tokio::process::{Child, Command};
38use tokio::sync::{oneshot, Mutex};
39
40pub mod assets;
41#[cfg(test)]
42mod tests;
43
44/// Default `playwright-core` version pinned in `assets/playwright-sidecar/package.json`.
45/// Overridable via `--playwright-version` (CLI) or [`SidecarConfig::version`].
46pub const DEFAULT_PLAYWRIGHT_VERSION: &str = "1.49.1";
47
48/// Bound Playwright's initial CDP attach below common MCP client call
49/// timeouts, so browser-control can classify and explain connection-layer
50/// failures instead of letting the client time out first.
51const CONNECT_TIMEOUT_MS: u64 = 5_000;
52
53/// User-facing configuration for spawning a sidecar.
54#[derive(Debug, Clone, Default)]
55pub struct SidecarConfig {
56    /// `playwright-core` version string. `None` uses [`DEFAULT_PLAYWRIGHT_VERSION`].
57    pub version: Option<String>,
58}
59
60impl SidecarConfig {
61    fn resolved_version(&self) -> &str {
62        self.version
63            .as_deref()
64            .unwrap_or(DEFAULT_PLAYWRIGHT_VERSION)
65    }
66}
67
68/// Which runtime + package manager we use to launch the sidecar.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Launcher {
71    /// Bun: faster startup, single binary. Uses `bun install` + `bun run`.
72    Bun,
73    /// Node + npm: more universally installed. Uses `npm install --silent` + `node`.
74    Node,
75}
76
77impl Launcher {
78    /// Detect what's available. Prefers `bun`, falls back to `node` + `npm`.
79    pub fn detect() -> Result<Launcher> {
80        if which::which("bun").is_ok() {
81            return Ok(Launcher::Bun);
82        }
83        if which::which("node").is_ok() && which::which("npm").is_ok() {
84            return Ok(Launcher::Node);
85        }
86        Err(anyhow!(
87            "neither `bun` nor `node`+`npm` is on PATH. \
88             Install Bun (https://bun.sh/) or Node.js (https://nodejs.org/)."
89        ))
90    }
91}
92
93/// Pending-request channel map: id → response sender. Held inside the
94/// reader task; cleared on shutdown.
95type PendingMap = HashMap<u64, oneshot::Sender<Result<Value>>>;
96
97/// Live sidecar handle. Cloneable: behaviour is shared through `Arc`s
98/// inside.
99#[derive(Clone)]
100pub struct Sidecar {
101    next_id: Arc<AtomicU64>,
102    pending: Arc<Mutex<PendingMap>>,
103    write_tx: tokio::sync::mpsc::UnboundedSender<String>,
104    /// Held to keep the child alive; aborted on drop of the last clone.
105    _inner: Arc<SidecarInner>,
106}
107
108/// Inner state with non-clonable handles, kept behind `Arc` so the public
109/// `Sidecar` can be `Clone`.
110struct SidecarInner {
111    #[allow(dead_code)] // kept alive; killed via Drop
112    child: Mutex<Option<Child>>,
113    reader_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
114    writer_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
115}
116
117/// Truncate a stdout line to a bounded prefix for logging, so a large payload
118/// never floods the diagnostics.
119fn truncate_line(line: &str) -> std::borrow::Cow<'_, str> {
120    const MAX: usize = 200;
121    if line.len() <= MAX {
122        std::borrow::Cow::Borrowed(line)
123    } else {
124        let end = line
125            .char_indices()
126            .take_while(|(i, _)| *i < MAX)
127            .last()
128            .map(|(i, c)| i + c.len_utf8())
129            .unwrap_or(0);
130        std::borrow::Cow::Owned(format!("{}… ({} bytes total)", &line[..end], line.len()))
131    }
132}
133
134impl Drop for SidecarInner {
135    fn drop(&mut self) {
136        // Best-effort: kill the child and abort the IO tasks. Reader/writer
137        // will short-circuit when the pipes close.
138        if let Ok(mut guard) = self.child.try_lock() {
139            if let Some(mut c) = guard.take() {
140                let _ = c.start_kill();
141            }
142        }
143        if let Ok(mut guard) = self.reader_handle.try_lock() {
144            if let Some(h) = guard.take() {
145                h.abort();
146            }
147        }
148        if let Ok(mut guard) = self.writer_handle.try_lock() {
149            if let Some(h) = guard.take() {
150                h.abort();
151            }
152        }
153    }
154}
155
156impl Sidecar {
157    /// Start a sidecar process. Prepares the cache directory (one-time
158    /// install) and spawns the child. The returned handle is not yet
159    /// connected to a browser — call [`Sidecar::connect`] next.
160    pub async fn start(config: SidecarConfig) -> Result<Self> {
161        let launcher = Launcher::detect()?;
162        let cache_dir = assets::ensure_sidecar_dir(config.resolved_version())
163            .await
164            .context("preparing sidecar cache directory")?;
165        Self::install_deps(launcher, &cache_dir).await?;
166        Self::spawn(launcher, &cache_dir).await
167    }
168
169    /// Run `bun install` / `npm install` in `cache_dir` if the
170    /// dependencies aren't already present. Idempotent.
171    async fn install_deps(launcher: Launcher, cache_dir: &PathBuf) -> Result<()> {
172        let marker = cache_dir.join("node_modules").join("playwright-core");
173        if tokio::fs::metadata(&marker).await.is_ok() {
174            return Ok(());
175        }
176        let (program, args) = match launcher {
177            Launcher::Bun => ("bun", vec!["install", "--silent"]),
178            Launcher::Node => ("npm", vec!["install", "--silent"]),
179        };
180        let status = Command::new(program)
181            .args(&args)
182            .current_dir(cache_dir)
183            .status()
184            .await
185            .with_context(|| format!("running `{program} {}` in {cache_dir:?}", args.join(" ")))?;
186        if !status.success() {
187            return Err(anyhow!(
188                "`{program} {}` in {cache_dir:?} exited with status {status}",
189                args.join(" ")
190            ));
191        }
192        Ok(())
193    }
194
195    /// Spawn the child process and wire stdio.
196    async fn spawn(launcher: Launcher, cache_dir: &PathBuf) -> Result<Self> {
197        let (program, args) = match launcher {
198            Launcher::Bun => ("bun", vec!["run", "sidecar.mjs"]),
199            Launcher::Node => ("node", vec!["sidecar.mjs"]),
200        };
201        let mut child = Command::new(program)
202            .args(&args)
203            .current_dir(cache_dir)
204            .stdin(Stdio::piped())
205            .stdout(Stdio::piped())
206            .stderr(Stdio::inherit())
207            .kill_on_drop(true)
208            .spawn()
209            .with_context(|| format!("spawning `{program} {}`", args.join(" ")))?;
210
211        let mut child_stdin = child
212            .stdin
213            .take()
214            .ok_or_else(|| anyhow!("no child stdin"))?;
215        let child_stdout = child
216            .stdout
217            .take()
218            .ok_or_else(|| anyhow!("no child stdout"))?;
219
220        let pending: Arc<Mutex<PendingMap>> = Arc::new(Mutex::new(HashMap::new()));
221        let (write_tx, mut write_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
222
223        let writer_handle = tokio::spawn(async move {
224            while let Some(line) = write_rx.recv().await {
225                if child_stdin.write_all(line.as_bytes()).await.is_err() {
226                    break;
227                }
228                if child_stdin.write_all(b"\n").await.is_err() {
229                    break;
230                }
231                let _ = child_stdin.flush().await;
232            }
233        });
234
235        let pending_r = pending.clone();
236        let reader_handle = tokio::spawn(async move {
237            let mut lines = BufReader::new(child_stdout).lines();
238            while let Ok(Some(line)) = lines.next_line().await {
239                if line.trim().is_empty() {
240                    continue;
241                }
242                let v: Value = match serde_json::from_str(&line) {
243                    Ok(v) => v,
244                    // Unparseable NDJSON line: can't route it, so it's dropped —
245                    // log (truncated) so the request it would have answered has
246                    // a breadcrumb instead of hanging in silence.
247                    Err(e) => {
248                        tracing::warn!(
249                            error = %e,
250                            line = %truncate_line(&line),
251                            "sidecar: dropping unparseable stdout line"
252                        );
253                        continue;
254                    }
255                };
256                let id = match v.get("id").and_then(|x| x.as_u64()) {
257                    Some(i) => i,
258                    None => {
259                        tracing::debug!(
260                            line = %truncate_line(&line),
261                            "sidecar: dropping idless stdout line"
262                        );
263                        continue;
264                    }
265                };
266                let result = if let Some(err) = v.get("error") {
267                    let msg = err
268                        .get("message")
269                        .and_then(|m| m.as_str())
270                        .unwrap_or("(no message)");
271                    Err(anyhow!("{msg}"))
272                } else {
273                    Ok(v.get("result").cloned().unwrap_or(Value::Null))
274                };
275                let tx = {
276                    let mut p = pending_r.lock().await;
277                    p.remove(&id)
278                };
279                if let Some(tx) = tx {
280                    let _ = tx.send(result);
281                }
282            }
283            // Reader closed: drain pending with a SidecarGone-flavoured error.
284            let mut p = pending_r.lock().await;
285            for (_, tx) in p.drain() {
286                let _ = tx.send(Err(anyhow!("sidecar stdout closed")));
287            }
288        });
289
290        Ok(Self {
291            next_id: Arc::new(AtomicU64::new(1)),
292            pending,
293            write_tx,
294            _inner: Arc::new(SidecarInner {
295                child: Mutex::new(Some(child)),
296                reader_handle: Mutex::new(Some(reader_handle)),
297                writer_handle: Mutex::new(Some(writer_handle)),
298            }),
299        })
300    }
301
302    /// Tell the sidecar to connect to a CDP `endpoint` (full ws://… URL
303    /// from `/json/version`). Holds a Playwright `Browser` for the
304    /// sidecar's lifetime; future calls reuse it.
305    pub async fn connect(&self, endpoint: &str) -> Result<Value> {
306        self.call(
307            "connect",
308            json!({ "endpoint": endpoint, "timeout_ms": CONNECT_TIMEOUT_MS }),
309        )
310        .await
311    }
312
313    /// Send a method call, await the response.
314    pub async fn call(&self, method: &str, params: Value) -> Result<Value> {
315        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
316        let req = json!({ "id": id, "method": method, "params": params });
317        let line = serde_json::to_string(&req)?;
318        let (tx, rx) = oneshot::channel();
319        {
320            let mut p = self.pending.lock().await;
321            p.insert(id, tx);
322        }
323        if self.write_tx.send(line).is_err() {
324            let mut p = self.pending.lock().await;
325            p.remove(&id);
326            return Err(anyhow!("sidecar writer closed"));
327        }
328        match rx.await {
329            Ok(r) => r,
330            Err(_) => Err(anyhow!("sidecar response channel dropped")),
331        }
332    }
333}