browser_control/sidecar/
mod.rs1use 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
44pub const DEFAULT_PLAYWRIGHT_VERSION: &str = "1.49.1";
47
48const CONNECT_TIMEOUT_MS: u64 = 5_000;
52
53#[derive(Debug, Clone, Default)]
55pub struct SidecarConfig {
56 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Launcher {
71 Bun,
73 Node,
75}
76
77impl Launcher {
78 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
93type PendingMap = HashMap<u64, oneshot::Sender<Result<Value>>>;
96
97#[derive(Clone)]
100pub struct Sidecar {
101 next_id: Arc<AtomicU64>,
102 pending: Arc<Mutex<PendingMap>>,
103 write_tx: tokio::sync::mpsc::UnboundedSender<String>,
104 _inner: Arc<SidecarInner>,
106}
107
108struct SidecarInner {
111 #[allow(dead_code)] child: Mutex<Option<Child>>,
113 reader_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
114 writer_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
115}
116
117fn 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 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 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 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 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 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 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 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 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}