@solarisdk/browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Solari Browser
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @solarisdk/browser
2
+
3
+ Official client SDK for [Solari Browser](https://solaribrowser.com) — managed,
4
+ stealth-hardened browsers as an API.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ npm install @solarisdk/browser
10
+ ```
11
+
12
+ ## Quickstart
13
+
14
+ ```ts
15
+ import { Solari } from "@solarisdk/browser";
16
+
17
+ const solari = new Solari({
18
+ apiKey: process.env.SOLARI_API_KEY!,
19
+ // region: "us-west", // default — additional regions coming soon
20
+ });
21
+
22
+ const browser = await solari.launch({ stealth: true });
23
+ const page = await browser.newPage();
24
+ await page.goto("https://example.com");
25
+ console.log(await page.title());
26
+ await browser.close();
27
+ ```
28
+
29
+ Or with explicit resource management (Node 22+):
30
+
31
+ ```ts
32
+ await using browser = await solari.launch({ stealth: true });
33
+ const page = await browser.newPage();
34
+ await page.goto("https://example.com");
35
+ ```
36
+
37
+ ## Sessions
38
+
39
+ Drive a session yourself instead of using `launch()`:
40
+
41
+ ```ts
42
+ const session = await solari.sessions.create({ stealth: true });
43
+ // connect via Playwright wire protocol
44
+ import { chromium } from "patchright-core";
45
+ const browser = await chromium.connect(session.wsEndpoint);
46
+ // ...
47
+ await browser.close();
48
+ await solari.sessions.releaseAndWait(session.id);
49
+ ```
50
+
51
+ Or via raw CDP (Puppeteer, browser-use, playwright-core):
52
+
53
+ ```ts
54
+ const session = await solari.sessions.create();
55
+ const browser = await chromium.connectOverCDP(session.cdpEndpoint);
56
+ ```
57
+
58
+ ## Regions
59
+
60
+ Solari runs in `us-west` (N. California) today, with additional regions
61
+ coming soon. Sessions, replays, and profile storage are colocated with
62
+ the region you pick.
63
+
64
+ ```ts
65
+ const solari = new Solari({ apiKey, region: "us-west" });
66
+ ```
67
+
68
+ For staging or self-hosted gateways, pass `baseUrl` instead of `region`.
69
+
70
+ ## Managed proxies
71
+
72
+ ```ts
73
+ await solari.launch({
74
+ stealth: true,
75
+ proxy: { country: "us", tier: "mobile" },
76
+ });
77
+ ```
78
+
79
+ For targets whose stealth posture is unknown a priori, `proxy: "smart"`
80
+ runs an escalation ladder (rotating residential → direct → mobile → fresh
81
+ residential → residential GB) and swaps the egress in-place when a block
82
+ page is detected mid-session — no client-side retry required.
83
+
84
+ ```ts
85
+ await solari.launch({ stealth: true, proxy: "smart" });
86
+ ```
87
+
88
+ See the [Solari docs](https://docs.solaribrowser.com) for the full proxy,
89
+ stealth, captcha, and profile reference.
90
+
91
+ ## Errors
92
+
93
+ ```ts
94
+ import { SolariError } from "@solarisdk/browser";
95
+
96
+ try {
97
+ await solari.sessions.create({ stealth: true });
98
+ } catch (err) {
99
+ if (err instanceof SolariError && err.code === "FeatureRequiresPlan") {
100
+ // ...
101
+ }
102
+ }
103
+ ```
104
+
105
+ ## License
106
+
107
+ Apache-2.0
@@ -0,0 +1,36 @@
1
+ import type { Browser, BrowserContext, BrowserContextOptions, Page } from "patchright-core";
2
+ import type { Solari } from "./index.js";
3
+ import type { Session } from "./index.js";
4
+ export declare class BrowserSession {
5
+ private readonly client;
6
+ readonly session: Session;
7
+ private readonly browser;
8
+ private closed;
9
+ constructor(client: Solari, session: Session, browser: Browser);
10
+ /** Session id. */
11
+ get id(): string;
12
+ /** Plan-tier deadline (ISO 8601 UTC); session auto-releases at this point. */
13
+ get expiresAt(): string;
14
+ /** Resolved proxy credentials, when a proxy was requested. */
15
+ get proxy(): Session["proxy"];
16
+ /** Playwright wire-protocol endpoint (loopback-wrapped). */
17
+ get wsEndpoint(): string;
18
+ /** Raw CDP endpoint (loopback-wrapped). */
19
+ get cdpEndpoint(): string;
20
+ /** Underlying Browser. Use for advanced APIs not surfaced on this wrapper. */
21
+ get raw(): Browser;
22
+ /** `true` while the underlying browser connection is alive. */
23
+ isConnected(): boolean;
24
+ /** Browser version string (e.g. `"Chromium/130.0.6723.31"`). */
25
+ version(): string;
26
+ /** All open contexts. Sessions ship with a default context at `contexts()[0]`. */
27
+ contexts(): BrowserContext[];
28
+ /** Open a fresh browser context. Most callers want `contexts()[0]` instead. */
29
+ newContext(options?: BrowserContextOptions): Promise<BrowserContext>;
30
+ /** Open a fresh page in a new context. */
31
+ newPage(): Promise<Page>;
32
+ /** Close the browser and release the session. Idempotent. */
33
+ close(): Promise<void>;
34
+ /** Supports `await using browser = await solari.launch()` (Node 22+). */
35
+ [Symbol.asyncDispose](): Promise<void>;
36
+ }
@@ -0,0 +1,82 @@
1
+ export class BrowserSession {
2
+ client;
3
+ session;
4
+ browser;
5
+ closed = false;
6
+ constructor(client, session, browser) {
7
+ this.client = client;
8
+ this.session = session;
9
+ this.browser = browser;
10
+ }
11
+ /** Session id. */
12
+ get id() {
13
+ return this.session.id;
14
+ }
15
+ /** Plan-tier deadline (ISO 8601 UTC); session auto-releases at this point. */
16
+ get expiresAt() {
17
+ return this.session.expiresAt;
18
+ }
19
+ /** Resolved proxy credentials, when a proxy was requested. */
20
+ get proxy() {
21
+ return this.session.proxy;
22
+ }
23
+ /** Playwright wire-protocol endpoint (loopback-wrapped). */
24
+ get wsEndpoint() {
25
+ return this.session.wsEndpoint;
26
+ }
27
+ /** Raw CDP endpoint (loopback-wrapped). */
28
+ get cdpEndpoint() {
29
+ return this.session.cdpEndpoint;
30
+ }
31
+ /** Underlying Browser. Use for advanced APIs not surfaced on this wrapper. */
32
+ get raw() {
33
+ return this.browser;
34
+ }
35
+ /** `true` while the underlying browser connection is alive. */
36
+ isConnected() {
37
+ return !this.closed && this.browser.isConnected();
38
+ }
39
+ /** Browser version string (e.g. `"Chromium/130.0.6723.31"`). */
40
+ version() {
41
+ return this.browser.version();
42
+ }
43
+ /** All open contexts. Sessions ship with a default context at `contexts()[0]`. */
44
+ contexts() {
45
+ return this.browser.contexts();
46
+ }
47
+ /** Open a fresh browser context. Most callers want `contexts()[0]` instead. */
48
+ async newContext(options) {
49
+ return this.browser.newContext(options);
50
+ }
51
+ /** Open a fresh page in a new context. */
52
+ async newPage() {
53
+ return this.browser.newPage();
54
+ }
55
+ /** Close the browser and release the session. Idempotent. */
56
+ async close() {
57
+ if (this.closed)
58
+ return;
59
+ this.closed = true;
60
+ let browserErr;
61
+ try {
62
+ await this.browser.close();
63
+ }
64
+ catch (e) {
65
+ browserErr = e;
66
+ }
67
+ try {
68
+ await this.client.sessions.releaseAndWait(this.session.id);
69
+ }
70
+ catch (releaseErr) {
71
+ if (browserErr)
72
+ throw browserErr;
73
+ throw releaseErr;
74
+ }
75
+ if (browserErr)
76
+ throw browserErr;
77
+ }
78
+ /** Supports `await using browser = await solari.launch()` (Node 22+). */
79
+ async [Symbol.asyncDispose]() {
80
+ await this.close();
81
+ }
82
+ }
@@ -0,0 +1,187 @@
1
+ import { type ProxyKind } from "./local-proxy.js";
2
+ import { BrowserSession } from "./browser-session.js";
3
+ export { BrowserSession } from "./browser-session.js";
4
+ /** Supported Solari regions. More coming soon. */
5
+ export type SolariRegion = "us-west";
6
+ export interface SolariOptions {
7
+ apiKey: string;
8
+ /** Region to route sessions through. Defaults to `"us-west"`.
9
+ * Additional regions (e.g. `us-east`) are coming soon. */
10
+ region?: SolariRegion;
11
+ /** Override the resolved region URL. Useful for staging / self-hosted
12
+ * gateways. When set, `region` is ignored. */
13
+ baseUrl?: string;
14
+ maxAttempts?: number;
15
+ backoffMs?: number;
16
+ timeoutMs?: number;
17
+ }
18
+ export interface StorageState {
19
+ cookies?: Array<{
20
+ name: string;
21
+ value: string;
22
+ domain?: string;
23
+ path?: string;
24
+ expires?: number;
25
+ httpOnly?: boolean;
26
+ secure?: boolean;
27
+ sameSite?: "Strict" | "Lax" | "None";
28
+ }>;
29
+ origins?: Array<{
30
+ origin: string;
31
+ localStorage?: Array<{
32
+ name: string;
33
+ value: string;
34
+ }>;
35
+ }>;
36
+ [k: string]: unknown;
37
+ }
38
+ /** Managed proxy egress request. */
39
+ export interface ProxyRequest {
40
+ /** ISO-3166-1 alpha-2, lowercase. Defaults to "us". */
41
+ country?: string;
42
+ /** Proxy tier: `"residential"` (default, rotating), `"static"` (fixed ISP IP),
43
+ * or `"mobile"` (carrier IPs). */
44
+ tier?: "residential" | "static" | "mobile";
45
+ /** @deprecated Use `tier: "static"`. */
46
+ static?: boolean;
47
+ /** Pin egress to a specific ASN (e.g. "20057" for AT&T Mobility). */
48
+ asn?: string;
49
+ /** Sticky-session id (alnum + dash, ≤32 chars). Pins egress IP for
50
+ * `sessionDuration` minutes. */
51
+ session?: string;
52
+ /** Sticky lifetime in minutes (1–30, default 10). Only with `session`. */
53
+ sessionDuration?: number;
54
+ /** US-only geo narrowing (e.g. "california"). */
55
+ state?: string;
56
+ /** US-only city pin (e.g. "los_angeles"). */
57
+ city?: string;
58
+ }
59
+ /** Resolved proxy credentials returned on the session response. */
60
+ export interface ResolvedProxyConfig {
61
+ server: string;
62
+ username: string;
63
+ password: string;
64
+ timezoneId: string;
65
+ country: string;
66
+ tier?: "residential" | "static" | "mobile";
67
+ }
68
+ export interface Session {
69
+ id: string;
70
+ /** Playwright wire-protocol endpoint for `chromium.connect()`. */
71
+ wsEndpoint: string;
72
+ /** Raw CDP endpoint for `chromium.connectOverCDP()` or Puppeteer. */
73
+ cdpEndpoint: string;
74
+ /** Plan-tier deadline (ISO 8601 UTC); session auto-releases at this point. */
75
+ expiresAt: string;
76
+ /** `undefined` = no profile attached; `null` = profile exists but empty. */
77
+ storageState?: StorageState | null;
78
+ /** Present only when a managed proxy was requested. */
79
+ proxy?: ResolvedProxyConfig;
80
+ }
81
+ export interface Profile {
82
+ id: string;
83
+ name: string;
84
+ [k: string]: unknown;
85
+ }
86
+ export interface CreateSessionOptions {
87
+ /** Attach a stored browser profile (cookies + localStorage). */
88
+ profileId?: string;
89
+ /** Enable session recording. Off by default. */
90
+ recording?: boolean;
91
+ /** Enable the runtime stealth shim. Off by default. */
92
+ stealth?: boolean;
93
+ /** Enable managed captcha solving. Requires `stealth: true`. */
94
+ captcha?: boolean;
95
+ /** Opt in to Cloudflare Web Bot Auth — sign every outbound HTTP
96
+ * request with an Ed25519 key registered to our verified bot
97
+ * directory. Independent of `stealth`. Silently inert if the
98
+ * acquired slot has no signing key configured. */
99
+ webBotAuth?: boolean;
100
+ /** Request managed proxy egress. Requires `stealth: true`.
101
+ *
102
+ * proxy: "us"
103
+ * proxy: { country: "gb" }
104
+ * proxy: { country: "us", tier: "static" }
105
+ * proxy: { country: "us", tier: "mobile" }
106
+ * proxy: { country: "us", session: "warm-1" }
107
+ * proxy: "smart"
108
+ * proxy: "off"
109
+ */
110
+ proxy?: string | ProxyRequest | "off" | "smart";
111
+ }
112
+ export interface ReplayUrl {
113
+ url: string;
114
+ expiresInSeconds: number;
115
+ contentEncoding: string;
116
+ }
117
+ /** Options for `Solari.launch()`. Strict superset of `CreateSessionOptions`. */
118
+ export interface LaunchOptions extends CreateSessionOptions {
119
+ /** Additional re-launch attempts after the first failure. Default 0. */
120
+ retries?: number;
121
+ /** Probe the connected browser before returning. Defaults to `true` when
122
+ * `retries > 0`, `false` otherwise. */
123
+ probe?: boolean;
124
+ /** Hard upper bound on the post-connect probe in ms. Default 2000. */
125
+ probeTimeoutMs?: number;
126
+ }
127
+ export type SolariErrorCode = "FeatureRequiresPlan" | "ConcurrencyLimitExceeded" | "PlanLimitExceeded" | "BrowserUnhealthy";
128
+ export declare class SolariError extends Error {
129
+ readonly status?: number | undefined;
130
+ readonly cause?: unknown | undefined;
131
+ readonly code?: (SolariErrorCode | string) | undefined;
132
+ constructor(message: string, status?: number | undefined, cause?: unknown | undefined, code?: (SolariErrorCode | string) | undefined);
133
+ }
134
+ declare class ProfilesResource {
135
+ private readonly client;
136
+ constructor(client: Solari);
137
+ create(opts: {
138
+ name: string;
139
+ }): Promise<Profile>;
140
+ list(): Promise<Profile[]>;
141
+ delete(id: string): Promise<void>;
142
+ save(id: string, storageState: StorageState): Promise<{
143
+ version: number;
144
+ sizeBytes: number;
145
+ }>;
146
+ }
147
+ declare class SessionsResource {
148
+ private readonly client;
149
+ constructor(client: Solari);
150
+ create(options?: CreateSessionOptions): Promise<Session>;
151
+ /** Fire-and-forget release. Use `releaseAndWait` for confirmation. */
152
+ release(id: string): void;
153
+ releaseAndWait(id: string): Promise<void>;
154
+ /** Presigned URL for the session's replay. Available ~1-3s after `releaseAndWait`. */
155
+ getReplayUrl(id: string): Promise<ReplayUrl>;
156
+ /** Download the replay as NDJSON bytes. */
157
+ downloadReplay(id: string): Promise<Uint8Array>;
158
+ }
159
+ export declare class Solari {
160
+ readonly sessions: SessionsResource;
161
+ readonly profiles: ProfilesResource;
162
+ private readonly apiKey;
163
+ private readonly baseUrl;
164
+ private readonly maxAttempts;
165
+ private readonly backoffMs;
166
+ private readonly timeoutMs;
167
+ private readonly proxy;
168
+ constructor(opts: SolariOptions);
169
+ close(): Promise<void>;
170
+ /**
171
+ * Create a session and return a connected browser.
172
+ *
173
+ * const browser = await solari.launch({ stealth: true });
174
+ * const page = await browser.newPage();
175
+ * await page.goto("https://example.com");
176
+ * await browser.close();
177
+ *
178
+ * Or with explicit resource management (Node 22+):
179
+ *
180
+ * await using browser = await solari.launch({ stealth: true });
181
+ */
182
+ launch(options?: LaunchOptions): Promise<BrowserSession>;
183
+ private _probeBrowserHealth;
184
+ _wrapSessionEndpoint(sessionId: string, upstreamEndpoint: string, kind?: ProxyKind): Promise<string>;
185
+ _unregisterSession(sessionId: string): void;
186
+ request(method: string, path: string, body?: unknown): Promise<Response>;
187
+ }
package/dist/index.js ADDED
@@ -0,0 +1,401 @@
1
+ import { chromium } from "patchright-core";
2
+ import { LocalProxy } from "./local-proxy.js";
3
+ import { BrowserSession } from "./browser-session.js";
4
+ export { BrowserSession } from "./browser-session.js";
5
+ export class SolariError extends Error {
6
+ status;
7
+ cause;
8
+ code;
9
+ constructor(message, status, cause, code) {
10
+ super(message);
11
+ this.status = status;
12
+ this.cause = cause;
13
+ this.code = code;
14
+ this.name = "SolariError";
15
+ }
16
+ }
17
+ const REGION_URLS = {
18
+ "us-west": "https://api.solaribrowser.com",
19
+ };
20
+ const DEFAULT_REGION = "us-west";
21
+ const DEFAULT_MAX_ATTEMPTS = 2;
22
+ const DEFAULT_BACKOFF_MS = 500;
23
+ const DEFAULT_TIMEOUT_MS = 90_000;
24
+ function isRetryableStatus(status) {
25
+ return status === 502 || status === 503 || status === 504;
26
+ }
27
+ function isRetryableError(_err) {
28
+ return true;
29
+ }
30
+ function isLikelyTransientConnect(err) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ if (!message)
33
+ return false;
34
+ return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|WebSocket|connect|handshake|Browser closed|Target page, context or browser has been closed/i
35
+ .test(message);
36
+ }
37
+ function sleep(ms) {
38
+ return new Promise((r) => setTimeout(r, ms));
39
+ }
40
+ async function fetchPresignedStorageState(url) {
41
+ if (!url)
42
+ return null;
43
+ let res;
44
+ try {
45
+ res = await fetch(url, {
46
+ method: "GET",
47
+ signal: AbortSignal.timeout(8_000),
48
+ });
49
+ }
50
+ catch (err) {
51
+ throw new SolariError(`Solari: failed to fetch storageState: ${err instanceof Error ? err.message : String(err)}`, undefined, err);
52
+ }
53
+ if (!res.ok) {
54
+ const body = await res.text().catch(() => "");
55
+ throw new SolariError(`Solari: storageState fetch returned ${res.status}: ${body.slice(0, 256)}`, res.status);
56
+ }
57
+ try {
58
+ return (await res.json());
59
+ }
60
+ catch (err) {
61
+ throw new SolariError(`Solari: storageState response was not valid JSON: ${err instanceof Error ? err.message : String(err)}`, undefined, err);
62
+ }
63
+ }
64
+ function deriveCdpFromWs(wsEndpoint) {
65
+ try {
66
+ const u = new URL(wsEndpoint);
67
+ if (u.pathname.startsWith("/ws/")) {
68
+ u.pathname = "/cdp/" + u.pathname.slice("/ws/".length);
69
+ return u.toString();
70
+ }
71
+ }
72
+ catch { }
73
+ return wsEndpoint;
74
+ }
75
+ class ProfilesResource {
76
+ client;
77
+ constructor(client) {
78
+ this.client = client;
79
+ }
80
+ async create(opts) {
81
+ const res = await this.client.request("POST", "/profiles", { name: opts.name });
82
+ if (!res.ok) {
83
+ const text = await res.text().catch(() => "");
84
+ let code;
85
+ try {
86
+ const parsed = JSON.parse(text);
87
+ if (typeof parsed.code === "string")
88
+ code = parsed.code;
89
+ }
90
+ catch { }
91
+ throw new SolariError(`Solari POST /profiles failed: ${res.status} ${text}`, res.status, undefined, code);
92
+ }
93
+ return (await res.json());
94
+ }
95
+ async list() {
96
+ const res = await this.client.request("GET", "/profiles");
97
+ if (!res.ok) {
98
+ const body = await res.text().catch(() => "");
99
+ throw new SolariError(`Solari GET /profiles failed: ${res.status} ${body}`, res.status);
100
+ }
101
+ return (await res.json());
102
+ }
103
+ async delete(id) {
104
+ const res = await this.client.request("DELETE", `/profiles/${encodeURIComponent(id)}`);
105
+ if (!res.ok && res.status !== 404) {
106
+ const body = await res.text().catch(() => "");
107
+ throw new SolariError(`Solari DELETE /profiles/${id} failed: ${res.status} ${body}`, res.status);
108
+ }
109
+ }
110
+ async save(id, storageState) {
111
+ const res = await this.client.request("POST", `/profiles/${encodeURIComponent(id)}/save`, { storageState });
112
+ if (!res.ok) {
113
+ const body = await res.text().catch(() => "");
114
+ throw new SolariError(`Solari POST /profiles/${id}/save failed: ${res.status} ${body}`, res.status);
115
+ }
116
+ const data = (await res.json());
117
+ return {
118
+ version: data.version ?? 0,
119
+ sizeBytes: data.sizeBytes ?? 0,
120
+ };
121
+ }
122
+ }
123
+ class SessionsResource {
124
+ client;
125
+ constructor(client) {
126
+ this.client = client;
127
+ }
128
+ async create(options) {
129
+ const body = {};
130
+ if (options?.profileId)
131
+ body.profileId = options.profileId;
132
+ if (options?.recording)
133
+ body.recording = true;
134
+ if (options?.stealth === true)
135
+ body.stealth = true;
136
+ if (options?.captcha === true)
137
+ body.captcha = true;
138
+ if (options?.webBotAuth === true)
139
+ body.webBotAuth = true;
140
+ if (options?.proxy !== undefined)
141
+ body.proxy = options.proxy;
142
+ const res = await this.client.request("POST", "/sessions", Object.keys(body).length ? body : undefined);
143
+ if (!res.ok) {
144
+ const text = await res.text().catch(() => "");
145
+ let code;
146
+ try {
147
+ const parsed = JSON.parse(text);
148
+ if (typeof parsed?.code === "string")
149
+ code = parsed.code;
150
+ }
151
+ catch { }
152
+ throw new SolariError(`Solari POST /sessions failed: ${res.status} ${text}`, res.status, undefined, code);
153
+ }
154
+ const data = (await res.json());
155
+ if (!data.sessionId || !data.wsEndpoint) {
156
+ throw new SolariError(`Solari: unexpected session response: ${JSON.stringify(data)}`);
157
+ }
158
+ const cdpUpstream = data.cdpEndpoint ?? deriveCdpFromWs(data.wsEndpoint);
159
+ const localWsEndpoint = await this.client._wrapSessionEndpoint(data.sessionId, data.wsEndpoint, "ws");
160
+ const localCdpEndpoint = await this.client._wrapSessionEndpoint(data.sessionId, cdpUpstream, "cdp");
161
+ const expiresAt = data.expiresAt
162
+ ?? new Date(Date.now() + 60 * 60_000).toISOString();
163
+ const session = {
164
+ id: data.sessionId,
165
+ wsEndpoint: localWsEndpoint,
166
+ cdpEndpoint: localCdpEndpoint,
167
+ expiresAt,
168
+ };
169
+ if (options?.profileId !== undefined) {
170
+ if (data.storageStateUrl !== undefined) {
171
+ session.storageState = await fetchPresignedStorageState(data.storageStateUrl.url);
172
+ }
173
+ else {
174
+ session.storageState = data.storageState ?? null;
175
+ }
176
+ }
177
+ if (data.proxy) {
178
+ session.proxy = data.proxy;
179
+ }
180
+ return session;
181
+ }
182
+ /** Fire-and-forget release. Use `releaseAndWait` for confirmation. */
183
+ release(id) {
184
+ this.client._unregisterSession(id);
185
+ void this.client.request("DELETE", `/sessions/${encodeURIComponent(id)}`).then((res) => {
186
+ if (!res.ok && res.status !== 404) {
187
+ res.text().catch(() => "").then((body) => {
188
+ console.warn(`[Solari] DELETE /sessions/${id} returned ${res.status}: ${body}`);
189
+ });
190
+ }
191
+ }, (err) => {
192
+ console.warn(`[Solari] DELETE /sessions/${id} failed (fire-and-forget):`, err);
193
+ });
194
+ }
195
+ async releaseAndWait(id) {
196
+ this.client._unregisterSession(id);
197
+ const res = await this.client.request("DELETE", `/sessions/${encodeURIComponent(id)}`);
198
+ if (!res.ok && res.status !== 404) {
199
+ const body = await res.text().catch(() => "");
200
+ throw new SolariError(`Solari DELETE /sessions/${id} failed: ${res.status} ${body}`, res.status);
201
+ }
202
+ }
203
+ /** Presigned URL for the session's replay. Available ~1-3s after `releaseAndWait`. */
204
+ async getReplayUrl(id) {
205
+ const res = await this.client.request("GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
206
+ if (!res.ok) {
207
+ const body = await res.text().catch(() => "");
208
+ throw new SolariError(`Solari GET /sessions/${id}/replay-url failed: ${res.status} ${body}`, res.status);
209
+ }
210
+ const data = (await res.json());
211
+ if (!data.url) {
212
+ throw new SolariError(`Solari: unexpected replay-url response: ${JSON.stringify(data)}`);
213
+ }
214
+ return {
215
+ url: data.url,
216
+ expiresInSeconds: data.expiresInSeconds ?? 0,
217
+ contentEncoding: data.contentEncoding ?? "gzip",
218
+ };
219
+ }
220
+ /** Download the replay as NDJSON bytes. */
221
+ async downloadReplay(id) {
222
+ const { url } = await this.getReplayUrl(id);
223
+ const res = await fetch(url);
224
+ if (!res.ok) {
225
+ throw new SolariError(`Solari: replay download failed: ${res.status} ${res.statusText}`, res.status);
226
+ }
227
+ const buf = await res.arrayBuffer();
228
+ return new Uint8Array(buf);
229
+ }
230
+ }
231
+ export class Solari {
232
+ sessions;
233
+ profiles;
234
+ apiKey;
235
+ baseUrl;
236
+ maxAttempts;
237
+ backoffMs;
238
+ timeoutMs;
239
+ proxy = new LocalProxy();
240
+ constructor(opts) {
241
+ if (!opts.apiKey) {
242
+ throw new SolariError("Solari: apiKey is required");
243
+ }
244
+ this.apiKey = opts.apiKey;
245
+ const region = opts.region ?? DEFAULT_REGION;
246
+ const regionUrl = REGION_URLS[region];
247
+ if (!regionUrl) {
248
+ throw new SolariError(`Solari: unsupported region "${region}". Supported: ${Object.keys(REGION_URLS).join(", ")}.`);
249
+ }
250
+ this.baseUrl = (opts.baseUrl ?? regionUrl).replace(/\/$/, "");
251
+ this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
252
+ this.backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
253
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
254
+ this.sessions = new SessionsResource(this);
255
+ this.profiles = new ProfilesResource(this);
256
+ }
257
+ async close() {
258
+ await this.proxy.stop();
259
+ }
260
+ /**
261
+ * Create a session and return a connected browser.
262
+ *
263
+ * const browser = await solari.launch({ stealth: true });
264
+ * const page = await browser.newPage();
265
+ * await page.goto("https://example.com");
266
+ * await browser.close();
267
+ *
268
+ * Or with explicit resource management (Node 22+):
269
+ *
270
+ * await using browser = await solari.launch({ stealth: true });
271
+ */
272
+ async launch(options) {
273
+ const retries = Math.max(0, options?.retries ?? 0);
274
+ const probeEnabled = options?.probe ?? retries > 0;
275
+ const probeTimeoutMs = Math.max(1, options?.probeTimeoutMs ?? 2_000);
276
+ const totalAttempts = retries + 1;
277
+ let sessionOpts;
278
+ if (options) {
279
+ const { retries: _r, probe: _p, probeTimeoutMs: _t, ...rest } = options;
280
+ sessionOpts = rest;
281
+ }
282
+ let lastErr;
283
+ for (let attempt = 1; attempt <= totalAttempts; attempt++) {
284
+ const session = await this.sessions.create(sessionOpts);
285
+ let browser;
286
+ try {
287
+ browser = await chromium.connect(session.wsEndpoint);
288
+ if (probeEnabled) {
289
+ await this._probeBrowserHealth(browser, probeTimeoutMs);
290
+ }
291
+ return new BrowserSession(this, session, browser);
292
+ }
293
+ catch (err) {
294
+ if (browser) {
295
+ try {
296
+ await browser.close();
297
+ }
298
+ catch { }
299
+ }
300
+ try {
301
+ this.sessions.release(session.id);
302
+ }
303
+ catch { }
304
+ lastErr = err;
305
+ const isHealth = err instanceof SolariError && err.code === "BrowserUnhealthy";
306
+ const transientConnect = !isHealth && isLikelyTransientConnect(err);
307
+ const retryable = isHealth || transientConnect;
308
+ if (attempt < totalAttempts && retryable) {
309
+ await sleep(100 * attempt);
310
+ continue;
311
+ }
312
+ throw err;
313
+ }
314
+ }
315
+ throw lastErr ?? new SolariError("Solari.launch: exhausted attempts");
316
+ }
317
+ async _probeBrowserHealth(browser, timeoutMs) {
318
+ let timer;
319
+ const timeoutPromise = new Promise((_, reject) => {
320
+ timer = setTimeout(() => {
321
+ reject(new SolariError(`Solari: browser health probe timed out after ${timeoutMs}ms`, undefined, undefined, "BrowserUnhealthy"));
322
+ }, timeoutMs);
323
+ });
324
+ const probe = async () => {
325
+ const ctx = await browser.newContext();
326
+ try {
327
+ const page = await ctx.newPage();
328
+ await page.evaluate("1");
329
+ }
330
+ finally {
331
+ await ctx.close().catch(() => { });
332
+ }
333
+ };
334
+ try {
335
+ await Promise.race([probe(), timeoutPromise]);
336
+ }
337
+ catch (err) {
338
+ if (err instanceof SolariError && err.code === "BrowserUnhealthy") {
339
+ throw err;
340
+ }
341
+ throw new SolariError(`Solari: browser health probe failed: ${err instanceof Error ? err.message : String(err)}`, undefined, err, "BrowserUnhealthy");
342
+ }
343
+ finally {
344
+ if (timer)
345
+ clearTimeout(timer);
346
+ }
347
+ }
348
+ async _wrapSessionEndpoint(sessionId, upstreamEndpoint, kind = "ws") {
349
+ let upstream;
350
+ try {
351
+ upstream = new URL(upstreamEndpoint);
352
+ }
353
+ catch (e) {
354
+ throw new SolariError(`Solari: invalid upstream ${kind}Endpoint: ${upstreamEndpoint}`, undefined, e);
355
+ }
356
+ await this.proxy.start();
357
+ this.proxy.register(sessionId, upstream, kind);
358
+ return this.proxy.getLocalEndpoint(sessionId, kind);
359
+ }
360
+ _unregisterSession(sessionId) {
361
+ this.proxy.unregister(sessionId);
362
+ }
363
+ async request(method, path, body) {
364
+ const url = `${this.baseUrl}${path}`;
365
+ const headers = {
366
+ Authorization: `Bearer ${this.apiKey}`,
367
+ "Content-Type": "application/json",
368
+ };
369
+ const payload = body === undefined ? undefined : JSON.stringify(body);
370
+ let lastErr;
371
+ for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
372
+ const ac = new AbortController();
373
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
374
+ try {
375
+ const res = await fetch(url, {
376
+ method,
377
+ headers,
378
+ body: payload,
379
+ signal: ac.signal,
380
+ });
381
+ clearTimeout(timer);
382
+ if (res.ok)
383
+ return res;
384
+ if (!isRetryableStatus(res.status))
385
+ return res;
386
+ await res.text().catch(() => { });
387
+ lastErr = new SolariError(`Solari ${method} ${path}: ${res.status}`, res.status);
388
+ }
389
+ catch (e) {
390
+ clearTimeout(timer);
391
+ if (!isRetryableError(e))
392
+ throw e;
393
+ lastErr = e;
394
+ }
395
+ if (attempt < this.maxAttempts) {
396
+ await sleep(this.backoffMs);
397
+ }
398
+ }
399
+ throw new SolariError(`Solari ${method} ${path}: exhausted ${this.maxAttempts} attempts`, undefined, lastErr);
400
+ }
401
+ }
@@ -0,0 +1,14 @@
1
+ export type ProxyKind = "ws" | "cdp";
2
+ export declare class LocalProxy {
3
+ private server;
4
+ private localPort;
5
+ private starting;
6
+ private readonly sessions;
7
+ start(): Promise<void>;
8
+ stop(): Promise<void>;
9
+ register(sessionId: string, upstream: URL, kind?: ProxyKind): void;
10
+ unregister(sessionId: string): void;
11
+ getLocalEndpoint(sessionId: string, kind?: ProxyKind): string;
12
+ private handleUpgrade;
13
+ private connectAndPipe;
14
+ }
@@ -0,0 +1,173 @@
1
+ import http from "node:http";
2
+ import net from "node:net";
3
+ import tls from "node:tls";
4
+ const MAX_UPSTREAM_ATTEMPTS = 3;
5
+ const UPSTREAM_BACKOFF_MS = 50;
6
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 2_000;
7
+ export class LocalProxy {
8
+ server = null;
9
+ localPort = null;
10
+ starting = null;
11
+ sessions = new Map();
12
+ async start() {
13
+ if (this.server)
14
+ return;
15
+ if (this.starting)
16
+ return this.starting;
17
+ this.starting = new Promise((resolve, reject) => {
18
+ const server = http.createServer();
19
+ server.on("upgrade", (req, clientSocket, head) => {
20
+ this.handleUpgrade(req, clientSocket, head);
21
+ });
22
+ server.once("error", reject);
23
+ server.listen(0, "127.0.0.1", () => {
24
+ const addr = server.address();
25
+ this.server = server;
26
+ this.localPort = addr.port;
27
+ server.off("error", reject);
28
+ server.on("error", (err) => {
29
+ console.error("[solari-sdk:local-proxy] server error:", err);
30
+ });
31
+ resolve();
32
+ });
33
+ });
34
+ return this.starting;
35
+ }
36
+ async stop() {
37
+ if (!this.server)
38
+ return;
39
+ const server = this.server;
40
+ this.server = null;
41
+ this.localPort = null;
42
+ this.sessions.clear();
43
+ await new Promise((r) => server.close(() => r()));
44
+ }
45
+ register(sessionId, upstream, kind = "ws") {
46
+ this.sessions.set(`${kind}:${sessionId}`, upstream);
47
+ }
48
+ unregister(sessionId) {
49
+ this.sessions.delete(`ws:${sessionId}`);
50
+ this.sessions.delete(`cdp:${sessionId}`);
51
+ }
52
+ getLocalEndpoint(sessionId, kind = "ws") {
53
+ if (!this.localPort)
54
+ throw new Error("LocalProxy not started");
55
+ return `ws://127.0.0.1:${this.localPort}/${kind}/${encodeURIComponent(sessionId)}`;
56
+ }
57
+ handleUpgrade(req, clientSocket, head) {
58
+ const denyClient = (status, reason) => {
59
+ try {
60
+ clientSocket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
61
+ }
62
+ catch { }
63
+ clientSocket.destroy();
64
+ };
65
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
66
+ const match = url.pathname.match(/^\/(ws|cdp)\/([^/]+)$/);
67
+ if (!match)
68
+ return denyClient(404, "Not Found");
69
+ const kind = match[1];
70
+ const sessionId = decodeURIComponent(match[2]);
71
+ const upstream = this.sessions.get(`${kind}:${sessionId}`);
72
+ if (!upstream)
73
+ return denyClient(404, "Session Not Found");
74
+ clientSocket.pause();
75
+ void this.connectAndPipe(req, clientSocket, head, upstream, sessionId).catch((err) => {
76
+ console.error(`[solari-sdk:local-proxy] upstream failed for ${sessionId.slice(0, 8)}:`, err?.message ?? err);
77
+ denyClient(502, "Bad Gateway");
78
+ });
79
+ }
80
+ async connectAndPipe(req, clientSocket, head, upstream, sessionId) {
81
+ const upstreamHost = upstream.hostname;
82
+ const upstreamPort = Number(upstream.port || (upstream.protocol === "wss:" ? 443 : 80));
83
+ const useTls = upstream.protocol === "wss:";
84
+ let upstreamSocket = null;
85
+ let lastErr;
86
+ for (let attempt = 1; attempt <= MAX_UPSTREAM_ATTEMPTS; attempt++) {
87
+ try {
88
+ upstreamSocket = await tcpConnect(upstreamHost, upstreamPort, UPSTREAM_CONNECT_TIMEOUT_MS, useTls);
89
+ if (attempt > 1) {
90
+ console.error(`[solari-sdk:local-proxy] upstream recovered for ${sessionId.slice(0, 8)} on attempt ${attempt}`);
91
+ }
92
+ break;
93
+ }
94
+ catch (e) {
95
+ lastErr = e;
96
+ if (attempt < MAX_UPSTREAM_ATTEMPTS) {
97
+ await sleep(UPSTREAM_BACKOFF_MS);
98
+ }
99
+ }
100
+ }
101
+ if (!upstreamSocket) {
102
+ throw lastErr ?? new Error("upstream connect: exhausted retries");
103
+ }
104
+ try {
105
+ upstreamSocket.setNoDelay(true);
106
+ }
107
+ catch { }
108
+ try {
109
+ clientSocket.setNoDelay(true);
110
+ }
111
+ catch { }
112
+ const upstreamPath = (upstream.pathname || "/") + (upstream.search || "");
113
+ const headerLines = [`GET ${upstreamPath} HTTP/1.1`];
114
+ headerLines.push(`Host: ${upstreamHost}:${upstreamPort}`);
115
+ for (const [k, v] of Object.entries(req.headers)) {
116
+ if (v === undefined)
117
+ continue;
118
+ if (k.toLowerCase() === "host")
119
+ continue;
120
+ if (Array.isArray(v)) {
121
+ for (const vv of v)
122
+ headerLines.push(`${k}: ${vv}`);
123
+ }
124
+ else {
125
+ headerLines.push(`${k}: ${v}`);
126
+ }
127
+ }
128
+ headerLines.push("", "");
129
+ upstreamSocket.write(headerLines.join("\r\n"));
130
+ if (head && head.length)
131
+ upstreamSocket.write(head);
132
+ const cleanup = () => {
133
+ try {
134
+ upstreamSocket.destroy();
135
+ }
136
+ catch { }
137
+ try {
138
+ clientSocket.destroy();
139
+ }
140
+ catch { }
141
+ };
142
+ upstreamSocket.once("error", cleanup);
143
+ clientSocket.once("error", cleanup);
144
+ upstreamSocket.once("close", () => clientSocket.destroy());
145
+ clientSocket.once("close", () => upstreamSocket.destroy());
146
+ upstreamSocket.pipe(clientSocket);
147
+ clientSocket.pipe(upstreamSocket);
148
+ clientSocket.resume();
149
+ }
150
+ }
151
+ function tcpConnect(host, port, timeoutMs, useTls = false) {
152
+ return new Promise((resolve, reject) => {
153
+ let settled = false;
154
+ const done = (err, socket) => {
155
+ if (settled)
156
+ return;
157
+ settled = true;
158
+ clearTimeout(timer);
159
+ if (err || !socket)
160
+ reject(err ?? new Error("no socket"));
161
+ else
162
+ resolve(socket);
163
+ };
164
+ const socket = useTls
165
+ ? tls.connect({ host, port, servername: host }, () => done(null, socket))
166
+ : net.connect(port, host, () => done(null, socket));
167
+ const timer = setTimeout(() => { socket.destroy(); done(new Error(`connect timeout ${timeoutMs}ms to ${host}:${port}`)); }, timeoutMs);
168
+ socket.once("error", (err) => { done(err); });
169
+ });
170
+ }
171
+ function sleep(ms) {
172
+ return new Promise((r) => setTimeout(r, ms));
173
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@solarisdk/browser",
3
+ "version": "0.1.0",
4
+ "description": "Official client SDK for Solari Browser.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "browser",
22
+ "automation",
23
+ "playwright",
24
+ "chromium",
25
+ "cdp",
26
+ "headless",
27
+ "stealth",
28
+ "scraping",
29
+ "solari"
30
+ ],
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "prepublishOnly": "node ../scripts/check-sdk-version-pin.mjs && tsc"
40
+ },
41
+ "dependencies": {
42
+ "patchright-core": "1.59.3"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^20.0.0",
46
+ "typescript": "^5.4.0"
47
+ }
48
+ }