use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{oneshot, Mutex};
pub mod assets;
#[cfg(test)]
mod tests;
pub const DEFAULT_PLAYWRIGHT_VERSION: &str = "1.49.1";
#[derive(Debug, Clone, Default)]
pub struct SidecarConfig {
pub version: Option<String>,
}
impl SidecarConfig {
fn resolved_version(&self) -> &str {
self.version
.as_deref()
.unwrap_or(DEFAULT_PLAYWRIGHT_VERSION)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Launcher {
Bun,
Node,
}
impl Launcher {
pub fn detect() -> Result<Launcher> {
if which::which("bun").is_ok() {
return Ok(Launcher::Bun);
}
if which::which("node").is_ok() && which::which("npm").is_ok() {
return Ok(Launcher::Node);
}
Err(anyhow!(
"neither `bun` nor `node`+`npm` is on PATH. \
Install Bun (https://bun.sh/) or Node.js (https://nodejs.org/)."
))
}
}
type PendingMap = HashMap<u64, oneshot::Sender<Result<Value>>>;
#[derive(Clone)]
pub struct Sidecar {
next_id: Arc<AtomicU64>,
pending: Arc<Mutex<PendingMap>>,
write_tx: tokio::sync::mpsc::UnboundedSender<String>,
_inner: Arc<SidecarInner>,
}
struct SidecarInner {
#[allow(dead_code)] child: Mutex<Option<Child>>,
reader_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
writer_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
fn truncate_line(line: &str) -> std::borrow::Cow<'_, str> {
const MAX: usize = 200;
if line.len() <= MAX {
std::borrow::Cow::Borrowed(line)
} else {
let end = line
.char_indices()
.take_while(|(i, _)| *i < MAX)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
std::borrow::Cow::Owned(format!("{}… ({} bytes total)", &line[..end], line.len()))
}
}
impl Drop for SidecarInner {
fn drop(&mut self) {
if let Ok(mut guard) = self.child.try_lock() {
if let Some(mut c) = guard.take() {
let _ = c.start_kill();
}
}
if let Ok(mut guard) = self.reader_handle.try_lock() {
if let Some(h) = guard.take() {
h.abort();
}
}
if let Ok(mut guard) = self.writer_handle.try_lock() {
if let Some(h) = guard.take() {
h.abort();
}
}
}
}
impl Sidecar {
pub async fn start(config: SidecarConfig) -> Result<Self> {
let launcher = Launcher::detect()?;
let cache_dir = assets::ensure_sidecar_dir(config.resolved_version())
.await
.context("preparing sidecar cache directory")?;
Self::install_deps(launcher, &cache_dir).await?;
Self::spawn(launcher, &cache_dir).await
}
async fn install_deps(launcher: Launcher, cache_dir: &PathBuf) -> Result<()> {
let marker = cache_dir.join("node_modules").join("playwright-core");
if tokio::fs::metadata(&marker).await.is_ok() {
return Ok(());
}
let (program, args) = match launcher {
Launcher::Bun => ("bun", vec!["install", "--silent"]),
Launcher::Node => ("npm", vec!["install", "--silent"]),
};
let status = Command::new(program)
.args(&args)
.current_dir(cache_dir)
.status()
.await
.with_context(|| format!("running `{program} {}` in {cache_dir:?}", args.join(" ")))?;
if !status.success() {
return Err(anyhow!(
"`{program} {}` in {cache_dir:?} exited with status {status}",
args.join(" ")
));
}
Ok(())
}
async fn spawn(launcher: Launcher, cache_dir: &PathBuf) -> Result<Self> {
let (program, args) = match launcher {
Launcher::Bun => ("bun", vec!["run", "sidecar.mjs"]),
Launcher::Node => ("node", vec!["sidecar.mjs"]),
};
let mut child = Command::new(program)
.args(&args)
.current_dir(cache_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("spawning `{program} {}`", args.join(" ")))?;
let mut child_stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("no child stdin"))?;
let child_stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("no child stdout"))?;
let pending: Arc<Mutex<PendingMap>> = Arc::new(Mutex::new(HashMap::new()));
let (write_tx, mut write_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let writer_handle = tokio::spawn(async move {
while let Some(line) = write_rx.recv().await {
if child_stdin.write_all(line.as_bytes()).await.is_err() {
break;
}
if child_stdin.write_all(b"\n").await.is_err() {
break;
}
let _ = child_stdin.flush().await;
}
});
let pending_r = pending.clone();
let reader_handle = tokio::spawn(async move {
let mut lines = BufReader::new(child_stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
let v: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
error = %e,
line = %truncate_line(&line),
"sidecar: dropping unparseable stdout line"
);
continue;
}
};
let id = match v.get("id").and_then(|x| x.as_u64()) {
Some(i) => i,
None => {
tracing::debug!(
line = %truncate_line(&line),
"sidecar: dropping idless stdout line"
);
continue;
}
};
let result = if let Some(err) = v.get("error") {
let msg = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("(no message)");
Err(anyhow!("{msg}"))
} else {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
};
let tx = {
let mut p = pending_r.lock().await;
p.remove(&id)
};
if let Some(tx) = tx {
let _ = tx.send(result);
}
}
let mut p = pending_r.lock().await;
for (_, tx) in p.drain() {
let _ = tx.send(Err(anyhow!("sidecar stdout closed")));
}
});
Ok(Self {
next_id: Arc::new(AtomicU64::new(1)),
pending,
write_tx,
_inner: Arc::new(SidecarInner {
child: Mutex::new(Some(child)),
reader_handle: Mutex::new(Some(reader_handle)),
writer_handle: Mutex::new(Some(writer_handle)),
}),
})
}
pub async fn connect(&self, endpoint: &str) -> Result<Value> {
self.call("connect", json!({ "endpoint": endpoint })).await
}
pub async fn call(&self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let req = json!({ "id": id, "method": method, "params": params });
let line = serde_json::to_string(&req)?;
let (tx, rx) = oneshot::channel();
{
let mut p = self.pending.lock().await;
p.insert(id, tx);
}
if self.write_tx.send(line).is_err() {
let mut p = self.pending.lock().await;
p.remove(&id);
return Err(anyhow!("sidecar writer closed"));
}
match rx.await {
Ok(r) => r,
Err(_) => Err(anyhow!("sidecar response channel dropped")),
}
}
}