mod config;
pub use config::{BrowsrClientConfig, DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL};
pub use browsr_types::{
BrowserStepInput, BrowserStepRequest, BrowserStepResult, CrawlApiRequest, CrawlApiResponse,
JsonExtractionOptions, NetworkAccess, ObserveOptions, RelayEvent, RelayEventsResponse,
RelaySessionInfo, RelaySessionListResponse, ScrapeAction, ScrapeApiRequest,
ScrapeApiResponse, ScrapeData, ScrapeFormat, SessionCreated, SessionDetail,
SessionListResponse, SessionType, ShellCreateSessionRequest,
ShellCreateSessionResponse, ShellExecRequest, ShellExecResponse, ShellExecResult,
ShellListItem, ShellListResponse, ShellTerminateResponse,
};
use browsr_types::{
AutomateResponse, BrowserContext, Commands, ObserveResponse, SearchOptions,
SearchResponse,
};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use serde_json::{Value, json};
use thiserror::Error;
#[derive(Debug, Clone)]
pub enum TransportConfig {
Http { base_url: String },
}
#[derive(Debug, Clone)]
pub struct BrowsrClient {
transport: BrowsrTransport,
config: BrowsrClientConfig,
}
#[derive(Debug, Clone)]
enum BrowsrTransport {
Http(HttpTransport),
}
impl BrowsrClient {
pub fn new(base_url: impl Into<String>) -> Self {
let config = BrowsrClientConfig::new(base_url);
Self::from_client_config(config)
}
pub fn from_env() -> Self {
let config = BrowsrClientConfig::from_env();
Self::from_client_config(config)
}
pub fn from_client_config(config: BrowsrClientConfig) -> Self {
let http = config
.build_http_client()
.expect("Failed to build HTTP client");
Self {
transport: BrowsrTransport::Http(HttpTransport::new_with_client(
&config.base_url,
http,
)),
config,
}
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.config = self.config.with_api_key(api_key);
let http = self
.config
.build_http_client()
.expect("Failed to build HTTP client");
self.transport =
BrowsrTransport::Http(HttpTransport::new_with_client(&self.config.base_url, http));
self
}
pub fn new_http(base_url: impl Into<String>) -> Self {
Self::new(base_url)
}
pub fn from_config(cfg: TransportConfig) -> Self {
match cfg {
TransportConfig::Http { base_url } => Self::new_http(base_url),
}
}
pub fn base_url(&self) -> &str {
&self.config.base_url
}
pub fn config(&self) -> &BrowsrClientConfig {
&self.config
}
pub fn has_auth(&self) -> bool {
self.config.has_auth()
}
pub fn is_local(&self) -> bool {
self.config.is_local()
}
pub async fn list_sessions(&self) -> Result<SessionListResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.get("/sessions").await,
}
}
pub async fn create_session(&self) -> Result<SessionCreated, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => {
inner.post("/sessions", &Value::Null).await
}
}
}
pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner
.delete(&format!("/sessions/{}", session_id))
.await
.map(|_: Value| ()),
}
}
pub async fn create_shell_session(
&self,
request: ShellCreateSessionRequest,
) -> Result<ShellCreateSessionResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/shells", &request).await,
}
}
pub async fn list_shell_sessions(&self) -> Result<ShellListResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.get("/shells").await,
}
}
pub async fn terminate_shell_session(
&self,
session_id: &str,
) -> Result<ShellTerminateResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.delete(&format!("/shells/{}", session_id)).await,
}
}
pub async fn shell_exec(
&self,
request: ShellExecRequest,
) -> Result<ShellExecResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/shells/exec", &request).await,
}
}
pub async fn pool_status(&self) -> Result<Value, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.get("/shells/pool/status").await,
}
}
pub async fn pool_probe(&self) -> Result<Value, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/shells/pool/probe", &json!({})).await,
}
}
pub async fn pool_warm(&self, image: &str, count: usize) -> Result<Value, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => {
inner
.post("/shells/pool/warm", &json!({"image": image, "count": count}))
.await
}
}
}
pub async fn pool_drain(&self, image: &str) -> Result<Value, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => {
inner
.post("/shells/pool/drain", &json!({"image": image}))
.await
}
}
}
pub async fn proxy_session_port(
&self,
session_id: &str,
port: u16,
path: &str,
) -> Result<String, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => {
let url = inner.url(&format!(
"/shells/{}/proxy?port={}&path={}",
session_id,
port,
path.trim_start_matches('/'),
));
let resp = inner.client.get(&url).send().await?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(ClientError::InvalidResponse(text));
}
resp.text().await.map_err(Into::into)
}
}
}
pub async fn execute_commands(
&self,
commands: Vec<Commands>,
session_id: Option<String>,
headless: Option<bool>,
context: Option<BrowserContext>,
) -> Result<AutomateResponse, ClientError> {
let payload = CommandsPayload {
commands,
session_id,
headless: headless.or(self.config.headless),
context,
};
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
}
}
pub async fn execute_command(
&self,
command: Commands,
session_id: Option<String>,
headless: Option<bool>,
) -> Result<AutomateResponse, ClientError> {
self.execute_commands(vec![command], session_id, headless, None)
.await
}
pub async fn navigate(
&self,
url: &str,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::NavigateTo {
url: url.to_string(),
},
session_id,
None,
)
.await
}
pub async fn click(
&self,
selector: &str,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::Click {
selector: selector.to_string(),
},
session_id,
None,
)
.await
}
pub async fn type_text(
&self,
selector: &str,
text: &str,
clear: Option<bool>,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::TypeText {
selector: selector.to_string(),
text: text.to_string(),
clear,
},
session_id,
None,
)
.await
}
pub async fn wait_for_element(
&self,
selector: &str,
timeout_ms: Option<u64>,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::WaitForElement {
selector: selector.to_string(),
timeout_ms,
visible_only: None,
},
session_id,
None,
)
.await
}
pub async fn screenshot(
&self,
full_page: bool,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::Screenshot {
full_page: Some(full_page),
path: None,
},
session_id,
None,
)
.await
}
pub async fn get_title(
&self,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(Commands::GetTitle, session_id, None)
.await
}
pub async fn get_text(
&self,
selector: &str,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::GetText {
selector: selector.to_string(),
},
session_id,
None,
)
.await
}
pub async fn get_content(
&self,
selector: Option<String>,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::GetContent {
selector,
kind: None,
},
session_id,
None,
)
.await
}
pub async fn evaluate(
&self,
expression: &str,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::Evaluate {
expression: expression.to_string(),
},
session_id,
None,
)
.await
}
pub async fn extract_structured(
&self,
query: &str,
schema: Option<serde_json::Value>,
max_chars: Option<usize>,
session_id: Option<String>,
) -> Result<AutomateResponse, ClientError> {
self.execute_command(
Commands::ExtractStructuredContent {
query: query.to_string(),
schema,
max_chars,
},
session_id,
None,
)
.await
}
pub async fn observe(
&self,
session_id: Option<String>,
headless: Option<bool>,
opts: ObserveOptions,
) -> Result<ObserveResponse, ClientError> {
let payload = ObservePayload {
session_id,
headless: headless.or(self.config.headless),
use_image: opts.use_image,
full_page: opts.full_page,
wait_ms: opts.wait_ms,
include_content: opts.include_content,
};
match &self.transport {
BrowsrTransport::Http(inner) => {
let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
Ok(envelope.observation)
}
}
}
pub async fn cdp(
&self,
session_id: impl Into<String>,
method: impl Into<String>,
params: Option<Value>,
) -> Result<Value, ClientError> {
let payload = json!({
"session_id": session_id.into(),
"method": method.into(),
"params": params.unwrap_or_else(|| json!({})),
});
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/cdp", &payload).await,
}
}
pub async fn relay_events(
&self,
session_id: &str,
limit: Option<usize>,
) -> Result<RelayEventsResponse, ClientError> {
let path = match limit {
Some(limit) => format!("/relay/sessions/{}/events?limit={}", session_id, limit),
None => format!("/relay/sessions/{}/events", session_id),
};
match &self.transport {
BrowsrTransport::Http(inner) => inner.get(&path).await,
}
}
pub async fn list_relay_sessions(&self) -> Result<RelaySessionListResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.get("/relay/sessions").await,
}
}
pub async fn clear_relay_events(&self, session_id: &str) -> Result<Value, ClientError> {
let path = format!("/relay/sessions/{}/events", session_id);
match &self.transport {
BrowsrTransport::Http(inner) => inner.delete(&path).await,
}
}
pub async fn scrape_v1(&self, request: ScrapeApiRequest) -> Result<ScrapeApiResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/v1/scrape", &request).await,
}
}
pub async fn scrape_url(&self, url: &str) -> Result<ScrapeApiResponse, ClientError> {
self.scrape_v1(ScrapeApiRequest::new(url)).await
}
pub async fn crawl(&self, request: CrawlApiRequest) -> Result<CrawlApiResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/v1/crawl", &request).await,
}
}
pub async fn crawl_url(&self, url: &str) -> Result<CrawlApiResponse, ClientError> {
self.crawl(CrawlApiRequest::new(url)).await
}
pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/v1/search", &options).await,
}
}
pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
let options = SearchOptions {
query: query.to_string(),
limit: None,
};
self.search(options).await
}
pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
match &self.transport {
BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
}
}
pub async fn step_commands(
&self,
commands: Vec<Commands>,
) -> Result<BrowserStepResult, ClientError> {
let input = BrowserStepInput::new(commands);
let request = BrowserStepRequest::new(input);
self.step(request).await
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommandsPayload {
commands: Vec<Commands>,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
headless: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
context: Option<BrowserContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ObservePayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headless: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_image: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_page: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_content: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ObserveEnvelope {
pub session_id: String,
pub observation: ObserveResponse,
}
#[derive(Debug, Error)]
pub enum ClientError {
#[error("http request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("invalid response: {0}")]
InvalidResponse(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
struct HttpTransport {
base_url: String,
client: reqwest::Client,
}
impl HttpTransport {
fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
Self {
base_url: base_url.into(),
client,
}
}
fn url(&self, path: &str) -> String {
let base = self.base_url.trim_end_matches('/');
let suffix = path.trim_start_matches('/');
format!("{}/{}", base, suffix)
}
async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
let resp = self.client.get(self.url(path)).send().await?;
Self::handle_response(resp).await
}
async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
let resp = self.client.delete(self.url(path)).send().await?;
Self::handle_response(resp).await
}
async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
&self,
path: &str,
body: &B,
) -> Result<T, ClientError> {
let resp = self.client.post(self.url(path)).json(body).send().await?;
Self::handle_response(resp).await
}
async fn handle_response<T: DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, ClientError> {
let status = resp.status();
if status == StatusCode::NO_CONTENT {
let empty: Value = Value::Null;
let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
return Ok(value);
}
let text = resp.text().await?;
if !status.is_success() {
return Err(ClientError::InvalidResponse(format!(
"{}: {}",
status, text
)));
}
serde_json::from_str(&text).map_err(ClientError::Serialization)
}
}
pub fn default_base_url() -> Option<String> {
if let Ok(url) = std::env::var("BROWSR_API_URL") {
return Some(url);
}
if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
return Some(url);
}
if let Ok(port) = std::env::var("BROWSR_PORT") {
let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
return Some(format!("http://{}:{}", host, port));
}
Some(DEFAULT_BASE_URL.to_string())
}
pub fn default_transport() -> TransportConfig {
TransportConfig::Http {
base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
}
}