# 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`:
| `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>`:
| `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