@solarisdk/browser 0.1.3 → 0.1.5

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.
@@ -5,8 +5,14 @@ export declare class BrowserSession {
5
5
  private readonly client;
6
6
  readonly session: Session;
7
7
  private readonly browser;
8
+ /** The session's primary context — seeded with the attached profile's
9
+ * storageState when there is one. See {@link context}. */
10
+ private readonly primaryContext?;
8
11
  private closed;
9
- constructor(client: Solari, session: Session, browser: Browser);
12
+ constructor(client: Solari, session: Session, browser: Browser,
13
+ /** The session's primary context — seeded with the attached profile's
14
+ * storageState when there is one. See {@link context}. */
15
+ primaryContext?: BrowserContext | undefined);
10
16
  /** Session id. */
11
17
  get id(): string;
12
18
  /** Plan-tier deadline (ISO 8601 UTC); session auto-releases at this point. */
@@ -23,11 +29,28 @@ export declare class BrowserSession {
23
29
  isConnected(): boolean;
24
30
  /** Browser version string (e.g. `"Chromium/130.0.6723.31"`). */
25
31
  version(): string;
26
- /** All open contexts. Sessions ship with a default context at `contexts()[0]`. */
32
+ /**
33
+ * The session's primary context — the one carrying the attached profile's
34
+ * cookies and localStorage. Use this (or {@link newPage}) rather than
35
+ * `contexts()[0]`, which is not guaranteed to be the seeded one.
36
+ */
37
+ get context(): BrowserContext | undefined;
38
+ /**
39
+ * All open contexts, straight from the browser.
40
+ *
41
+ * NOTE: this is NOT guaranteed to be non-empty, and `contexts()[0]` is NOT
42
+ * guaranteed to be the profile-seeded context — the pool only pre-creates an
43
+ * eager context when a proxy was requested. Prefer {@link context}.
44
+ */
27
45
  contexts(): BrowserContext[];
28
- /** Open a fresh browser context. Most callers want `contexts()[0]` instead. */
46
+ /** Open an ADDITIONAL, empty browser context. This does NOT carry the
47
+ * attached profile — use {@link context} for that. */
29
48
  newContext(options?: BrowserContextOptions): Promise<BrowserContext>;
30
- /** Open a fresh page in a new context. */
49
+ /**
50
+ * Open a page in the session's primary context, so an attached profile's
51
+ * cookies/localStorage are actually present. Falls back to a browser-level
52
+ * page only when there is no primary context.
53
+ */
31
54
  newPage(): Promise<Page>;
32
55
  /** Close the browser and release the session. Idempotent. */
33
56
  close(): Promise<void>;
@@ -2,11 +2,16 @@ export class BrowserSession {
2
2
  client;
3
3
  session;
4
4
  browser;
5
+ primaryContext;
5
6
  closed = false;
6
- constructor(client, session, browser) {
7
+ constructor(client, session, browser,
8
+ /** The session's primary context — seeded with the attached profile's
9
+ * storageState when there is one. See {@link context}. */
10
+ primaryContext) {
7
11
  this.client = client;
8
12
  this.session = session;
9
13
  this.browser = browser;
14
+ this.primaryContext = primaryContext;
10
15
  }
11
16
  /** Session id. */
12
17
  get id() {
@@ -40,16 +45,37 @@ export class BrowserSession {
40
45
  version() {
41
46
  return this.browser.version();
42
47
  }
43
- /** All open contexts. Sessions ship with a default context at `contexts()[0]`. */
48
+ /**
49
+ * The session's primary context — the one carrying the attached profile's
50
+ * cookies and localStorage. Use this (or {@link newPage}) rather than
51
+ * `contexts()[0]`, which is not guaranteed to be the seeded one.
52
+ */
53
+ get context() {
54
+ return this.primaryContext;
55
+ }
56
+ /**
57
+ * All open contexts, straight from the browser.
58
+ *
59
+ * NOTE: this is NOT guaranteed to be non-empty, and `contexts()[0]` is NOT
60
+ * guaranteed to be the profile-seeded context — the pool only pre-creates an
61
+ * eager context when a proxy was requested. Prefer {@link context}.
62
+ */
44
63
  contexts() {
45
64
  return this.browser.contexts();
46
65
  }
47
- /** Open a fresh browser context. Most callers want `contexts()[0]` instead. */
66
+ /** Open an ADDITIONAL, empty browser context. This does NOT carry the
67
+ * attached profile — use {@link context} for that. */
48
68
  async newContext(options) {
49
69
  return this.browser.newContext(options);
50
70
  }
51
- /** Open a fresh page in a new context. */
71
+ /**
72
+ * Open a page in the session's primary context, so an attached profile's
73
+ * cookies/localStorage are actually present. Falls back to a browser-level
74
+ * page only when there is no primary context.
75
+ */
52
76
  async newPage() {
77
+ if (this.primaryContext)
78
+ return this.primaryContext.newPage();
53
79
  return this.browser.newPage();
54
80
  }
55
81
  /** Close the browser and release the session. Idempotent. */
package/dist/index.d.ts CHANGED
@@ -103,11 +103,6 @@ export interface CreateSessionOptions {
103
103
  stealth?: boolean;
104
104
  /** Enable managed captcha solving. Requires `stealth: true`. */
105
105
  captcha?: boolean;
106
- /** Opt in to Cloudflare Web Bot Auth — sign every outbound HTTP
107
- * request with an Ed25519 key registered to our verified bot
108
- * directory. Independent of `stealth`. Silently inert if the
109
- * acquired slot has no signing key configured. */
110
- webBotAuth?: boolean;
111
106
  /** Request managed proxy egress. Requires `stealth: true`.
112
107
  *
113
108
  * proxy: "us"
@@ -136,6 +131,17 @@ export interface LaunchOptions extends CreateSessionOptions {
136
131
  probeTimeoutMs?: number;
137
132
  }
138
133
  export type SolariErrorCode = "FeatureRequiresPlan" | "ConcurrencyLimitExceeded" | "PlanLimitExceeded" | "BrowserUnhealthy" | "InvalidSessionId";
134
+ /** Supported egress countries and residential-proxy status, from
135
+ * {@link Solari.proxy}.countries() (`GET /proxy/countries`). */
136
+ export interface ProxyCountries {
137
+ /** Whether RESIDENTIAL managed-proxy credentials are configured on the
138
+ * gateway. Reflects only the residential tier — the static/mobile tiers
139
+ * have separate credentials this flag does not report, so `true` is not a
140
+ * guarantee that `tier: "mobile"` will resolve. */
141
+ enabled: boolean;
142
+ /** Supported ISO-3166-1 alpha-2 egress countries (lowercase, sorted). */
143
+ countries: string[];
144
+ }
139
145
  export declare class SolariError extends Error {
140
146
  readonly status?: number | undefined;
141
147
  readonly cause?: unknown | undefined;
@@ -155,6 +161,14 @@ declare class ProfilesResource {
155
161
  sizeBytes: number;
156
162
  }>;
157
163
  }
164
+ declare class ProxyResource {
165
+ private readonly client;
166
+ constructor(client: Solari);
167
+ /** `GET /proxy/countries` — the egress countries this gateway supports and
168
+ * whether residential proxy credentials are configured. Matches the Go/Rust
169
+ * `proxy.countries()` and C++ `proxyCountries()`. */
170
+ countries(): Promise<ProxyCountries>;
171
+ }
158
172
  declare class SessionsResource {
159
173
  private readonly client;
160
174
  constructor(client: Solari);
@@ -170,12 +184,13 @@ declare class SessionsResource {
170
184
  export declare class Solari {
171
185
  readonly sessions: SessionsResource;
172
186
  readonly profiles: ProfilesResource;
187
+ readonly proxy: ProxyResource;
173
188
  private readonly apiKey;
174
189
  private readonly baseUrl;
175
190
  private readonly maxAttempts;
176
191
  private readonly backoffMs;
177
192
  private readonly timeoutMs;
178
- private readonly proxy;
193
+ private readonly localProxy;
179
194
  constructor(opts: SolariOptions);
180
195
  close(): Promise<void>;
181
196
  /**
@@ -194,5 +209,7 @@ export declare class Solari {
194
209
  private _probeBrowserHealth;
195
210
  _wrapSessionEndpoint(sessionId: string, upstreamEndpoint: string, kind?: ProxyKind): Promise<string>;
196
211
  _unregisterSession(sessionId: string): void;
197
- request(method: string, path: string, body?: unknown): Promise<Response>;
212
+ request(method: string, path: string, body?: unknown, opts?: {
213
+ idempotencyKey?: string;
214
+ }): Promise<Response>;
198
215
  }
package/dist/index.js CHANGED
@@ -2,6 +2,39 @@ import { chromium } from "patchright-core";
2
2
  import { LocalProxy } from "./local-proxy.js";
3
3
  import { BrowserSession } from "./browser-session.js";
4
4
  export { BrowserSession } from "./browser-session.js";
5
+ /**
6
+ * Narrow a {@link StorageState} to the shape `newContext({storageState})` wants.
7
+ *
8
+ * Our StorageState is deliberately permissive — every field optional, plus an
9
+ * index signature — because it doubles as the INPUT to `profiles.save()`, where
10
+ * callers hand-build partial objects. Playwright's parameter type requires the
11
+ * cookie fields to be present, so passing ours straight through is a TS2322 and
12
+ * callers were resorting to `as any`.
13
+ *
14
+ * Rather than tighten the public type (which would break anyone constructing a
15
+ * partial state to save), fill the defaults at this one seam. Values that are
16
+ * actually present always win; the defaults only cover fields the gateway
17
+ * omitted, and they match Playwright's own defaults for a host-only cookie.
18
+ */
19
+ function toPlaywrightStorageState(state) {
20
+ return {
21
+ cookies: (state.cookies ?? []).map((c) => ({
22
+ name: c.name,
23
+ value: c.value,
24
+ domain: c.domain ?? "",
25
+ path: c.path ?? "/",
26
+ // -1 is Playwright's "session cookie" sentinel.
27
+ expires: c.expires ?? -1,
28
+ httpOnly: c.httpOnly ?? false,
29
+ secure: c.secure ?? false,
30
+ sameSite: c.sameSite ?? "Lax",
31
+ })),
32
+ origins: (state.origins ?? []).map((o) => ({
33
+ origin: o.origin,
34
+ localStorage: o.localStorage ?? [],
35
+ })),
36
+ };
37
+ }
5
38
  export class SolariError extends Error {
6
39
  status;
7
40
  cause;
@@ -21,8 +54,60 @@ const DEFAULT_REGION = "us-west";
21
54
  const DEFAULT_MAX_ATTEMPTS = 2;
22
55
  const DEFAULT_BACKOFF_MS = 500;
23
56
  const DEFAULT_TIMEOUT_MS = 90_000;
57
+ // 507 is in the list although THIS gateway does not emit one (censused
58
+ // 2026-09-22: browser emits 501/502/503 only). It is here so the client is
59
+ // right on its own terms: the desktop gateway's InsufficientCapacity 507 is
60
+ // transient, and a client's correctness must not depend on which gateway
61
+ // build it reaches. Inert today, and deliberately so — do not delete it as
62
+ // dead code.
24
63
  function isRetryableStatus(status) {
25
- return status === 502 || status === 503 || status === 504;
64
+ return status === 502 || status === 503 || status === 504 || status === 507;
65
+ }
66
+ // Methods that are safe to send twice. The gateway issues no Idempotency-Key
67
+ // for the browser API, so the `retryable` hint is honoured ONLY here: a
68
+ // re-sent POST /sessions could leave a second live session behind.
69
+ function isIdempotentMethod(method) {
70
+ const m = method.toUpperCase();
71
+ return m === "GET" || m === "HEAD" || m === "DELETE" || m === "PUT";
72
+ }
73
+ // Is THIS REQUEST safe to send again? Deliberately a per-request question, not
74
+ // a per-method one. A POST is not idempotent by verb, but a POST carrying an
75
+ // Idempotency-Key is safe to replay because the server answers the second copy
76
+ // from the first one's result instead of creating twice.
77
+ //
78
+ // This is what the old comment here was waiting for. It read: "The browser API
79
+ // issues no Idempotency-Key, so the `retryable` hint is honoured ONLY for
80
+ // idempotent methods: a re-sent POST /sessions could leave a second live
81
+ // session behind." That reason is being removed, not worked around.
82
+ function isSafeToReplay(method, idempotencyKey) {
83
+ return isIdempotentMethod(method) || idempotencyKey !== undefined;
84
+ }
85
+ // A key must identify the CALL, not the attempt: minted once by the caller and
86
+ // reused by every retry of that call. Minting per attempt would make each retry
87
+ // a fresh create and defeat the whole mechanism.
88
+ function newIdempotencyKey() {
89
+ const c = typeof globalThis.crypto !== "undefined" ? globalThis.crypto : undefined;
90
+ if (c?.randomUUID)
91
+ return c.randomUUID();
92
+ // Pre-19 Node / older browsers: not a v4 UUID, just unique enough to key on.
93
+ return `slr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
94
+ }
95
+ // The gateway can mark a response retryable on a status that is NOT in the
96
+ // 5xx allowlist — today `404 ReplayPending`, where the recording upload is
97
+ // still in flight. Honour that hint rather than flattening it into a
98
+ // terminal error.
99
+ //
100
+ // The body is read from a CLONE: the caller still owns the original and
101
+ // parses `code` out of it, and a Response body can only be consumed once.
102
+ async function responseSaysRetryable(res) {
103
+ try {
104
+ const body = (await res.clone().json());
105
+ return body?.retryable === true;
106
+ }
107
+ catch {
108
+ // Non-JSON or already-consumed body: no hint, not retryable.
109
+ return false;
110
+ }
26
111
  }
27
112
  function isRetryableError(_err) {
28
113
  return true;
@@ -153,6 +238,23 @@ async function releaseRejection(res) {
153
238
  return null;
154
239
  return { body, code };
155
240
  }
241
+ class ProxyResource {
242
+ client;
243
+ constructor(client) {
244
+ this.client = client;
245
+ }
246
+ /** `GET /proxy/countries` — the egress countries this gateway supports and
247
+ * whether residential proxy credentials are configured. Matches the Go/Rust
248
+ * `proxy.countries()` and C++ `proxyCountries()`. */
249
+ async countries() {
250
+ const res = await this.client.request("GET", "/proxy/countries");
251
+ if (!res.ok) {
252
+ const body = await res.text().catch(() => "");
253
+ throw new SolariError(`Solari GET /proxy/countries failed: ${res.status} ${body}`, res.status);
254
+ }
255
+ return (await res.json());
256
+ }
257
+ }
156
258
  class SessionsResource {
157
259
  client;
158
260
  constructor(client) {
@@ -168,11 +270,12 @@ class SessionsResource {
168
270
  body.stealth = true;
169
271
  if (options?.captcha === true)
170
272
  body.captcha = true;
171
- if (options?.webBotAuth === true)
172
- body.webBotAuth = true;
173
273
  if (options?.proxy !== undefined)
174
274
  body.proxy = options.proxy;
175
- const res = await this.client.request("POST", "/sessions", Object.keys(body).length ? body : undefined);
275
+ // A create allocates a slot and launches a browser before it can answer,
276
+ // so a response lost after that point makes a retry a SECOND session.
277
+ // The key lets the gateway answer the retry from the first result.
278
+ const res = await this.client.request("POST", "/sessions", Object.keys(body).length ? body : undefined, { idempotencyKey: newIdempotencyKey() });
176
279
  if (!res.ok) {
177
280
  const text = await res.text().catch(() => "");
178
281
  let code;
@@ -265,12 +368,13 @@ class SessionsResource {
265
368
  export class Solari {
266
369
  sessions;
267
370
  profiles;
371
+ proxy;
268
372
  apiKey;
269
373
  baseUrl;
270
374
  maxAttempts;
271
375
  backoffMs;
272
376
  timeoutMs;
273
- proxy = new LocalProxy();
377
+ localProxy = new LocalProxy();
274
378
  constructor(opts) {
275
379
  if (!opts.apiKey) {
276
380
  throw new SolariError("Solari: apiKey is required");
@@ -287,9 +391,10 @@ export class Solari {
287
391
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
288
392
  this.sessions = new SessionsResource(this);
289
393
  this.profiles = new ProfilesResource(this);
394
+ this.proxy = new ProxyResource(this);
290
395
  }
291
396
  async close() {
292
- await this.proxy.stop();
397
+ await this.localProxy.stop();
293
398
  }
294
399
  /**
295
400
  * Create a session and return a connected browser.
@@ -322,7 +427,32 @@ export class Solari {
322
427
  if (probeEnabled) {
323
428
  await this._probeBrowserHealth(browser, probeTimeoutMs);
324
429
  }
325
- return new BrowserSession(this, session, browser);
430
+ // Materialise ONE primary context for the session.
431
+ //
432
+ // The gateway already fetches an attached profile's cookies +
433
+ // origins into session.storageState (and its own comment says it
434
+ // does so "so the SDK skips the newContext({storageState}) seed")
435
+ // — but the SDK never performed that seed, so every run wrote a
436
+ // new profile version and read none back. Seed it here.
437
+ //
438
+ // Also makes a context reliably available: the pool only
439
+ // pre-creates one when a proxy was requested, so on a plain
440
+ // launch() browser.contexts() was empty and contexts()[0]
441
+ // undefined — which the SDK's own docs pointed callers at.
442
+ const ctxOptions = {};
443
+ const tz = session.proxy?.timezoneId;
444
+ if (tz)
445
+ ctxOptions.timezoneId = tz;
446
+ if (session.storageState) {
447
+ ctxOptions.storageState = toPlaywrightStorageState(session.storageState);
448
+ }
449
+ const primary =
450
+ // Reuse the pool's eager context only when we have nothing
451
+ // to seed — storageState can only be applied at creation.
452
+ !ctxOptions.storageState && browser.contexts().length > 0
453
+ ? browser.contexts()[0]
454
+ : await browser.newContext(ctxOptions);
455
+ return new BrowserSession(this, session, browser, primary);
326
456
  }
327
457
  catch (err) {
328
458
  if (browser) {
@@ -387,18 +517,23 @@ export class Solari {
387
517
  catch (e) {
388
518
  throw new SolariError(`Solari: invalid upstream ${kind}Endpoint: ${upstreamEndpoint}`, undefined, e);
389
519
  }
390
- await this.proxy.start();
391
- this.proxy.register(sessionId, upstream, kind);
392
- return this.proxy.getLocalEndpoint(sessionId, kind);
520
+ await this.localProxy.start();
521
+ this.localProxy.register(sessionId, upstream, kind);
522
+ return this.localProxy.getLocalEndpoint(sessionId, kind);
393
523
  }
394
524
  _unregisterSession(sessionId) {
395
- this.proxy.unregister(sessionId);
525
+ this.localProxy.unregister(sessionId);
396
526
  }
397
- async request(method, path, body) {
527
+ async request(method, path, body, opts) {
398
528
  const url = `${this.baseUrl}${path}`;
529
+ // Built ONCE, outside the retry loop, so every attempt of this call
530
+ // carries the same key. See newIdempotencyKey.
399
531
  const headers = {
400
532
  Authorization: `Bearer ${this.apiKey}`,
401
533
  "Content-Type": "application/json",
534
+ ...(opts?.idempotencyKey
535
+ ? { "Idempotency-Key": opts.idempotencyKey }
536
+ : {}),
402
537
  };
403
538
  const payload = body === undefined ? undefined : JSON.stringify(body);
404
539
  let lastErr;
@@ -415,7 +550,10 @@ export class Solari {
415
550
  clearTimeout(timer);
416
551
  if (res.ok)
417
552
  return res;
418
- if (!isRetryableStatus(res.status))
553
+ const retryThis = isRetryableStatus(res.status) ||
554
+ (isSafeToReplay(method, opts?.idempotencyKey) &&
555
+ (await responseSaysRetryable(res)));
556
+ if (!retryThis)
419
557
  return res;
420
558
  await res.text().catch(() => { });
421
559
  lastErr = new SolariError(`Solari ${method} ${path}: ${res.status}`, res.status);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/browser",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Official client SDK for Solari Browser.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,6 +36,8 @@
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc",
39
+ "typecheck": "node ../scripts/typecheck-guard.mjs 2 -- tsc --noEmit",
40
+ "test": "tsc && node --test test/*.test.mjs",
39
41
  "prepublishOnly": "node ../scripts/check-sdk-version-pin.mjs && tsc"
40
42
  },
41
43
  "dependencies": {