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, SolariErrorCode};
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_with_key(
225                "POST",
226                "/sessions",
227                body.as_ref(),
228                // A create allocates a slot and launches a browser before it can
229                // answer, so a response lost after that point would make a retry
230                // a SECOND session.
231                Some(&crate::http::new_idempotency_key()),
232            )
233            .await?;
234        if !res.ok() {
235            return Err(api_error("POST", "/sessions", res.status, &res.body));
236        }
237
238        let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
239            SolariError::protocol(format!("Solari: unexpected session response: {e}"))
240        })?;
241
242        let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
243            (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
244            _ => {
245                return Err(SolariError::protocol(format!(
246                    "Solari: unexpected session response: {}",
247                    res.body
248                )))
249            }
250        };
251
252        let cdp_endpoint = data
253            .cdp_endpoint
254            .clone()
255            .filter(|c| !c.is_empty())
256            .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
257
258        let expires_at = data
259            .expires_at
260            .clone()
261            .filter(|e| !e.is_empty())
262            .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
263
264        let mut session = Session {
265            id: session_id,
266            ws_endpoint,
267            cdp_endpoint,
268            expires_at,
269            storage_state: None,
270            proxy: data.proxy,
271        };
272
273        // Storage state is only populated when a profile was requested — TS
274        // gates on `options?.profileId !== undefined`.
275        if options.profile_id.is_some() {
276            session.storage_state = Some(match data.storage_state_url {
277                // `storageStateUrl` present → fetch the presigned object.
278                Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
279                // Explicit null → profile exists but is empty.
280                Some(None) => None,
281                // Absent → fall back to any inline state (TS: `?? null`).
282                None => data.storage_state.flatten(),
283            });
284        }
285
286        Ok(session)
287    }
288
289    /// `GET /sessions/:id`.
290    pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
291        let path = format!("/sessions/{}", enc(id));
292        let res = self.client.http.request("GET", &path, None).await?;
293        if !res.ok() {
294            return Err(api_error("GET", &path, res.status, &res.body));
295        }
296        serde_json::from_str(&res.body)
297            .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
298    }
299
300    /// `DELETE /sessions/:id` — release the session. A bare 404 is tolerated
301    /// (the session already ended).
302    ///
303    /// A 404 carrying [`SolariErrorCode::InvalidSessionId`] is an error. The
304    /// gateway acks 204 for any authentic session id, including one whose
305    /// session has already ended — the handler is idempotent by design and never
306    /// consults the pool before acking. So a 404 does not mean "already
307    /// released"; it means the gateway refused the id (malformed, forged, or
308    /// another org's) and released nothing, leaving the pool slot held until
309    /// orphan-grace. Treating that as success is what made these releases leak
310    /// slots silently.
311    ///
312    /// A bare 404 with no code stays tolerated — that is a pre-InvalidSessionId
313    /// gateway, where a 404 may legitimately mean the session is already gone.
314    /// Mirrors `releaseRejection` in sdk/src/index.ts.
315    ///
316    /// Unlike the TS SDK there is no fire-and-forget variant; this is the
317    /// equivalent of `releaseAndWait`. Spawn it yourself if you don't want to
318    /// await the round-trip.
319    pub async fn release(&self, id: &str) -> Result<(), SolariError> {
320        let path = format!("/sessions/{}", enc(id));
321        let res = self.client.http.request("DELETE", &path, None).await?;
322        if res.ok() {
323            return Ok(());
324        }
325        let err = api_error("DELETE", &path, res.status, &res.body);
326        if res.status == 404 && err.code() != Some(&SolariErrorCode::InvalidSessionId) {
327            return Ok(());
328        }
329        Err(err)
330    }
331
332    /// `GET /sessions/:id/replay-url` — presigned URL for the session's replay.
333    /// Available ~1-3s after [`Sessions::release`].
334    pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
335        let path = format!("/sessions/{}/replay-url", enc(id));
336        let res = self.client.http.request("GET", &path, None).await?;
337        if !res.ok() {
338            return Err(api_error("GET", &path, res.status, &res.body));
339        }
340        let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
341            SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
342        })?;
343        let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
344            SolariError::protocol(format!(
345                "Solari: unexpected replay-url response: {}",
346                res.body
347            ))
348        })?;
349        Ok(ReplayUrl {
350            url,
351            expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
352            content_encoding: data
353                .content_encoding
354                .unwrap_or_else(|| "gzip".to_string()),
355        })
356    }
357
358    /// Download the session's replay as NDJSON bytes. May or may not
359    /// actually be gzip despite what was uploaded — GCS decompresses
360    /// transparently on an ordinary GET, so check `content_encoding` (now
361    /// provider-accurate) before attempting to decompress.
362    pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
363        let replay = self.replay_url(id).await?;
364        let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
365        if !(200..300).contains(&status) {
366            return Err(SolariError::Api {
367                status,
368                code: None,
369                message: format!("Solari: replay download failed: {status}"),
370            });
371        }
372        Ok(bytes)
373    }
374
375    /// GET a presigned storage-state object. `None` url → `None` state.
376    async fn fetch_presigned_storage_state(
377        &self,
378        url: Option<&str>,
379    ) -> Result<Option<StorageState>, SolariError> {
380        let Some(url) = url.filter(|u| !u.is_empty()) else {
381            return Ok(None);
382        };
383        let res = self
384            .client
385            .http
386            .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
387            .await
388            .map_err(|e| {
389                SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
390            })?;
391        if !res.ok() {
392            let snippet: String = res.body.chars().take(256).collect();
393            return Err(SolariError::Api {
394                status: res.status,
395                code: None,
396                message: format!(
397                    "Solari: storageState fetch returned {}: {snippet}",
398                    res.status
399                ),
400            });
401        }
402        serde_json::from_str(&res.body).map(Some).map_err(|e| {
403            SolariError::protocol(format!(
404                "Solari: storageState response was not valid JSON: {e}"
405            ))
406        })
407    }
408}
409
410// ---------------------------------------------------------------------------
411// profiles
412// ---------------------------------------------------------------------------
413
414/// The `profiles` namespace. Obtain via [`Client::profiles`].
415pub struct Profiles<'a> {
416    client: &'a Client,
417}
418
419impl Profiles<'_> {
420    /// `GET /profiles`.
421    pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
422        let res = self.client.http.request("GET", "/profiles", None).await?;
423        if !res.ok() {
424            return Err(api_error("GET", "/profiles", res.status, &res.body));
425        }
426        serde_json::from_str(&res.body)
427            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
428    }
429
430    /// `POST /profiles`.
431    pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
432        let body = json!({ "name": name.into() });
433        let res = self
434            .client
435            .http
436            .request("POST", "/profiles", Some(&body))
437            .await?;
438        if !res.ok() {
439            return Err(api_error("POST", "/profiles", res.status, &res.body));
440        }
441        serde_json::from_str(&res.body)
442            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
443    }
444
445    /// `DELETE /profiles/:id`. 404 is tolerated (already gone).
446    pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
447        let path = format!("/profiles/{}", enc(id));
448        let res = self.client.http.request("DELETE", &path, None).await?;
449        if !res.ok() && res.status != 404 {
450            return Err(api_error("DELETE", &path, res.status, &res.body));
451        }
452        Ok(())
453    }
454
455    /// `POST /profiles/:id/save` — persist a storage state onto the profile.
456    pub async fn save(
457        &self,
458        id: &str,
459        storage_state: &StorageState,
460    ) -> Result<ProfileSaveResult, SolariError> {
461        let path = format!("/profiles/{}/save", enc(id));
462        let state = serde_json::to_value(storage_state)
463            .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
464        let body = json!({ "storageState": state });
465        let res = self.client.http.request("POST", &path, Some(&body)).await?;
466        if !res.ok() {
467            return Err(api_error("POST", &path, res.status, &res.body));
468        }
469        let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
470            SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
471        })?;
472        Ok(ProfileSaveResult {
473            version: data.version.unwrap_or(0),
474            size_bytes: data.size_bytes.unwrap_or(0),
475        })
476    }
477}
478
479// ---------------------------------------------------------------------------
480// proxy
481// ---------------------------------------------------------------------------
482
483/// The `proxy` namespace. Obtain via [`Client::proxy`].
484pub struct Proxy<'a> {
485    client: &'a Client,
486}
487
488impl Proxy<'_> {
489    /// `GET /proxy/countries` — supported egress countries, and whether managed
490    /// proxy egress is configured on this gateway at all.
491    pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
492        let res = self
493            .client
494            .http
495            .request("GET", "/proxy/countries", None)
496            .await?;
497        if !res.ok() {
498            return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
499        }
500        serde_json::from_str(&res.body).map_err(|e| {
501            SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
502        })
503    }
504}
505
506// ---------------------------------------------------------------------------
507// helpers
508// ---------------------------------------------------------------------------
509
510/// Percent-encode a path segment. Session ids are HMAC composites (`.`-joined
511/// base64url) and profile ids are opaque, so only a conservative set of
512/// unreserved characters is passed through.
513fn enc(s: &str) -> String {
514    let mut out = String::with_capacity(s.len());
515    for b in s.bytes() {
516        match b {
517            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
518            | b'\'' | b'(' | b')' => out.push(b as char),
519            _ => out.push_str(&format!("%{b:02X}")),
520        }
521    }
522    out
523}
524
525/// Rewrite a Playwright ws endpoint into its raw-CDP sibling: `/ws/<id>` →
526/// `/cdp/<id>`. Mirrors `deriveCdpFromWs`; returns the input unchanged when it
527/// does not parse or does not match.
528pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
529    match Url::parse(ws_endpoint) {
530        Ok(mut u) => {
531            let path = u.path().to_string();
532            if let Some(rest) = path.strip_prefix("/ws/") {
533                u.set_path(&format!("/cdp/{rest}"));
534                u.to_string()
535            } else {
536                ws_endpoint.to_string()
537            }
538        }
539        Err(_) => ws_endpoint.to_string(),
540    }
541}
542
543/// An ISO-8601 UTC timestamp `d` from now — the fallback when the API omits
544/// `expiresAt` (TS: `new Date(Date.now() + 60*60_000).toISOString()`).
545fn iso8601_utc_in(d: Duration) -> String {
546    let now = std::time::SystemTime::now()
547        .duration_since(std::time::UNIX_EPOCH)
548        .map(|x| x.as_secs())
549        .unwrap_or(0);
550    iso8601_utc(now as i64 + d.as_secs() as i64)
551}
552
553/// Format a Unix timestamp as `YYYY-MM-DDTHH:MM:SS.000Z`.
554pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
555    let days = unix_secs.div_euclid(86_400);
556    let sod = unix_secs.rem_euclid(86_400);
557    let (y, m, d) = civil_from_days(days);
558    format!(
559        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
560        y,
561        m,
562        d,
563        sod / 3600,
564        (sod % 3600) / 60,
565        sod % 60
566    )
567}
568
569/// Howard Hinnant's `civil_from_days`: days-since-epoch → (year, month, day).
570fn civil_from_days(z: i64) -> (i64, u32, u32) {
571    let z = z + 719_468;
572    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
573    let doe = z - era * 146_097; // [0, 146096]
574    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
575    let y = yoe + era * 400;
576    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
577    let mp = (5 * doy + 2) / 153; // [0, 11]
578    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
579    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
580    (if m <= 2 { y + 1 } else { y }, m, d)
581}