Skip to main content

browsr_client/
lib.rs

1//! Browsr Client - HTTP client for browser automation
2//!
3//! This crate provides a client for interacting with Browsr servers for browser automation,
4//! web scraping, and structured content extraction.
5//!
6//! # Quick Start
7//!
8//! ```rust,ignore
9//! use browsr_client::{BrowsrClient, BrowsrClientConfig};
10//! use browsr_types::Commands;
11//!
12//! // From environment variables
13//! let client = BrowsrClient::from_env();
14//!
15//! // Navigate to a page
16//! let response = client.navigate("https://example.com", None).await?;
17//!
18//! // Extract structured content
19//! let data = client.extract_structured(
20//!     "Extract the main heading and first paragraph",
21//!     None,
22//!     None,
23//! ).await?;
24//! ```
25//!
26//! # Configuration
27//!
28//! The client can be configured via environment variables or programmatically:
29//!
30//! - `BROWSR_BASE_URL`: Base URL (defaults to `https://api.browsr.dev`)
31//! - `BROWSR_API_KEY`: Optional API key for authentication
32
33mod config;
34
35pub use config::{BrowsrClientConfig, DEFAULT_BASE_URL, ENV_API_KEY, ENV_BASE_URL};
36
37// Re-export browser_step types for convenient access
38pub 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/// Browsr HTTP client for browser automation.
64///
65/// # Example
66///
67/// ```rust,ignore
68/// use browsr_client::BrowsrClient;
69///
70/// // From environment variables (BROWSR_BASE_URL, BROWSR_API_KEY)
71/// let client = BrowsrClient::from_env();
72///
73/// // From explicit URL (for local development)
74/// let client = BrowsrClient::new("http://localhost:8082");
75///
76/// // With API key authentication
77/// let client = BrowsrClient::new("https://api.browsr.dev")
78///     .with_api_key("your-api-key");
79/// ```
80#[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    /// Create a new client with the specified base URL (no authentication).
94    /// For local development, use this method.
95    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    /// Create a new client from environment variables.
101    ///
102    /// - `BROWSR_BASE_URL`: Base URL (defaults to `https://api.browsr.dev`)
103    /// - `BROWSR_API_KEY`: Optional API key for authentication
104    pub fn from_env() -> Self {
105        let config = BrowsrClientConfig::from_env();
106        Self::from_client_config(config)
107    }
108
109    /// Create a new client from explicit configuration.
110    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    /// Set the API key for authentication.
125    /// This rebuilds the HTTP client with the new authentication header.
126    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    /// Create HTTP transport client (legacy method).
138    pub fn new_http(base_url: impl Into<String>) -> Self {
139        Self::new(base_url)
140    }
141
142    /// Create stdout transport client.
143    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    /// Create client from transport config (legacy method).
151    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    /// Get the base URL.
159    pub fn base_url(&self) -> &str {
160        &self.config.base_url
161    }
162
163    /// Get the current configuration.
164    pub fn config(&self) -> &BrowsrClientConfig {
165        &self.config
166    }
167
168    /// Check if the client has authentication configured.
169    pub fn has_auth(&self) -> bool {
170        self.config.has_auth()
171    }
172
173    /// Check if this is a local development client.
174    pub fn is_local(&self) -> bool {
175        self.config.is_local()
176    }
177
178    // ============================================================
179    // Session Management
180    // ============================================================
181
182    /// List all active browser sessions.
183    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    /// Create a new browser session.
194    /// Returns the full session info including viewer_url, sse_url, and frame_url.
195    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    /// Destroy a browser session.
213    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    /// Create a new shell session.
224    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    /// List active shell sessions.
237    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    /// Terminate a shell session.
247    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    /// Execute a shell command in an existing shell session.
260    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    // ============================================================
273    // Command Execution
274    // ============================================================
275
276    /// Execute a list of browser commands.
277    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    /// Execute a single browser command.
298    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    // ============================================================
309    // Convenience Methods for Common Commands
310    // ============================================================
311
312    /// Navigate to a URL.
313    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    /// Click an element by selector.
329    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    /// Type text into an element.
345    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    /// Wait for an element to appear.
365    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    /// Take a screenshot.
384    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    /// Get page title.
401    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    /// Get text content of an element.
410    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    /// Get HTML content of an element or page.
426    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    /// Evaluate JavaScript expression.
443    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    // ============================================================
459    // Structured Extraction
460    // ============================================================
461
462    /// Extract structured content from the current page using AI.
463    ///
464    /// # Arguments
465    /// * `query` - Natural language description of what to extract
466    /// * `schema` - Optional JSON schema for the output
467    /// * `max_chars` - Optional maximum characters to process
468    /// * `session_id` - Optional session ID
469    ///
470    /// # Example
471    /// ```rust,ignore
472    /// let data = client.extract_structured(
473    ///     "Extract all product names and prices",
474    ///     None,
475    ///     None,
476    /// ).await?;
477    /// ```
478    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    // ============================================================
498    // Observation
499    // ============================================================
500
501    /// Observe the current browser state (screenshot + DOM snapshot).
502    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    /// Execute a raw CDP request against an existing session.
527    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    /// List recent buffered relay events for a relay session.
546    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    /// List relay sessions visible to the current authenticated user.
566    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    /// Clear buffered relay events for a relay session.
574    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    // ============================================================
585    // Scraping (v1 API)
586    // ============================================================
587
588    /// Scrape a URL with full format options (v1 API).
589    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    /// Scrape a URL with default options (markdown output).
597    pub async fn scrape_url(&self, url: &str) -> Result<ScrapeApiResponse, ClientError> {
598        self.scrape_v1(ScrapeApiRequest::new(url)).await
599    }
600
601    /// Crawl a website starting from a URL.
602    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    /// Crawl a URL with default options (markdown, 10 pages, depth 2).
610    pub async fn crawl_url(&self, url: &str) -> Result<CrawlApiResponse, ClientError> {
611        self.crawl(CrawlApiRequest::new(url)).await
612    }
613
614    // ============================================================
615    // Search
616    // ============================================================
617
618    /// Perform a web search.
619    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    /// Search with a query string.
627    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    // ============================================================
636    // Browser Step (Agent Integration)
637    // ============================================================
638
639    /// Execute a browser step with commands and optional context.
640    ///
641    /// This is the primary method for agent integration, providing full control
642    /// over browser automation with context for sequence persistence.
643    ///
644    /// # Arguments
645    /// * `request` - Full browser step request with commands and context
646    ///
647    /// # Example
648    /// ```rust,ignore
649    /// use browsr_client::{BrowsrClient, BrowserStepRequest, BrowserStepInput};
650    /// use browsr_types::Commands;
651    ///
652    /// let client = BrowsrClient::from_env();
653    ///
654    /// let input = BrowserStepInput::new(vec![
655    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
656    ///     Commands::Screenshot { full_page: Some(false), path: None },
657    /// ]);
658    ///
659    /// let request = BrowserStepRequest::new(input)
660    ///     .with_session_id("my-session")
661    ///     .with_thread_id("thread-123");
662    ///
663    /// let result = client.step(request).await?;
664    /// println!("Success: {}, URL: {:?}", result.success, result.url);
665    /// ```
666    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    /// Execute a browser step with just commands (simple usage).
674    ///
675    /// Use this for quick automation tasks without context tracking.
676    ///
677    /// # Example
678    /// ```rust,ignore
679    /// use browsr_client::BrowsrClient;
680    /// use browsr_types::Commands;
681    ///
682    /// let client = BrowsrClient::from_env();
683    ///
684    /// let result = client.step_commands(vec![
685    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
686    /// ]).await?;
687    /// ```
688    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// ============================================================
737// Error Types
738// ============================================================
739
740#[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// ============================================================
755// HTTP Transport
756// ============================================================
757
758#[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// ============================================================
820// Stdout Transport
821// ============================================================
822
823#[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
905// ============================================================
906// Legacy Helper Functions
907// ============================================================
908
909/// Derive a default base URL for HTTP transport from standard env vars.
910pub 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    // Return cloud default
922    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}