1mod config;
34
35pub use config::{BrowsrClientConfig, DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL};
36
37pub use browsr_types::{BrowserStepInput, BrowserStepRequest, BrowserStepResult};
39
40use browsr_types::{
41 AutomateResponse, BrowserContext, Commands, ObserveResponse, ScrapeOptions, SearchOptions,
42 SearchResponse,
43};
44use reqwest::StatusCode;
45use serde::{Deserialize, Serialize, de::DeserializeOwned};
46use serde_json::{Value, json};
47use thiserror::Error;
48use tokio::process::Command;
49
50#[derive(Debug, Clone)]
51pub enum TransportConfig {
52 Http { base_url: String },
53 Stdout { command: String },
54}
55
56#[derive(Debug, Clone)]
74pub struct BrowsrClient {
75 transport: BrowsrTransport,
76 config: BrowsrClientConfig,
77}
78
79#[derive(Debug, Clone)]
80enum BrowsrTransport {
81 Http(HttpTransport),
82 Stdout(StdoutTransport),
83}
84
85impl BrowsrClient {
86 pub fn new(base_url: impl Into<String>) -> Self {
89 let config = BrowsrClientConfig::new(base_url);
90 Self::from_client_config(config)
91 }
92
93 pub fn from_env() -> Self {
98 let config = BrowsrClientConfig::from_env();
99 Self::from_client_config(config)
100 }
101
102 pub fn from_client_config(config: BrowsrClientConfig) -> Self {
104 let http = config
105 .build_http_client()
106 .expect("Failed to build HTTP client");
107
108 Self {
109 transport: BrowsrTransport::Http(HttpTransport::new_with_client(
110 &config.base_url,
111 http,
112 )),
113 config,
114 }
115 }
116
117 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
120 self.config = self.config.with_api_key(api_key);
121 let http = self
122 .config
123 .build_http_client()
124 .expect("Failed to build HTTP client");
125 self.transport =
126 BrowsrTransport::Http(HttpTransport::new_with_client(&self.config.base_url, http));
127 self
128 }
129
130 pub fn new_http(base_url: impl Into<String>) -> Self {
132 Self::new(base_url)
133 }
134
135 pub fn new_stdout(command: impl Into<String>) -> Self {
137 Self {
138 transport: BrowsrTransport::Stdout(StdoutTransport::new(command)),
139 config: BrowsrClientConfig::default(),
140 }
141 }
142
143 pub fn from_config(cfg: TransportConfig) -> Self {
145 match cfg {
146 TransportConfig::Http { base_url } => Self::new_http(base_url),
147 TransportConfig::Stdout { command } => Self::new_stdout(command),
148 }
149 }
150
151 pub fn base_url(&self) -> &str {
153 &self.config.base_url
154 }
155
156 pub fn config(&self) -> &BrowsrClientConfig {
158 &self.config
159 }
160
161 pub fn has_auth(&self) -> bool {
163 self.config.has_auth()
164 }
165
166 pub fn is_local(&self) -> bool {
168 self.config.is_local()
169 }
170
171 pub async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
177 match &self.transport {
178 BrowsrTransport::Http(inner) => {
179 let response: SessionList = inner.get("/sessions").await?;
180 Ok(response.sessions)
181 }
182 BrowsrTransport::Stdout(inner) => inner.list_sessions().await,
183 }
184 }
185
186 pub async fn create_session(&self) -> Result<SessionCreated, ClientError> {
189 match &self.transport {
190 BrowsrTransport::Http(inner) => {
191 inner.post("/sessions", &Value::Null).await
192 }
193 BrowsrTransport::Stdout(inner) => {
194 let session_id = inner.create_session().await?;
195 Ok(SessionCreated {
196 session_id,
197 sse_url: None,
198 frame_url: None,
199 })
200 }
201 }
202 }
203
204 pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
206 match &self.transport {
207 BrowsrTransport::Http(inner) => inner
208 .delete(&format!("/sessions/{}", session_id))
209 .await
210 .map(|_: Value| ()),
211 BrowsrTransport::Stdout(inner) => inner.destroy_session(session_id).await,
212 }
213 }
214
215 pub async fn execute_commands(
221 &self,
222 commands: Vec<Commands>,
223 session_id: Option<String>,
224 headless: Option<bool>,
225 context: Option<BrowserContext>,
226 ) -> Result<AutomateResponse, ClientError> {
227 let payload = CommandsPayload {
228 commands,
229 session_id,
230 headless: headless.or(self.config.headless),
231 context,
232 };
233
234 match &self.transport {
235 BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
236 BrowsrTransport::Stdout(inner) => inner.execute_commands(&payload).await,
237 }
238 }
239
240 pub async fn execute_command(
242 &self,
243 command: Commands,
244 session_id: Option<String>,
245 headless: Option<bool>,
246 ) -> Result<AutomateResponse, ClientError> {
247 self.execute_commands(vec![command], session_id, headless, None)
248 .await
249 }
250
251 pub async fn navigate(
257 &self,
258 url: &str,
259 session_id: Option<String>,
260 ) -> Result<AutomateResponse, ClientError> {
261 self.execute_command(
262 Commands::NavigateTo {
263 url: url.to_string(),
264 },
265 session_id,
266 None,
267 )
268 .await
269 }
270
271 pub async fn click(
273 &self,
274 selector: &str,
275 session_id: Option<String>,
276 ) -> Result<AutomateResponse, ClientError> {
277 self.execute_command(
278 Commands::Click {
279 selector: selector.to_string(),
280 },
281 session_id,
282 None,
283 )
284 .await
285 }
286
287 pub async fn type_text(
289 &self,
290 selector: &str,
291 text: &str,
292 clear: Option<bool>,
293 session_id: Option<String>,
294 ) -> Result<AutomateResponse, ClientError> {
295 self.execute_command(
296 Commands::TypeText {
297 selector: selector.to_string(),
298 text: text.to_string(),
299 clear,
300 },
301 session_id,
302 None,
303 )
304 .await
305 }
306
307 pub async fn wait_for_element(
309 &self,
310 selector: &str,
311 timeout_ms: Option<u64>,
312 session_id: Option<String>,
313 ) -> Result<AutomateResponse, ClientError> {
314 self.execute_command(
315 Commands::WaitForElement {
316 selector: selector.to_string(),
317 timeout_ms,
318 visible_only: None,
319 },
320 session_id,
321 None,
322 )
323 .await
324 }
325
326 pub async fn screenshot(
328 &self,
329 full_page: bool,
330 session_id: Option<String>,
331 ) -> Result<AutomateResponse, ClientError> {
332 self.execute_command(
333 Commands::Screenshot {
334 full_page: Some(full_page),
335 path: None,
336 },
337 session_id,
338 None,
339 )
340 .await
341 }
342
343 pub async fn get_title(
345 &self,
346 session_id: Option<String>,
347 ) -> Result<AutomateResponse, ClientError> {
348 self.execute_command(Commands::GetTitle, session_id, None)
349 .await
350 }
351
352 pub async fn get_text(
354 &self,
355 selector: &str,
356 session_id: Option<String>,
357 ) -> Result<AutomateResponse, ClientError> {
358 self.execute_command(
359 Commands::GetText {
360 selector: selector.to_string(),
361 },
362 session_id,
363 None,
364 )
365 .await
366 }
367
368 pub async fn get_content(
370 &self,
371 selector: Option<String>,
372 session_id: Option<String>,
373 ) -> Result<AutomateResponse, ClientError> {
374 self.execute_command(
375 Commands::GetContent {
376 selector,
377 kind: None,
378 },
379 session_id,
380 None,
381 )
382 .await
383 }
384
385 pub async fn evaluate(
387 &self,
388 expression: &str,
389 session_id: Option<String>,
390 ) -> Result<AutomateResponse, ClientError> {
391 self.execute_command(
392 Commands::Evaluate {
393 expression: expression.to_string(),
394 },
395 session_id,
396 None,
397 )
398 .await
399 }
400
401 pub async fn extract_structured(
422 &self,
423 query: &str,
424 schema: Option<serde_json::Value>,
425 max_chars: Option<usize>,
426 session_id: Option<String>,
427 ) -> Result<AutomateResponse, ClientError> {
428 self.execute_command(
429 Commands::ExtractStructuredContent {
430 query: query.to_string(),
431 schema,
432 max_chars,
433 },
434 session_id,
435 None,
436 )
437 .await
438 }
439
440 pub async fn observe(
446 &self,
447 session_id: Option<String>,
448 headless: Option<bool>,
449 opts: ObserveOptions,
450 ) -> Result<ObserveResponse, ClientError> {
451 let payload = ObservePayload {
452 session_id,
453 headless: headless.or(self.config.headless),
454 use_image: opts.use_image,
455 full_page: opts.full_page,
456 wait_ms: opts.wait_ms,
457 include_content: opts.include_content,
458 };
459
460 match &self.transport {
461 BrowsrTransport::Http(inner) => {
462 let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
463 Ok(envelope.observation)
464 }
465 BrowsrTransport::Stdout(inner) => inner.observe(&payload).await,
466 }
467 }
468
469 pub async fn scrape(&self, options: ScrapeOptions) -> Result<Value, ClientError> {
475 match &self.transport {
476 BrowsrTransport::Http(inner) => inner.post("/scrape", &options).await,
477 BrowsrTransport::Stdout(inner) => inner.scrape(&options).await,
478 }
479 }
480
481 pub async fn scrape_url(&self, url: &str) -> Result<Value, ClientError> {
483 let options = ScrapeOptions {
484 url: url.to_string(),
485 use_js: None,
486 wait_for: None,
487 extract_links: None,
488 selector: None,
489 extract_images: None,
490 extract_metadata: None,
491 extract_tables: None,
492 extract_forms: None,
493 extract_structured_data: None,
494 readable_content: None,
495 remove_base64_images: None,
496 };
497 self.scrape(options).await
498 }
499
500 pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
506 match &self.transport {
507 BrowsrTransport::Http(inner) => inner.post("/search", &options).await,
508 BrowsrTransport::Stdout(inner) => inner.search(&options).await,
509 }
510 }
511
512 pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
514 let options = SearchOptions {
515 query: query.to_string(),
516 limit: None,
517 };
518 self.search(options).await
519 }
520
521 pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
553 match &self.transport {
554 BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
555 BrowsrTransport::Stdout(inner) => inner.browser_step(&request).await,
556 }
557 }
558
559 pub async fn step_commands(
575 &self,
576 commands: Vec<Commands>,
577 ) -> Result<BrowserStepResult, ClientError> {
578 let input = BrowserStepInput::new(commands);
579 let request = BrowserStepRequest::new(input);
580 self.step(request).await
581 }
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
589struct SessionList {
590 sessions: Vec<String>,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct SessionCreated {
596 pub session_id: String,
597 #[serde(default)]
599 pub sse_url: Option<String>,
600 #[serde(default)]
602 pub frame_url: Option<String>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
606struct CommandsPayload {
607 commands: Vec<Commands>,
608 #[serde(skip_serializing_if = "Option::is_none")]
609 session_id: Option<String>,
610 #[serde(skip_serializing_if = "Option::is_none")]
611 headless: Option<bool>,
612 #[serde(skip_serializing_if = "Option::is_none")]
613 context: Option<BrowserContext>,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct ObserveOptions {
619 pub use_image: Option<bool>,
620 pub full_page: Option<bool>,
621 pub wait_ms: Option<u64>,
622 pub include_content: Option<bool>,
623}
624
625impl Default for ObserveOptions {
626 fn default() -> Self {
627 Self {
628 use_image: Some(true),
629 full_page: None,
630 wait_ms: None,
631 include_content: Some(true),
632 }
633 }
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize)]
637struct ObservePayload {
638 #[serde(default, skip_serializing_if = "Option::is_none")]
639 pub session_id: Option<String>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub headless: Option<bool>,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub use_image: Option<bool>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub full_page: Option<bool>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub wait_ms: Option<u64>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub include_content: Option<bool>,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653struct ObserveEnvelope {
654 pub session_id: String,
655 pub observation: ObserveResponse,
656}
657
658#[derive(Debug, Error)]
663pub enum ClientError {
664 #[error("http request failed: {0}")]
665 Http(#[from] reqwest::Error),
666 #[error("stdout transport failed: {0}")]
667 Stdout(String),
668 #[error("invalid response: {0}")]
669 InvalidResponse(String),
670 #[error("serialization error: {0}")]
671 Serialization(#[from] serde_json::Error),
672 #[error("io error: {0}")]
673 Io(#[from] std::io::Error),
674}
675
676#[derive(Debug, Clone)]
681struct HttpTransport {
682 base_url: String,
683 client: reqwest::Client,
684}
685
686impl HttpTransport {
687 fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
688 Self {
689 base_url: base_url.into(),
690 client,
691 }
692 }
693
694 fn url(&self, path: &str) -> String {
695 let base = self.base_url.trim_end_matches('/');
696 let suffix = path.trim_start_matches('/');
697 format!("{}/{}", base, suffix)
698 }
699
700 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
701 let resp = self.client.get(self.url(path)).send().await?;
702 Self::handle_response(resp).await
703 }
704
705 async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
706 let resp = self.client.delete(self.url(path)).send().await?;
707 Self::handle_response(resp).await
708 }
709
710 async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
711 &self,
712 path: &str,
713 body: &B,
714 ) -> Result<T, ClientError> {
715 let resp = self.client.post(self.url(path)).json(body).send().await?;
716 Self::handle_response(resp).await
717 }
718
719 async fn handle_response<T: DeserializeOwned>(
720 resp: reqwest::Response,
721 ) -> Result<T, ClientError> {
722 let status = resp.status();
723 if status == StatusCode::NO_CONTENT {
724 let empty: Value = Value::Null;
725 let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
726 return Ok(value);
727 }
728
729 let text = resp.text().await?;
730 if !status.is_success() {
731 return Err(ClientError::InvalidResponse(format!(
732 "{}: {}",
733 status, text
734 )));
735 }
736
737 serde_json::from_str(&text).map_err(ClientError::Serialization)
738 }
739}
740
741#[derive(Debug, Clone)]
746struct StdoutTransport {
747 command: String,
748}
749
750impl StdoutTransport {
751 fn new(command: impl Into<String>) -> Self {
752 Self {
753 command: command.into(),
754 }
755 }
756
757 async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
758 let envelope: SessionList = self.request("sessions", &Value::Null).await?;
759 Ok(envelope.sessions)
760 }
761
762 async fn create_session(&self) -> Result<String, ClientError> {
763 let created: SessionCreated = self.request("create_session", &Value::Null).await?;
764 Ok(created.session_id)
765 }
766
767 async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
768 let payload = json!({ "session_id": session_id });
769 let _: Value = self.request("destroy_session", &payload).await?;
770 Ok(())
771 }
772
773 async fn execute_commands(
774 &self,
775 payload: &CommandsPayload,
776 ) -> Result<AutomateResponse, ClientError> {
777 self.request("execute", payload).await
778 }
779
780 async fn observe(&self, payload: &ObservePayload) -> Result<ObserveResponse, ClientError> {
781 let envelope: ObserveEnvelope = self.request("observe", payload).await?;
782 Ok(envelope.observation)
783 }
784
785 async fn scrape(&self, options: &ScrapeOptions) -> Result<Value, ClientError> {
786 self.request("scrape", options).await
787 }
788
789 async fn search(&self, options: &SearchOptions) -> Result<SearchResponse, ClientError> {
790 self.request("search", options).await
791 }
792
793 async fn browser_step(
794 &self,
795 request: &BrowserStepRequest,
796 ) -> Result<BrowserStepResult, ClientError> {
797 self.request("browser_step", request).await
798 }
799
800 async fn request<T: DeserializeOwned, B: Serialize>(
801 &self,
802 operation: &str,
803 payload: &B,
804 ) -> Result<T, ClientError> {
805 let mut cmd = Command::new(&self.command);
806 cmd.arg("client").arg(operation);
807
808 let payload_str = serde_json::to_string(&payload).map_err(ClientError::Serialization)?;
809 cmd.env("BROWSR_CLIENT_PAYLOAD", &payload_str);
810
811 let output = cmd.output().await?;
812 if !output.status.success() {
813 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
814 return Err(ClientError::Stdout(format!(
815 "browsr command failed ({}): {}",
816 output.status, stderr
817 )));
818 }
819
820 let stdout = String::from_utf8_lossy(&output.stdout);
821 if stdout.trim().is_empty() {
822 return Err(ClientError::InvalidResponse(
823 "empty stdout from browsr".to_string(),
824 ));
825 }
826
827 serde_json::from_str(stdout.trim()).map_err(ClientError::Serialization)
828 }
829}
830
831pub fn default_base_url() -> Option<String> {
837 if let Ok(url) = std::env::var("BROWSR_API_URL") {
838 return Some(url);
839 }
840 if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
841 return Some(url);
842 }
843 if let Ok(port) = std::env::var("BROWSR_PORT") {
844 let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
845 return Some(format!("http://{}:{}", host, port));
846 }
847 Some(DEFAULT_BASE_URL.to_string())
849}
850
851pub fn default_transport() -> TransportConfig {
852 TransportConfig::Http {
853 base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
854 }
855}