solari-browser 0.5.0

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.

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

🚨 CORRECTED 2026-10-04 — path = "sdk/rust" is the in-tree dev-source path from the monorepo (pinetree-browser/sdk/rust), not an install for this published crate. Published on crates.io as solari-browser (currently 0.2.2).

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

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

With browser automation (pulls in chromiumoxide):

[dependencies]
solari-browser = { version = "0.5", features = ["connect"] }

~~```toml [dependencies] solari-browser = { path = "sdk/rust" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

With browser automation (pulls in `chromiumoxide`):
```toml
[dependencies]
solari-browser = { path = "sdk/rust", 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)

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:

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

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);
}

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

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

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

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.

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:

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.