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, NetworkAccess, ObserveOptions, RelayEvent, RelayEventsResponse,
41    RelaySessionInfo, RelaySessionListResponse, ScrapeAction, ScrapeApiRequest,
42    ScrapeApiResponse, ScrapeData, ScrapeFormat, SessionCreated, SessionDetail,
43    SessionListResponse, SessionType, ShellCreateSessionRequest,
44    ShellCreateSessionResponse, ShellExecRequest, ShellExecResponse, ShellExecResult,
45    ShellListItem, ShellListResponse, ShellTerminateResponse,
46};
47
48use browsr_types::{
49    AutomateResponse, BrowserContext, Commands, ObserveResponse, SearchOptions,
50    SearchResponse,
51};
52use reqwest::StatusCode;
53use serde::{Deserialize, Serialize, de::DeserializeOwned};
54use serde_json::{Value, json};
55use thiserror::Error;
56
57#[derive(Debug, Clone)]
58pub enum TransportConfig {
59    Http { base_url: String },
60}
61
62/// Browsr HTTP client for browser automation.
63///
64/// # Example
65///
66/// ```rust,ignore
67/// use browsr_client::BrowsrClient;
68///
69/// // From environment variables (BROWSR_BASE_URL, BROWSR_API_KEY)
70/// let client = BrowsrClient::from_env();
71///
72/// // From explicit URL (for local development)
73/// let client = BrowsrClient::new("http://localhost:8082");
74///
75/// // With API key authentication
76/// let client = BrowsrClient::new("https://api.browsr.dev")
77///     .with_api_key("your-api-key");
78/// ```
79#[derive(Debug, Clone)]
80pub struct BrowsrClient {
81    transport: BrowsrTransport,
82    config: BrowsrClientConfig,
83}
84
85#[derive(Debug, Clone)]
86enum BrowsrTransport {
87    Http(HttpTransport),
88}
89
90impl BrowsrClient {
91    /// Create a new client with the specified base URL (no authentication).
92    /// For local development, use this method.
93    pub fn new(base_url: impl Into<String>) -> Self {
94        let config = BrowsrClientConfig::new(base_url);
95        Self::from_client_config(config)
96    }
97
98    /// Create a new client from environment variables.
99    ///
100    /// - `BROWSR_BASE_URL`: Base URL (defaults to `https://api.browsr.dev`)
101    /// - `BROWSR_API_KEY`: Optional API key for authentication
102    pub fn from_env() -> Self {
103        let config = BrowsrClientConfig::from_env();
104        Self::from_client_config(config)
105    }
106
107    /// Create a new client from explicit configuration.
108    pub fn from_client_config(config: BrowsrClientConfig) -> Self {
109        let http = config
110            .build_http_client()
111            .expect("Failed to build HTTP client");
112
113        Self {
114            transport: BrowsrTransport::Http(HttpTransport::new_with_client(
115                &config.base_url,
116                http,
117            )),
118            config,
119        }
120    }
121
122    /// Set the API key for authentication.
123    /// This rebuilds the HTTP client with the new authentication header.
124    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
125        self.config = self.config.with_api_key(api_key);
126        let http = self
127            .config
128            .build_http_client()
129            .expect("Failed to build HTTP client");
130        self.transport =
131            BrowsrTransport::Http(HttpTransport::new_with_client(&self.config.base_url, http));
132        self
133    }
134
135    /// Create HTTP transport client (legacy method).
136    pub fn new_http(base_url: impl Into<String>) -> Self {
137        Self::new(base_url)
138    }
139
140    /// Create client from transport config (legacy method).
141    pub fn from_config(cfg: TransportConfig) -> Self {
142        match cfg {
143            TransportConfig::Http { base_url } => Self::new_http(base_url),
144        }
145    }
146
147    /// Get the base URL.
148    pub fn base_url(&self) -> &str {
149        &self.config.base_url
150    }
151
152    /// Get the current configuration.
153    pub fn config(&self) -> &BrowsrClientConfig {
154        &self.config
155    }
156
157    /// Check if the client has authentication configured.
158    pub fn has_auth(&self) -> bool {
159        self.config.has_auth()
160    }
161
162    /// Check if this is a local development client.
163    pub fn is_local(&self) -> bool {
164        self.config.is_local()
165    }
166
167    // ============================================================
168    // Session Management
169    // ============================================================
170
171    /// List all active browser sessions.
172    pub async fn list_sessions(&self) -> Result<SessionListResponse, ClientError> {
173        match &self.transport {
174            BrowsrTransport::Http(inner) => inner.get("/sessions").await,
175        }
176    }
177
178    /// Create a new browser session.
179    /// Returns the full session info including viewer_url, sse_url, and frame_url.
180    pub async fn create_session(&self) -> Result<SessionCreated, ClientError> {
181        match &self.transport {
182            BrowsrTransport::Http(inner) => {
183                inner.post("/sessions", &Value::Null).await
184            }
185        }
186    }
187
188    /// Destroy a browser session.
189    pub async fn destroy_session(&self, session_id: &str) -> Result<(), ClientError> {
190        match &self.transport {
191            BrowsrTransport::Http(inner) => inner
192                .delete(&format!("/sessions/{}", session_id))
193                .await
194                .map(|_: Value| ()),
195        }
196    }
197
198    /// Create a new shell session.
199    pub async fn create_shell_session(
200        &self,
201        request: ShellCreateSessionRequest,
202    ) -> Result<ShellCreateSessionResponse, ClientError> {
203        match &self.transport {
204            BrowsrTransport::Http(inner) => inner.post("/shells", &request).await,
205        }
206    }
207
208    /// List active shells.
209    pub async fn list_shell_sessions(&self) -> Result<ShellListResponse, ClientError> {
210        match &self.transport {
211            BrowsrTransport::Http(inner) => inner.get("/shells").await,
212        }
213    }
214
215    /// Terminate a shell session.
216    pub async fn terminate_shell_session(
217        &self,
218        session_id: &str,
219    ) -> Result<ShellTerminateResponse, ClientError> {
220        match &self.transport {
221            BrowsrTransport::Http(inner) => inner.delete(&format!("/shells/{}", session_id)).await,
222        }
223    }
224
225    /// Execute a shell command in an existing shell session.
226    pub async fn shell_exec(
227        &self,
228        request: ShellExecRequest,
229    ) -> Result<ShellExecResponse, ClientError> {
230        match &self.transport {
231            BrowsrTransport::Http(inner) => inner.post("/shells/exec", &request).await,
232        }
233    }
234
235    // ============================================================
236    // Pool Management
237    // ============================================================
238
239    /// Get warm pool status.
240    pub async fn pool_status(&self) -> Result<Value, ClientError> {
241        match &self.transport {
242            BrowsrTransport::Http(inner) => inner.get("/shells/pool/status").await,
243        }
244    }
245
246    /// Probe health of warm containers.
247    pub async fn pool_probe(&self) -> Result<Value, ClientError> {
248        match &self.transport {
249            BrowsrTransport::Http(inner) => inner.post("/shells/pool/probe", &json!({})).await,
250        }
251    }
252
253    /// Warm N containers for an image.
254    pub async fn pool_warm(&self, image: &str, count: usize) -> Result<Value, ClientError> {
255        match &self.transport {
256            BrowsrTransport::Http(inner) => {
257                inner
258                    .post("/shells/pool/warm", &json!({"image": image, "count": count}))
259                    .await
260            }
261        }
262    }
263
264    /// Drain all idle containers for an image.
265    pub async fn pool_drain(&self, image: &str) -> Result<Value, ClientError> {
266        match &self.transport {
267            BrowsrTransport::Http(inner) => {
268                inner
269                    .post("/shells/pool/drain", &json!({"image": image}))
270                    .await
271            }
272        }
273    }
274
275    /// Proxy an HTTP GET request to a container port.
276    pub async fn proxy_session_port(
277        &self,
278        session_id: &str,
279        port: u16,
280        path: &str,
281    ) -> Result<String, ClientError> {
282        match &self.transport {
283            BrowsrTransport::Http(inner) => {
284                let url = inner.url(&format!(
285                    "/shells/{}/proxy?port={}&path={}",
286                    session_id,
287                    port,
288                    path.trim_start_matches('/'),
289                ));
290                let resp = inner.client.get(&url).send().await?;
291                if !resp.status().is_success() {
292                    let text = resp.text().await.unwrap_or_default();
293                    return Err(ClientError::InvalidResponse(text));
294                }
295                resp.text().await.map_err(Into::into)
296            }
297        }
298    }
299
300    // ============================================================
301    // Command Execution
302    // ============================================================
303
304    /// Execute a list of browser commands.
305    pub async fn execute_commands(
306        &self,
307        commands: Vec<Commands>,
308        session_id: Option<String>,
309        headless: Option<bool>,
310        context: Option<BrowserContext>,
311    ) -> Result<AutomateResponse, ClientError> {
312        let payload = CommandsPayload {
313            commands,
314            session_id,
315            headless: headless.or(self.config.headless),
316            context,
317        };
318
319        match &self.transport {
320            BrowsrTransport::Http(inner) => inner.post("/commands", &payload).await,
321        }
322    }
323
324    /// Execute a single browser command.
325    pub async fn execute_command(
326        &self,
327        command: Commands,
328        session_id: Option<String>,
329        headless: Option<bool>,
330    ) -> Result<AutomateResponse, ClientError> {
331        self.execute_commands(vec![command], session_id, headless, None)
332            .await
333    }
334
335    // ============================================================
336    // Convenience Methods for Common Commands
337    // ============================================================
338
339    /// Navigate to a URL.
340    pub async fn navigate(
341        &self,
342        url: &str,
343        session_id: Option<String>,
344    ) -> Result<AutomateResponse, ClientError> {
345        self.execute_command(
346            Commands::NavigateTo {
347                url: url.to_string(),
348            },
349            session_id,
350            None,
351        )
352        .await
353    }
354
355    /// Click an element by selector.
356    pub async fn click(
357        &self,
358        selector: &str,
359        session_id: Option<String>,
360    ) -> Result<AutomateResponse, ClientError> {
361        self.execute_command(
362            Commands::Click {
363                selector: selector.to_string(),
364            },
365            session_id,
366            None,
367        )
368        .await
369    }
370
371    /// Type text into an element.
372    pub async fn type_text(
373        &self,
374        selector: &str,
375        text: &str,
376        clear: Option<bool>,
377        session_id: Option<String>,
378    ) -> Result<AutomateResponse, ClientError> {
379        self.execute_command(
380            Commands::TypeText {
381                selector: selector.to_string(),
382                text: text.to_string(),
383                clear,
384            },
385            session_id,
386            None,
387        )
388        .await
389    }
390
391    /// Wait for an element to appear.
392    pub async fn wait_for_element(
393        &self,
394        selector: &str,
395        timeout_ms: Option<u64>,
396        session_id: Option<String>,
397    ) -> Result<AutomateResponse, ClientError> {
398        self.execute_command(
399            Commands::WaitForElement {
400                selector: selector.to_string(),
401                timeout_ms,
402                visible_only: None,
403            },
404            session_id,
405            None,
406        )
407        .await
408    }
409
410    /// Take a screenshot.
411    pub async fn screenshot(
412        &self,
413        full_page: bool,
414        session_id: Option<String>,
415    ) -> Result<AutomateResponse, ClientError> {
416        self.execute_command(
417            Commands::Screenshot {
418                full_page: Some(full_page),
419                path: None,
420            },
421            session_id,
422            None,
423        )
424        .await
425    }
426
427    /// Get page title.
428    pub async fn get_title(
429        &self,
430        session_id: Option<String>,
431    ) -> Result<AutomateResponse, ClientError> {
432        self.execute_command(Commands::GetTitle, session_id, None)
433            .await
434    }
435
436    /// Get text content of an element.
437    pub async fn get_text(
438        &self,
439        selector: &str,
440        session_id: Option<String>,
441    ) -> Result<AutomateResponse, ClientError> {
442        self.execute_command(
443            Commands::GetText {
444                selector: selector.to_string(),
445            },
446            session_id,
447            None,
448        )
449        .await
450    }
451
452    /// Get HTML content of an element or page.
453    pub async fn get_content(
454        &self,
455        selector: Option<String>,
456        session_id: Option<String>,
457    ) -> Result<AutomateResponse, ClientError> {
458        self.execute_command(
459            Commands::GetContent {
460                selector,
461                kind: None,
462            },
463            session_id,
464            None,
465        )
466        .await
467    }
468
469    /// Evaluate JavaScript expression.
470    pub async fn evaluate(
471        &self,
472        expression: &str,
473        session_id: Option<String>,
474    ) -> Result<AutomateResponse, ClientError> {
475        self.execute_command(
476            Commands::Evaluate {
477                expression: expression.to_string(),
478            },
479            session_id,
480            None,
481        )
482        .await
483    }
484
485    // ============================================================
486    // Structured Extraction
487    // ============================================================
488
489    /// Extract structured content from the current page using AI.
490    ///
491    /// # Arguments
492    /// * `query` - Natural language description of what to extract
493    /// * `schema` - Optional JSON schema for the output
494    /// * `max_chars` - Optional maximum characters to process
495    /// * `session_id` - Optional session ID
496    ///
497    /// # Example
498    /// ```rust,ignore
499    /// let data = client.extract_structured(
500    ///     "Extract all product names and prices",
501    ///     None,
502    ///     None,
503    /// ).await?;
504    /// ```
505    pub async fn extract_structured(
506        &self,
507        query: &str,
508        schema: Option<serde_json::Value>,
509        max_chars: Option<usize>,
510        session_id: Option<String>,
511    ) -> Result<AutomateResponse, ClientError> {
512        self.execute_command(
513            Commands::ExtractStructuredContent {
514                query: query.to_string(),
515                schema,
516                max_chars,
517            },
518            session_id,
519            None,
520        )
521        .await
522    }
523
524    // ============================================================
525    // Observation
526    // ============================================================
527
528    /// Observe the current browser state (screenshot + DOM snapshot).
529    pub async fn observe(
530        &self,
531        session_id: Option<String>,
532        headless: Option<bool>,
533        opts: ObserveOptions,
534    ) -> Result<ObserveResponse, ClientError> {
535        let payload = ObservePayload {
536            session_id,
537            headless: headless.or(self.config.headless),
538            use_image: opts.use_image,
539            full_page: opts.full_page,
540            wait_ms: opts.wait_ms,
541            include_content: opts.include_content,
542        };
543
544        match &self.transport {
545            BrowsrTransport::Http(inner) => {
546                let envelope: ObserveEnvelope = inner.post("/observe", &payload).await?;
547                Ok(envelope.observation)
548            }
549        }
550    }
551
552    /// Execute a raw CDP request against an existing session.
553    pub async fn cdp(
554        &self,
555        session_id: impl Into<String>,
556        method: impl Into<String>,
557        params: Option<Value>,
558    ) -> Result<Value, ClientError> {
559        let payload = json!({
560            "session_id": session_id.into(),
561            "method": method.into(),
562            "params": params.unwrap_or_else(|| json!({})),
563        });
564
565        match &self.transport {
566            BrowsrTransport::Http(inner) => inner.post("/cdp", &payload).await,
567        }
568    }
569
570    /// List recent buffered relay events for a relay session.
571    pub async fn relay_events(
572        &self,
573        session_id: &str,
574        limit: Option<usize>,
575    ) -> Result<RelayEventsResponse, ClientError> {
576        let path = match limit {
577            Some(limit) => format!("/relay/sessions/{}/events?limit={}", session_id, limit),
578            None => format!("/relay/sessions/{}/events", session_id),
579        };
580
581        match &self.transport {
582            BrowsrTransport::Http(inner) => inner.get(&path).await,
583        }
584    }
585
586    /// List relay sessions visible to the current authenticated user.
587    pub async fn list_relay_sessions(&self) -> Result<RelaySessionListResponse, ClientError> {
588        match &self.transport {
589            BrowsrTransport::Http(inner) => inner.get("/relay/sessions").await,
590        }
591    }
592
593    /// Clear buffered relay events for a relay session.
594    pub async fn clear_relay_events(&self, session_id: &str) -> Result<Value, ClientError> {
595        let path = format!("/relay/sessions/{}/events", session_id);
596        match &self.transport {
597            BrowsrTransport::Http(inner) => inner.delete(&path).await,
598        }
599    }
600
601    // ============================================================
602    // Scraping (v1 API)
603    // ============================================================
604
605    /// Scrape a URL with full format options (v1 API).
606    pub async fn scrape_v1(&self, request: ScrapeApiRequest) -> Result<ScrapeApiResponse, ClientError> {
607        match &self.transport {
608            BrowsrTransport::Http(inner) => inner.post("/v1/scrape", &request).await,
609        }
610    }
611
612    /// Scrape a URL with default options (markdown output).
613    pub async fn scrape_url(&self, url: &str) -> Result<ScrapeApiResponse, ClientError> {
614        self.scrape_v1(ScrapeApiRequest::new(url)).await
615    }
616
617    /// Crawl a website starting from a URL.
618    pub async fn crawl(&self, request: CrawlApiRequest) -> Result<CrawlApiResponse, ClientError> {
619        match &self.transport {
620            BrowsrTransport::Http(inner) => inner.post("/v1/crawl", &request).await,
621        }
622    }
623
624    /// Crawl a URL with default options (markdown, 10 pages, depth 2).
625    pub async fn crawl_url(&self, url: &str) -> Result<CrawlApiResponse, ClientError> {
626        self.crawl(CrawlApiRequest::new(url)).await
627    }
628
629    // ============================================================
630    // Search
631    // ============================================================
632
633    /// Perform a web search via the /v1/search endpoint.
634    pub async fn search(&self, options: SearchOptions) -> Result<SearchResponse, ClientError> {
635        match &self.transport {
636            BrowsrTransport::Http(inner) => inner.post("/v1/search", &options).await,
637        }
638    }
639
640    /// Search with a query string.
641    pub async fn search_query(&self, query: &str) -> Result<SearchResponse, ClientError> {
642        let options = SearchOptions {
643            query: query.to_string(),
644            limit: None,
645        };
646        self.search(options).await
647    }
648
649    // ============================================================
650    // Browser Step (Agent Integration)
651    // ============================================================
652
653    /// Execute a browser step with commands and optional context.
654    ///
655    /// This is the primary method for agent integration, providing full control
656    /// over browser automation with context for sequence persistence.
657    ///
658    /// # Arguments
659    /// * `request` - Full browser step request with commands and context
660    ///
661    /// # Example
662    /// ```rust,ignore
663    /// use browsr_client::{BrowsrClient, BrowserStepRequest, BrowserStepInput};
664    /// use browsr_types::Commands;
665    ///
666    /// let client = BrowsrClient::from_env();
667    ///
668    /// let input = BrowserStepInput::new(vec![
669    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
670    ///     Commands::Screenshot { full_page: Some(false), path: None },
671    /// ]);
672    ///
673    /// let request = BrowserStepRequest::new(input)
674    ///     .with_session_id("my-session")
675    ///     .with_thread_id("thread-123");
676    ///
677    /// let result = client.step(request).await?;
678    /// println!("Success: {}, URL: {:?}", result.success, result.url);
679    /// ```
680    pub async fn step(&self, request: BrowserStepRequest) -> Result<BrowserStepResult, ClientError> {
681        match &self.transport {
682            BrowsrTransport::Http(inner) => inner.post("/browser_step", &request).await,
683        }
684    }
685
686    /// Execute a browser step with just commands (simple usage).
687    ///
688    /// Use this for quick automation tasks without context tracking.
689    ///
690    /// # Example
691    /// ```rust,ignore
692    /// use browsr_client::BrowsrClient;
693    /// use browsr_types::Commands;
694    ///
695    /// let client = BrowsrClient::from_env();
696    ///
697    /// let result = client.step_commands(vec![
698    ///     Commands::NavigateTo { url: "https://example.com".to_string() },
699    /// ]).await?;
700    /// ```
701    pub async fn step_commands(
702        &self,
703        commands: Vec<Commands>,
704    ) -> Result<BrowserStepResult, ClientError> {
705        let input = BrowserStepInput::new(commands);
706        let request = BrowserStepRequest::new(input);
707        self.step(request).await
708    }
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
712struct CommandsPayload {
713    commands: Vec<Commands>,
714    #[serde(skip_serializing_if = "Option::is_none")]
715    session_id: Option<String>,
716    #[serde(skip_serializing_if = "Option::is_none")]
717    headless: Option<bool>,
718    #[serde(skip_serializing_if = "Option::is_none")]
719    context: Option<BrowserContext>,
720}
721
722#[derive(Debug, Clone, Serialize, Deserialize)]
723struct ObservePayload {
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub session_id: Option<String>,
726    #[serde(default, skip_serializing_if = "Option::is_none")]
727    pub headless: Option<bool>,
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub use_image: Option<bool>,
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub full_page: Option<bool>,
732    #[serde(default, skip_serializing_if = "Option::is_none")]
733    pub wait_ms: Option<u64>,
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub include_content: Option<bool>,
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize)]
739struct ObserveEnvelope {
740    pub session_id: String,
741    pub observation: ObserveResponse,
742}
743
744// ============================================================
745// Error Types
746// ============================================================
747
748#[derive(Debug, Error)]
749pub enum ClientError {
750    #[error("http request failed: {0}")]
751    Http(#[from] reqwest::Error),
752    #[error("invalid response: {0}")]
753    InvalidResponse(String),
754    #[error("serialization error: {0}")]
755    Serialization(#[from] serde_json::Error),
756    #[error("io error: {0}")]
757    Io(#[from] std::io::Error),
758}
759
760// ============================================================
761// HTTP Transport
762// ============================================================
763
764#[derive(Debug, Clone)]
765struct HttpTransport {
766    base_url: String,
767    client: reqwest::Client,
768}
769
770impl HttpTransport {
771    fn new_with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
772        Self {
773            base_url: base_url.into(),
774            client,
775        }
776    }
777
778    fn url(&self, path: &str) -> String {
779        let base = self.base_url.trim_end_matches('/');
780        let suffix = path.trim_start_matches('/');
781        format!("{}/{}", base, suffix)
782    }
783
784    async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
785        let resp = self.client.get(self.url(path)).send().await?;
786        Self::handle_response(resp).await
787    }
788
789    async fn delete<T: DeserializeOwned>(&self, path: &str) -> Result<T, ClientError> {
790        let resp = self.client.delete(self.url(path)).send().await?;
791        Self::handle_response(resp).await
792    }
793
794    async fn post<T: DeserializeOwned, B: Serialize + ?Sized>(
795        &self,
796        path: &str,
797        body: &B,
798    ) -> Result<T, ClientError> {
799        let resp = self.client.post(self.url(path)).json(body).send().await?;
800        Self::handle_response(resp).await
801    }
802
803    async fn handle_response<T: DeserializeOwned>(
804        resp: reqwest::Response,
805    ) -> Result<T, ClientError> {
806        let status = resp.status();
807        if status == StatusCode::NO_CONTENT {
808            let empty: Value = Value::Null;
809            let value: T = serde_json::from_value(empty).map_err(ClientError::Serialization)?;
810            return Ok(value);
811        }
812
813        let text = resp.text().await?;
814        if !status.is_success() {
815            return Err(ClientError::InvalidResponse(format!(
816                "{}: {}",
817                status, text
818            )));
819        }
820
821        serde_json::from_str(&text).map_err(ClientError::Serialization)
822    }
823}
824
825// ============================================================
826// Legacy Helper Functions
827// ============================================================
828
829/// Derive a default base URL for HTTP transport from standard env vars.
830pub fn default_base_url() -> Option<String> {
831    if let Ok(url) = std::env::var("BROWSR_API_URL") {
832        return Some(url);
833    }
834    if let Ok(url) = std::env::var("BROWSR_BASE_URL") {
835        return Some(url);
836    }
837    if let Ok(port) = std::env::var("BROWSR_PORT") {
838        let host = std::env::var("BROWSR_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
839        return Some(format!("http://{}:{}", host, port));
840    }
841    // Return cloud default
842    Some(DEFAULT_BASE_URL.to_string())
843}
844
845pub fn default_transport() -> TransportConfig {
846    TransportConfig::Http {
847        base_url: default_base_url().unwrap_or_else(|| DEFAULT_BASE_URL.to_string()),
848    }
849}