1mod config;
34
35pub use config::{BrowsrClientConfig, DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL};
36
37pub use browsr_types::{
39 BrowserStepInput, BrowserStepRequest, BrowserStepResult, CrawlApiRequest, CrawlApiResponse,
40 JsonExtractionOptions, ObserveOptions, RelayEvent, RelayEventsResponse,
41 RelaySessionInfo, RelaySessionListResponse, ScrapeAction, ScrapeApiRequest,
42 ScrapeApiResponse, ScrapeData, ScrapeFormat, SessionCreated, ShellCreateSessionRequest,
43 ShellCreateSessionResponse, ShellExecRequest, ShellExecResponse, ShellSessionListItem,
44 ShellSessionListResponse, ShellTerminateResponse,
45};
46
47use browsr_types::{
48 AutomateResponse, BrowserContext, Commands, ObserveResponse, SearchOptions,
49 SearchResponse,
50};
51use reqwest::StatusCode;
52use serde::{Deserialize, Serialize, de::DeserializeOwned};
53use serde_json::{Value, json};
54use thiserror::Error;
55use tokio::process::Command;
56
57#[derive(Debug, Clone)]
58pub enum TransportConfig {
59 Http { base_url: String },
60 Stdout { command: String },
61}
62
63#[derive(Debug, Clone)]
81pub struct BrowsrClient {
82 transport: BrowsrTransport,
83 config: BrowsrClientConfig,
84}
85
86#[derive(Debug, Clone)]
87enum BrowsrTransport {
88 Http(HttpTransport),
89 Stdout(StdoutTransport),
90}
91
92impl BrowsrClient {
93 pub fn new(base_url: impl Into<String>) -> Self {
96 let config = BrowsrClientConfig::new(base_url);
97 Self::from_client_config(config)
98 }
99
100 pub fn from_env() -> Self {
105 let config = BrowsrClientConfig::from_env();
106 Self::from_client_config(config)
107 }
108
109 pub fn from_client_config(config: BrowsrClientConfig) -> Self {
111 let http = config
112 .build_http_client()
113 .expect("Failed to build HTTP client");
114
115 Self {
116 transport: BrowsrTransport::Http(HttpTransport::new_with_client(
117 &config.base_url,
118 http,
119 )),
120 config,
121 }
122 }
123
124 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
127 self.config = self.config.with_api_key(api_key);
128 let http = self
129 .config
130 .build_http_client()
131 .expect("Failed to build HTTP client");
132 self.transport =
133 BrowsrTransport::Http(HttpTransport::new_with_client(&self.config.base_url, http));
134 self
135 }
136
137 pub fn new_http(base_url: impl Into<String>) -> Self {
139 Self::new(base_url)
140 }
141
142 pub fn new_stdout(command: impl Into<String>) -> Self {
144 Self {
145 transport: BrowsrTransport::Stdout(StdoutTransport::new(command)),
146 config: BrowsrClientConfig::default(),
147 }
148 }
149
150 pub fn from_config(cfg: TransportConfig) -> Self {
152 match cfg {
153 TransportConfig::Http { base_url } => Self::new_http(base_url),
154 TransportConfig::Stdout { command } => Self::new_stdout(command),
155 }
156 }
157
158 pub fn base_url(&self) -> &str {
160 &self.config.base_url
161 }
162
163 pub fn config(&self) -> &BrowsrClientConfig {
165 &self.config
166 }
167
168 pub fn has_auth(&self) -> bool {
170 self.config.has_auth()
171 }
172
173 pub fn is_local(&self) -> bool {
175 self.config.is_local()
176 }
177
178 pub async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
184 match &self.transport {
185 BrowsrTransport::Http(inner) => {
186 let response: SessionList = inner.get("/sessions").await?;
187 Ok(response.sessions)
188 }
189 BrowsrTransport::Stdout(inner) => inner.list_sessions().await,
190 }
191 }
192
193 pub async fn create_session(&self) -> Result<SessionCreated, ClientError> {
196 match &self.transport {
197 BrowsrTransport::Http(inner) => {
198 inner.post("/sessions", &Value::Null).await
199 }
200 BrowsrTransport::Stdout(inner) => {
201 let session_id = inner.create_session().await?;
202 Ok(SessionCreated {
203 session_id,
204 sse_url: None,
205 frame_url: None,
206 frame_token: None,
207 })
208 }
209 }
210 }
211
212 pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
214 match &self.transport {
215 BrowsrTransport::Http(inner) => inner
216 .delete(&format!("/sessions/{}", session_id))
217 .await
218 .map(|_: Value| ()),
219 BrowsrTransport::Stdout(inner) => inner.destroy_session(session_id).await,
220 }
221 }
222
223 pub async fn create_shell_session(
225 &self,
226 request: ShellCreateSessionRequest,
227 ) -> Result<ShellCreateSessionResponse, ClientError> {
228 match &self.transport {
229 BrowsrTransport::Http(inner) => inner.post("/shell/sessions", &request).await,
230 BrowsrTransport::Stdout(_) => Err(ClientError::InvalidResponse(
231 "shell sessions are not supported over stdout transport".to_string(),
232 )),
233 }
234 }
235
236 pub async fn list_shell_sessions(&self) -> Result<ShellSessionListResponse, ClientError> {
238 match &self.transport {
239 BrowsrTransport::Http(inner) => inner.get("/shell/sessions").await,
240 BrowsrTransport::Stdout(_) => Err(ClientError::InvalidResponse(
241 "shell sessions are not supported over stdout transport".to_string(),
242 )),
243 }
244 }
245
246 pub async fn terminate_shell_session(
248 &self,
249 session_id: &str,
250 ) -> Result<ShellTerminateResponse, ClientError> {
251 match &self.transport {
252 BrowsrTransport::Http(inner) => inner.delete(&format!("/shell/sessions/{}", session_id)).await,
253 BrowsrTransport::Stdout(_) => Err(ClientError::InvalidResponse(
254 "shell sessions are not supported over stdout transport".to_string(),
255 )),
256 }
257 }
258
259 pub async fn shell_exec(
261 &self,
262 request: ShellExecRequest,
263 ) -> Result<ShellExecResponse, ClientError> {
264 match &self.transport {
265 BrowsrTransport::Http(inner) => inner.post("/shell/exec", &request).await,
266 BrowsrTransport::Stdout(_) => Err(ClientError::InvalidResponse(
267 "shell sessions are not supported over stdout transport".to_string(),
268 )),
269 }
270 }
271
272 pub async fn execute_commands(
278 &self,
279 commands: Vec<Commands>,
280 session_id: Option<String>,
281 headless: Option<bool>,
282 context: Option<BrowserContext>,
283 ) -> Result<AutomateResponse, ClientError> {
284 let payload = CommandsPayload {
285 commands,
286 session_id,
287 headless: headless.or(self.config.headless),
288 context,
289 };
290
291 match &self.transport {
292 BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
293 BrowsrTransport::Stdout(inner) => inner.execute_commands(&payload).await,
294 }
295 }
296
297 pub async fn execute_command(
299 &self,
300 command: Commands,
301 session_id: Option<String>,
302 headless: Option<bool>,
303 ) -> Result<AutomateResponse, ClientError> {
304 self.execute_commands(vec![command], session_id, headless, None)
305 .await
306 }
307
308 pub async fn navigate(
314 &self,
315 url: &str,
316 session_id: Option<String>,
317 ) -> Result<AutomateResponse, ClientError> {
318 self.execute_command(
319 Commands::NavigateTo {
320 url: url.to_string(),
321 },
322 session_id,
323 None,
324 )
325 .await
326 }
327
328 pub async fn click(
330 &self,
331 selector: &str,
332 session_id: Option<String>,
333 ) -> Result<AutomateResponse, ClientError> {
334 self.execute_command(
335 Commands::Click {
336 selector: selector.to_string(),
337 },
338 session_id,
339 None,
340 )
341 .await
342 }
343
344 pub async fn type_text(
346 &self,
347 selector: &str,
348 text: &str,
349 clear: Option<bool>,
350 session_id: Option<String>,
351 ) -> Result<AutomateResponse, ClientError> {
352 self.execute_command(
353 Commands::TypeText {
354 selector: selector.to_string(),
355 text: text.to_string(),
356 clear,
357 },
358 session_id,
359 None,
360 )
361 .await
362 }
363
364 pub async fn wait_for_element(
366 &self,
367 selector: &str,
368 timeout_ms: Option<u64>,
369 session_id: Option<String>,
370 ) -> Result<AutomateResponse, ClientError> {
371 self.execute_command(
372 Commands::WaitForElement {
373 selector: selector.to_string(),
374 timeout_ms,
375 visible_only: None,
376 },
377 session_id,
378 None,
379 )
380 .await
381 }
382
383 pub async fn screenshot(
385 &self,
386 full_page: bool,
387 session_id: Option<String>,
388 ) -> Result<AutomateResponse, ClientError> {
389 self.execute_command(
390 Commands::Screenshot {
391 full_page: Some(full_page),
392 path: None,
393 },
394 session_id,
395 None,
396 )
397 .await
398 }
399
400 pub async fn get_title(
402 &self,
403 session_id: Option<String>,
404 ) -> Result<AutomateResponse, ClientError> {
405 self.execute_command(Commands::GetTitle, session_id, None)
406 .await
407 }
408
409 pub async fn get_text(
411 &self,
412 selector: &str,
413 session_id: Option<String>,
414 ) -> Result<AutomateResponse, ClientError> {
415 self.execute_command(
416 Commands::GetText {
417 selector: selector.to_string(),
418 },
419 session_id,
420 None,
421 )
422 .await
423 }
424
425 pub async fn get_content(
427 &self,
428 selector: Option<String>,
429 session_id: Option<String>,
430 ) -> Result<AutomateResponse, ClientError> {
431 self.execute_command(
432 Commands::GetContent {
433 selector,
434 kind: None,
435 },
436 session_id,
437 None,
438 )
439 .await
440 }
441
442 pub async fn evaluate(
444 &self,
445 expression: &str,
446 session_id: Option<String>,
447 ) -> Result<AutomateResponse, ClientError> {
448 self.execute_command(
449 Commands::Evaluate {
450 expression: expression.to_string(),
451 },
452 session_id,
453 None,
454 )
455 .await
456 }
457
458 pub async fn extract_structured(
479 &self,
480 query: &str,
481 schema: Option<serde_json::Value>,
482 max_chars: Option<usize>,
483 session_id: Option<String>,
484 ) -> Result<AutomateResponse, ClientError> {
485 self.execute_command(
486 Commands::ExtractStructuredContent {
487 query: query.to_string(),
488 schema,
489 max_chars,
490 },
491 session_id,
492 None,
493 )
494 .await
495 }
496
497 pub async fn observe(
503 &self,
504 session_id: Option<String>,
505 headless: Option<bool>,
506 opts: ObserveOptions,
507 ) -> Result<ObserveResponse, ClientError> {
508 let payload = ObservePayload {
509 session_id,
510 headless: headless.or(self.config.headless),
511 use_image: opts.use_image,
512 full_page: opts.full_page,
513 wait_ms: opts.wait_ms,
514 include_content: opts.include_content,
515 };
516
517 match &self.transport {
518 BrowsrTransport::Http(inner) => {
519 let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
520 Ok(envelope.observation)
521 }
522 BrowsrTransport::Stdout(inner) => inner.observe(&payload).await,
523 }
524 }
525
526 pub async fn cdp(
528 &self,
529 session_id: impl Into<String>,
530 method: impl Into<String>,
531 params: Option<Value>,
532 ) -> Result<Value, ClientError> {
533 let payload = json!({
534 "session_id": session_id.into(),
535 "method": method.into(),
536 "params": params.unwrap_or_else(|| json!({})),
537 });
538
539 match &self.transport {
540 BrowsrTransport::Http(inner) => inner.post("/cdp", &payload).await,
541 BrowsrTransport::Stdout(inner) => inner.request("cdp", &payload).await,
542 }
543 }
544
545 pub async fn relay_events(
547 &self,
548 session_id: &str,
549 limit: Option<usize>,
550 ) -> Result<RelayEventsResponse, ClientError> {
551 let path = match limit {
552 Some(limit) => format!("/relay/sessions/{}/events?limit={}", session_id, limit),
553 None => format!("/relay/sessions/{}/events", session_id),
554 };
555
556 match &self.transport {
557 BrowsrTransport::Http(inner) => inner.get(&path).await,
558 BrowsrTransport::Stdout(inner) => inner.request("relay_events", &json!({
559 "session_id": session_id,
560 "limit": limit,
561 })).await,
562 }
563 }
564
565 pub async fn list_relay_sessions(&self) -> Result<RelaySessionListResponse, ClientError> {
567 match &self.transport {
568 BrowsrTransport::Http(inner) => inner.get("/relay/sessions").await,
569 BrowsrTransport::Stdout(inner) => inner.request("list_relay_sessions", &Value::Null).await,
570 }
571 }
572
573 pub async fn clear_relay_events(&self, session_id: &str) -> Result<Value, ClientError> {
575 let path = format!("/relay/sessions/{}/events", session_id);
576 match &self.transport {
577 BrowsrTransport::Http(inner) => inner.delete(&path).await,
578 BrowsrTransport::Stdout(inner) => inner.request("clear_relay_events", &json!({
579 "session_id": session_id,
580 })).await,
581 }
582 }
583
584 pub async fn scrape_v1(&self, request: ScrapeApiRequest) -> Result<ScrapeApiResponse, ClientError> {
590 match &self.transport {
591 BrowsrTransport::Http(inner) => inner.post("/v1/scrape", &request).await,
592 BrowsrTransport::Stdout(inner) => inner.request("scrape", &request).await,
593 }
594 }
595
596 pub async fn scrape_url(&self, url: &str) -> Result<ScrapeApiResponse, ClientError> {
598 self.scrape_v1(ScrapeApiRequest::new(url)).await
599 }
600
601 pub async fn crawl(&self, request: CrawlApiRequest) -> Result<CrawlApiResponse, ClientError> {
603 match &self.transport {
604 BrowsrTransport::Http(inner) => inner.post("/v1/crawl", &request).await,
605 BrowsrTransport::Stdout(inner) => inner.request("crawl", &request).await,
606 }
607 }
608
609 pub async fn crawl_url(&self, url: &str) -> Result<CrawlApiResponse, ClientError> {
611 self.crawl(CrawlApiRequest::new(url)).await
612 }
613
614 pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
620 match &self.transport {
621 BrowsrTransport::Http(inner) => inner.post("/search", &options).await,
622 BrowsrTransport::Stdout(inner) => inner.search(&options).await,
623 }
624 }
625
626 pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
628 let options = SearchOptions {
629 query: query.to_string(),
630 limit: None,
631 };
632 self.search(options).await
633 }
634
635 pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
667 match &self.transport {
668 BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
669 BrowsrTransport::Stdout(inner) => inner.browser_step(&request).await,
670 }
671 }
672
673 pub async fn step_commands(
689 &self,
690 commands: Vec<Commands>,
691 ) -> Result<BrowserStepResult, ClientError> {
692 let input = BrowserStepInput::new(commands);
693 let request = BrowserStepRequest::new(input);
694 self.step(request).await
695 }
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
699struct SessionList {
700 sessions: Vec<String>,
701}
702
703#[derive(Debug, Clone, Serialize, Deserialize)]
704struct CommandsPayload {
705 commands: Vec<Commands>,
706 #[serde(skip_serializing_if = "Option::is_none")]
707 session_id: Option<String>,
708 #[serde(skip_serializing_if = "Option::is_none")]
709 headless: Option<bool>,
710 #[serde(skip_serializing_if = "Option::is_none")]
711 context: Option<BrowserContext>,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
715struct ObservePayload {
716 #[serde(default, skip_serializing_if = "Option::is_none")]
717 pub session_id: Option<String>,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub headless: Option<bool>,
720 #[serde(default, skip_serializing_if = "Option::is_none")]
721 pub use_image: Option<bool>,
722 #[serde(default, skip_serializing_if = "Option::is_none")]
723 pub full_page: Option<bool>,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub wait_ms: Option<u64>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub include_content: Option<bool>,
728}
729
730#[derive(Debug, Clone, Serialize, Deserialize)]
731struct ObserveEnvelope {
732 pub session_id: String,
733 pub observation: ObserveResponse,
734}
735
736#[derive(Debug, Error)]
741pub enum ClientError {
742 #[error("http request failed: {0}")]
743 Http(#[from] reqwest::Error),
744 #[error("stdout transport failed: {0}")]
745 Stdout(String),
746 #[error("invalid response: {0}")]
747 InvalidResponse(String),
748 #[error("serialization error: {0}")]
749 Serialization(#[from] serde_json::Error),
750 #[error("io error: {0}")]
751 Io(#[from] std::io::Error),
752}
753
754#[derive(Debug, Clone)]
759struct HttpTransport {
760 base_url: String,
761 client: reqwest::Client,
762}
763
764impl HttpTransport {
765 fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
766 Self {
767 base_url: base_url.into(),
768 client,
769 }
770 }
771
772 fn url(&self, path: &str) -> String {
773 let base = self.base_url.trim_end_matches('/');
774 let suffix = path.trim_start_matches('/');
775 format!("{}/{}", base, suffix)
776 }
777
778 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
779 let resp = self.client.get(self.url(path)).send().await?;
780 Self::handle_response(resp).await
781 }
782
783 async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
784 let resp = self.client.delete(self.url(path)).send().await?;
785 Self::handle_response(resp).await
786 }
787
788 async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
789 &self,
790 path: &str,
791 body: &B,
792 ) -> Result<T, ClientError> {
793 let resp = self.client.post(self.url(path)).json(body).send().await?;
794 Self::handle_response(resp).await
795 }
796
797 async fn handle_response<T: DeserializeOwned>(
798 resp: reqwest::Response,
799 ) -> Result<T, ClientError> {
800 let status = resp.status();
801 if status == StatusCode::NO_CONTENT {
802 let empty: Value = Value::Null;
803 let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
804 return Ok(value);
805 }
806
807 let text = resp.text().await?;
808 if !status.is_success() {
809 return Err(ClientError::InvalidResponse(format!(
810 "{}: {}",
811 status, text
812 )));
813 }
814
815 serde_json::from_str(&text).map_err(ClientError::Serialization)
816 }
817}
818
819#[derive(Debug, Clone)]
824struct StdoutTransport {
825 command: String,
826}
827
828impl StdoutTransport {
829 fn new(command: impl Into<String>) -> Self {
830 Self {
831 command: command.into(),
832 }
833 }
834
835 async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
836 let envelope: SessionList = self.request("sessions", &Value::Null).await?;
837 Ok(envelope.sessions)
838 }
839
840 async fn create_session(&self) -> Result<String, ClientError> {
841 let created: SessionCreated = self.request("create_session", &Value::Null).await?;
842 Ok(created.session_id)
843 }
844
845 async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
846 let payload = json!({ "session_id": session_id });
847 let _: Value = self.request("destroy_session", &payload).await?;
848 Ok(())
849 }
850
851 async fn execute_commands(
852 &self,
853 payload: &CommandsPayload,
854 ) -> Result<AutomateResponse, ClientError> {
855 self.request("execute", payload).await
856 }
857
858 async fn observe(&self, payload: &ObservePayload) -> Result<ObserveResponse, ClientError> {
859 let envelope: ObserveEnvelope = self.request("observe", payload).await?;
860 Ok(envelope.observation)
861 }
862
863 async fn search(&self, options: &SearchOptions) -> Result<SearchResponse, ClientError> {
864 self.request("search", options).await
865 }
866
867 async fn browser_step(
868 &self,
869 request: &BrowserStepRequest,
870 ) -> Result<BrowserStepResult, ClientError> {
871 self.request("browser_step", request).await
872 }
873
874 async fn request<T: DeserializeOwned, B: Serialize>(
875 &self,
876 operation: &str,
877 payload: &B,
878 ) -> Result<T, ClientError> {
879 let mut cmd = Command::new(&self.command);
880 cmd.arg("client").arg(operation);
881
882 let payload_str = serde_json::to_string(&payload).map_err(ClientError::Serialization)?;
883 cmd.env("BROWSR_CLIENT_PAYLOAD", &payload_str);
884
885 let output = cmd.output().await?;
886 if !output.status.success() {
887 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
888 return Err(ClientError::Stdout(format!(
889 "browsr command failed ({}): {}",
890 output.status, stderr
891 )));
892 }
893
894 let stdout = String::from_utf8_lossy(&output.stdout);
895 if stdout.trim().is_empty() {
896 return Err(ClientError::InvalidResponse(
897 "empty stdout from browsr".to_string(),
898 ));
899 }
900
901 serde_json::from_str(stdout.trim()).map_err(ClientError::Serialization)
902 }
903}
904
905pub fn default_base_url() -> Option<String> {
911 if let Ok(url) = std::env::var("BROWSR_API_URL") {
912 return Some(url);
913 }
914 if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
915 return Some(url);
916 }
917 if let Ok(port) = std::env::var("BROWSR_PORT") {
918 let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
919 return Some(format!("http://{}:{}", host, port));
920 }
921 Some(DEFAULT_BASE_URL.to_string())
923}
924
925pub fn default_transport() -> TransportConfig {
926 TransportConfig::Http {
927 base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
928 }
929}