1use browsr_types::{
2 AutomateResponse, BrowserContext, Commands, ObserveResponse, ScrapeOptions, SearchOptions,
3 SearchResponse,
4};
5use reqwest::StatusCode;
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7use serde_json::{Value, json};
8use thiserror::Error;
9use tokio::process::Command;
10
11#[derive(Debug, Clone)]
12pub enum TransportConfig {
13 Http { base_url: String },
14 Stdout { command: String },
15}
16
17#[derive(Debug, Clone)]
18pub struct BrowsrClient {
19 transport: BrowsrTransport,
20}
21
22#[derive(Debug, Clone)]
23enum BrowsrTransport {
24 Http(HttpTransport),
25 Stdout(StdoutTransport),
26}
27
28impl BrowsrClient {
29 pub fn new_http(base_url: impl Into<String>) -> Self {
30 Self {
31 transport: BrowsrTransport::Http(HttpTransport::new(base_url)),
32 }
33 }
34
35 pub fn new_stdout(command: impl Into<String>) -> Self {
36 Self {
37 transport: BrowsrTransport::Stdout(StdoutTransport::new(command)),
38 }
39 }
40
41 pub fn from_config(cfg: TransportConfig) -> Self {
42 match cfg {
43 TransportConfig::Http { base_url } => Self::new_http(base_url),
44 TransportConfig::Stdout { command } => Self::new_stdout(command),
45 }
46 }
47
48 pub async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
49 match &self.transport {
50 BrowsrTransport::Http(inner) => {
51 let response: SessionList = inner.get("/sessions").await?;
52 Ok(response.sessions)
53 }
54 BrowsrTransport::Stdout(inner) => inner.list_sessions().await,
55 }
56 }
57
58 pub async fn create_session(&self) -> Result<String, ClientError> {
59 match &self.transport {
60 BrowsrTransport::Http(inner) => {
61 let response: SessionCreated = inner.post("/sessions", &Value::Null).await?;
62 Ok(response.session_id)
63 }
64 BrowsrTransport::Stdout(inner) => inner.create_session().await,
65 }
66 }
67
68 pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
69 match &self.transport {
70 BrowsrTransport::Http(inner) => inner
71 .delete(&format!("/sessions/{}", session_id))
72 .await
73 .map(|_: Value| ()),
74 BrowsrTransport::Stdout(inner) => inner.destroy_session(session_id).await,
75 }
76 }
77
78 pub async fn execute_commands(
79 &self,
80 commands: Vec<Commands>,
81 session_id: Option<String>,
82 headless: Option<bool>,
83 context: Option<BrowserContext>,
84 ) -> Result<AutomateResponse, ClientError> {
85 let payload = CommandsPayload {
86 commands,
87 session_id,
88 headless,
89 context,
90 };
91
92 match &self.transport {
93 BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
94 BrowsrTransport::Stdout(inner) => inner.execute_commands(&payload).await,
95 }
96 }
97
98 pub async fn observe(
99 &self,
100 session_id: Option<String>,
101 headless: Option<bool>,
102 opts: ObserveOptions,
103 ) -> Result<ObserveResponse, ClientError> {
104 let payload = ObservePayload {
105 session_id,
106 headless,
107 use_image: opts.use_image,
108 full_page: opts.full_page,
109 wait_ms: opts.wait_ms,
110 include_content: opts.include_content,
111 };
112
113 match &self.transport {
114 BrowsrTransport::Http(inner) => {
115 let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
116 Ok(envelope.observation)
117 }
118 BrowsrTransport::Stdout(inner) => inner.observe(&payload).await,
119 }
120 }
121
122 pub async fn scrape(&self, options: ScrapeOptions) -> Result<Value, ClientError> {
123 match &self.transport {
124 BrowsrTransport::Http(inner) => inner.post("/scrape", &options).await,
125 BrowsrTransport::Stdout(inner) => inner.scrape(&options).await,
126 }
127 }
128
129 pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
130 match &self.transport {
131 BrowsrTransport::Http(inner) => inner.post("/search", &options).await,
132 BrowsrTransport::Stdout(inner) => inner.search(&options).await,
133 }
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138struct SessionList {
139 sessions: Vec<String>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143struct SessionCreated {
144 session_id: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148struct CommandsPayload {
149 commands: Vec<Commands>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 session_id: Option<String>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 headless: Option<bool>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 context: Option<BrowserContext>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ObserveOptions {
160 pub use_image: Option<bool>,
161 pub full_page: Option<bool>,
162 pub wait_ms: Option<u64>,
163 pub include_content: Option<bool>,
164}
165
166impl Default for ObserveOptions {
167 fn default() -> Self {
168 Self {
169 use_image: Some(true),
170 full_page: None,
171 wait_ms: None,
172 include_content: Some(true),
173 }
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178struct ObservePayload {
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub session_id: Option<String>,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub headless: Option<bool>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub use_image: Option<bool>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub full_page: Option<bool>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub wait_ms: Option<u64>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub include_content: Option<bool>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194struct ObserveEnvelope {
195 pub session_id: String,
196 pub observation: ObserveResponse,
197}
198
199#[derive(Debug, Error)]
200pub enum ClientError {
201 #[error("http request failed: {0}")]
202 Http(#[from] reqwest::Error),
203 #[error("stdout transport failed: {0}")]
204 Stdout(String),
205 #[error("invalid response: {0}")]
206 InvalidResponse(String),
207 #[error("serialization error: {0}")]
208 Serialization(#[from] serde_json::Error),
209 #[error("io error: {0}")]
210 Io(#[from] std::io::Error),
211}
212
213#[derive(Debug, Clone)]
214struct HttpTransport {
215 base_url: String,
216 client: reqwest::Client,
217}
218
219impl HttpTransport {
220 fn new(base_url: impl Into<String>) -> Self {
221 Self {
222 base_url: base_url.into(),
223 client: reqwest::Client::new(),
224 }
225 }
226
227 fn url(&self, path: &str) -> String {
228 let base = self.base_url.trim_end_matches('/');
229 let suffix = path.trim_start_matches('/');
230 format!("{}/{}", base, suffix)
231 }
232
233 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
234 let resp = self.client.get(self.url(path)).send().await?;
235 Self::handle_response(resp).await
236 }
237
238 async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
239 let resp = self.client.delete(self.url(path)).send().await?;
240 Self::handle_response(resp).await
241 }
242
243 async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
244 &self,
245 path: &str,
246 body: &B,
247 ) -> Result<T, ClientError> {
248 let resp = self.client.post(self.url(path)).json(body).send().await?;
249 Self::handle_response(resp).await
250 }
251
252 async fn handle_response<T: DeserializeOwned>(
253 resp: reqwest::Response,
254 ) -> Result<T, ClientError> {
255 let status = resp.status();
256 if status == StatusCode::NO_CONTENT {
257 let empty: Value = Value::Null;
258 let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
259 return Ok(value);
260 }
261
262 let text = resp.text().await?;
263 if !status.is_success() {
264 return Err(ClientError::InvalidResponse(format!(
265 "{}: {}",
266 status, text
267 )));
268 }
269
270 serde_json::from_str(&text).map_err(ClientError::Serialization)
271 }
272}
273
274#[derive(Debug, Clone)]
275struct StdoutTransport {
276 command: String,
277}
278
279impl StdoutTransport {
280 fn new(command: impl Into<String>) -> Self {
281 Self {
282 command: command.into(),
283 }
284 }
285
286 async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
287 let envelope: SessionList = self.request("sessions", &Value::Null).await?;
288 Ok(envelope.sessions)
289 }
290
291 async fn create_session(&self) -> Result<String, ClientError> {
292 let created: SessionCreated = self.request("create_session", &Value::Null).await?;
293 Ok(created.session_id)
294 }
295
296 async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
297 let payload = json!({ "session_id": session_id });
298 let _: Value = self.request("destroy_session", &payload).await?;
299 Ok(())
300 }
301
302 async fn execute_commands(
303 &self,
304 payload: &CommandsPayload,
305 ) -> Result<AutomateResponse, ClientError> {
306 self.request("execute", payload).await
307 }
308
309 async fn observe(&self, payload: &ObservePayload) -> Result<ObserveResponse, ClientError> {
310 let envelope: ObserveEnvelope = self.request("observe", payload).await?;
311 Ok(envelope.observation)
312 }
313
314 async fn scrape(&self, options: &ScrapeOptions) -> Result<Value, ClientError> {
315 self.request("scrape", options).await
316 }
317
318 async fn search(&self, options: &SearchOptions) -> Result<SearchResponse, ClientError> {
319 self.request("search", options).await
320 }
321
322 async fn request<T: DeserializeOwned, B: Serialize>(
323 &self,
324 operation: &str,
325 payload: &B,
326 ) -> Result<T, ClientError> {
327 let mut cmd = Command::new(&self.command);
328 cmd.arg("client").arg(operation);
329
330 let payload_str = serde_json::to_string(&payload).map_err(ClientError::Serialization)?;
331 cmd.env("BROWSR_CLIENT_PAYLOAD", &payload_str);
332
333 let output = cmd.output().await?;
334 if !output.status.success() {
335 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
336 return Err(ClientError::Stdout(format!(
337 "browsr command failed ({}): {}",
338 output.status, stderr
339 )));
340 }
341
342 let stdout = String::from_utf8_lossy(&output.stdout);
343 if stdout.trim().is_empty() {
344 return Err(ClientError::InvalidResponse(
345 "empty stdout from browsr".to_string(),
346 ));
347 }
348
349 serde_json::from_str(stdout.trim()).map_err(ClientError::Serialization)
350 }
351}
352
353pub fn default_base_url() -> Option<String> {
355 if let Ok(url) = std::env::var("BROWSR_API_URL") {
356 return Some(url);
357 }
358 if let Ok(port) = std::env::var("BROWSR_PORT") {
359 let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
360 return Some(format!("http://{}:{}", host, port));
361 }
362 Some("http://127.0.0.1:8082".to_string())
364}
365
366pub fn default_transport() -> TransportConfig {
367 TransportConfig::Http {
368 base_url: default_base_url().unwrap_or_else(|| "http://127.0.0.1:8082".to_string()),
369 }
370}