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::{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/// Browsr HTTP client for browser automation.
57///
58/// # Example
59///
60/// ```rust,ignore
61/// use browsr_client::BrowsrClient;
62///
63/// // From environment variables (BROWSR_BASE_URL, BROWSR_API_KEY)
64/// let client = BrowsrClient::from_env();
65///
66/// // From explicit URL (for local development)
67/// let client = BrowsrClient::new("http://localhost:8082");
68///
69/// // With API key authentication
70/// let client = BrowsrClient::new("https://api.browsr.dev")
71///     .with_api_key("your-api-key");
72/// ```
73#[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    /// Create a new client with the specified base URL (no authentication).
87    /// For local development, use this method.
88    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    /// Create a new client from environment variables.
94    ///
95    /// - `BROWSR_BASE_URL`: Base URL (defaults to `https://api.browsr.dev`)
96    /// - `BROWSR_API_KEY`: Optional API key for authentication
97    pub fn from_env() -> Self {
98        let config = BrowsrClientConfig::from_env();
99        Self::from_client_config(config)
100    }
101
102    /// Create a new client from explicit configuration.
103    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    /// Set the API key for authentication.
118    /// This rebuilds the HTTP client with the new authentication header.
119    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    /// Create HTTP transport client (legacy method).
131    pub fn new_http(base_url: impl Into<String>) -> Self {
132        Self::new(base_url)
133    }
134
135    /// Create stdout transport client.
136    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    /// Create client from transport config (legacy method).
144    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    /// Get the base URL.
152    pub fn base_url(&self) -> &str {
153        &self.config.base_url
154    }
155
156    /// Get the current configuration.
157    pub fn config(&self) -> &BrowsrClientConfig {
158        &self.config
159    }
160
161    /// Check if the client has authentication configured.
162    pub fn has_auth(&self) -> bool {
163        self.config.has_auth()
164    }
165
166    /// Check if this is a local development client.
167    pub fn is_local(&self) -> bool {
168        self.config.is_local()
169    }
170
171    // ============================================================
172    // Session Management
173    // ============================================================
174
175    /// List all active browser sessions.
176    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    /// Create a new browser session.
187    /// Returns the full session info including viewer_url, sse_url, and frame_url.
188    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    /// Destroy a browser session.
206    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    // ============================================================
217    // Command Execution
218    // ============================================================
219
220    /// Execute a list of browser commands.
221    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    /// Execute a single browser command.
242    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    // ============================================================
253    // Convenience Methods for Common Commands
254    // ============================================================
255
256    /// Navigate to a URL.
257    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    /// Click an element by selector.
273    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    /// Type text into an element.
289    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    /// Wait for an element to appear.
309    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    /// Take a screenshot.
328    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    /// Get page title.
345    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    /// Get text content of an element.
354    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    /// Get HTML content of an element or page.
370    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    /// Evaluate JavaScript expression.
387    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    // ============================================================
403    // Structured Extraction
404    // ============================================================
405
406    /// Extract structured content from the current page using AI.
407    ///
408    /// # Arguments
409    /// * `query` - Natural language description of what to extract
410    /// * `schema` - Optional JSON schema for the output
411    /// * `max_chars` - Optional maximum characters to process
412    /// * `session_id` - Optional session ID
413    ///
414    /// # Example
415    /// ```rust,ignore
416    /// let data = client.extract_structured(
417    ///     "Extract all product names and prices",
418    ///     None,
419    ///     None,
420    /// ).await?;
421    /// ```
422    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    // ============================================================
442    // Observation
443    // ============================================================
444
445    /// Observe the current browser state (screenshot + DOM snapshot).
446    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    // ============================================================
471    // Scraping
472    // ============================================================
473
474    /// Scrape content from a URL or the current page.
475    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    /// Scrape a URL with default options.
483    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    // ============================================================
502    // Search
503    // ============================================================
504
505    /// Perform a web search.
506    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    /// Search with a query string.
514    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    // ============================================================
523    // Browser Step (Agent Integration)
524    // ============================================================
525
526    /// Execute a browser step with commands and optional context.
527    ///
528    /// This is the primary method for agent integration, providing full control
529    /// over browser automation with context for sequence persistence.
530    ///
531    /// # Arguments
532    /// * `request` - Full browser step request with commands and context
533    ///
534    /// # Example
535    /// ```rust,ignore
536    /// use browsr_client::{BrowsrClient, BrowserStepRequest, BrowserStepInput};
537    /// use browsr_types::Commands;
538    ///
539    /// let client = BrowsrClient::from_env();
540    ///
541    /// let input = BrowserStepInput::new(vec![
542    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
543    ///     Commands::Screenshot { full_page: Some(false), path: None },
544    /// ]);
545    ///
546    /// let request = BrowserStepRequest::new(input)
547    ///     .with_session_id("my-session")
548    ///     .with_thread_id("thread-123");
549    ///
550    /// let result = client.step(request).await?;
551    /// println!("Success: {}, URL: {:?}", result.success, result.url);
552    /// ```
553    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    /// Execute a browser step with just commands (simple usage).
561    ///
562    /// Use this for quick automation tasks without context tracking.
563    ///
564    /// # Example
565    /// ```rust,ignore
566    /// use browsr_client::BrowsrClient;
567    /// use browsr_types::Commands;
568    ///
569    /// let client = BrowsrClient::from_env();
570    ///
571    /// let result = client.step_commands(vec![
572    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
573    /// ]).await?;
574    /// ```
575    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// ============================================================
586// Internal Types
587// ============================================================
588
589#[derive(Debug, Clone, Serialize, Deserialize)]
590struct SessionList {
591    sessions: Vec<String>,
592}
593
594/// Response from creating a browser session
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct SessionCreated {
597    pub session_id: String,
598    /// SSE stream URL (backend API) for event streaming
599    #[serde(default)]
600    pub sse_url: Option<String>,
601    /// Frame URL for embedding (frontend app with token)
602    #[serde(default)]
603    pub frame_url: Option<String>,
604    /// Frame token for SSE authentication
605    #[serde(default)]
606    pub frame_token: Option<String>,
607}
608
609impl SessionCreated {
610    /// Build SSE stream URL with authentication token and optional dimensions
611    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        // Add token if available
617        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        // Add dimensions if provided
623        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/// Options for observing the browser state.
647#[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// ============================================================
689// Error Types
690// ============================================================
691
692#[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// ============================================================
707// HTTP Transport
708// ============================================================
709
710#[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// ============================================================
772// Stdout Transport
773// ============================================================
774
775#[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
861// ============================================================
862// Legacy Helper Functions
863// ============================================================
864
865/// Derive a default base URL for HTTP transport from standard env vars.
866pub 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    // Return cloud default
878    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}