@solarisdk/browser 0.1.4 → 0.1.6

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.
@@ -0,0 +1,66 @@
1
+ export interface CaptchaFailure {
2
+ code: string;
3
+ message: string;
4
+ retryable: boolean;
5
+ nextAction: "retry_fresh_challenge" | "manual" | "stop";
6
+ vendorErrorCode?: string;
7
+ }
8
+ export interface CaptchaStatus {
9
+ id: string;
10
+ provider: string;
11
+ vendor: string;
12
+ url: string;
13
+ stage: "detected" | "solving" | "vendor_solved" | "applied" | "failed" | "cancelled";
14
+ startedAt: number;
15
+ updatedAt: number;
16
+ durationMs: number;
17
+ websiteVerification: "unknown" | "accepted" | "rejected" | "timed_out";
18
+ verificationSource?: "client";
19
+ error?: CaptchaFailure;
20
+ }
21
+ export declare class CaptchaError extends Error {
22
+ readonly failure: CaptchaFailure;
23
+ readonly status?: CaptchaStatus | undefined;
24
+ constructor(failure: CaptchaFailure, status?: CaptchaStatus | undefined);
25
+ }
26
+ interface CaptchaTransport {
27
+ send(method: string, params?: Record<string, unknown>): Promise<any>;
28
+ on(event: string, listener: (status: CaptchaStatus) => void): unknown;
29
+ off(event: string, listener: (status: CaptchaStatus) => void): unknown;
30
+ }
31
+ /** Session outcome polling or an existing raw-CDP transport. No token is exposed. */
32
+ export declare class CaptchaObserver {
33
+ private readonly transport;
34
+ private readonly disconnect;
35
+ private readonly history;
36
+ private readonly listeners;
37
+ private closed;
38
+ private readonly waits;
39
+ private readonly receive;
40
+ private constructor();
41
+ /** Poll over the public session API, without creating another browser
42
+ * connection. Works for both Playwright wire and raw-CDP task clients. */
43
+ static forSession(client: {
44
+ request(method: string, path: string, body?: unknown): Promise<Response>;
45
+ }, sessionId: string): Promise<CaptchaObserver>;
46
+ /** Also accepts an existing raw-CDP transport; useful for other agents. */
47
+ static attach(transport: CaptchaTransport, disconnect?: () => Promise<void>): Promise<CaptchaObserver>;
48
+ onStatus(listener: (status: CaptchaStatus) => void): () => void;
49
+ statuses(): Promise<CaptchaStatus[]>;
50
+ reportVerification(attemptId: string, accepted: boolean): Promise<boolean>;
51
+ /** Resolves only after the caller's website-specific verifier returns true.
52
+ * Await this before resuming the agent. Manual recovery runs at most once
53
+ * per attempt and shares the same deadline; it never refreshes/re-bills
54
+ * automatically. A rejected/expired token requires a fresh challenge. */
55
+ waitForVerification(options: {
56
+ verify: () => Promise<boolean>;
57
+ /** Scope multi-widget/tab sessions to the challenge being verified. */
58
+ attemptId?: string;
59
+ provider?: string;
60
+ timeoutMs?: number;
61
+ onManualRequired?: (status: CaptchaStatus, signal: AbortSignal) => Promise<void>;
62
+ signal?: AbortSignal;
63
+ }): Promise<CaptchaStatus>;
64
+ close(): Promise<void>;
65
+ }
66
+ export {};
@@ -0,0 +1,229 @@
1
+ export class CaptchaError extends Error {
2
+ failure;
3
+ status;
4
+ constructor(failure, status) {
5
+ super(`${failure.code}: ${failure.message}`);
6
+ this.failure = failure;
7
+ this.status = status;
8
+ this.name = "CaptchaError";
9
+ }
10
+ }
11
+ function timeoutFailure() {
12
+ return new CaptchaError({ code: "TIMEOUT", message: "CAPTCHA verification did not finish before the deadline",
13
+ retryable: false, nextAction: "manual" });
14
+ }
15
+ /** Session outcome polling or an existing raw-CDP transport. No token is exposed. */
16
+ export class CaptchaObserver {
17
+ transport;
18
+ disconnect;
19
+ history = new Map();
20
+ listeners = new Set();
21
+ closed = false;
22
+ waits = new Set();
23
+ receive = (status) => {
24
+ if (this.closed)
25
+ return;
26
+ const previous = this.history.get(status.id);
27
+ if (previous) {
28
+ if (status.updatedAt < previous.updatedAt)
29
+ return;
30
+ // A delayed HTTP snapshot may arrive after a terminal CDP event.
31
+ // The server never reopens an attempt after application/failure.
32
+ const terminal = ["applied", "failed", "cancelled"].includes(previous.stage);
33
+ if (terminal && status.stage !== previous.stage)
34
+ return;
35
+ const rank = { detected: 0, solving: 1, vendor_solved: 2, applied: 3, failed: 3, cancelled: 3 };
36
+ if (status.updatedAt === previous.updatedAt && rank[status.stage] < rank[previous.stage])
37
+ return;
38
+ if (previous.verificationSource === "client" && previous.websiteVerification !== "unknown" &&
39
+ (status.websiteVerification === "unknown" || previous.websiteVerification !== "timed_out" && status.websiteVerification !== previous.websiteVerification))
40
+ return;
41
+ if (JSON.stringify(previous) === JSON.stringify(status))
42
+ return;
43
+ }
44
+ this.history.set(status.id, structuredClone(status));
45
+ if (this.history.size > 64)
46
+ this.history.delete(this.history.keys().next().value);
47
+ for (const listener of this.listeners) {
48
+ try {
49
+ listener(structuredClone(status));
50
+ }
51
+ catch { }
52
+ }
53
+ };
54
+ constructor(transport, disconnect) {
55
+ this.transport = transport;
56
+ this.disconnect = disconnect;
57
+ }
58
+ /** Poll over the public session API, without creating another browser
59
+ * connection. Works for both Playwright wire and raw-CDP task clients. */
60
+ static async forSession(client, sessionId) {
61
+ const prefix = `/sessions/${encodeURIComponent(sessionId)}/captcha`;
62
+ const transport = {
63
+ on() { }, off() { },
64
+ async send(method, params) {
65
+ if (method === "Solari.enableCaptchaEvents")
66
+ return {};
67
+ const response = await client.request(method === "Solari.getCaptchaStatus" ? "GET" : "POST", prefix + (method === "Solari.getCaptchaStatus" ? "" : "/verification"), params);
68
+ if (!response.ok)
69
+ throw new CaptchaError({ code: "STATUS_UNAVAILABLE",
70
+ message: `CAPTCHA status HTTP ${response.status}`, retryable: response.status >= 500, nextAction: "stop" });
71
+ return response.json();
72
+ },
73
+ };
74
+ const observer = await CaptchaObserver.attach(transport);
75
+ await observer.statuses();
76
+ return observer;
77
+ }
78
+ /** Also accepts an existing raw-CDP transport; useful for other agents. */
79
+ static async attach(transport, disconnect = async () => { }) {
80
+ const observer = new CaptchaObserver(transport, disconnect);
81
+ transport.on("Solari.captchaStatusChanged", observer.receive);
82
+ try {
83
+ await transport.send("Solari.enableCaptchaEvents", { includeStatus: true });
84
+ }
85
+ catch (error) {
86
+ transport.off("Solari.captchaStatusChanged", observer.receive);
87
+ throw error;
88
+ }
89
+ return observer;
90
+ }
91
+ onStatus(listener) {
92
+ this.listeners.add(listener);
93
+ return () => { this.listeners.delete(listener); };
94
+ }
95
+ async statuses() {
96
+ if (this.closed)
97
+ throw new Error("CAPTCHA observer is closed");
98
+ const result = await this.transport.send("Solari.getCaptchaStatus");
99
+ for (const status of result.attempts ?? [])
100
+ this.receive(status);
101
+ return [...this.history.values()].sort((a, b) => a.startedAt - b.startedAt).map(status => structuredClone(status));
102
+ }
103
+ async reportVerification(attemptId, accepted) {
104
+ if (this.closed)
105
+ throw new Error("CAPTCHA observer is closed");
106
+ const result = await this.transport.send("Solari.reportCaptchaVerification", { attemptId, accepted });
107
+ return result.recorded === true;
108
+ }
109
+ /** Resolves only after the caller's website-specific verifier returns true.
110
+ * Await this before resuming the agent. Manual recovery runs at most once
111
+ * per attempt and shares the same deadline; it never refreshes/re-bills
112
+ * automatically. A rejected/expired token requires a fresh challenge. */
113
+ async waitForVerification(options) {
114
+ const timeoutMs = options.timeoutMs ?? 300_000;
115
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
116
+ throw new Error("timeoutMs must be positive");
117
+ const controller = new AbortController();
118
+ this.waits.add(controller);
119
+ const deadline = Date.now() + timeoutMs;
120
+ const timer = setTimeout(() => controller.abort(timeoutFailure()), timeoutMs);
121
+ const abort = () => controller.abort(options.signal?.reason ?? new Error("CAPTCHA wait aborted"));
122
+ options.signal?.addEventListener("abort", abort, { once: true });
123
+ if (options.signal?.aborted)
124
+ abort();
125
+ const manual = new Set();
126
+ let lastAttempt;
127
+ // Start lazily: a cancelled deadline must not initiate another request,
128
+ // verifier, manual callback or an unobserved rejected promise.
129
+ const bounded = (operation) => new Promise((resolve, reject) => {
130
+ if (controller.signal.aborted) {
131
+ reject(controller.signal.reason);
132
+ return;
133
+ }
134
+ const fail = () => reject(controller.signal.reason);
135
+ controller.signal.addEventListener("abort", fail, { once: true });
136
+ try {
137
+ operation().then(resolve, reject).finally(() => controller.signal.removeEventListener("abort", fail));
138
+ }
139
+ catch (error) {
140
+ controller.signal.removeEventListener("abort", fail);
141
+ reject(error);
142
+ }
143
+ });
144
+ const selected = (statuses) => statuses.filter(status => (!options.attemptId || status.id === options.attemptId) &&
145
+ (!options.provider || status.provider === options.provider));
146
+ const active = (statuses) => statuses.some(status => ["detected", "solving", "vendor_solved"].includes(status.stage));
147
+ const latestOf = (statuses) => statuses.filter(status => status.stage !== "cancelled").at(-1);
148
+ const stillCurrent = (statuses, id) => !active(statuses) && latestOf(statuses)?.id === id && latestOf(statuses)?.websiteVerification !== "rejected";
149
+ const pollDelay = () => bounded(() => new Promise(resolve => setTimeout(resolve, 250)));
150
+ try {
151
+ while (Date.now() < deadline) {
152
+ if (this.closed)
153
+ throw new Error("CAPTCHA observer is closed");
154
+ const statuses = selected(await bounded(() => this.statuses()));
155
+ const latest = latestOf(statuses);
156
+ lastAttempt = latest;
157
+ if (!active(statuses) && latest) {
158
+ if (latest.websiteVerification === "rejected")
159
+ throw new CaptchaError({
160
+ code: "WEBSITE_REJECTED", message: "The website rejected CAPTCHA verification",
161
+ retryable: true, nextAction: "retry_fresh_challenge",
162
+ }, latest);
163
+ if (await bounded(() => options.verify())) {
164
+ // The verifier and the report are asynchronous. A new
165
+ // challenge may have started during either operation.
166
+ const beforeReport = selected(await bounded(() => this.statuses()));
167
+ lastAttempt = latestOf(beforeReport);
168
+ if (!stillCurrent(beforeReport, latest.id)) {
169
+ await pollDelay();
170
+ continue;
171
+ }
172
+ const result = await bounded(() => this.transport.send("Solari.reportCaptchaVerification", { attemptId: latest.id, accepted: true }));
173
+ if (!result.recorded)
174
+ throw new Error("CAPTCHA verification could not be recorded for this attempt");
175
+ const afterReport = selected(await bounded(() => this.statuses()));
176
+ lastAttempt = latestOf(afterReport);
177
+ if (!stillCurrent(afterReport, latest.id)) {
178
+ await pollDelay();
179
+ continue;
180
+ }
181
+ return { ...latestOf(afterReport), websiteVerification: "accepted", verificationSource: "client" };
182
+ }
183
+ if (latest.stage === "failed") {
184
+ if (!options.onManualRequired || latest.error?.nextAction === "stop")
185
+ throw new CaptchaError(latest.error ?? { code: "UNKNOWN", message: "CAPTCHA solving failed", retryable: false, nextAction: "manual" }, latest);
186
+ if (!manual.has(latest.id)) {
187
+ const beforeHandoff = selected(await bounded(() => this.statuses()));
188
+ lastAttempt = latestOf(beforeHandoff);
189
+ if (!stillCurrent(beforeHandoff, latest.id)) {
190
+ await pollDelay();
191
+ continue;
192
+ }
193
+ manual.add(latest.id);
194
+ await bounded(() => options.onManualRequired(latest, controller.signal));
195
+ }
196
+ }
197
+ }
198
+ await pollDelay();
199
+ }
200
+ throw timeoutFailure();
201
+ }
202
+ catch (error) {
203
+ if (error instanceof CaptchaError && error.failure.code === "TIMEOUT" && lastAttempt?.stage === "applied") {
204
+ let reportTimer;
205
+ await Promise.race([
206
+ this.transport.send("Solari.reportCaptchaVerification", { attemptId: lastAttempt.id, outcome: "timed_out" }).catch(() => { }),
207
+ new Promise(resolve => { reportTimer = setTimeout(resolve, 1_000); }),
208
+ ]).finally(() => clearTimeout(reportTimer));
209
+ }
210
+ throw error;
211
+ }
212
+ finally {
213
+ this.waits.delete(controller);
214
+ clearTimeout(timer);
215
+ options.signal?.removeEventListener("abort", abort);
216
+ }
217
+ }
218
+ async close() {
219
+ if (this.closed)
220
+ return;
221
+ this.closed = true;
222
+ this.listeners.clear();
223
+ for (const wait of this.waits)
224
+ wait.abort(new Error("CAPTCHA observer is closed"));
225
+ this.waits.clear();
226
+ this.transport.off("Solari.captchaStatusChanged", this.receive);
227
+ await this.disconnect();
228
+ }
229
+ }
package/dist/index.d.ts CHANGED
@@ -130,7 +130,20 @@ export interface LaunchOptions extends CreateSessionOptions {
130
130
  /** Hard upper bound on the post-connect probe in ms. Default 2000. */
131
131
  probeTimeoutMs?: number;
132
132
  }
133
- export type SolariErrorCode = "FeatureRequiresPlan" | "ConcurrencyLimitExceeded" | "PlanLimitExceeded" | "BrowserUnhealthy" | "InvalidSessionId";
133
+ export type SolariErrorCode = "FeatureRequiresPlan" | "ConcurrencyLimitExceeded" | "PlanLimitExceeded" | "BrowserUnhealthy" | "InvalidSessionId"
134
+ /** A proxy tier you explicitly requested cannot be served right now (503). No session was created. */
135
+ | "ProxyTierUnavailable";
136
+ /** Supported egress countries and residential-proxy status, from
137
+ * {@link Solari.proxy}.countries() (`GET /proxy/countries`). */
138
+ export interface ProxyCountries {
139
+ /** Whether RESIDENTIAL managed-proxy credentials are configured on the
140
+ * gateway. Reflects only the residential tier — the static/mobile tiers
141
+ * have separate credentials this flag does not report, so `true` is not a
142
+ * guarantee that `tier: "mobile"` will resolve. */
143
+ enabled: boolean;
144
+ /** Supported ISO-3166-1 alpha-2 egress countries (lowercase, sorted). */
145
+ countries: string[];
146
+ }
134
147
  export declare class SolariError extends Error {
135
148
  readonly status?: number | undefined;
136
149
  readonly cause?: unknown | undefined;
@@ -150,6 +163,14 @@ declare class ProfilesResource {
150
163
  sizeBytes: number;
151
164
  }>;
152
165
  }
166
+ declare class ProxyResource {
167
+ private readonly client;
168
+ constructor(client: Solari);
169
+ /** `GET /proxy/countries` — the egress countries this gateway supports and
170
+ * whether residential proxy credentials are configured. Matches the Go/Rust
171
+ * `proxy.countries()` and C++ `proxyCountries()`. */
172
+ countries(): Promise<ProxyCountries>;
173
+ }
153
174
  declare class SessionsResource {
154
175
  private readonly client;
155
176
  constructor(client: Solari);
@@ -165,12 +186,13 @@ declare class SessionsResource {
165
186
  export declare class Solari {
166
187
  readonly sessions: SessionsResource;
167
188
  readonly profiles: ProfilesResource;
189
+ readonly proxy: ProxyResource;
168
190
  private readonly apiKey;
169
191
  private readonly baseUrl;
170
192
  private readonly maxAttempts;
171
193
  private readonly backoffMs;
172
194
  private readonly timeoutMs;
173
- private readonly proxy;
195
+ private readonly localProxy;
174
196
  constructor(opts: SolariOptions);
175
197
  close(): Promise<void>;
176
198
  /**
@@ -189,5 +211,7 @@ export declare class Solari {
189
211
  private _probeBrowserHealth;
190
212
  _wrapSessionEndpoint(sessionId: string, upstreamEndpoint: string, kind?: ProxyKind): Promise<string>;
191
213
  _unregisterSession(sessionId: string): void;
192
- request(method: string, path: string, body?: unknown): Promise<Response>;
214
+ request(method: string, path: string, body?: unknown, opts?: {
215
+ idempotencyKey?: string;
216
+ }): Promise<Response>;
193
217
  }
package/dist/index.js CHANGED
@@ -54,8 +54,74 @@ 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
+ }
111
+ }
112
+ // A few 503s are answers, not outages: retrying cannot change them, and swallowing the body would hide the
113
+ // code from the caller. `ProxyTierUnavailable` (explicitly requested proxy tier not servable) is one.
114
+ const TERMINAL_503_CODES = new Set(["ProxyTierUnavailable"]);
115
+ async function responseIsTerminal503(res) {
116
+ if (res.status !== 503)
117
+ return false;
118
+ try {
119
+ const body = (await res.clone().json());
120
+ return typeof body?.code === "string" && TERMINAL_503_CODES.has(body.code);
121
+ }
122
+ catch {
123
+ return false;
124
+ }
59
125
  }
60
126
  function isRetryableError(_err) {
61
127
  return true;
@@ -186,6 +252,23 @@ async function releaseRejection(res) {
186
252
  return null;
187
253
  return { body, code };
188
254
  }
255
+ class ProxyResource {
256
+ client;
257
+ constructor(client) {
258
+ this.client = client;
259
+ }
260
+ /** `GET /proxy/countries` — the egress countries this gateway supports and
261
+ * whether residential proxy credentials are configured. Matches the Go/Rust
262
+ * `proxy.countries()` and C++ `proxyCountries()`. */
263
+ async countries() {
264
+ const res = await this.client.request("GET", "/proxy/countries");
265
+ if (!res.ok) {
266
+ const body = await res.text().catch(() => "");
267
+ throw new SolariError(`Solari GET /proxy/countries failed: ${res.status} ${body}`, res.status);
268
+ }
269
+ return (await res.json());
270
+ }
271
+ }
189
272
  class SessionsResource {
190
273
  client;
191
274
  constructor(client) {
@@ -203,7 +286,10 @@ class SessionsResource {
203
286
  body.captcha = true;
204
287
  if (options?.proxy !== undefined)
205
288
  body.proxy = options.proxy;
206
- const res = await this.client.request("POST", "/sessions", Object.keys(body).length ? body : undefined);
289
+ // A create allocates a slot and launches a browser before it can answer,
290
+ // so a response lost after that point makes a retry a SECOND session.
291
+ // The key lets the gateway answer the retry from the first result.
292
+ const res = await this.client.request("POST", "/sessions", Object.keys(body).length ? body : undefined, { idempotencyKey: newIdempotencyKey() });
207
293
  if (!res.ok) {
208
294
  const text = await res.text().catch(() => "");
209
295
  let code;
@@ -296,12 +382,13 @@ class SessionsResource {
296
382
  export class Solari {
297
383
  sessions;
298
384
  profiles;
385
+ proxy;
299
386
  apiKey;
300
387
  baseUrl;
301
388
  maxAttempts;
302
389
  backoffMs;
303
390
  timeoutMs;
304
- proxy = new LocalProxy();
391
+ localProxy = new LocalProxy();
305
392
  constructor(opts) {
306
393
  if (!opts.apiKey) {
307
394
  throw new SolariError("Solari: apiKey is required");
@@ -318,9 +405,10 @@ export class Solari {
318
405
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
319
406
  this.sessions = new SessionsResource(this);
320
407
  this.profiles = new ProfilesResource(this);
408
+ this.proxy = new ProxyResource(this);
321
409
  }
322
410
  async close() {
323
- await this.proxy.stop();
411
+ await this.localProxy.stop();
324
412
  }
325
413
  /**
326
414
  * Create a session and return a connected browser.
@@ -365,6 +453,17 @@ export class Solari {
365
453
  // pre-creates one when a proxy was requested, so on a plain
366
454
  // launch() browser.contexts() was empty and contexts()[0]
367
455
  // undefined — which the SDK's own docs pointed callers at.
456
+ //
457
+ // DELIBERATE DOUBLE-SEED (audit-profiles-not-applied): the pool
458
+ // now ALSO seeds browser.contexts()[0] server-side whenever
459
+ // storageStateUrl is present, so a published build of this SDK
460
+ // talking to a fixed gateway seeds the same profile twice (one
461
+ // extra GCS fetch, harmless). DO NOT remove this seed on the
462
+ // assumption the server already did it — this SDK version may
463
+ // be talking to an OLDER gateway that doesn't carry the
464
+ // server-side fix, and the server has no way to tell the SDK
465
+ // whether it already seeded. Version skew means the SDK must
466
+ // assume the server did nothing.
368
467
  const ctxOptions = {};
369
468
  const tz = session.proxy?.timezoneId;
370
469
  if (tz)
@@ -443,18 +542,23 @@ export class Solari {
443
542
  catch (e) {
444
543
  throw new SolariError(`Solari: invalid upstream ${kind}Endpoint: ${upstreamEndpoint}`, undefined, e);
445
544
  }
446
- await this.proxy.start();
447
- this.proxy.register(sessionId, upstream, kind);
448
- return this.proxy.getLocalEndpoint(sessionId, kind);
545
+ await this.localProxy.start();
546
+ this.localProxy.register(sessionId, upstream, kind);
547
+ return this.localProxy.getLocalEndpoint(sessionId, kind);
449
548
  }
450
549
  _unregisterSession(sessionId) {
451
- this.proxy.unregister(sessionId);
550
+ this.localProxy.unregister(sessionId);
452
551
  }
453
- async request(method, path, body) {
552
+ async request(method, path, body, opts) {
454
553
  const url = `${this.baseUrl}${path}`;
554
+ // Built ONCE, outside the retry loop, so every attempt of this call
555
+ // carries the same key. See newIdempotencyKey.
455
556
  const headers = {
456
557
  Authorization: `Bearer ${this.apiKey}`,
457
558
  "Content-Type": "application/json",
559
+ ...(opts?.idempotencyKey
560
+ ? { "Idempotency-Key": opts.idempotencyKey }
561
+ : {}),
458
562
  };
459
563
  const payload = body === undefined ? undefined : JSON.stringify(body);
460
564
  let lastErr;
@@ -471,7 +575,10 @@ export class Solari {
471
575
  clearTimeout(timer);
472
576
  if (res.ok)
473
577
  return res;
474
- if (!isRetryableStatus(res.status))
578
+ const retryThis = (isRetryableStatus(res.status) && !(await responseIsTerminal503(res))) ||
579
+ (isSafeToReplay(method, opts?.idempotencyKey) &&
580
+ (await responseSaysRetryable(res)));
581
+ if (!retryThis)
475
582
  return res;
476
583
  await res.text().catch(() => { });
477
584
  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.6",
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": {