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                })
200            }
201        }
202    }
203
204    /// Destroy a browser session.
205    pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
206        match &self.transport {
207            BrowsrTransport::Http(inner) => inner
208                .delete(&format!("/sessions/{}", session_id))
209                .await
210                .map(|_: Value| ()),
211            BrowsrTransport::Stdout(inner) => inner.destroy_session(session_id).await,
212        }
213    }
214
215    // ============================================================
216    // Command Execution
217    // ============================================================
218
219    /// Execute a list of browser commands.
220    pub async fn execute_commands(
221        &self,
222        commands: Vec<Commands>,
223        session_id: Option<String>,
224        headless: Option<bool>,
225        context: Option<BrowserContext>,
226    ) -> Result<AutomateResponse, ClientError> {
227        let payload = CommandsPayload {
228            commands,
229            session_id,
230            headless: headless.or(self.config.headless),
231            context,
232        };
233
234        match &self.transport {
235            BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
236            BrowsrTransport::Stdout(inner) => inner.execute_commands(&payload).await,
237        }
238    }
239
240    /// Execute a single browser command.
241    pub async fn execute_command(
242        &self,
243        command: Commands,
244        session_id: Option<String>,
245        headless: Option<bool>,
246    ) -> Result<AutomateResponse, ClientError> {
247        self.execute_commands(vec![command], session_id, headless, None)
248            .await
249    }
250
251    // ============================================================
252    // Convenience Methods for Common Commands
253    // ============================================================
254
255    /// Navigate to a URL.
256    pub async fn navigate(
257        &self,
258        url: &str,
259        session_id: Option<String>,
260    ) -> Result<AutomateResponse, ClientError> {
261        self.execute_command(
262            Commands::NavigateTo {
263                url: url.to_string(),
264            },
265            session_id,
266            None,
267        )
268        .await
269    }
270
271    /// Click an element by selector.
272    pub async fn click(
273        &self,
274        selector: &str,
275        session_id: Option<String>,
276    ) -> Result<AutomateResponse, ClientError> {
277        self.execute_command(
278            Commands::Click {
279                selector: selector.to_string(),
280            },
281            session_id,
282            None,
283        )
284        .await
285    }
286
287    /// Type text into an element.
288    pub async fn type_text(
289        &self,
290        selector: &str,
291        text: &str,
292        clear: Option<bool>,
293        session_id: Option<String>,
294    ) -> Result<AutomateResponse, ClientError> {
295        self.execute_command(
296            Commands::TypeText {
297                selector: selector.to_string(),
298                text: text.to_string(),
299                clear,
300            },
301            session_id,
302            None,
303        )
304        .await
305    }
306
307    /// Wait for an element to appear.
308    pub async fn wait_for_element(
309        &self,
310        selector: &str,
311        timeout_ms: Option<u64>,
312        session_id: Option<String>,
313    ) -> Result<AutomateResponse, ClientError> {
314        self.execute_command(
315            Commands::WaitForElement {
316                selector: selector.to_string(),
317                timeout_ms,
318                visible_only: None,
319            },
320            session_id,
321            None,
322        )
323        .await
324    }
325
326    /// Take a screenshot.
327    pub async fn screenshot(
328        &self,
329        full_page: bool,
330        session_id: Option<String>,
331    ) -> Result<AutomateResponse, ClientError> {
332        self.execute_command(
333            Commands::Screenshot {
334                full_page: Some(full_page),
335                path: None,
336            },
337            session_id,
338            None,
339        )
340        .await
341    }
342
343    /// Get page title.
344    pub async fn get_title(
345        &self,
346        session_id: Option<String>,
347    ) -> Result<AutomateResponse, ClientError> {
348        self.execute_command(Commands::GetTitle, session_id, None)
349            .await
350    }
351
352    /// Get text content of an element.
353    pub async fn get_text(
354        &self,
355        selector: &str,
356        session_id: Option<String>,
357    ) -> Result<AutomateResponse, ClientError> {
358        self.execute_command(
359            Commands::GetText {
360                selector: selector.to_string(),
361            },
362            session_id,
363            None,
364        )
365        .await
366    }
367
368    /// Get HTML content of an element or page.
369    pub async fn get_content(
370        &self,
371        selector: Option<String>,
372        session_id: Option<String>,
373    ) -> Result<AutomateResponse, ClientError> {
374        self.execute_command(
375            Commands::GetContent {
376                selector,
377                kind: None,
378            },
379            session_id,
380            None,
381        )
382        .await
383    }
384
385    /// Evaluate JavaScript expression.
386    pub async fn evaluate(
387        &self,
388        expression: &str,
389        session_id: Option<String>,
390    ) -> Result<AutomateResponse, ClientError> {
391        self.execute_command(
392            Commands::Evaluate {
393                expression: expression.to_string(),
394            },
395            session_id,
396            None,
397        )
398        .await
399    }
400
401    // ============================================================
402    // Structured Extraction
403    // ============================================================
404
405    /// Extract structured content from the current page using AI.
406    ///
407    /// # Arguments
408    /// * `query` - Natural language description of what to extract
409    /// * `schema` - Optional JSON schema for the output
410    /// * `max_chars` - Optional maximum characters to process
411    /// * `session_id` - Optional session ID
412    ///
413    /// # Example
414    /// ```rust,ignore
415    /// let data = client.extract_structured(
416    ///     "Extract all product names and prices",
417    ///     None,
418    ///     None,
419    /// ).await?;
420    /// ```
421    pub async fn extract_structured(
422        &self,
423        query: &str,
424        schema: Option<serde_json::Value>,
425        max_chars: Option<usize>,
426        session_id: Option<String>,
427    ) -> Result<AutomateResponse, ClientError> {
428        self.execute_command(
429            Commands::ExtractStructuredContent {
430                query: query.to_string(),
431                schema,
432                max_chars,
433            },
434            session_id,
435            None,
436        )
437        .await
438    }
439
440    // ============================================================
441    // Observation
442    // ============================================================
443
444    /// Observe the current browser state (screenshot + DOM snapshot).
445    pub async fn observe(
446        &self,
447        session_id: Option<String>,
448        headless: Option<bool>,
449        opts: ObserveOptions,
450    ) -> Result<ObserveResponse, ClientError> {
451        let payload = ObservePayload {
452            session_id,
453            headless: headless.or(self.config.headless),
454            use_image: opts.use_image,
455            full_page: opts.full_page,
456            wait_ms: opts.wait_ms,
457            include_content: opts.include_content,
458        };
459
460        match &self.transport {
461            BrowsrTransport::Http(inner) => {
462                let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
463                Ok(envelope.observation)
464            }
465            BrowsrTransport::Stdout(inner) => inner.observe(&payload).await,
466        }
467    }
468
469    // ============================================================
470    // Scraping
471    // ============================================================
472
473    /// Scrape content from a URL or the current page.
474    pub async fn scrape(&self, options: ScrapeOptions) -> Result<Value, ClientError> {
475        match &self.transport {
476            BrowsrTransport::Http(inner) => inner.post("/scrape", &options).await,
477            BrowsrTransport::Stdout(inner) => inner.scrape(&options).await,
478        }
479    }
480
481    /// Scrape a URL with default options.
482    pub async fn scrape_url(&self, url: &str) -> Result<Value, ClientError> {
483        let options = ScrapeOptions {
484            url: url.to_string(),
485            use_js: None,
486            wait_for: None,
487            extract_links: None,
488            selector: None,
489            extract_images: None,
490            extract_metadata: None,
491            extract_tables: None,
492            extract_forms: None,
493            extract_structured_data: None,
494            readable_content: None,
495            remove_base64_images: None,
496        };
497        self.scrape(options).await
498    }
499
500    // ============================================================
501    // Search
502    // ============================================================
503
504    /// Perform a web search.
505    pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
506        match &self.transport {
507            BrowsrTransport::Http(inner) => inner.post("/search", &options).await,
508            BrowsrTransport::Stdout(inner) => inner.search(&options).await,
509        }
510    }
511
512    /// Search with a query string.
513    pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
514        let options = SearchOptions {
515            query: query.to_string(),
516            limit: None,
517        };
518        self.search(options).await
519    }
520
521    // ============================================================
522    // Browser Step (Agent Integration)
523    // ============================================================
524
525    /// Execute a browser step with commands and optional context.
526    ///
527    /// This is the primary method for agent integration, providing full control
528    /// over browser automation with context for sequence persistence.
529    ///
530    /// # Arguments
531    /// * `request` - Full browser step request with commands and context
532    ///
533    /// # Example
534    /// ```rust,ignore
535    /// use browsr_client::{BrowsrClient, BrowserStepRequest, BrowserStepInput};
536    /// use browsr_types::Commands;
537    ///
538    /// let client = BrowsrClient::from_env();
539    ///
540    /// let input = BrowserStepInput::new(vec![
541    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
542    ///     Commands::Screenshot { full_page: Some(false), path: None },
543    /// ]);
544    ///
545    /// let request = BrowserStepRequest::new(input)
546    ///     .with_session_id("my-session")
547    ///     .with_thread_id("thread-123");
548    ///
549    /// let result = client.step(request).await?;
550    /// println!("Success: {}, URL: {:?}", result.success, result.url);
551    /// ```
552    pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
553        match &self.transport {
554            BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
555            BrowsrTransport::Stdout(inner) => inner.browser_step(&request).await,
556        }
557    }
558
559    /// Execute a browser step with just commands (simple usage).
560    ///
561    /// Use this for quick automation tasks without context tracking.
562    ///
563    /// # Example
564    /// ```rust,ignore
565    /// use browsr_client::BrowsrClient;
566    /// use browsr_types::Commands;
567    ///
568    /// let client = BrowsrClient::from_env();
569    ///
570    /// let result = client.step_commands(vec![
571    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
572    /// ]).await?;
573    /// ```
574    pub async fn step_commands(
575        &self,
576        commands: Vec<Commands>,
577    ) -> Result<BrowserStepResult, ClientError> {
578        let input = BrowserStepInput::new(commands);
579        let request = BrowserStepRequest::new(input);
580        self.step(request).await
581    }
582}
583
584// ============================================================
585// Internal Types
586// ============================================================
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
589struct SessionList {
590    sessions: Vec<String>,
591}
592
593/// Response from creating a browser session
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct SessionCreated {
596    pub session_id: String,
597    /// SSE stream URL (backend API) for event streaming
598    #[serde(default)]
599    pub sse_url: Option<String>,
600    /// Frame URL for embedding (frontend app with token)
601    #[serde(default)]
602    pub frame_url: Option<String>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize)]
606struct CommandsPayload {
607    commands: Vec<Commands>,
608    #[serde(skip_serializing_if = "Option::is_none")]
609    session_id: Option<String>,
610    #[serde(skip_serializing_if = "Option::is_none")]
611    headless: Option<bool>,
612    #[serde(skip_serializing_if = "Option::is_none")]
613    context: Option<BrowserContext>,
614}
615
616/// Options for observing the browser state.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct ObserveOptions {
619    pub use_image: Option<bool>,
620    pub full_page: Option<bool>,
621    pub wait_ms: Option<u64>,
622    pub include_content: Option<bool>,
623}
624
625impl Default for ObserveOptions {
626    fn default() -> Self {
627        Self {
628            use_image: Some(true),
629            full_page: None,
630            wait_ms: None,
631            include_content: Some(true),
632        }
633    }
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize)]
637struct ObservePayload {
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub session_id: Option<String>,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub headless: Option<bool>,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub use_image: Option<bool>,
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub full_page: Option<bool>,
646    #[serde(default, skip_serializing_if = "Option::is_none")]
647    pub wait_ms: Option<u64>,
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub include_content: Option<bool>,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize)]
653struct ObserveEnvelope {
654    pub session_id: String,
655    pub observation: ObserveResponse,
656}
657
658// ============================================================
659// Error Types
660// ============================================================
661
662#[derive(Debug, Error)]
663pub enum ClientError {
664    #[error("http request failed: {0}")]
665    Http(#[from] reqwest::Error),
666    #[error("stdout transport failed: {0}")]
667    Stdout(String),
668    #[error("invalid response: {0}")]
669    InvalidResponse(String),
670    #[error("serialization error: {0}")]
671    Serialization(#[from] serde_json::Error),
672    #[error("io error: {0}")]
673    Io(#[from] std::io::Error),
674}
675
676// ============================================================
677// HTTP Transport
678// ============================================================
679
680#[derive(Debug, Clone)]
681struct HttpTransport {
682    base_url: String,
683    client: reqwest::Client,
684}
685
686impl HttpTransport {
687    fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
688        Self {
689            base_url: base_url.into(),
690            client,
691        }
692    }
693
694    fn url(&self, path: &str) -> String {
695        let base = self.base_url.trim_end_matches('/');
696        let suffix = path.trim_start_matches('/');
697        format!("{}/{}", base, suffix)
698    }
699
700    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
701        let resp = self.client.get(self.url(path)).send().await?;
702        Self::handle_response(resp).await
703    }
704
705    async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
706        let resp = self.client.delete(self.url(path)).send().await?;
707        Self::handle_response(resp).await
708    }
709
710    async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
711        &self,
712        path: &str,
713        body: &B,
714    ) -> Result<T, ClientError> {
715        let resp = self.client.post(self.url(path)).json(body).send().await?;
716        Self::handle_response(resp).await
717    }
718
719    async fn handle_response<T: DeserializeOwned>(
720        resp: reqwest::Response,
721    ) -> Result<T, ClientError> {
722        let status = resp.status();
723        if status == StatusCode::NO_CONTENT {
724            let empty: Value = Value::Null;
725            let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
726            return Ok(value);
727        }
728
729        let text = resp.text().await?;
730        if !status.is_success() {
731            return Err(ClientError::InvalidResponse(format!(
732                "{}: {}",
733                status, text
734            )));
735        }
736
737        serde_json::from_str(&text).map_err(ClientError::Serialization)
738    }
739}
740
741// ============================================================
742// Stdout Transport
743// ============================================================
744
745#[derive(Debug, Clone)]
746struct StdoutTransport {
747    command: String,
748}
749
750impl StdoutTransport {
751    fn new(command: impl Into<String>) -> Self {
752        Self {
753            command: command.into(),
754        }
755    }
756
757    async fn list_sessions(&self) -> Result<Vec<String>, ClientError> {
758        let envelope: SessionList = self.request("sessions", &Value::Null).await?;
759        Ok(envelope.sessions)
760    }
761
762    async fn create_session(&self) -> Result<String, ClientError> {
763        let created: SessionCreated = self.request("create_session", &Value::Null).await?;
764        Ok(created.session_id)
765    }
766
767    async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
768        let payload = json!({ "session_id": session_id });
769        let _: Value = self.request("destroy_session", &payload).await?;
770        Ok(())
771    }
772
773    async fn execute_commands(
774        &self,
775        payload: &CommandsPayload,
776    ) -> Result<AutomateResponse, ClientError> {
777        self.request("execute", payload).await
778    }
779
780    async fn observe(&self, payload: &ObservePayload) -> Result<ObserveResponse, ClientError> {
781        let envelope: ObserveEnvelope = self.request("observe", payload).await?;
782        Ok(envelope.observation)
783    }
784
785    async fn scrape(&self, options: &ScrapeOptions) -> Result<Value, ClientError> {
786        self.request("scrape", options).await
787    }
788
789    async fn search(&self, options: &SearchOptions) -> Result<SearchResponse, ClientError> {
790        self.request("search", options).await
791    }
792
793    async fn browser_step(
794        &self,
795        request: &BrowserStepRequest,
796    ) -> Result<BrowserStepResult, ClientError> {
797        self.request("browser_step", request).await
798    }
799
800    async fn request<T: DeserializeOwned, B: Serialize>(
801        &self,
802        operation: &str,
803        payload: &B,
804    ) -> Result<T, ClientError> {
805        let mut cmd = Command::new(&self.command);
806        cmd.arg("client").arg(operation);
807
808        let payload_str = serde_json::to_string(&payload).map_err(ClientError::Serialization)?;
809        cmd.env("BROWSR_CLIENT_PAYLOAD", &payload_str);
810
811        let output = cmd.output().await?;
812        if !output.status.success() {
813            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
814            return Err(ClientError::Stdout(format!(
815                "browsr command failed ({}): {}",
816                output.status, stderr
817            )));
818        }
819
820        let stdout = String::from_utf8_lossy(&output.stdout);
821        if stdout.trim().is_empty() {
822            return Err(ClientError::InvalidResponse(
823                "empty stdout from browsr".to_string(),
824            ));
825        }
826
827        serde_json::from_str(stdout.trim()).map_err(ClientError::Serialization)
828    }
829}
830
831// ============================================================
832// Legacy Helper Functions
833// ============================================================
834
835/// Derive a default base URL for HTTP transport from standard env vars.
836pub fn default_base_url() -> Option<String> {
837    if let Ok(url) = std::env::var("BROWSR_API_URL") {
838        return Some(url);
839    }
840    if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
841        return Some(url);
842    }
843    if let Ok(port) = std::env::var("BROWSR_PORT") {
844        let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
845        return Some(format!("http://{}:{}", host, port));
846    }
847    // Return cloud default
848    Some(DEFAULT_BASE_URL.to_string())
849}
850
851pub fn default_transport() -> TransportConfig {
852    TransportConfig::Http {
853        base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
854    }
855}