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