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 frame_token: None,
200 })
201 }
202 }
203 }
204
205 pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
207 match &self.transport {
208 BrowsrTransport::Http(inner) => inner
209 .delete(&format!("/sessions/{}", session_id))
210 .await
211 .map(|_: Value| ()),
212 BrowsrTransport::Stdout(inner) => inner.destroy_session(session_id).await,
213 }
214 }
215
216 pub async fn execute_commands(
222 &self,
223 commands: Vec<Commands>,
224 session_id: Option<String>,
225 headless: Option<bool>,
226 context: Option<BrowserContext>,
227 ) -> Result<AutomateResponse, ClientError> {
228 let payload = CommandsPayload {
229 commands,
230 session_id,
231 headless: headless.or(self.config.headless),
232 context,
233 };
234
235 match &self.transport {
236 BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
237 BrowsrTransport::Stdout(inner) => inner.execute_commands(&payload).await,
238 }
239 }
240
241 pub async fn execute_command(
243 &self,
244 command: Commands,
245 session_id: Option<String>,
246 headless: Option<bool>,
247 ) -> Result<AutomateResponse, ClientError> {
248 self.execute_commands(vec![command], session_id, headless, None)
249 .await
250 }
251
252 pub async fn navigate(
258 &self,
259 url: &str,
260 session_id: Option<String>,
261 ) -> Result<AutomateResponse, ClientError> {
262 self.execute_command(
263 Commands::NavigateTo {
264 url: url.to_string(),
265 },
266 session_id,
267 None,
268 )
269 .await
270 }
271
272 pub async fn click(
274 &self,
275 selector: &str,
276 session_id: Option<String>,
277 ) -> Result<AutomateResponse, ClientError> {
278 self.execute_command(
279 Commands::Click {
280 selector: selector.to_string(),
281 },
282 session_id,
283 None,
284 )
285 .await
286 }
287
288 pub async fn type_text(
290 &self,
291 selector: &str,
292 text: &str,
293 clear: Option<bool>,
294 session_id: Option<String>,
295 ) -> Result<AutomateResponse, ClientError> {
296 self.execute_command(
297 Commands::TypeText {
298 selector: selector.to_string(),
299 text: text.to_string(),
300 clear,
301 },
302 session_id,
303 None,
304 )
305 .await
306 }
307
308 pub async fn wait_for_element(
310 &self,
311 selector: &str,
312 timeout_ms: Option<u64>,
313 session_id: Option<String>,
314 ) -> Result<AutomateResponse, ClientError> {
315 self.execute_command(
316 Commands::WaitForElement {
317 selector: selector.to_string(),
318 timeout_ms,
319 visible_only: None,
320 },
321 session_id,
322 None,
323 )
324 .await
325 }
326
327 pub async fn screenshot(
329 &self,
330 full_page: bool,
331 session_id: Option<String>,
332 ) -> Result<AutomateResponse, ClientError> {
333 self.execute_command(
334 Commands::Screenshot {
335 full_page: Some(full_page),
336 path: None,
337 },
338 session_id,
339 None,
340 )
341 .await
342 }
343
344 pub async fn get_title(
346 &self,
347 session_id: Option<String>,
348 ) -> Result<AutomateResponse, ClientError> {
349 self.execute_command(Commands::GetTitle, session_id, None)
350 .await
351 }
352
353 pub async fn get_text(
355 &self,
356 selector: &str,
357 session_id: Option<String>,
358 ) -> Result<AutomateResponse, ClientError> {
359 self.execute_command(
360 Commands::GetText {
361 selector: selector.to_string(),
362 },
363 session_id,
364 None,
365 )
366 .await
367 }
368
369 pub async fn get_content(
371 &self,
372 selector: Option<String>,
373 session_id: Option<String>,
374 ) -> Result<AutomateResponse, ClientError> {
375 self.execute_command(
376 Commands::GetContent {
377 selector,
378 kind: None,
379 },
380 session_id,
381 None,
382 )
383 .await
384 }
385
386 pub async fn evaluate(
388 &self,
389 expression: &str,
390 session_id: Option<String>,
391 ) -> Result<AutomateResponse, ClientError> {
392 self.execute_command(
393 Commands::Evaluate {
394 expression: expression.to_string(),
395 },
396 session_id,
397 None,
398 )
399 .await
400 }
401
402 pub async fn extract_structured(
423 &self,
424 query: &str,
425 schema: Option<serde_json::Value>,
426 max_chars: Option<usize>,
427 session_id: Option<String>,
428 ) -> Result<AutomateResponse, ClientError> {
429 self.execute_command(
430 Commands::ExtractStructuredContent {
431 query: query.to_string(),
432 schema,
433 max_chars,
434 },
435 session_id,
436 None,
437 )
438 .await
439 }
440
441 pub async fn observe(
447 &self,
448 session_id: Option<String>,
449 headless: Option<bool>,
450 opts: ObserveOptions,
451 ) -> Result<ObserveResponse, ClientError> {
452 let payload = ObservePayload {
453 session_id,
454 headless: headless.or(self.config.headless),
455 use_image: opts.use_image,
456 full_page: opts.full_page,
457 wait_ms: opts.wait_ms,
458 include_content: opts.include_content,
459 };
460
461 match &self.transport {
462 BrowsrTransport::Http(inner) => {
463 let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
464 Ok(envelope.observation)
465 }
466 BrowsrTransport::Stdout(inner) => inner.observe(&payload).await,
467 }
468 }
469
470 pub async fn scrape(&self, options: ScrapeOptions) -> Result<Value, ClientError> {
476 match &self.transport {
477 BrowsrTransport::Http(inner) => inner.post("/scrape", &options).await,
478 BrowsrTransport::Stdout(inner) => inner.scrape(&options).await,
479 }
480 }
481
482 pub async fn scrape_url(&self, url: &str) -> Result<Value, ClientError> {
484 let options = ScrapeOptions {
485 url: url.to_string(),
486 use_js: None,
487 wait_for: None,
488 extract_links: None,
489 selector: None,
490 extract_images: None,
491 extract_metadata: None,
492 extract_tables: None,
493 extract_forms: None,
494 extract_structured_data: None,
495 readable_content: None,
496 remove_base64_images: None,
497 };
498 self.scrape(options).await
499 }
500
501 pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
507 match &self.transport {
508 BrowsrTransport::Http(inner) => inner.post("/search", &options).await,
509 BrowsrTransport::Stdout(inner) => inner.search(&options).await,
510 }
511 }
512
513 pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
515 let options = SearchOptions {
516 query: query.to_string(),
517 limit: None,
518 };
519 self.search(options).await
520 }
521
522 pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
554 match &self.transport {
555 BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
556 BrowsrTransport::Stdout(inner) => inner.browser_step(&request).await,
557 }
558 }
559
560 pub async fn step_commands(
576 &self,
577 commands: Vec<Commands>,
578 ) -> Result<BrowserStepResult, ClientError> {
579 let input = BrowserStepInput::new(commands);
580 let request = BrowserStepRequest::new(input);
581 self.step(request).await
582 }
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
590struct SessionList {
591 sessions: Vec<String>,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct SessionCreated {
597 pub session_id: String,
598 #[serde(default)]
600 pub sse_url: Option<String>,
601 #[serde(default)]
603 pub frame_url: Option<String>,
604 #[serde(default)]
606 pub frame_token: Option<String>,
607}
608
609impl SessionCreated {
610 pub fn build_sse_url(&self, base_url: &str, width: Option<u32>, height: Option<u32>) -> String {
612 let mut url = self.sse_url.clone().unwrap_or_else(|| {
613 format!("{}/stream/sse?session_id={}", base_url, self.session_id)
614 });
615
616 if let Some(ref token) = self.frame_token {
618 let sep = if url.contains('?') { "&" } else { "?" };
619 url = format!("{}{}token={}", url, sep, token);
620 }
621
622 if let Some(w) = width {
624 let sep = if url.contains('?') { "&" } else { "?" };
625 url = format!("{}{}width={}", url, sep, w);
626 }
627 if let Some(h) = height {
628 url = format!("{}&height={}", url, h);
629 }
630
631 url
632 }
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
636struct CommandsPayload {
637 commands: Vec<Commands>,
638 #[serde(skip_serializing_if = "Option::is_none")]
639 session_id: Option<String>,
640 #[serde(skip_serializing_if = "Option::is_none")]
641 headless: Option<bool>,
642 #[serde(skip_serializing_if = "Option::is_none")]
643 context: Option<BrowserContext>,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct ObserveOptions {
649 pub use_image: Option<bool>,
650 pub full_page: Option<bool>,
651 pub wait_ms: Option<u64>,
652 pub include_content: Option<bool>,
653}
654
655impl Default for ObserveOptions {
656 fn default() -> Self {
657 Self {
658 use_image: Some(true),
659 full_page: None,
660 wait_ms: None,
661 include_content: Some(true),
662 }
663 }
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize)]
667struct ObservePayload {
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub session_id: Option<String>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub headless: Option<bool>,
672 #[serde(default, skip_serializing_if = "Option::is_none")]
673 pub use_image: Option<bool>,
674 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub full_page: Option<bool>,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub wait_ms: Option<u64>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub include_content: Option<bool>,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize)]
683struct ObserveEnvelope {
684 pub session_id: String,
685 pub observation: ObserveResponse,
686}
687
688#[derive(Debug, Error)]
693pub enum ClientError {
694 #[error("http request failed: {0}")]
695 Http(#[from] reqwest::Error),
696 #[error("stdout transport failed: {0}")]
697 Stdout(String),
698 #[error("invalid response: {0}")]
699 InvalidResponse(String),
700 #[error("serialization error: {0}")]
701 Serialization(#[from] serde_json::Error),
702 #[error("io error: {0}")]
703 Io(#[from] std::io::Error),
704}
705
706#[derive(Debug, Clone)]
711struct HttpTransport {
712 base_url: String,
713 client: reqwest::Client,
714}
715
716impl HttpTransport {
717 fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
718 Self {
719 base_url: base_url.into(),
720 client,
721 }
722 }
723
724 fn url(&self, path: &str) -> String {
725 let base = self.base_url.trim_end_matches('/');
726 let suffix = path.trim_start_matches('/');
727 format!("{}/{}", base, suffix)
728 }
729
730 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
731 let resp = self.client.get(self.url(path)).send().await?;
732 Self::handle_response(resp).await
733 }
734
735 async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
736 let resp = self.client.delete(self.url(path)).send().await?;
737 Self::handle_response(resp).await
738 }
739
740 async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
741 &self,
742 path: &str,
743 body: &B,
744 ) -> Result<T, ClientError> {
745 let resp = self.client.post(self.url(path)).json(body).send().await?;
746 Self::handle_response(resp).await
747 }
748
749 async fn handle_response<T: DeserializeOwned>(
750 resp: reqwest::Response,
751 ) -> Result<T, ClientError> {
752 let status = resp.status();
753 if status == StatusCode::NO_CONTENT {
754 let empty: Value = Value::Null;
755 let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
756 return Ok(value);
757 }
758
759 let text = resp.text().await?;
760 if !status.is_success() {
761 return Err(ClientError::InvalidResponse(format!(
762 "{}: {}",
763 status, text
764 )));
765 }
766
767 serde_json::from_str(&text).map_err(ClientError::Serialization)
768 }
769}
770
771#[derive(Debug, Clone)]
776struct StdoutTransport {
777 command: String,
778}
779
780impl StdoutTransport {
781 fn new(command: impl Into<String>) -> Self {
782 Self {
783 command: command.into(),
784 }
785 }
786
787 async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
788 let envelope: SessionList = self.request("sessions", &Value::Null).await?;
789 Ok(envelope.sessions)
790 }
791
792 async fn create_session(&self) -> Result<String, ClientError> {
793 let created: SessionCreated = self.request("create_session", &Value::Null).await?;
794 Ok(created.session_id)
795 }
796
797 async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
798 let payload = json!({ "session_id": session_id });
799 let _: Value = self.request("destroy_session", &payload).await?;
800 Ok(())
801 }
802
803 async fn execute_commands(
804 &self,
805 payload: &CommandsPayload,
806 ) -> Result<AutomateResponse, ClientError> {
807 self.request("execute", payload).await
808 }
809
810 async fn observe(&self, payload: &ObservePayload) -> Result<ObserveResponse, ClientError> {
811 let envelope: ObserveEnvelope = self.request("observe", payload).await?;
812 Ok(envelope.observation)
813 }
814
815 async fn scrape(&self, options: &ScrapeOptions) -> Result<Value, ClientError> {
816 self.request("scrape", options).await
817 }
818
819 async fn search(&self, options: &SearchOptions) -> Result<SearchResponse, ClientError> {
820 self.request("search", options).await
821 }
822
823 async fn browser_step(
824 &self,
825 request: &BrowserStepRequest,
826 ) -> Result<BrowserStepResult, ClientError> {
827 self.request("browser_step", request).await
828 }
829
830 async fn request<T: DeserializeOwned, B: Serialize>(
831 &self,
832 operation: &str,
833 payload: &B,
834 ) -> Result<T, ClientError> {
835 let mut cmd = Command::new(&self.command);
836 cmd.arg("client").arg(operation);
837
838 let payload_str = serde_json::to_string(&payload).map_err(ClientError::Serialization)?;
839 cmd.env("BROWSR_CLIENT_PAYLOAD", &payload_str);
840
841 let output = cmd.output().await?;
842 if !output.status.success() {
843 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
844 return Err(ClientError::Stdout(format!(
845 "browsr command failed ({}): {}",
846 output.status, stderr
847 )));
848 }
849
850 let stdout = String::from_utf8_lossy(&output.stdout);
851 if stdout.trim().is_empty() {
852 return Err(ClientError::InvalidResponse(
853 "empty stdout from browsr".to_string(),
854 ));
855 }
856
857 serde_json::from_str(stdout.trim()).map_err(ClientError::Serialization)
858 }
859}
860
861pub fn default_base_url() -> Option<String> {
867 if let Ok(url) = std::env::var("BROWSR_API_URL") {
868 return Some(url);
869 }
870 if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
871 return Some(url);
872 }
873 if let Ok(port) = std::env::var("BROWSR_PORT") {
874 let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
875 return Some(format!("http://{}:{}", host, port));
876 }
877 Some(DEFAULT_BASE_URL.to_string())
879}
880
881pub fn default_transport() -> TransportConfig {
882 TransportConfig::Http {
883 base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
884 }
885}