solari-browser 0.6.5

Rust binding for the Solari Browser platform control plane (sessions, profiles, replays, managed proxy).
Documentation
# solari-browser (Rust)

Rust binding for the **Solari Browser** platform — the **control plane**: acquire
and release pooled stealth Chromium sessions, manage stored browser profiles,
pull session replays, and inspect managed-proxy egress. It speaks the same wire
contract as the reference `@solarisdk/browser` TypeScript package
(`sdk/src/index.ts`).

`reqwest` (rustls) for REST, `serde` for the wire types, `thiserror` for
`SolariError`. Optionally [`chromiumoxide`] for driving the browser.

The crate is `solari-browser`; the root module is `solari_browser`. (Distinct
from the desktop/sandbox crate, which is `solari-sdk` / `solari`.)

Feature coverage across all five languages is on the
[SDK feature matrix](https://docs.getsolari.com/sdk-capabilities).

## Scope: browser attachment, not a Playwright port

The TypeScript SDK's `Solari.launch()` returns a live **Playwright** `Browser`.
Playwright has no Rust binding, so there is no port of that object model, and
there won't be one.

Rust drives sessions over the **raw CDP** endpoint instead, via
[`chromiumoxide`], at two levels — both behind the `connect` feature:

- `solari_browser::connect()` — attach to a session you already created.
- `Client::launch()` — a thin one-call convenience (create, connect, seed an
  attached profile's cookies, health-probe, retry, release on close) that
  hands back the same `chromiumoxide::Browser` to drive. Added 2026-10-06 —
  previously Go/Rust had no equivalent of TS/Python's `launch()` at all.

Note the distinction on every `Session`:

| field          | protocol                  | use from Rust                          |
| -------------- | ------------------------- | -------------------------------------- |
| `ws_endpoint`  | Playwright wire protocol  | ❌ chromiumoxide can't speak this      |
| `cdp_endpoint` | raw CDP                   | ✅ this is the one — Puppeteer-shaped  |

One other deliberate divergence: the TS SDK proxies both endpoints through a
Node-side loopback `LocalProxy`. This crate returns the **upstream** gateway
endpoints verbatim — there is nothing to route through.

## Install

Published on crates.io as `solari-browser`.

Control plane only (dependency-light — `reqwest`, `serde`, `thiserror`, `url`):

```toml
[dependencies]
solari-browser = "0.6"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```

With browser automation (pulls in `chromiumoxide`):

```toml
[dependencies]
solari-browser = { version = "0.6", features = ["connect"] }
```

## Usage

```rust
use solari_browser::{Client, ClientOptions, CreateSessionOptions, ProxySpec};

#[tokio::main]
async fn main() -> Result<(), solari_browser::SolariError> {
    // Default region "us-west" → https://api.getsolari.com
    let client = Client::new(ClientOptions::default_region("slr_live_…"))?;

    // Or point at staging / a self-hosted gateway:
    //   Client::new(ClientOptions::new("slr_live_…", "https://gw.example.com"))?
    // There is no env-var fallback — the key and URL are always explicit.

    let session = client
        .sessions()
        .create(
            CreateSessionOptions::new()
                .stealth(true)
                .proxy(ProxySpec::country("us")),
        )
        .await?;

    println!("cdp {}", session.cdp_endpoint);
    println!("expires {}", session.expires_at);

    client.sessions().release(&session.id).await?;
    Ok(())
}
```

### Driving the browser (feature `connect`)

```rust
use solari_browser::CreateSessionOptions;

let session = client
    .sessions()
    .create(CreateSessionOptions::new().stealth(true))
    .await?;

// Attaches to session.cdp_endpoint; returns the chromiumoxide Browser plus the
// spawned task pumping its event handler (every call resolves through it).
let connected = solari_browser::connect(&session).await?;

let page = connected.browser.new_page("https://example.com").await.unwrap();
println!("{:?}", page.url().await.ok());

connected.disconnect().await;          // drops the browser, awaits the handler
client.sessions().release(&session.id).await?;
```

### One-call launch (feature `connect`)

Create + connect + seed + probe + retry + release, in one call:

```rust
use solari_browser::{CreateSessionOptions, LaunchOptions};

let launched = client
    .launch(LaunchOptions::new(CreateSessionOptions::new().stealth(true)).retries(2))
    .await?;

let page = launched.browser.new_page("https://example.com").await.unwrap();
println!("{:?}", page.url().await.ok());

launched.close().await?;   // disconnects and releases the session
```

An attached profile's **cookies** are seeded into the live browser automatically;
`localStorage` is not (Playwright restores it for free on the TS/Python path,
chromiumoxide has no equivalent) — seed it yourself against `launched.browser` if
you need it. `retries` controls the whole create+connect+probe sequence, not
individual HTTP requests (that's the `max_attempts` client setting above) — on a
transient failure the dead session is released and a fresh one created.

### Profiles

```rust
use solari_browser::{CreateSessionOptions, StorageState};

let profile = client.profiles().create("amazon").await?;

// Attach it — the session's storage_state is fetched from its presigned URL.
let session = client
    .sessions()
    .create(CreateSessionOptions::new().profile_id(&profile.id))
    .await?;

match &session.storage_state {
    None => println!("no profile attached"),
    Some(None) => println!("profile attached, but empty"),
    Some(Some(state)) => println!("{} cookie(s)", state.cookies.as_ref().map_or(0, |c| c.len())),
}

// …drive the browser, then persist what it accumulated.
client.profiles().save(&profile.id, &StorageState::default()).await?;

for p in client.profiles().list().await? {
    println!("{} {}", p.id, p.name);
}
```

### Editing a profile

`profiles().edit` opens the profile editor on an existing profile and returns a
`ProfileEditor`. Send the person to `editor.url`; it is valid for about 30
minutes (`editor.expires_at`). There is no auto-save and no save without closing.

```rust
let editor = client.profiles().edit(&profile.id, Some("refresh login")).await?; // reason is optional
println!("{} (start version {})", editor.url, editor.start_version);

let st = editor.status().await?;                 // st.editor_status: Idle | Launching | Editing | Saving | Error
let version = editor.save_and_close().await?;    // store the state and close the editor
editor.exit().await?;                            // close without saving
let version = editor.wait_for_save(Some(Duration::from_secs(600))).await?; // poll until someone saves from the page
```

Errors are `SolariError::Api`: an expired or used link has status 404, an editor
that is not running 409, a failed save 502 (the editor goes to error: `exit()` and start a new `edit`; a second save gets 409). Save and exit are never retried automatically.
`wait_for_save` fails with status 408 on timeout or link expiry.

`storage_state` is three-state, mirroring the TS `storageState?: StorageState | null`:
`None` = no profile attached, `Some(None)` = profile attached but empty,
`Some(Some(_))` = the profile's state.

### Managed proxy

```rust
use solari_browser::{ProxyRequest, ProxySpec, ProxyTier};

ProxySpec::country("gb");                          // proxy: "gb"
ProxySpec::smart();                                // gateway sweeps on block
ProxySpec::off();                                  // explicitly disabled
ProxySpec::from(
    ProxyRequest::country("us")
        .tier(ProxyTier::Mobile)                   // residential | static | mobile
        .session("warm-1")                         // sticky egress IP
        .session_duration(15),                     // minutes (1–30)
);
```

Managed proxy requires `stealth(true)`. `client.proxy().countries()` lists the
supported egress countries and reports whether the gateway has proxy credentials
configured at all — this endpoint has no TypeScript equivalent.

### Timeout, keep-alive, metadata, get and list

```rust
let mut meta = serde_json::Map::new();
meta.insert("job".into(), serde_json::json!("nightly-42"));
let session = client.sessions().create(
    CreateSessionOptions::new()
        .session_timeout_ms(600_000) // end earlier than the plan maximum, in ms (at least 60000)
        .keep_alive(true)       // hold the session if the client drops, until its deadline or release
        .user_metadata(meta),   // stored with the session
).await?;

let view = client.sessions().get(&session.id).await?;   // status, kind, user_metadata, endpoints
let mut opts = ListSessionsOptions::default();
opts.limit = Some(50);
opts.metadata.insert("job".into(), "nightly-42".into()); // optional: match user_metadata
let page = client.sessions().list(opts).await?;          // page.sessions, page.next_cursor
```

All durations are milliseconds. `session_timeout_ms` is rounded up to whole seconds
for the gateway (1001 ms is 2 s) and the gateway refuses anything under 60 s; zero or
unset leaves the plan maximum. `LaunchOptions::probe_timeout_ms` bounds the launch probe.

**Deprecated:** `CreateSessionOptions::timeout_seconds` (field and builder method)
still works; use `session_timeout_ms`. When both are set the new one wins. It will not
be removed before the next major release.

`list` never returns the websocket endpoints (they are bearer capabilities): call
`get` for a single session's. Pass `page.next_cursor` as `cursor` for the next
page; it is `None` on the last one.

### Replays

```rust
client.sessions().release(id).await?;             // replays land ~1–3s after release

let replay = client.sessions().replay_url(id).await?;
println!("{} ({}, {}s)", replay.url, replay.content_encoding, replay.expires_in_seconds);

let bytes = client.sessions().download_replay(id).await?;   // NDJSON, gzip by default
```

## Errors

Every fallible method returns `Result<T, SolariError>`:

| variant     | when                                                        |
| ----------- | ----------------------------------------------------------- |
| `Api`       | non-2xx from the API — carries `status` + parsed `code`      |
| `Transport` | network failure, or retries exhausted                       |
| `Protocol`  | a 2xx body that was malformed or missing required fields    |
| `Config`    | invalid client options (empty api key, bad base URL)        |
| `Connect`   | CDP attach failed (feature `connect`)                       |

`err.status()` and `err.code()` work on any error. `code()` yields the typed
`SolariErrorCode` — `FeatureRequiresPlan`, `ConcurrencyLimitExceeded`,
`PlanLimitExceeded`, `BrowserUnhealthy` — with unknown wire codes preserved as
`Other(String)` rather than dropped.

```rust
use solari_browser::SolariErrorCode;

match err.code() {
    Some(SolariErrorCode::ConcurrencyLimitExceeded) => { /* back off and retry */ }
    Some(SolariErrorCode::FeatureRequiresPlan) => { /* upgrade required */ }
    _ => {}
}
```

## Retries

Defaults: `max_attempts = 2`, `backoff_ms = 500` (a **fixed** delay, not
exponential), `timeout_ms = 90_000`. Only **502 / 503 / 504** and transport
errors are retried — every other status, including 4xx and 500, returns on the
first attempt. Tune per-client:

```rust
use solari_browser::{Client, ClientOptions};

let client = Client::new(
    ClientOptions::default_region("slr_live_…")
        .max_attempts(3)
        .backoff_ms(250)
        .timeout_ms(30_000),
)?;
```

`Debug` for `Client` and `ClientOptions` is hand-written to redact the API key —
neither will print your credentials.

## Testing

Fully offline — the API is mocked with an in-process TCP HTTP server; no live
gateway is needed.

```
cargo build
cargo test
```

The `connect` feature is off by default, so the core build never pulls in
`chromiumoxide`. To verify it too: `cargo build --features connect`.

[`chromiumoxide`]: https://docs.rs/chromiumoxide