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    /// Opt in to Cloudflare Web Bot Auth — sign every outbound HTTP request
110    /// with an Ed25519 key registered to our verified bot directory.
111    /// Independent of `stealth`; silently inert if the acquired slot has no
112    /// signing key configured.
113    pub web_bot_auth: bool,
114    /// Request managed proxy egress. Requires `stealth`.
115    pub proxy: Option<ProxySpec>,
116}
117
118impl CreateSessionOptions {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    pub fn profile_id(mut self, id: impl Into<String>) -> Self {
124        self.profile_id = Some(id.into());
125        self
126    }
127
128    pub fn recording(mut self, on: bool) -> Self {
129        self.recording = on;
130        self
131    }
132
133    pub fn stealth(mut self, on: bool) -> Self {
134        self.stealth = on;
135        self
136    }
137
138    pub fn captcha(mut self, on: bool) -> Self {
139        self.captcha = on;
140        self
141    }
142
143    pub fn web_bot_auth(mut self, on: bool) -> Self {
144        self.web_bot_auth = on;
145        self
146    }
147
148    pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
149        self.proxy = Some(spec.into());
150        self
151    }
152}
153
154/// Talks the Solari Browser control-plane REST API.
155#[derive(Debug)]
156pub struct Client {
157    http: HttpTransport,
158}
159
160impl Client {
161    pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
162        if opts.api_key.is_empty() {
163            return Err(SolariError::config("Solari: apiKey is required"));
164        }
165        if opts.base_url.is_empty() {
166            return Err(SolariError::config("Solari: baseUrl is required"));
167        }
168        let base_url = opts.base_url.trim_end_matches('/').to_string();
169        let http = HttpTransport::new(
170            opts.api_key,
171            base_url,
172            opts.max_attempts,
173            opts.backoff_ms,
174            opts.timeout_ms,
175        )?;
176        Ok(Client { http })
177    }
178
179    /// Session lifecycle: create / get / release / replay.
180    pub fn sessions(&self) -> Sessions<'_> {
181        Sessions { client: self }
182    }
183
184    /// Stored browser profiles.
185    pub fn profiles(&self) -> Profiles<'_> {
186        Profiles { client: self }
187    }
188
189    /// Managed proxy metadata.
190    pub fn proxy(&self) -> Proxy<'_> {
191        Proxy { client: self }
192    }
193
194    /// The resolved API base URL (trailing slash stripped).
195    pub fn base_url(&self) -> &str {
196        self.http.base_url()
197    }
198}
199
200// ---------------------------------------------------------------------------
201// sessions
202// ---------------------------------------------------------------------------
203
204/// The `sessions` namespace. Obtain via [`Client::sessions`].
205pub struct Sessions<'a> {
206    client: &'a Client,
207}
208
209impl Sessions<'_> {
210    /// `POST /sessions` — acquire a browser session.
211    pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
212        let req = CreateSessionRequest {
213            // TS: `if (options?.profileId)` — falsy (unset OR empty) is omitted.
214            profile_id: options
215                .profile_id
216                .clone()
217                .filter(|p| !p.is_empty()),
218            recording: options.recording,
219            stealth: options.stealth,
220            captcha: options.captcha,
221            web_bot_auth: options.web_bot_auth,
222            proxy: options.proxy.clone(),
223        };
224        let body = serde_json::to_value(&req)
225            .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
226        // Omit the body entirely when nothing was set.
227        let body = match body.as_object() {
228            Some(o) if o.is_empty() => None,
229            _ => Some(body),
230        };
231
232        let res = self
233            .client
234            .http
235            .request("POST", "/sessions", body.as_ref())
236            .await?;
237        if !res.ok() {
238            return Err(api_error("POST", "/sessions", res.status, &res.body));
239        }
240
241        let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
242            SolariError::protocol(format!("Solari: unexpected session response: {e}"))
243        })?;
244
245        let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
246            (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
247            _ => {
248                return Err(SolariError::protocol(format!(
249                    "Solari: unexpected session response: {}",
250                    res.body
251                )))
252            }
253        };
254
255        let cdp_endpoint = data
256            .cdp_endpoint
257            .clone()
258            .filter(|c| !c.is_empty())
259            .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
260
261        let expires_at = data
262            .expires_at
263            .clone()
264            .filter(|e| !e.is_empty())
265            .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
266
267        let mut session = Session {
268            id: session_id,
269            ws_endpoint,
270            cdp_endpoint,
271            expires_at,
272            storage_state: None,
273            proxy: data.proxy,
274        };
275
276        // Storage state is only populated when a profile was requested — TS
277        // gates on `options?.profileId !== undefined`.
278        if options.profile_id.is_some() {
279            session.storage_state = Some(match data.storage_state_url {
280                // `storageStateUrl` present → fetch the presigned object.
281                Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
282                // Explicit null → profile exists but is empty.
283                Some(None) => None,
284                // Absent → fall back to any inline state (TS: `?? null`).
285                None => data.storage_state.flatten(),
286            });
287        }
288
289        Ok(session)
290    }
291
292    /// `GET /sessions/:id`.
293    pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
294        let path = format!("/sessions/{}", enc(id));
295        let res = self.client.http.request("GET", &path, None).await?;
296        if !res.ok() {
297            return Err(api_error("GET", &path, res.status, &res.body));
298        }
299        serde_json::from_str(&res.body)
300            .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
301    }
302
303    /// `DELETE /sessions/:id` — release the session. 404 is tolerated (the
304    /// session already ended).
305    ///
306    /// Unlike the TS SDK there is no fire-and-forget variant; this is the
307    /// equivalent of `releaseAndWait`. Spawn it yourself if you don't want to
308    /// await the round-trip.
309    pub async fn release(&self, id: &str) -> Result<(), SolariError> {
310        let path = format!("/sessions/{}", enc(id));
311        let res = self.client.http.request("DELETE", &path, None).await?;
312        if !res.ok() && res.status != 404 {
313            return Err(api_error("DELETE", &path, res.status, &res.body));
314        }
315        Ok(())
316    }
317
318    /// `GET /sessions/:id/replay-url` — presigned URL for the session's replay.
319    /// Available ~1-3s after [`Sessions::release`].
320    pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
321        let path = format!("/sessions/{}/replay-url", enc(id));
322        let res = self.client.http.request("GET", &path, None).await?;
323        if !res.ok() {
324            return Err(api_error("GET", &path, res.status, &res.body));
325        }
326        let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
327            SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
328        })?;
329        let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
330            SolariError::protocol(format!(
331                "Solari: unexpected replay-url response: {}",
332                res.body
333            ))
334        })?;
335        Ok(ReplayUrl {
336            url,
337            expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
338            content_encoding: data
339                .content_encoding
340                .unwrap_or_else(|| "gzip".to_string()),
341        })
342    }
343
344    /// Download the session's replay as NDJSON bytes (still `content_encoding`
345    /// encoded — gzip by default).
346    pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
347        let replay = self.replay_url(id).await?;
348        let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
349        if !(200..300).contains(&status) {
350            return Err(SolariError::Api {
351                status,
352                code: None,
353                message: format!("Solari: replay download failed: {status}"),
354            });
355        }
356        Ok(bytes)
357    }
358
359    /// GET a presigned storage-state object. `None` url → `None` state.
360    async fn fetch_presigned_storage_state(
361        &self,
362        url: Option<&str>,
363    ) -> Result<Option<StorageState>, SolariError> {
364        let Some(url) = url.filter(|u| !u.is_empty()) else {
365            return Ok(None);
366        };
367        let res = self
368            .client
369            .http
370            .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
371            .await
372            .map_err(|e| {
373                SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
374            })?;
375        if !res.ok() {
376            let snippet: String = res.body.chars().take(256).collect();
377            return Err(SolariError::Api {
378                status: res.status,
379                code: None,
380                message: format!(
381                    "Solari: storageState fetch returned {}: {snippet}",
382                    res.status
383                ),
384            });
385        }
386        serde_json::from_str(&res.body).map(Some).map_err(|e| {
387            SolariError::protocol(format!(
388                "Solari: storageState response was not valid JSON: {e}"
389            ))
390        })
391    }
392}
393
394// ---------------------------------------------------------------------------
395// profiles
396// ---------------------------------------------------------------------------
397
398/// The `profiles` namespace. Obtain via [`Client::profiles`].
399pub struct Profiles<'a> {
400    client: &'a Client,
401}
402
403impl Profiles<'_> {
404    /// `GET /profiles`.
405    pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
406        let res = self.client.http.request("GET", "/profiles", None).await?;
407        if !res.ok() {
408            return Err(api_error("GET", "/profiles", res.status, &res.body));
409        }
410        serde_json::from_str(&res.body)
411            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
412    }
413
414    /// `POST /profiles`.
415    pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
416        let body = json!({ "name": name.into() });
417        let res = self
418            .client
419            .http
420            .request("POST", "/profiles", Some(&body))
421            .await?;
422        if !res.ok() {
423            return Err(api_error("POST", "/profiles", res.status, &res.body));
424        }
425        serde_json::from_str(&res.body)
426            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
427    }
428
429    /// `DELETE /profiles/:id`. 404 is tolerated (already gone).
430    pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
431        let path = format!("/profiles/{}", enc(id));
432        let res = self.client.http.request("DELETE", &path, None).await?;
433        if !res.ok() && res.status != 404 {
434            return Err(api_error("DELETE", &path, res.status, &res.body));
435        }
436        Ok(())
437    }
438
439    /// `POST /profiles/:id/save` — persist a storage state onto the profile.
440    pub async fn save(
441        &self,
442        id: &str,
443        storage_state: &StorageState,
444    ) -> Result<ProfileSaveResult, SolariError> {
445        let path = format!("/profiles/{}/save", enc(id));
446        let state = serde_json::to_value(storage_state)
447            .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
448        let body = json!({ "storageState": state });
449        let res = self.client.http.request("POST", &path, Some(&body)).await?;
450        if !res.ok() {
451            return Err(api_error("POST", &path, res.status, &res.body));
452        }
453        let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
454            SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
455        })?;
456        Ok(ProfileSaveResult {
457            version: data.version.unwrap_or(0),
458            size_bytes: data.size_bytes.unwrap_or(0),
459        })
460    }
461}
462
463// ---------------------------------------------------------------------------
464// proxy
465// ---------------------------------------------------------------------------
466
467/// The `proxy` namespace. Obtain via [`Client::proxy`].
468pub struct Proxy<'a> {
469    client: &'a Client,
470}
471
472impl Proxy<'_> {
473    /// `GET /proxy/countries` — supported egress countries, and whether managed
474    /// proxy egress is configured on this gateway at all.
475    pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
476        let res = self
477            .client
478            .http
479            .request("GET", "/proxy/countries", None)
480            .await?;
481        if !res.ok() {
482            return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
483        }
484        serde_json::from_str(&res.body).map_err(|e| {
485            SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
486        })
487    }
488}
489
490// ---------------------------------------------------------------------------
491// helpers
492// ---------------------------------------------------------------------------
493
494/// Percent-encode a path segment. Session ids are HMAC composites (`.`-joined
495/// base64url) and profile ids are opaque, so only a conservative set of
496/// unreserved characters is passed through.
497fn enc(s: &str) -> String {
498    let mut out = String::with_capacity(s.len());
499    for b in s.bytes() {
500        match b {
501            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
502            | b'\'' | b'(' | b')' => out.push(b as char),
503            _ => out.push_str(&format!("%{b:02X}")),
504        }
505    }
506    out
507}
508
509/// Rewrite a Playwright ws endpoint into its raw-CDP sibling: `/ws/<id>` →
510/// `/cdp/<id>`. Mirrors `deriveCdpFromWs`; returns the input unchanged when it
511/// does not parse or does not match.
512pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
513    match Url::parse(ws_endpoint) {
514        Ok(mut u) => {
515            let path = u.path().to_string();
516            if let Some(rest) = path.strip_prefix("/ws/") {
517                u.set_path(&format!("/cdp/{rest}"));
518                u.to_string()
519            } else {
520                ws_endpoint.to_string()
521            }
522        }
523        Err(_) => ws_endpoint.to_string(),
524    }
525}
526
527/// An ISO-8601 UTC timestamp `d` from now — the fallback when the API omits
528/// `expiresAt` (TS: `new Date(Date.now() + 60*60_000).toISOString()`).
529fn iso8601_utc_in(d: Duration) -> String {
530    let now = std::time::SystemTime::now()
531        .duration_since(std::time::UNIX_EPOCH)
532        .map(|x| x.as_secs())
533        .unwrap_or(0);
534    iso8601_utc(now as i64 + d.as_secs() as i64)
535}
536
537/// Format a Unix timestamp as `YYYY-MM-DDTHH:MM:SS.000Z`.
538pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
539    let days = unix_secs.div_euclid(86_400);
540    let sod = unix_secs.rem_euclid(86_400);
541    let (y, m, d) = civil_from_days(days);
542    format!(
543        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
544        y,
545        m,
546        d,
547        sod / 3600,
548        (sod % 3600) / 60,
549        sod % 60
550    )
551}
552
553/// Howard Hinnant's `civil_from_days`: days-since-epoch → (year, month, day).
554fn civil_from_days(z: i64) -> (i64, u32, u32) {
555    let z = z + 719_468;
556    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
557    let doe = z - era * 146_097; // [0, 146096]
558    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
559    let y = yoe + era * 400;
560    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
561    let mp = (5 * doy + 2) / 153; // [0, 11]
562    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
563    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
564    (if m <= 2 { y + 1 } else { y }, m, d)
565}