Solari

Browsers

solari::browser (target solari_browser) has two classes, Client and CdpConnection. The browser control plane: sessions, profiles, replays, proxy countries. Blocking and synchronous over libcurl. See the C++ SDK hub for install and configuration.

#include <solari/browser/browser.hpp>  // umbrella header
No launch(): C++ has no Playwright client
The TypeScript SDK’s launch() returns a live Playwright Browser. Playwright and Puppeteer have no C++ client, so this binding stops at the control plane: it creates the session and hands back Session::cdpEndpoint. Drive that with a third-party CDP client, or with the minimal CdpConnection escape hatch. For anything non-trivial, split the process. Drive the browser from a language with a real Playwright client and keep C++ on the session/profile lifecycle. Stealth, proxy, captcha, and recording are all applied server-side, so nothing is lost.

Contents

Client

The API client. Creates sessions and manages profiles. Not copyable.

Properties

  • sessions SessionsResource: a member object, not a method: client.sessions.create().
  • profiles ProfilesResource: likewise, client.profiles.list().

Constructors

Client()

explicit Client(const ClientOptions& options)

Creates a client. Opens no connection.

Parameters:

Throws: SolariError if apiKey is empty.

Example:

solari::browser::ClientOptions opts;
opts.apiKey = "slr_live_...";
solari::browser::Client client(opts);

Methods

proxyCountries()

ProxyCountries proxyCountries()

GET /proxy/countries: the egress countries this gateway supports, and whether proxying is configured at all.

Returns: ProxyCountries (enabled, countries).

Throws: SolariError on a non-2xx response.

Example:

auto pc = client.proxyCountries();
if (!pc.enabled) std::cerr << "managed proxy is unavailable\n";
for (const auto& c : pc.countries) std::cout << c << "\n";

http()

HttpTransport& http()

The underlying transport, for endpoints the SDK does not wrap. Use http().request(method, path, body). It applies the client’s auth, retry, and timeout policy.

Returns: HttpTransport&, whose request() returns an HttpResponse (status, body, ok()). Non-2xx is data, not an exception.

Example:

auto res = client.http().request("GET", "/profiles");
std::cout << res.status << " " << res.body << "\n";

sessions.create()

Session create(const CreateSessionOptions& opts = {})

POST /sessions: acquires a browser session. The returned Session carries the endpoints you drive.

Parameters:

  • opts CreateSessionOptions: profileId, recording, stealth, captcha, webBotAuth, proxy.

Returns: Session

Throws: SolariError carrying status and the server code (e.g. FeatureRequiresPlan, ConcurrencyLimitExceeded).

Example:

solari::browser::CreateSessionOptions o;
o.stealth = true;
o.proxy = solari::browser::ProxyRequest{};  // or std::string("us")

auto session = client.sessions.create(o);
std::cout << session.cdpEndpoint << "\n";
A default-constructed options struct sends no body
buildCreateBody() omits every falsy/unset field, so create({}) yields an empty object and the client sends no body at all. storageState is fetched only when you passed a profileId.
captcha and proxy require stealth
The gateway rejects captcha = true and any proxy request unless stealth = true is also set.

sessions.get()

Json get(const std::string& id)

GET /sessions/:id, returned as raw nlohmann::json.

Parameters:

  • id std::string: session id.

Returns: Json, whatever the pool returns.

Throws: SolariError on a non-2xx response. Today, always.

Example:

auto raw = client.sessions.get(session.id);  // throws SolariError, status 404
Dead upstream: this reliably 404s
The gateway proxies this straight through to the pool host, which implements no GET /sessions/:id route. Every call fails with SolariError, status 404. It is kept for completeness and returns raw JSON because a response that is never produced cannot be typed. Do not build on it.

sessions.release()

void release(const std::string& id)

DELETE /sessions/:id. Blocking, with the TS SDK’s releaseAndWait() semantics, not its fire-and-forget release(). Idempotent: a 404 is success.

Parameters:

  • id std::string: session id.

Throws: SolariError on any non-404 error status.

Example:

client.sessions.release(session.id);
// the replay is available ~1-3s later

sessions.getReplayUrl()

ReplayUrl getReplayUrl(const std::string& id)

GET /sessions/:id/replay-url: a presigned link to the recording. Requires recording = true at create time.

Parameters:

  • id std::string: session id.

Returns: ReplayUrl (url, expiresInSeconds, contentEncoding).

Throws: SolariError, a 404 until the replay lands, about 1 to 3s after release.

Example:

auto replay = client.sessions.getReplayUrl(session.id);
std::cout << replay.url << " expires in " << replay.expiresInSeconds << "s\n";

sessions.downloadReplay()

std::string downloadReplay(const std::string& id)

Resolves the replay URL and downloads the bytes in one call.

Parameters:

  • id std::string: session id.

Returns: std::string, gzipped NDJSON bytes.

Throws: SolariError if the download fails.

Example:

auto bytes = client.sessions.downloadReplay(session.id);
std::ofstream("replay.ndjson.gz", std::ios::binary).write(bytes.data(), bytes.size());
Presigned GETs are single-shot
The presigned fetch bypasses the retry loop and sends no Authorization header. S3 signs the URL itself and rejects the header. One attempt, then it throws.

profiles.list()

std::vector<Profile> list()

GET /profiles: every profile owned by the caller’s org. Non-object rows are skipped.

Returns: std::vector<Profile>

Throws: SolariError on a non-2xx response.

Example:

for (const auto& p : client.profiles.list())
  std::cout << p.id << " " << p.name << "\n";

profiles.create()

Profile create(const std::string& name)

POST /profiles: creates an empty profile. Attach it to a session via CreateSessionOptions::profileId to persist cookies and localStorage.

Parameters:

  • name std::string: human-facing name.

Returns: Profile (id, name, raw).

Throws: SolariError, e.g. PlanLimitExceeded when the profile quota is spent.

Example:

auto profile = client.profiles.create("logged-in");

solari::browser::CreateSessionOptions o;
o.profileId = profile.id;
auto session = client.sessions.create(o);

profiles.remove()

void remove(const std::string& id)

DELETE /profiles/:id. Named remove(), not delete, which is a C++ keyword. Idempotent: a 404 is success.

Parameters:

  • id std::string: profile id.

Throws: SolariError on any non-404 error status.

Example:

client.profiles.remove(profile.id);

profiles.save()

SaveProfileResult save(const std::string& id, const StorageState& storageState)

POST /profiles/:id/save: overwrites a profile with the given storage state.

Parameters:

  • id std::string: profile id.
  • storageState StorageState: a nlohmann::json blob of cookies + origins.

Returns: SaveProfileResult (version, sizeBytes, both 0 when the gateway omits them).

Throws: SolariError on a non-2xx response.

Example:

solari::browser::StorageState state = nlohmann::json::parse(R"({
  "cookies": [{"name": "sid", "value": "abc", "domain": "example.com"}],
  "origins": []
})");

auto r = client.profiles.save(profile.id, state);
std::cout << "v" << r.version << " " << r.sizeBytes << " bytes\n";

CdpConnection

One raw-CDP WebSocket. Compiled only when built with SOLARI_WITH_WS=ON (the default). Not copyable; send() is thread-safe.

An escape hatch, not an automation API
CdpConnection does exactly one thing: send a CDP command and block for its correlated reply. No page/frame/element model, no auto-attach, no waiting primitives, no navigation lifecycle. C++’s CDP ecosystem is thin. Evaluate any third-party client yourself rather than taking a recommendation. For non-trivial work, split the process (see the note at the top).
Raw CDP bypasses input humanization
The pool’s mouse and keyboard humanization lives on the Playwright path. Input you synthesize over raw CDP does not get it, which is a detectable difference on sites that score interaction behaviour. Stealth, proxy, and captcha are unaffected. Those are server-side.

Constructors

CdpConnection()

explicit CdpConnection(std::string cdpEndpoint, long callTimeoutMs = 30000)

Wraps a session’s raw-CDP URL. Opens nothing until connect().

Parameters:

  • cdpEndpoint std::string: Session::cdpEndpoint.
  • callTimeoutMs long: per-send() timeout. Default 30000.

Example:

solari::browser::CdpConnection cdp(session.cdpEndpoint);
The URL is the credential
The /cdp/ upgrade needs no Bearer header. The session id in the path is HMAC-signed, so the URL authenticates itself. Treat it as a secret.

Methods

connect()

void connect()

Opens the socket and blocks until the handshake completes.

Throws: SolariError if the handshake fails.

Example:

cdp.connect();

send()

nlohmann::json send(const std::string& method,
                    const nlohmann::json& params = nlohmann::json::object(),
                    const std::optional<std::string>& sessionId = std::nullopt)

Sends one CDP command and blocks for its reply. Returns the result object.

Parameters:

  • method std::string: CDP method, e.g. "Page.navigate".
  • params nlohmann::json: command params.
  • sessionId std::optional<std::string>: targets a flattened target session. Required for page-scoped commands.

Returns: nlohmann::json, the result object.

Throws: SolariError on a protocol error, on timeout, or when the socket is not open.

Example:

auto version = cdp.send("Browser.getVersion");
std::cout << version["product"].get<std::string>() << "\n";

// Attach to a page target; flattened sessions carry a CDP sessionId.
auto targets = cdp.send("Target.getTargets");
auto targetId = targets["targetInfos"][0]["targetId"].get<std::string>();
auto att = cdp.send("Target.attachToTarget", {{"targetId", targetId}, {"flatten", true}});
auto sid = att["sessionId"].get<std::string>();

cdp.send("Page.enable", {}, sid);
cdp.send("Page.navigate", {{"url", "https://example.com"}}, sid);

onEvent()

void onEvent(std::function<void(const nlohmann::json&)> handler)

Subscribes to CDP events (any frame without an id, e.g. Page.loadEventFired).

Parameters:

  • handler std::function<void(const nlohmann::json&)>: invoked on the receive thread.

Example:

cdp.onEvent([](const nlohmann::json& ev) {
  if (ev.value("method", "") == "Page.loadEventFired") std::cout << "loaded\n";
});
cdp.connect();  // set the handler BEFORE connecting
Set it before connect(), and don't re-enter
The handler is read without a lock, so it must be set before connect(), otherwise early events are missed. It runs on the receive thread: do not call send() or close() from inside it.

close()

void close()

Closes the socket and fails every in-flight send(). Idempotent.

Example:

cdp.close();
client.sessions.release(session.id);  // close() does NOT release the session

connected()

bool connected() const

Whether the socket is currently open.

Returns: bool

Example:

if (!cdp.connected()) throw std::runtime_error("cdp dropped");

Pure helpers

The request builders and response parsers are exported so you can assert wire shapes in tests with no network. They are pure functions over nlohmann::json.

FunctionPurpose
buildCreateBody(opts)The POST /sessions body. Empty options → empty object → no body sent.
parseSessionResponse(body)A 201 body → Session. Derives cdpEndpoint when omitted; defaults expiresAt to one hour out.
parseReplayUrl(body)ReplayUrl, defaulting expiresInSeconds0 and contentEncoding"gzip".
parseProfile / parseProfiles / parseSaveResult / parseProxyCountriesThe remaining response shapes.
deriveCdpFromWs(wsEndpoint)Swaps the /ws/<id> path prefix for /cdp/<id>.
HttpTransport::prepare(...)The concrete request bytes (method, url, headers, body) without sending.
encodeURIComponent / isRetryableStatus / iso8601FromNowPath-segment escaping, the 502/503/504 predicate, and ISO-8601 stamping.
proxyToJson(spec)A ProxySpec → the JSON sent under proxy. Unset ProxyRequest fields are omitted.
parseErrorCode(body) / throwHttpError(what, status, body)Lift code out of a JSON error body (nullopt when absent or not a string), and raise the corresponding SolariError. The client already does this for you.
fetchUrl(url, timeoutMs)GET an absolute URL with no auth header, for presigned S3 links, which carry their own signature and reject Authorization.
using namespace solari::browser;

// No client, no gateway, no network.
CreateSessionOptions o;
o.stealth = true;
CHECK(buildCreateBody(o) == nlohmann::json({{"stealth", true}}));
CHECK(buildCreateBody(CreateSessionOptions{}).empty());  // -> no body

CHECK(deriveCdpFromWs("wss://api.getsolari.com/ws/abc?x=1")
      == "wss://api.getsolari.com/cdp/abc?x=1");
deriveCdpFromWs is byte-identical to the TS implementation
Verified across 9 cases, including query-string preservation and a host that itself contains "ws" (which it does not mangle). Everything but the path prefix is preserved; an input that does not parse, or whose path is not /ws/…, is returned unchanged.

Types

ClientOptions

  • apiKey std::string: required. Format slr_live_<id>_<secret>.
  • baseUrl std::string: default https://api.getsolari.com.
  • maxAttempts int: default 2 (one retry). TOTAL attempts, not retries.
  • backoffMs long: default 500, fixed.
  • timeoutMs long: default 90000, per attempt.

CreateSessionOptions

  • profileId std::optional<std::string>: attach a stored profile.
  • recording bool: record the session. false by default.
  • stealth bool: enable the runtime stealth shim. false by default.
  • captcha bool: managed captcha solving. Requires stealth.
  • webBotAuth bool: sign outbound requests for Cloudflare Web Bot Auth. Independent of stealth.
  • proxy std::optional<ProxySpec>: managed egress. Requires stealth.

ProxySpec

std::variant<std::string, ProxyRequest> mirrors the TypeScript union string | ProxyRequest. The string form takes a country code, or the sentinels "off" / "smart".

CreateSessionOptions o;
o.stealth = true;

o.proxy = std::string("smart");        // sentinel
o.proxy = std::string("us");           // country

ProxyRequest p;                         // structured
p.country = "gb";
p.tier = "mobile";
o.proxy = p;

ProxyRequest

Every field is optional.

  • country: ISO-3166-1 alpha-2, lowercase. Gateway default "us".
  • tier: "residential" (default, rotating), "static" (fixed ISP IP), or "mobile".
  • asn: pin egress to an ASN, e.g. "20057".
  • session: sticky-session id (alnum + dash, ≤32 chars). Pins the egress IP.
  • sessionDuration std::optional<int>: sticky lifetime in minutes (1 to 30, default 10). Only with session.
  • state: US-only geo narrowing, e.g. "california".
  • city: US-only city pin, e.g. "los_angeles".

Session

  • id std::string: session id.
  • wsEndpoint std::string: Playwright wire protocol (/ws/<id>). Returned for completeness; only a Playwright client can use it, and there is none for C++.
  • cdpEndpoint std::string: raw CDP (/cdp/<id>). This is the one you drive.
  • expiresAt std::string: ISO 8601 UTC deadline; the session auto-releases then.
  • storageState std::optional<StorageState>: tri-state. Unset = no profile attached; present-but-JSON-null (storageState->is_null()) = profile exists but is empty; an object = the profile’s state.
  • proxy std::optional<ResolvedProxyConfig>: set only when a managed proxy was requested.
Endpoints are UPSTREAM URLs
Unlike the TypeScript SDK, this binding runs no loopback proxy. What the gateway returned is what you get, verbatim. Nothing is rewritten and there is no background listener to shut down. Both are capability URLs: the session id is HMAC-signed, so the upgrade needs no Authorization header. Treat them as secrets.

StorageState

using StorageState = nlohmann::json: a Playwright storageState blob, {cookies: [...], origins: [...]}. Kept raw: the SDK never interprets it, only moves it between a profile and a browser.

ResolvedProxyConfig

  • server, username, password std::string: proxy URL and credentials.
  • timezoneId std::string: timezone matching the egress IP.
  • country std::string: resolved country.
  • tier std::optional<std::string>: resolved tier.

Profile

  • id std::string, name std::string.
  • raw Json: the full row the platform returned, so a new field never forces an SDK bump.

SaveProfileResult / ReplayUrl / ProxyCountries

  • SaveProfileResult: version, sizeBytes long long: both 0 when omitted.
  • ReplayUrl: url std::string, expiresInSeconds long long (default 0), contentEncoding std::string (default "gzip").
  • ProxyCountries: enabled bool: false when managed proxy is unavailable; countries std::vector<std::string>: lowercase alpha-2, sorted.

SolariError

Extends std::runtime_error. The single exception type the browser SDK throws.

  • status std::optional<int>: HTTP status; unset for transport-level errors.
  • code std::optional<std::string>: the machine-readable code lifted out of the gateway’s JSON error body.

Branch on code rather than the message. The constants live in solari::browser::error_code:

ConstantStatusMeaning
FeatureRequiresPlan403Stealth / proxy / captcha asked for on a plan without it.
ConcurrencyLimitExceeded429The org is at its concurrent-session cap.
PlanLimitExceeded403A plan quota (e.g. stored profiles) is exhausted.
BrowserUnhealthyNoneThe acquired browser failed its health probe.
try {
  auto session = client.sessions.create(o);
} catch (const solari::browser::SolariError& e) {
  if (e.code == solari::browser::error_code::ConcurrencyLimitExceeded) {
    // 429 is NOT retried by the transport. Back off yourself.
    std::this_thread::sleep_for(std::chrono::seconds(2));
  }
  std::cerr << e.what() << " status=" << e.status.value_or(0) << "\n";
}
429 needs caller-side backoff
The transport retries only 502/503/504 and transport errors, with a fixed pause. ConcurrencyLimitExceeded (429) is not retried. Handle it yourself.