Skip to main content

solari_browser/
client.rs

1//! [`Client`] plus the `sessions` / `profiles` / `proxy` resource namespaces.
2//! Ports `sdk/src/index.ts` (the TypeScript reference) minus `launch()`.
3//!
4//! One deliberate divergence from the reference: the TS SDK wraps every session
5//! endpoint in a Node-side `LocalProxy` loopback rewrite. This crate returns the
6//! UPSTREAM `wsEndpoint` / `cdpEndpoint` verbatim — there is no proxy to route
7//! through, and callers connect straight to the gateway.
8
9use std::time::Duration;
10
11use serde_json::json;
12use url::Url;
13
14use crate::error::{api_error, SolariError};
15use crate::http::HttpTransport;
16use crate::types::{
17    CreateSessionRequest, CreateSessionResponse, Profile, ProfileSaveResponse, ProfileSaveResult,
18    ProxyCountries, ProxySpec, ReplayUrl, ReplayUrlResponse, Session, SessionView, SolariRegion,
19    StorageState,
20};
21
22pub(crate) const DEFAULT_MAX_ATTEMPTS: u32 = 2;
23pub(crate) const DEFAULT_BACKOFF_MS: u64 = 500;
24pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 90_000;
25const STORAGE_STATE_FETCH_TIMEOUT: Duration = Duration::from_secs(8);
26
27/// Options for constructing a [`Client`].
28///
29/// There is no environment-variable fallback by design — the API key and base
30/// URL are always explicit constructor arguments.
31#[derive(Clone)]
32pub struct ClientOptions {
33    pub api_key: String,
34    pub base_url: String,
35    /// Total attempts per request (including the first). Default 2.
36    pub max_attempts: u32,
37    /// Fixed delay between attempts. Default 500ms.
38    pub backoff_ms: u64,
39    /// Per-request timeout. Default 90000ms.
40    pub timeout_ms: u64,
41}
42
43/// Hand-written so the API key is never printed. Do not derive this.
44impl std::fmt::Debug for ClientOptions {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("ClientOptions")
47            .field("api_key", &"<redacted>")
48            .field("base_url", &self.base_url)
49            .field("max_attempts", &self.max_attempts)
50            .field("backoff_ms", &self.backoff_ms)
51            .field("timeout_ms", &self.timeout_ms)
52            .finish()
53    }
54}
55
56impl ClientOptions {
57    /// Point the client at an explicit base URL (staging / self-hosted gateway).
58    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
59        ClientOptions {
60            api_key: api_key.into(),
61            base_url: base_url.into(),
62            max_attempts: DEFAULT_MAX_ATTEMPTS,
63            backoff_ms: DEFAULT_BACKOFF_MS,
64            timeout_ms: DEFAULT_TIMEOUT_MS,
65        }
66    }
67
68    /// Point the client at a region's public API. [`SolariRegion::UsWest`]
69    /// resolves to `https://api.getsolari.com`.
70    pub fn for_region(api_key: impl Into<String>, region: SolariRegion) -> Self {
71        ClientOptions::new(api_key, region.base_url())
72    }
73
74    /// Point the client at the default region (`us-west`).
75    pub fn default_region(api_key: impl Into<String>) -> Self {
76        ClientOptions::for_region(api_key, SolariRegion::default())
77    }
78
79    pub fn max_attempts(mut self, n: u32) -> Self {
80        self.max_attempts = n;
81        self
82    }
83
84    pub fn backoff_ms(mut self, ms: u64) -> Self {
85        self.backoff_ms = ms;
86        self
87    }
88
89    pub fn timeout_ms(mut self, ms: u64) -> Self {
90        self.timeout_ms = ms;
91        self
92    }
93}
94
95/// Options accepted by `POST /sessions`.
96///
97/// Falsy fields are omitted from the request body; if every field is falsy the
98/// request is sent with no body at all.
99#[derive(Debug, Clone, Default)]
100pub struct CreateSessionOptions {
101    /// Attach a stored browser profile (cookies + localStorage).
102    pub profile_id: Option<String>,
103    /// Enable session recording. Off by default.
104    pub recording: bool,
105    /// Enable the runtime stealth shim. Off by default.
106    pub stealth: bool,
107    /// Enable managed captcha solving. Requires `stealth`.
108    pub captcha: bool,
109    /// Request managed proxy egress. Requires `stealth`.
110    pub proxy: Option<ProxySpec>,
111}
112
113impl CreateSessionOptions {
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    pub fn profile_id(mut self, id: impl Into<String>) -> Self {
119        self.profile_id = Some(id.into());
120        self
121    }
122
123    pub fn recording(mut self, on: bool) -> Self {
124        self.recording = on;
125        self
126    }
127
128    pub fn stealth(mut self, on: bool) -> Self {
129        self.stealth = on;
130        self
131    }
132
133    pub fn captcha(mut self, on: bool) -> Self {
134        self.captcha = on;
135        self
136    }
137
138    pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
139        self.proxy = Some(spec.into());
140        self
141    }
142}
143
144/// Talks the Solari Browser control-plane REST API.
145#[derive(Debug)]
146pub struct Client {
147    http: HttpTransport,
148}
149
150impl Client {
151    pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
152        if opts.api_key.is_empty() {
153            return Err(SolariError::config("Solari: apiKey is required"));
154        }
155        if opts.base_url.is_empty() {
156            return Err(SolariError::config("Solari: baseUrl is required"));
157        }
158        let base_url = opts.base_url.trim_end_matches('/').to_string();
159        let http = HttpTransport::new(
160            opts.api_key,
161            base_url,
162            opts.max_attempts,
163            opts.backoff_ms,
164            opts.timeout_ms,
165        )?;
166        Ok(Client { http })
167    }
168
169    /// Session lifecycle: create / get / release / replay.
170    pub fn sessions(&self) -> Sessions<'_> {
171        Sessions { client: self }
172    }
173
174    /// Stored browser profiles.
175    pub fn profiles(&self) -> Profiles<'_> {
176        Profiles { client: self }
177    }
178
179    /// Managed proxy metadata.
180    pub fn proxy(&self) -> Proxy<'_> {
181        Proxy { client: self }
182    }
183
184    /// The resolved API base URL (trailing slash stripped).
185    pub fn base_url(&self) -> &str {
186        self.http.base_url()
187    }
188}
189
190// ---------------------------------------------------------------------------
191// sessions
192// ---------------------------------------------------------------------------
193
194/// The `sessions` namespace. Obtain via [`Client::sessions`].
195pub struct Sessions<'a> {
196    client: &'a Client,
197}
198
199impl Sessions<'_> {
200    /// `POST /sessions` — acquire a browser session.
201    pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
202        let req = CreateSessionRequest {
203            // TS: `if (options?.profileId)` — falsy (unset OR empty) is omitted.
204            profile_id: options
205                .profile_id
206                .clone()
207                .filter(|p| !p.is_empty()),
208            recording: options.recording,
209            stealth: options.stealth,
210            captcha: options.captcha,
211            proxy: options.proxy.clone(),
212        };
213        let body = serde_json::to_value(&req)
214            .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
215        // Omit the body entirely when nothing was set.
216        let body = match body.as_object() {
217            Some(o) if o.is_empty() => None,
218            _ => Some(body),
219        };
220
221        let res = self
222            .client
223            .http
224            .request("POST", "/sessions", body.as_ref())
225            .await?;
226        if !res.ok() {
227            return Err(api_error("POST", "/sessions", res.status, &res.body));
228        }
229
230        let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
231            SolariError::protocol(format!("Solari: unexpected session response: {e}"))
232        })?;
233
234        let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
235            (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
236            _ => {
237                return Err(SolariError::protocol(format!(
238                    "Solari: unexpected session response: {}",
239                    res.body
240                )))
241            }
242        };
243
244        let cdp_endpoint = data
245            .cdp_endpoint
246            .clone()
247            .filter(|c| !c.is_empty())
248            .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
249
250        let expires_at = data
251            .expires_at
252            .clone()
253            .filter(|e| !e.is_empty())
254            .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
255
256        let mut session = Session {
257            id: session_id,
258            ws_endpoint,
259            cdp_endpoint,
260            expires_at,
261            storage_state: None,
262            proxy: data.proxy,
263        };
264
265        // Storage state is only populated when a profile was requested — TS
266        // gates on `options?.profileId !== undefined`.
267        if options.profile_id.is_some() {
268            session.storage_state = Some(match data.storage_state_url {
269                // `storageStateUrl` present → fetch the presigned object.
270                Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
271                // Explicit null → profile exists but is empty.
272                Some(None) => None,
273                // Absent → fall back to any inline state (TS: `?? null`).
274                None => data.storage_state.flatten(),
275            });
276        }
277
278        Ok(session)
279    }
280
281    /// `GET /sessions/:id`.
282    pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
283        let path = format!("/sessions/{}", enc(id));
284        let res = self.client.http.request("GET", &path, None).await?;
285        if !res.ok() {
286            return Err(api_error("GET", &path, res.status, &res.body));
287        }
288        serde_json::from_str(&res.body)
289            .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
290    }
291
292    /// `DELETE /sessions/:id` — release the session. 404 is tolerated (the
293    /// session already ended).
294    ///
295    /// Unlike the TS SDK there is no fire-and-forget variant; this is the
296    /// equivalent of `releaseAndWait`. Spawn it yourself if you don't want to
297    /// await the round-trip.
298    pub async fn release(&self, id: &str) -> Result<(), SolariError> {
299        let path = format!("/sessions/{}", enc(id));
300        let res = self.client.http.request("DELETE", &path, None).await?;
301        if !res.ok() && res.status != 404 {
302            return Err(api_error("DELETE", &path, res.status, &res.body));
303        }
304        Ok(())
305    }
306
307    /// `GET /sessions/:id/replay-url` — presigned URL for the session's replay.
308    /// Available ~1-3s after [`Sessions::release`].
309    pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
310        let path = format!("/sessions/{}/replay-url", enc(id));
311        let res = self.client.http.request("GET", &path, None).await?;
312        if !res.ok() {
313            return Err(api_error("GET", &path, res.status, &res.body));
314        }
315        let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
316            SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
317        })?;
318        let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
319            SolariError::protocol(format!(
320                "Solari: unexpected replay-url response: {}",
321                res.body
322            ))
323        })?;
324        Ok(ReplayUrl {
325            url,
326            expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
327            content_encoding: data
328                .content_encoding
329                .unwrap_or_else(|| "gzip".to_string()),
330        })
331    }
332
333    /// Download the session's replay as NDJSON bytes (still `content_encoding`
334    /// encoded — gzip by default).
335    pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
336        let replay = self.replay_url(id).await?;
337        let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
338        if !(200..300).contains(&status) {
339            return Err(SolariError::Api {
340                status,
341                code: None,
342                message: format!("Solari: replay download failed: {status}"),
343            });
344        }
345        Ok(bytes)
346    }
347
348    /// GET a presigned storage-state object. `None` url → `None` state.
349    async fn fetch_presigned_storage_state(
350        &self,
351        url: Option<&str>,
352    ) -> Result<Option<StorageState>, SolariError> {
353        let Some(url) = url.filter(|u| !u.is_empty()) else {
354            return Ok(None);
355        };
356        let res = self
357            .client
358            .http
359            .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
360            .await
361            .map_err(|e| {
362                SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
363            })?;
364        if !res.ok() {
365            let snippet: String = res.body.chars().take(256).collect();
366            return Err(SolariError::Api {
367                status: res.status,
368                code: None,
369                message: format!(
370                    "Solari: storageState fetch returned {}: {snippet}",
371                    res.status
372                ),
373            });
374        }
375        serde_json::from_str(&res.body).map(Some).map_err(|e| {
376            SolariError::protocol(format!(
377                "Solari: storageState response was not valid JSON: {e}"
378            ))
379        })
380    }
381}
382
383// ---------------------------------------------------------------------------
384// profiles
385// ---------------------------------------------------------------------------
386
387/// The `profiles` namespace. Obtain via [`Client::profiles`].
388pub struct Profiles<'a> {
389    client: &'a Client,
390}
391
392impl Profiles<'_> {
393    /// `GET /profiles`.
394    pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
395        let res = self.client.http.request("GET", "/profiles", None).await?;
396        if !res.ok() {
397            return Err(api_error("GET", "/profiles", res.status, &res.body));
398        }
399        serde_json::from_str(&res.body)
400            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
401    }
402
403    /// `POST /profiles`.
404    pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
405        let body = json!({ "name": name.into() });
406        let res = self
407            .client
408            .http
409            .request("POST", "/profiles", Some(&body))
410            .await?;
411        if !res.ok() {
412            return Err(api_error("POST", "/profiles", res.status, &res.body));
413        }
414        serde_json::from_str(&res.body)
415            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
416    }
417
418    /// `DELETE /profiles/:id`. 404 is tolerated (already gone).
419    pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
420        let path = format!("/profiles/{}", enc(id));
421        let res = self.client.http.request("DELETE", &path, None).await?;
422        if !res.ok() && res.status != 404 {
423            return Err(api_error("DELETE", &path, res.status, &res.body));
424        }
425        Ok(())
426    }
427
428    /// `POST /profiles/:id/save` — persist a storage state onto the profile.
429    pub async fn save(
430        &self,
431        id: &str,
432        storage_state: &StorageState,
433    ) -> Result<ProfileSaveResult, SolariError> {
434        let path = format!("/profiles/{}/save", enc(id));
435        let state = serde_json::to_value(storage_state)
436            .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
437        let body = json!({ "storageState": state });
438        let res = self.client.http.request("POST", &path, Some(&body)).await?;
439        if !res.ok() {
440            return Err(api_error("POST", &path, res.status, &res.body));
441        }
442        let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
443            SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
444        })?;
445        Ok(ProfileSaveResult {
446            version: data.version.unwrap_or(0),
447            size_bytes: data.size_bytes.unwrap_or(0),
448        })
449    }
450}
451
452// ---------------------------------------------------------------------------
453// proxy
454// ---------------------------------------------------------------------------
455
456/// The `proxy` namespace. Obtain via [`Client::proxy`].
457pub struct Proxy<'a> {
458    client: &'a Client,
459}
460
461impl Proxy<'_> {
462    /// `GET /proxy/countries` — supported egress countries, and whether managed
463    /// proxy egress is configured on this gateway at all.
464    pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
465        let res = self
466            .client
467            .http
468            .request("GET", "/proxy/countries", None)
469            .await?;
470        if !res.ok() {
471            return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
472        }
473        serde_json::from_str(&res.body).map_err(|e| {
474            SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
475        })
476    }
477}
478
479// ---------------------------------------------------------------------------
480// helpers
481// ---------------------------------------------------------------------------
482
483/// Percent-encode a path segment. Session ids are HMAC composites (`.`-joined
484/// base64url) and profile ids are opaque, so only a conservative set of
485/// unreserved characters is passed through.
486fn enc(s: &str) -> String {
487    let mut out = String::with_capacity(s.len());
488    for b in s.bytes() {
489        match b {
490            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
491            | b'\'' | b'(' | b')' => out.push(b as char),
492            _ => out.push_str(&format!("%{b:02X}")),
493        }
494    }
495    out
496}
497
498/// Rewrite a Playwright ws endpoint into its raw-CDP sibling: `/ws/<id>` →
499/// `/cdp/<id>`. Mirrors `deriveCdpFromWs`; returns the input unchanged when it
500/// does not parse or does not match.
501pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
502    match Url::parse(ws_endpoint) {
503        Ok(mut u) => {
504            let path = u.path().to_string();
505            if let Some(rest) = path.strip_prefix("/ws/") {
506                u.set_path(&format!("/cdp/{rest}"));
507                u.to_string()
508            } else {
509                ws_endpoint.to_string()
510            }
511        }
512        Err(_) => ws_endpoint.to_string(),
513    }
514}
515
516/// An ISO-8601 UTC timestamp `d` from now — the fallback when the API omits
517/// `expiresAt` (TS: `new Date(Date.now() + 60*60_000).toISOString()`).
518fn iso8601_utc_in(d: Duration) -> String {
519    let now = std::time::SystemTime::now()
520        .duration_since(std::time::UNIX_EPOCH)
521        .map(|x| x.as_secs())
522        .unwrap_or(0);
523    iso8601_utc(now as i64 + d.as_secs() as i64)
524}
525
526/// Format a Unix timestamp as `YYYY-MM-DDTHH:MM:SS.000Z`.
527pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
528    let days = unix_secs.div_euclid(86_400);
529    let sod = unix_secs.rem_euclid(86_400);
530    let (y, m, d) = civil_from_days(days);
531    format!(
532        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
533        y,
534        m,
535        d,
536        sod / 3600,
537        (sod % 3600) / 60,
538        sod % 60
539    )
540}
541
542/// Howard Hinnant's `civil_from_days`: days-since-epoch → (year, month, day).
543fn civil_from_days(z: i64) -> (i64, u32, u32) {
544    let z = z + 719_468;
545    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
546    let doe = z - era * 146_097; // [0, 146096]
547    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
548    let y = yoe + era * 400;
549    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
550    let mp = (5 * doy + 2) / 153; // [0, 11]
551    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
552    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
553    (if m <= 2 { y + 1 } else { y }, m, d)
554}