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::collections::BTreeMap;
10use std::time::Duration;
11
12use serde_json::{json, Map, Value};
13use url::Url;
14
15use crate::error::{api_error, SolariError, SolariErrorCode};
16use crate::http::HttpTransport;
17use crate::profile_editor::ProfileEditor;
18use crate::types::{
19    CreateSessionRequest, CreateSessionResponse, Profile, ProfileSaveResponse, ProfileSaveResult,
20    ProxyCountries, ProxySpec, ReplayUrl, ReplayUrlResponse, Session, SessionList, SessionView,
21    SolariRegion,
22    StorageState,
23};
24
25pub(crate) const DEFAULT_MAX_ATTEMPTS: u32 = 2;
26pub(crate) const DEFAULT_BACKOFF_MS: u64 = 500;
27pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 90_000;
28const STORAGE_STATE_FETCH_TIMEOUT: Duration = Duration::from_secs(8);
29
30/// Options for constructing a [`Client`].
31///
32/// There is no environment-variable fallback by design — the API key and base
33/// URL are always explicit constructor arguments.
34#[derive(Clone)]
35pub struct ClientOptions {
36    pub api_key: String,
37    pub base_url: String,
38    /// Total attempts per request (including the first). Default 2.
39    pub max_attempts: u32,
40    /// Fixed delay between attempts. Default 500ms.
41    pub backoff_ms: u64,
42    /// Per-request timeout. Default 90000ms.
43    pub timeout_ms: u64,
44}
45
46/// Hand-written so the API key is never printed. Do not derive this.
47impl std::fmt::Debug for ClientOptions {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("ClientOptions")
50            .field("api_key", &"<redacted>")
51            .field("base_url", &self.base_url)
52            .field("max_attempts", &self.max_attempts)
53            .field("backoff_ms", &self.backoff_ms)
54            .field("timeout_ms", &self.timeout_ms)
55            .finish()
56    }
57}
58
59impl ClientOptions {
60    /// Point the client at an explicit base URL (staging / self-hosted gateway).
61    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
62        ClientOptions {
63            api_key: api_key.into(),
64            base_url: base_url.into(),
65            max_attempts: DEFAULT_MAX_ATTEMPTS,
66            backoff_ms: DEFAULT_BACKOFF_MS,
67            timeout_ms: DEFAULT_TIMEOUT_MS,
68        }
69    }
70
71    /// Point the client at a region's public API. [`SolariRegion::UsWest`]
72    /// resolves to `https://api.getsolari.com`.
73    pub fn for_region(api_key: impl Into<String>, region: SolariRegion) -> Self {
74        ClientOptions::new(api_key, region.base_url())
75    }
76
77    /// Point the client at the default region (`us-west`).
78    pub fn default_region(api_key: impl Into<String>) -> Self {
79        ClientOptions::for_region(api_key, SolariRegion::default())
80    }
81
82    pub fn max_attempts(mut self, n: u32) -> Self {
83        self.max_attempts = n;
84        self
85    }
86
87    pub fn backoff_ms(mut self, ms: u64) -> Self {
88        self.backoff_ms = ms;
89        self
90    }
91
92    pub fn timeout_ms(mut self, ms: u64) -> Self {
93        self.timeout_ms = ms;
94        self
95    }
96}
97
98/// Options accepted by `POST /sessions`.
99///
100/// Falsy fields are omitted from the request body; if every field is falsy the
101/// request is sent with no body at all.
102#[derive(Debug, Clone, Default)]
103pub struct CreateSessionOptions {
104    /// Attach a stored browser profile (cookies + localStorage).
105    pub profile_id: Option<String>,
106    /// Enable session recording. Off by default.
107    pub recording: bool,
108    /// Enable the runtime stealth shim. Off by default.
109    pub stealth: bool,
110    /// Enable managed captcha solving. Requires `stealth`.
111    pub captcha: bool,
112    /// Request managed proxy egress. Requires `stealth`.
113    pub proxy: Option<ProxySpec>,
114    /// End the session earlier than the plan maximum, in milliseconds. `None`
115    /// (or zero) leaves the plan maximum. The gateway counts whole seconds, so the
116    /// value is rounded up (1001 ms is 2 s), and it refuses anything under 60 s.
117    pub session_timeout_ms: Option<u64>,
118    /// The same limit in seconds.
119    #[deprecated(since = "0.5.0", note = "use `session_timeout_ms`; it wins when both are set")]
120    pub timeout_seconds: Option<u32>,
121    /// Hold the session after the client disconnects, until its deadline
122    /// (`session_timeout_ms`, else an hour, never past the plan maximum) or until
123    /// it is released, so a dropped client can reconnect.
124    pub keep_alive: bool,
125    /// Free-form JSON stored with the session and returned by `get` and `list`.
126    pub user_metadata: Option<Map<String, Value>>,
127}
128
129impl CreateSessionOptions {
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    pub fn profile_id(mut self, id: impl Into<String>) -> Self {
135        self.profile_id = Some(id.into());
136        self
137    }
138
139    pub fn recording(mut self, on: bool) -> Self {
140        self.recording = on;
141        self
142    }
143
144    pub fn stealth(mut self, on: bool) -> Self {
145        self.stealth = on;
146        self
147    }
148
149    pub fn captcha(mut self, on: bool) -> Self {
150        self.captcha = on;
151        self
152    }
153
154    pub fn proxy(mut self, spec: impl Into<ProxySpec>) -> Self {
155        self.proxy = Some(spec.into());
156        self
157    }
158
159    /// Session lifetime in milliseconds; see [`CreateSessionOptions::session_timeout_ms`].
160    pub fn session_timeout_ms(mut self, ms: u64) -> Self {
161        self.session_timeout_ms = Some(ms);
162        self
163    }
164
165    /// Session lifetime in seconds.
166    #[deprecated(since = "0.5.0", note = "use `session_timeout_ms`")]
167    #[allow(deprecated)]
168    pub fn timeout_seconds(mut self, seconds: u32) -> Self {
169        self.timeout_seconds = Some(seconds);
170        self
171    }
172
173    pub fn keep_alive(mut self, on: bool) -> Self {
174        self.keep_alive = on;
175        self
176    }
177
178    pub fn user_metadata(mut self, metadata: Map<String, Value>) -> Self {
179        self.user_metadata = Some(metadata);
180        self
181    }
182}
183
184/// Filters and paging for [`Sessions::list`]. The default lists the first page.
185#[derive(Debug, Clone, Default)]
186pub struct ListSessionsOptions {
187    /// Page size; `None` uses the gateway default.
188    pub limit: Option<u32>,
189    /// Continue from a previous page's `next_cursor`.
190    pub cursor: Option<String>,
191    /// Keep sessions whose `user_metadata` has all these string values (at most
192    /// 10 pairs; keys and values cannot contain a single quote).
193    pub metadata: BTreeMap<String, String>,
194}
195
196/// Talks the Solari Browser control-plane REST API.
197#[derive(Debug, Clone)]
198pub struct Client {
199    pub(crate) http: HttpTransport,
200}
201
202impl Client {
203    pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
204        if opts.api_key.is_empty() {
205            return Err(SolariError::config("Solari: apiKey is required"));
206        }
207        if opts.base_url.is_empty() {
208            return Err(SolariError::config("Solari: baseUrl is required"));
209        }
210        let base_url = opts.base_url.trim_end_matches('/').to_string();
211        let http = HttpTransport::new(
212            opts.api_key,
213            base_url,
214            opts.max_attempts,
215            opts.backoff_ms,
216            opts.timeout_ms,
217        )?;
218        Ok(Client { http })
219    }
220
221    /// Session lifecycle: create / get / release / replay.
222    pub fn sessions(&self) -> Sessions<'_> {
223        Sessions { client: self }
224    }
225
226    /// Stored browser profiles.
227    pub fn profiles(&self) -> Profiles<'_> {
228        Profiles { client: self }
229    }
230
231    /// Managed proxy metadata.
232    pub fn proxy(&self) -> Proxy<'_> {
233        Proxy { client: self }
234    }
235
236    /// The resolved API base URL (trailing slash stripped).
237    pub fn base_url(&self) -> &str {
238        self.http.base_url()
239    }
240}
241
242// ---------------------------------------------------------------------------
243// sessions
244// ---------------------------------------------------------------------------
245
246/// The `sessions` namespace. Obtain via [`Client::sessions`].
247pub struct Sessions<'a> {
248    client: &'a Client,
249}
250
251impl Sessions<'_> {
252    /// `POST /sessions` — acquire a browser session.
253    pub async fn create(&self, options: CreateSessionOptions) -> Result<Session, SolariError> {
254        let req = CreateSessionRequest {
255            // TS: `if (options?.profileId)` — falsy (unset OR empty) is omitted.
256            profile_id: options
257                .profile_id
258                .clone()
259                .filter(|p| !p.is_empty()),
260            recording: options.recording,
261            stealth: options.stealth,
262            captcha: options.captcha,
263            proxy: options.proxy.clone(),
264            timeout_seconds: session_timeout_seconds(
265                options.session_timeout_ms,
266                #[allow(deprecated)]
267                options.timeout_seconds,
268            )?,
269            keep_alive: options.keep_alive,
270            user_metadata: options.user_metadata.clone(),
271        };
272        let body = serde_json::to_value(&req)
273            .map_err(|e| SolariError::protocol(format!("Solari: bad create body: {e}")))?;
274        // Omit the body entirely when nothing was set.
275        let body = match body.as_object() {
276            Some(o) if o.is_empty() => None,
277            _ => Some(body),
278        };
279
280        let res = self
281            .client
282            .http
283            .request_with_key(
284                "POST",
285                "/sessions",
286                body.as_ref(),
287                // A create allocates a slot and launches a browser before it can
288                // answer, so a response lost after that point would make a retry
289                // a SECOND session.
290                Some(&crate::http::new_idempotency_key()),
291            )
292            .await?;
293        if !res.ok() {
294            return Err(api_error("POST", "/sessions", res.status, &res.body));
295        }
296
297        let data: CreateSessionResponse = serde_json::from_str(&res.body).map_err(|e| {
298            SolariError::protocol(format!("Solari: unexpected session response: {e}"))
299        })?;
300
301        let (session_id, ws_endpoint) = match (data.session_id.clone(), data.ws_endpoint.clone()) {
302            (Some(id), Some(ws)) if !id.is_empty() && !ws.is_empty() => (id, ws),
303            _ => {
304                return Err(SolariError::protocol(format!(
305                    "Solari: unexpected session response: {}",
306                    res.body
307                )))
308            }
309        };
310
311        let cdp_endpoint = data
312            .cdp_endpoint
313            .clone()
314            .filter(|c| !c.is_empty())
315            .unwrap_or_else(|| derive_cdp_from_ws(&ws_endpoint));
316
317        let expires_at = data
318            .expires_at
319            .clone()
320            .filter(|e| !e.is_empty())
321            .unwrap_or_else(|| iso8601_utc_in(Duration::from_secs(60 * 60)));
322
323        let mut session = Session {
324            id: session_id,
325            ws_endpoint,
326            cdp_endpoint,
327            expires_at,
328            storage_state: None,
329            proxy: data.proxy,
330            user_metadata: data.user_metadata,
331        };
332
333        // Storage state is only populated when a profile was requested — TS
334        // gates on `options?.profileId !== undefined`.
335        if options.profile_id.is_some() {
336            session.storage_state = Some(match data.storage_state_url {
337                // `storageStateUrl` present → fetch the presigned object.
338                Some(Some(ssu)) => self.fetch_presigned_storage_state(ssu.url.as_deref()).await?,
339                // Explicit null → profile exists but is empty.
340                Some(None) => None,
341                // Absent → fall back to any inline state (TS: `?? null`).
342                None => data.storage_state.flatten(),
343            });
344        }
345
346        Ok(session)
347    }
348
349    /// `GET /sessions/:id`.
350    pub async fn get(&self, id: &str) -> Result<SessionView, SolariError> {
351        let path = format!("/sessions/{}", enc(id));
352        let res = self.client.http.request("GET", &path, None).await?;
353        if !res.ok() {
354            return Err(api_error("GET", &path, res.status, &res.body));
355        }
356        serde_json::from_str(&res.body)
357            .map_err(|e| SolariError::protocol(format!("Solari: unexpected session view: {e}")))
358    }
359
360    /// `GET /sessions` — one page of the organisation's sessions. Rows never
361    /// carry the websocket endpoints; call [`Sessions::get`] for those.
362    pub async fn list(&self, options: ListSessionsOptions) -> Result<SessionList, SolariError> {
363        let mut query: Vec<String> = Vec::new();
364        if let Some(limit) = options.limit.filter(|l| *l > 0) {
365            query.push(format!("limit={limit}"));
366        }
367        if let Some(cursor) = options.cursor.as_deref().filter(|c| !c.is_empty()) {
368            query.push(format!("cursor={}", enc(cursor)));
369        }
370        if !options.metadata.is_empty() {
371            query.push(format!("q={}", enc(&metadata_filter(&options.metadata)?)));
372        }
373        let path = if query.is_empty() {
374            "/sessions".to_string()
375        } else {
376            format!("/sessions?{}", query.join("&"))
377        };
378        let res = self.client.http.request("GET", &path, None).await?;
379        if !res.ok() {
380            return Err(api_error("GET", "/sessions", res.status, &res.body));
381        }
382        serde_json::from_str(&res.body)
383            .map_err(|e| SolariError::protocol(format!("Solari: unexpected session list: {e}")))
384    }
385
386    /// `DELETE /sessions/:id` — release the session. A bare 404 is tolerated
387    /// (the session already ended).
388    ///
389    /// A 404 carrying [`SolariErrorCode::InvalidSessionId`] is an error. The
390    /// gateway acks 204 for any authentic session id, including one whose
391    /// session has already ended — the handler is idempotent by design and never
392    /// consults the pool before acking. So a 404 does not mean "already
393    /// released"; it means the gateway refused the id (malformed, forged, or
394    /// another org's) and released nothing, leaving the pool slot held until
395    /// orphan-grace. Treating that as success is what made these releases leak
396    /// slots silently.
397    ///
398    /// A bare 404 with no code stays tolerated — that is a pre-InvalidSessionId
399    /// gateway, where a 404 may legitimately mean the session is already gone.
400    /// Mirrors `releaseRejection` in sdk/src/index.ts.
401    ///
402    /// Unlike the TS SDK there is no fire-and-forget variant; this is the
403    /// equivalent of `releaseAndWait`. Spawn it yourself if you don't want to
404    /// await the round-trip.
405    pub async fn release(&self, id: &str) -> Result<(), SolariError> {
406        let path = format!("/sessions/{}", enc(id));
407        let res = self.client.http.request("DELETE", &path, None).await?;
408        if res.ok() {
409            return Ok(());
410        }
411        let err = api_error("DELETE", &path, res.status, &res.body);
412        if res.status == 404 && err.code() != Some(&SolariErrorCode::InvalidSessionId) {
413            return Ok(());
414        }
415        Err(err)
416    }
417
418    /// `GET /sessions/:id/replay-url` — presigned URL for the session's replay.
419    /// Available ~1-3s after [`Sessions::release`].
420    pub async fn replay_url(&self, id: &str) -> Result<ReplayUrl, SolariError> {
421        let path = format!("/sessions/{}/replay-url", enc(id));
422        let res = self.client.http.request("GET", &path, None).await?;
423        if !res.ok() {
424            return Err(api_error("GET", &path, res.status, &res.body));
425        }
426        let data: ReplayUrlResponse = serde_json::from_str(&res.body).map_err(|e| {
427            SolariError::protocol(format!("Solari: unexpected replay-url response: {e}"))
428        })?;
429        let url = data.url.filter(|u| !u.is_empty()).ok_or_else(|| {
430            SolariError::protocol(format!(
431                "Solari: unexpected replay-url response: {}",
432                res.body
433            ))
434        })?;
435        Ok(ReplayUrl {
436            url,
437            expires_in_seconds: data.expires_in_seconds.unwrap_or(0),
438            content_encoding: data
439                .content_encoding
440                .unwrap_or_else(|| "gzip".to_string()),
441        })
442    }
443
444    /// Download the session's replay as NDJSON bytes. May or may not
445    /// actually be gzip despite what was uploaded — GCS decompresses
446    /// transparently on an ordinary GET, so check `content_encoding` (now
447    /// provider-accurate) before attempting to decompress.
448    pub async fn download_replay(&self, id: &str) -> Result<Vec<u8>, SolariError> {
449        let replay = self.replay_url(id).await?;
450        let (status, bytes) = self.client.http.get_presigned_bytes(&replay.url).await?;
451        if !(200..300).contains(&status) {
452            return Err(SolariError::Api {
453                status,
454                code: None,
455                message: format!("Solari: replay download failed: {status}"),
456            });
457        }
458        Ok(bytes)
459    }
460
461    /// GET a presigned storage-state object. `None` url → `None` state.
462    async fn fetch_presigned_storage_state(
463        &self,
464        url: Option<&str>,
465    ) -> Result<Option<StorageState>, SolariError> {
466        let Some(url) = url.filter(|u| !u.is_empty()) else {
467            return Ok(None);
468        };
469        let res = self
470            .client
471            .http
472            .get_presigned(url, STORAGE_STATE_FETCH_TIMEOUT)
473            .await
474            .map_err(|e| {
475                SolariError::transport(format!("Solari: failed to fetch storageState: {e}"))
476            })?;
477        if !res.ok() {
478            let snippet: String = res.body.chars().take(256).collect();
479            return Err(SolariError::Api {
480                status: res.status,
481                code: None,
482                message: format!(
483                    "Solari: storageState fetch returned {}: {snippet}",
484                    res.status
485                ),
486            });
487        }
488        serde_json::from_str(&res.body).map(Some).map_err(|e| {
489            SolariError::protocol(format!(
490                "Solari: storageState response was not valid JSON: {e}"
491            ))
492        })
493    }
494}
495
496// ---------------------------------------------------------------------------
497// profiles
498// ---------------------------------------------------------------------------
499
500/// The `profiles` namespace. Obtain via [`Client::profiles`].
501pub struct Profiles<'a> {
502    client: &'a Client,
503}
504
505impl Profiles<'_> {
506    /// `GET /profiles`.
507    pub async fn list(&self) -> Result<Vec<Profile>, SolariError> {
508        let res = self.client.http.request("GET", "/profiles", None).await?;
509        if !res.ok() {
510            return Err(api_error("GET", "/profiles", res.status, &res.body));
511        }
512        serde_json::from_str(&res.body)
513            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profiles response: {e}")))
514    }
515
516    /// `POST /profiles`.
517    pub async fn create(&self, name: impl Into<String>) -> Result<Profile, SolariError> {
518        let body = json!({ "name": name.into() });
519        let res = self
520            .client
521            .http
522            .request("POST", "/profiles", Some(&body))
523            .await?;
524        if !res.ok() {
525            return Err(api_error("POST", "/profiles", res.status, &res.body));
526        }
527        serde_json::from_str(&res.body)
528            .map_err(|e| SolariError::protocol(format!("Solari: unexpected profile response: {e}")))
529    }
530
531    /// `DELETE /profiles/:id`. 404 is tolerated (already gone).
532    pub async fn delete(&self, id: &str) -> Result<(), SolariError> {
533        let path = format!("/profiles/{}", enc(id));
534        let res = self.client.http.request("DELETE", &path, None).await?;
535        if !res.ok() && res.status != 404 {
536            return Err(api_error("DELETE", &path, res.status, &res.body));
537        }
538        Ok(())
539    }
540
541    /// `POST /profiles/:id/login-handoff` — open the profile editor on an existing profile.
542    /// Send the person to [`ProfileEditor::url`]; it is valid for about 30 minutes.
543    /// `reason` is optional free text. There is no auto-save and no save without closing.
544    pub async fn edit(&self, id: &str, reason: Option<&str>) -> Result<ProfileEditor, SolariError> {
545        ProfileEditor::open(self.client.clone(), id, reason).await
546    }
547
548    /// `POST /profiles/:id/save` — persist a storage state onto the profile.
549    pub async fn save(
550        &self,
551        id: &str,
552        storage_state: &StorageState,
553    ) -> Result<ProfileSaveResult, SolariError> {
554        let path = format!("/profiles/{}/save", enc(id));
555        let state = serde_json::to_value(storage_state)
556            .map_err(|e| SolariError::protocol(format!("Solari: bad storageState: {e}")))?;
557        let body = json!({ "storageState": state });
558        let res = self.client.http.request("POST", &path, Some(&body)).await?;
559        if !res.ok() {
560            return Err(api_error("POST", &path, res.status, &res.body));
561        }
562        let data: ProfileSaveResponse = serde_json::from_str(&res.body).map_err(|e| {
563            SolariError::protocol(format!("Solari: unexpected profile save response: {e}"))
564        })?;
565        Ok(ProfileSaveResult {
566            version: data.version.unwrap_or(0),
567            size_bytes: data.size_bytes.unwrap_or(0),
568        })
569    }
570}
571
572// ---------------------------------------------------------------------------
573// proxy
574// ---------------------------------------------------------------------------
575
576/// The `proxy` namespace. Obtain via [`Client::proxy`].
577pub struct Proxy<'a> {
578    client: &'a Client,
579}
580
581impl Proxy<'_> {
582    /// `GET /proxy/countries` — supported egress countries, and whether managed
583    /// proxy egress is configured on this gateway at all.
584    pub async fn countries(&self) -> Result<ProxyCountries, SolariError> {
585        let res = self
586            .client
587            .http
588            .request("GET", "/proxy/countries", None)
589            .await?;
590        if !res.ok() {
591            return Err(api_error("GET", "/proxy/countries", res.status, &res.body));
592        }
593        serde_json::from_str(&res.body).map_err(|e| {
594            SolariError::protocol(format!("Solari: unexpected proxy/countries response: {e}"))
595        })
596    }
597}
598
599// ---------------------------------------------------------------------------
600// helpers
601// ---------------------------------------------------------------------------
602
603/// The most key/value pairs the gateway accepts in a list filter.
604const MAX_METADATA_CLAUSES: usize = 10;
605
606/// Render the gateway's list filter grammar: `user_metadata['key']:'value'`
607/// clauses joined by `AND`, in key order. A pair it cannot express is refused
608/// before any request is sent.
609fn metadata_filter(m: &BTreeMap<String, String>) -> Result<String, SolariError> {
610    if m.len() > MAX_METADATA_CLAUSES {
611        return Err(SolariError::config(format!(
612            "Solari: at most {MAX_METADATA_CLAUSES} metadata filter pairs"
613        )));
614    }
615    let mut clauses = Vec::with_capacity(m.len());
616    for (k, v) in m {
617        if k.is_empty() || k.chars().count() > 128 || k.contains('\'') || v.contains('\'') {
618            return Err(SolariError::config(format!(
619                "Solari: metadata filter key {k:?} or its value cannot be expressed (1-128 characters, no single quote)"
620            )));
621        }
622        clauses.push(format!("user_metadata['{k}']:'{v}'"));
623    }
624    Ok(clauses.join(" AND "))
625}
626
627/// The wire value for the gateway's `timeoutSeconds`: the millisecond option
628/// rounded up to whole seconds, else the deprecated seconds option. `None` or zero
629/// means "omit". Refuses a value whose seconds do not fit the wire field (int32, as
630/// in the Go SDK) before any request.
631pub(crate) fn session_timeout_seconds(
632    ms: Option<u64>,
633    legacy_seconds: Option<u32>,
634) -> Result<Option<u32>, SolariError> {
635    const WIRE_MAX: u32 = i32::MAX as u32;
636    match ms.filter(|m| *m > 0) {
637        Some(ms) => {
638            let secs = ms / 1000 + u64::from(ms % 1000 != 0);
639            u32::try_from(secs)
640                .ok()
641                .filter(|s| *s <= WIRE_MAX)
642                .map(Some)
643                .ok_or_else(|| SolariError::config("Solari: session_timeout_ms is too large"))
644        }
645        None => match legacy_seconds.filter(|s| *s > 0) {
646            Some(s) if s > WIRE_MAX => {
647                Err(SolariError::config("Solari: timeout_seconds is too large"))
648            }
649            other => Ok(other),
650        },
651    }
652}
653
654/// Percent-encode a path segment. Session ids are HMAC composites (`.`-joined
655/// base64url) and profile ids are opaque, so only a conservative set of
656/// unreserved characters is passed through.
657pub(crate) fn enc(s: &str) -> String {
658    let mut out = String::with_capacity(s.len());
659    for b in s.bytes() {
660        match b {
661            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'!' | b'*'
662            | b'\'' | b'(' | b')' => out.push(b as char),
663            _ => out.push_str(&format!("%{b:02X}")),
664        }
665    }
666    out
667}
668
669/// Rewrite a Playwright ws endpoint into its raw-CDP sibling: `/ws/<id>` →
670/// `/cdp/<id>`. Mirrors `deriveCdpFromWs`; returns the input unchanged when it
671/// does not parse or does not match.
672pub(crate) fn derive_cdp_from_ws(ws_endpoint: &str) -> String {
673    match Url::parse(ws_endpoint) {
674        Ok(mut u) => {
675            let path = u.path().to_string();
676            if let Some(rest) = path.strip_prefix("/ws/") {
677                u.set_path(&format!("/cdp/{rest}"));
678                u.to_string()
679            } else {
680                ws_endpoint.to_string()
681            }
682        }
683        Err(_) => ws_endpoint.to_string(),
684    }
685}
686
687/// An ISO-8601 UTC timestamp `d` from now — the fallback when the API omits
688/// `expiresAt` (TS: `new Date(Date.now() + 60*60_000).toISOString()`).
689fn iso8601_utc_in(d: Duration) -> String {
690    let now = std::time::SystemTime::now()
691        .duration_since(std::time::UNIX_EPOCH)
692        .map(|x| x.as_secs())
693        .unwrap_or(0);
694    iso8601_utc(now as i64 + d.as_secs() as i64)
695}
696
697/// Format a Unix timestamp as `YYYY-MM-DDTHH:MM:SS.000Z`.
698pub(crate) fn iso8601_utc(unix_secs: i64) -> String {
699    let days = unix_secs.div_euclid(86_400);
700    let sod = unix_secs.rem_euclid(86_400);
701    let (y, m, d) = civil_from_days(days);
702    format!(
703        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.000Z",
704        y,
705        m,
706        d,
707        sod / 3600,
708        (sod % 3600) / 60,
709        sod % 60
710    )
711}
712
713/// Howard Hinnant's `civil_from_days`: days-since-epoch → (year, month, day).
714fn civil_from_days(z: i64) -> (i64, u32, u32) {
715    let z = z + 719_468;
716    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
717    let doe = z - era * 146_097; // [0, 146096]
718    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
719    let y = yoe + era * 400;
720    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
721    let mp = (5 * doy + 2) / 153; // [0, 11]
722    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
723    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
724    (if m <= 2 { y + 1 } else { y }, m, d)
725}