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, NetworkAccess, ObserveOptions, RelayEvent, RelayEventsResponse,
41 RelaySessionInfo, RelaySessionListResponse, ScrapeAction, ScrapeApiRequest,
42 ScrapeApiResponse, ScrapeData, ScrapeFormat, SessionCreated, SessionDetail,
43 SessionListResponse, SessionType, ShellCreateSessionRequest,
44 ShellCreateSessionResponse, ShellExecRequest, ShellExecResponse, ShellExecResult,
45 ShellListItem, ShellListResponse, ShellTerminateResponse,
46};
47
48use browsr_types::{
49 AutomateResponse, BrowserContext, Commands, ObserveResponse, SearchOptions,
50 SearchResponse,
51};
52use reqwest::StatusCode;
53use serde::{Deserialize, Serialize, de::DeserializeOwned};
54use serde_json::{Value, json};
55use thiserror::Error;
56
57#[derive(Debug, Clone)]
58pub enum TransportConfig {
59 Http { base_url: String },
60}
61
62#[derive(Debug, Clone)]
80pub struct BrowsrClient {
81 transport: BrowsrTransport,
82 config: BrowsrClientConfig,
83}
84
85#[derive(Debug, Clone)]
86enum BrowsrTransport {
87 Http(HttpTransport),
88}
89
90impl BrowsrClient {
91 pub fn new(base_url: impl Into<String>) -> Self {
94 let config = BrowsrClientConfig::new(base_url);
95 Self::from_client_config(config)
96 }
97
98 pub fn from_env() -> Self {
103 let config = BrowsrClientConfig::from_env();
104 Self::from_client_config(config)
105 }
106
107 pub fn from_client_config(config: BrowsrClientConfig) -> Self {
109 let http = config
110 .build_http_client()
111 .expect("Failed to build HTTP client");
112
113 Self {
114 transport: BrowsrTransport::Http(HttpTransport::new_with_client(
115 &config.base_url,
116 http,
117 )),
118 config,
119 }
120 }
121
122 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
125 self.config = self.config.with_api_key(api_key);
126 let http = self
127 .config
128 .build_http_client()
129 .expect("Failed to build HTTP client");
130 self.transport =
131 BrowsrTransport::Http(HttpTransport::new_with_client(&self.config.base_url, http));
132 self
133 }
134
135 pub fn new_http(base_url: impl Into<String>) -> Self {
137 Self::new(base_url)
138 }
139
140 pub fn from_config(cfg: TransportConfig) -> Self {
142 match cfg {
143 TransportConfig::Http { base_url } => Self::new_http(base_url),
144 }
145 }
146
147 pub fn base_url(&self) -> &str {
149 &self.config.base_url
150 }
151
152 pub fn config(&self) -> &BrowsrClientConfig {
154 &self.config
155 }
156
157 pub fn has_auth(&self) -> bool {
159 self.config.has_auth()
160 }
161
162 pub fn is_local(&self) -> bool {
164 self.config.is_local()
165 }
166
167 pub async fn list_sessions(&self) -> Result<SessionListResponse, ClientError> {
173 match &self.transport {
174 BrowsrTransport::Http(inner) => inner.get("/sessions").await,
175 }
176 }
177
178 pub async fn create_session(&self) -> Result<SessionCreated, ClientError> {
181 match &self.transport {
182 BrowsrTransport::Http(inner) => {
183 inner.post("/sessions", &Value::Null).await
184 }
185 }
186 }
187
188 pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
190 match &self.transport {
191 BrowsrTransport::Http(inner) => inner
192 .delete(&format!("/sessions/{}", session_id))
193 .await
194 .map(|_: Value| ()),
195 }
196 }
197
198 pub async fn create_shell_session(
200 &self,
201 request: ShellCreateSessionRequest,
202 ) -> Result<ShellCreateSessionResponse, ClientError> {
203 match &self.transport {
204 BrowsrTransport::Http(inner) => inner.post("/shells", &request).await,
205 }
206 }
207
208 pub async fn list_shell_sessions(&self) -> Result<ShellListResponse, ClientError> {
210 match &self.transport {
211 BrowsrTransport::Http(inner) => inner.get("/shells").await,
212 }
213 }
214
215 pub async fn terminate_shell_session(
217 &self,
218 session_id: &str,
219 ) -> Result<ShellTerminateResponse, ClientError> {
220 match &self.transport {
221 BrowsrTransport::Http(inner) => inner.delete(&format!("/shells/{}", session_id)).await,
222 }
223 }
224
225 pub async fn shell_exec(
227 &self,
228 request: ShellExecRequest,
229 ) -> Result<ShellExecResponse, ClientError> {
230 match &self.transport {
231 BrowsrTransport::Http(inner) => inner.post("/shells/exec", &request).await,
232 }
233 }
234
235 pub async fn pool_status(&self) -> Result<Value, ClientError> {
241 match &self.transport {
242 BrowsrTransport::Http(inner) => inner.get("/shells/pool/status").await,
243 }
244 }
245
246 pub async fn pool_probe(&self) -> Result<Value, ClientError> {
248 match &self.transport {
249 BrowsrTransport::Http(inner) => inner.post("/shells/pool/probe", &json!({})).await,
250 }
251 }
252
253 pub async fn pool_warm(&self, image: &str, count: usize) -> Result<Value, ClientError> {
255 match &self.transport {
256 BrowsrTransport::Http(inner) => {
257 inner
258 .post("/shells/pool/warm", &json!({"image": image, "count": count}))
259 .await
260 }
261 }
262 }
263
264 pub async fn pool_drain(&self, image: &str) -> Result<Value, ClientError> {
266 match &self.transport {
267 BrowsrTransport::Http(inner) => {
268 inner
269 .post("/shells/pool/drain", &json!({"image": image}))
270 .await
271 }
272 }
273 }
274
275 pub async fn proxy_session_port(
277 &self,
278 session_id: &str,
279 port: u16,
280 path: &str,
281 ) -> Result<String, ClientError> {
282 match &self.transport {
283 BrowsrTransport::Http(inner) => {
284 let url = inner.url(&format!(
285 "/shells/{}/proxy?port={}&path={}",
286 session_id,
287 port,
288 path.trim_start_matches('/'),
289 ));
290 let resp = inner.client.get(&url).send().await?;
291 if !resp.status().is_success() {
292 let text = resp.text().await.unwrap_or_default();
293 return Err(ClientError::InvalidResponse(text));
294 }
295 resp.text().await.map_err(Into::into)
296 }
297 }
298 }
299
300 pub async fn execute_commands(
306 &self,
307 commands: Vec<Commands>,
308 session_id: Option<String>,
309 headless: Option<bool>,
310 context: Option<BrowserContext>,
311 ) -> Result<AutomateResponse, ClientError> {
312 let payload = CommandsPayload {
313 commands,
314 session_id,
315 headless: headless.or(self.config.headless),
316 context,
317 };
318
319 match &self.transport {
320 BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
321 }
322 }
323
324 pub async fn execute_command(
326 &self,
327 command: Commands,
328 session_id: Option<String>,
329 headless: Option<bool>,
330 ) -> Result<AutomateResponse, ClientError> {
331 self.execute_commands(vec![command], session_id, headless, None)
332 .await
333 }
334
335 pub async fn navigate(
341 &self,
342 url: &str,
343 session_id: Option<String>,
344 ) -> Result<AutomateResponse, ClientError> {
345 self.execute_command(
346 Commands::NavigateTo {
347 url: url.to_string(),
348 },
349 session_id,
350 None,
351 )
352 .await
353 }
354
355 pub async fn click(
357 &self,
358 selector: &str,
359 session_id: Option<String>,
360 ) -> Result<AutomateResponse, ClientError> {
361 self.execute_command(
362 Commands::Click {
363 selector: selector.to_string(),
364 },
365 session_id,
366 None,
367 )
368 .await
369 }
370
371 pub async fn type_text(
373 &self,
374 selector: &str,
375 text: &str,
376 clear: Option<bool>,
377 session_id: Option<String>,
378 ) -> Result<AutomateResponse, ClientError> {
379 self.execute_command(
380 Commands::TypeText {
381 selector: selector.to_string(),
382 text: text.to_string(),
383 clear,
384 },
385 session_id,
386 None,
387 )
388 .await
389 }
390
391 pub async fn wait_for_element(
393 &self,
394 selector: &str,
395 timeout_ms: Option<u64>,
396 session_id: Option<String>,
397 ) -> Result<AutomateResponse, ClientError> {
398 self.execute_command(
399 Commands::WaitForElement {
400 selector: selector.to_string(),
401 timeout_ms,
402 visible_only: None,
403 },
404 session_id,
405 None,
406 )
407 .await
408 }
409
410 pub async fn screenshot(
412 &self,
413 full_page: bool,
414 session_id: Option<String>,
415 ) -> Result<AutomateResponse, ClientError> {
416 self.execute_command(
417 Commands::Screenshot {
418 full_page: Some(full_page),
419 path: None,
420 },
421 session_id,
422 None,
423 )
424 .await
425 }
426
427 pub async fn get_title(
429 &self,
430 session_id: Option<String>,
431 ) -> Result<AutomateResponse, ClientError> {
432 self.execute_command(Commands::GetTitle, session_id, None)
433 .await
434 }
435
436 pub async fn get_text(
438 &self,
439 selector: &str,
440 session_id: Option<String>,
441 ) -> Result<AutomateResponse, ClientError> {
442 self.execute_command(
443 Commands::GetText {
444 selector: selector.to_string(),
445 },
446 session_id,
447 None,
448 )
449 .await
450 }
451
452 pub async fn get_content(
454 &self,
455 selector: Option<String>,
456 session_id: Option<String>,
457 ) -> Result<AutomateResponse, ClientError> {
458 self.execute_command(
459 Commands::GetContent {
460 selector,
461 kind: None,
462 },
463 session_id,
464 None,
465 )
466 .await
467 }
468
469 pub async fn evaluate(
471 &self,
472 expression: &str,
473 session_id: Option<String>,
474 ) -> Result<AutomateResponse, ClientError> {
475 self.execute_command(
476 Commands::Evaluate {
477 expression: expression.to_string(),
478 },
479 session_id,
480 None,
481 )
482 .await
483 }
484
485 pub async fn extract_structured(
506 &self,
507 query: &str,
508 schema: Option<serde_json::Value>,
509 max_chars: Option<usize>,
510 session_id: Option<String>,
511 ) -> Result<AutomateResponse, ClientError> {
512 self.execute_command(
513 Commands::ExtractStructuredContent {
514 query: query.to_string(),
515 schema,
516 max_chars,
517 },
518 session_id,
519 None,
520 )
521 .await
522 }
523
524 pub async fn observe(
530 &self,
531 session_id: Option<String>,
532 headless: Option<bool>,
533 opts: ObserveOptions,
534 ) -> Result<ObserveResponse, ClientError> {
535 let payload = ObservePayload {
536 session_id,
537 headless: headless.or(self.config.headless),
538 use_image: opts.use_image,
539 full_page: opts.full_page,
540 wait_ms: opts.wait_ms,
541 include_content: opts.include_content,
542 };
543
544 match &self.transport {
545 BrowsrTransport::Http(inner) => {
546 let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
547 Ok(envelope.observation)
548 }
549 }
550 }
551
552 pub async fn cdp(
554 &self,
555 session_id: impl Into<String>,
556 method: impl Into<String>,
557 params: Option<Value>,
558 ) -> Result<Value, ClientError> {
559 let payload = json!({
560 "session_id": session_id.into(),
561 "method": method.into(),
562 "params": params.unwrap_or_else(|| json!({})),
563 });
564
565 match &self.transport {
566 BrowsrTransport::Http(inner) => inner.post("/cdp", &payload).await,
567 }
568 }
569
570 pub async fn relay_events(
572 &self,
573 session_id: &str,
574 limit: Option<usize>,
575 ) -> Result<RelayEventsResponse, ClientError> {
576 let path = match limit {
577 Some(limit) => format!("/relay/sessions/{}/events?limit={}", session_id, limit),
578 None => format!("/relay/sessions/{}/events", session_id),
579 };
580
581 match &self.transport {
582 BrowsrTransport::Http(inner) => inner.get(&path).await,
583 }
584 }
585
586 pub async fn list_relay_sessions(&self) -> Result<RelaySessionListResponse, ClientError> {
588 match &self.transport {
589 BrowsrTransport::Http(inner) => inner.get("/relay/sessions").await,
590 }
591 }
592
593 pub async fn clear_relay_events(&self, session_id: &str) -> Result<Value, ClientError> {
595 let path = format!("/relay/sessions/{}/events", session_id);
596 match &self.transport {
597 BrowsrTransport::Http(inner) => inner.delete(&path).await,
598 }
599 }
600
601 pub async fn scrape_v1(&self, request: ScrapeApiRequest) -> Result<ScrapeApiResponse, ClientError> {
607 match &self.transport {
608 BrowsrTransport::Http(inner) => inner.post("/v1/scrape", &request).await,
609 }
610 }
611
612 pub async fn scrape_url(&self, url: &str) -> Result<ScrapeApiResponse, ClientError> {
614 self.scrape_v1(ScrapeApiRequest::new(url)).await
615 }
616
617 pub async fn crawl(&self, request: CrawlApiRequest) -> Result<CrawlApiResponse, ClientError> {
619 match &self.transport {
620 BrowsrTransport::Http(inner) => inner.post("/v1/crawl", &request).await,
621 }
622 }
623
624 pub async fn crawl_url(&self, url: &str) -> Result<CrawlApiResponse, ClientError> {
626 self.crawl(CrawlApiRequest::new(url)).await
627 }
628
629 pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
635 match &self.transport {
636 BrowsrTransport::Http(inner) => inner.post("/v1/search", &options).await,
637 }
638 }
639
640 pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
642 let options = SearchOptions {
643 query: query.to_string(),
644 limit: None,
645 };
646 self.search(options).await
647 }
648
649 pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
681 match &self.transport {
682 BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
683 }
684 }
685
686 pub async fn step_commands(
702 &self,
703 commands: Vec<Commands>,
704 ) -> Result<BrowserStepResult, ClientError> {
705 let input = BrowserStepInput::new(commands);
706 let request = BrowserStepRequest::new(input);
707 self.step(request).await
708 }
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
712struct CommandsPayload {
713 commands: Vec<Commands>,
714 #[serde(skip_serializing_if = "Option::is_none")]
715 session_id: Option<String>,
716 #[serde(skip_serializing_if = "Option::is_none")]
717 headless: Option<bool>,
718 #[serde(skip_serializing_if = "Option::is_none")]
719 context: Option<BrowserContext>,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize)]
723struct ObservePayload {
724 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub session_id: Option<String>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub headless: Option<bool>,
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub use_image: Option<bool>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub full_page: Option<bool>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub wait_ms: Option<u64>,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
735 pub include_content: Option<bool>,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
739struct ObserveEnvelope {
740 pub session_id: String,
741 pub observation: ObserveResponse,
742}
743
744#[derive(Debug, Error)]
749pub enum ClientError {
750 #[error("http request failed: {0}")]
751 Http(#[from] reqwest::Error),
752 #[error("invalid response: {0}")]
753 InvalidResponse(String),
754 #[error("serialization error: {0}")]
755 Serialization(#[from] serde_json::Error),
756 #[error("io error: {0}")]
757 Io(#[from] std::io::Error),
758}
759
760#[derive(Debug, Clone)]
765struct HttpTransport {
766 base_url: String,
767 client: reqwest::Client,
768}
769
770impl HttpTransport {
771 fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
772 Self {
773 base_url: base_url.into(),
774 client,
775 }
776 }
777
778 fn url(&self, path: &str) -> String {
779 let base = self.base_url.trim_end_matches('/');
780 let suffix = path.trim_start_matches('/');
781 format!("{}/{}", base, suffix)
782 }
783
784 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
785 let resp = self.client.get(self.url(path)).send().await?;
786 Self::handle_response(resp).await
787 }
788
789 async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
790 let resp = self.client.delete(self.url(path)).send().await?;
791 Self::handle_response(resp).await
792 }
793
794 async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
795 &self,
796 path: &str,
797 body: &B,
798 ) -> Result<T, ClientError> {
799 let resp = self.client.post(self.url(path)).json(body).send().await?;
800 Self::handle_response(resp).await
801 }
802
803 async fn handle_response<T: DeserializeOwned>(
804 resp: reqwest::Response,
805 ) -> Result<T, ClientError> {
806 let status = resp.status();
807 if status == StatusCode::NO_CONTENT {
808 let empty: Value = Value::Null;
809 let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
810 return Ok(value);
811 }
812
813 let text = resp.text().await?;
814 if !status.is_success() {
815 return Err(ClientError::InvalidResponse(format!(
816 "{}: {}",
817 status, text
818 )));
819 }
820
821 serde_json::from_str(&text).map_err(ClientError::Serialization)
822 }
823}
824
825pub fn default_base_url() -> Option<String> {
831 if let Ok(url) = std::env::var("BROWSR_API_URL") {
832 return Some(url);
833 }
834 if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
835 return Some(url);
836 }
837 if let Ok(port) = std::env::var("BROWSR_PORT") {
838 let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
839 return Some(format!("http://{}:{}", host, port));
840 }
841 Some(DEFAULT_BASE_URL.to_string())
843}
844
845pub fn default_transport() -> TransportConfig {
846 TransportConfig::Http {
847 base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
848 }
849}