@solarisdk/browser 0.1.4 → 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.
package/dist/index.d.ts CHANGED
@@ -131,6 +131,17 @@ export interface LaunchOptions extends CreateSessionOptions {
131
131
  probeTimeoutMs?: number;
132
132
  }
133
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
+ }
134
145
  export declare class SolariError extends Error {
135
146
  readonly status?: number | undefined;
136
147
  readonly cause?: unknown | undefined;
@@ -150,6 +161,14 @@ declare class ProfilesResource {
150
161
  sizeBytes: number;
151
162
  }>;
152
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
+ }
153
172
  declare class SessionsResource {
154
173
  private readonly client;
155
174
  constructor(client: Solari);
@@ -165,12 +184,13 @@ declare class SessionsResource {
165
184
  export declare class Solari {
166
185
  readonly sessions: SessionsResource;
167
186
  readonly profiles: ProfilesResource;
187
+ readonly proxy: ProxyResource;
168
188
  private readonly apiKey;
169
189
  private readonly baseUrl;
170
190
  private readonly maxAttempts;
171
191
  private readonly backoffMs;
172
192
  private readonly timeoutMs;
173
- private readonly proxy;
193
+ private readonly localProxy;
174
194
  constructor(opts: SolariOptions);
175
195
  close(): Promise<void>;
176
196
  /**
@@ -189,5 +209,7 @@ export declare class Solari {
189
209
  private _probeBrowserHealth;
190
210
  _wrapSessionEndpoint(sessionId: string, upstreamEndpoint: string, kind?: ProxyKind): Promise<string>;
191
211
  _unregisterSession(sessionId: string): void;
192
- request(method: string, path: string, body?: unknown): Promise<Response>;
212
+ request(method: string, path: string, body?: unknown, opts?: {
213
+ idempotencyKey?: string;
214
+ }): Promise<Response>;
193
215
  }
package/dist/index.js CHANGED
@@ -54,8 +54,60 @@ const DEFAULT_REGION = "us-west";
54
54
  const DEFAULT_MAX_ATTEMPTS = 2;
55
55
  const DEFAULT_BACKOFF_MS = 500;
56
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.
57
63
  function isRetryableStatus(status) {
58
- 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
+ }
59
111
  }
60
112
  function isRetryableError(_err) {
61
113
  return true;
@@ -186,6 +238,23 @@ async function releaseRejection(res) {
186
238
  return null;
187
239
  return { body, code };
188
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
+ }
189
258
  class SessionsResource {
190
259
  client;
191
260
  constructor(client) {
@@ -203,7 +272,10 @@ class SessionsResource {
203
272
  body.captcha = true;
204
273
  if (options?.proxy !== undefined)
205
274
  body.proxy = options.proxy;
206
- 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() });
207
279
  if (!res.ok) {
208
280
  const text = await res.text().catch(() => "");
209
281
  let code;
@@ -296,12 +368,13 @@ class SessionsResource {
296
368
  export class Solari {
297
369
  sessions;
298
370
  profiles;
371
+ proxy;
299
372
  apiKey;
300
373
  baseUrl;
301
374
  maxAttempts;
302
375
  backoffMs;
303
376
  timeoutMs;
304
- proxy = new LocalProxy();
377
+ localProxy = new LocalProxy();
305
378
  constructor(opts) {
306
379
  if (!opts.apiKey) {
307
380
  throw new SolariError("Solari: apiKey is required");
@@ -318,9 +391,10 @@ export class Solari {
318
391
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
319
392
  this.sessions = new SessionsResource(this);
320
393
  this.profiles = new ProfilesResource(this);
394
+ this.proxy = new ProxyResource(this);
321
395
  }
322
396
  async close() {
323
- await this.proxy.stop();
397
+ await this.localProxy.stop();
324
398
  }
325
399
  /**
326
400
  * Create a session and return a connected browser.
@@ -443,18 +517,23 @@ export class Solari {
443
517
  catch (e) {
444
518
  throw new SolariError(`Solari: invalid upstream ${kind}Endpoint: ${upstreamEndpoint}`, undefined, e);
445
519
  }
446
- await this.proxy.start();
447
- this.proxy.register(sessionId, upstream, kind);
448
- 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);
449
523
  }
450
524
  _unregisterSession(sessionId) {
451
- this.proxy.unregister(sessionId);
525
+ this.localProxy.unregister(sessionId);
452
526
  }
453
- async request(method, path, body) {
527
+ async request(method, path, body, opts) {
454
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.
455
531
  const headers = {
456
532
  Authorization: `Bearer ${this.apiKey}`,
457
533
  "Content-Type": "application/json",
534
+ ...(opts?.idempotencyKey
535
+ ? { "Idempotency-Key": opts.idempotencyKey }
536
+ : {}),
458
537
  };
459
538
  const payload = body === undefined ? undefined : JSON.stringify(body);
460
539
  let lastErr;
@@ -471,7 +550,10 @@ export class Solari {
471
550
  clearTimeout(timer);
472
551
  if (res.ok)
473
552
  return res;
474
- if (!isRetryableStatus(res.status))
553
+ const retryThis = isRetryableStatus(res.status) ||
554
+ (isSafeToReplay(method, opts?.idempotencyKey) &&
555
+ (await responseSaysRetryable(res)));
556
+ if (!retryThis)
475
557
  return res;
476
558
  await res.text().catch(() => { });
477
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.4",
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": {