@solarisdk/browser 0.1.5 → 0.1.7

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,9 @@ 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";
134
136
  /** Supported egress countries and residential-proxy status, from
135
137
  * {@link Solari.proxy}.countries() (`GET /proxy/countries`). */
136
138
  export interface ProxyCountries {
package/dist/index.js CHANGED
@@ -109,6 +109,24 @@ async function responseSaysRetryable(res) {
109
109
  return false;
110
110
  }
111
111
  }
112
+ // Some 5xx are answers, not outages: retrying cannot change them, and swallowing the body would hide the
113
+ // code from the caller. The gateway says so itself with `retryable: false` (502 `PoolResponseUnparseable`:
114
+ // the session may already exist, so a retry creates a second one; 503 `EgressBlockPeekUnsupportedOnFlavor`).
115
+ // `ProxyTierUnavailable` (explicitly requested proxy tier not servable) is also terminal, whatever its flag.
116
+ const TERMINAL_503_CODES = new Set(["ProxyTierUnavailable"]);
117
+ async function responseIsTerminal(res) {
118
+ try {
119
+ const body = (await res.clone().json());
120
+ if (body?.retryable === false)
121
+ return true;
122
+ return (res.status === 503 &&
123
+ typeof body?.code === "string" &&
124
+ TERMINAL_503_CODES.has(body.code));
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
112
130
  function isRetryableError(_err) {
113
131
  return true;
114
132
  }
@@ -439,6 +457,17 @@ export class Solari {
439
457
  // pre-creates one when a proxy was requested, so on a plain
440
458
  // launch() browser.contexts() was empty and contexts()[0]
441
459
  // undefined — which the SDK's own docs pointed callers at.
460
+ //
461
+ // DELIBERATE DOUBLE-SEED (audit-profiles-not-applied): the pool
462
+ // now ALSO seeds browser.contexts()[0] server-side whenever
463
+ // storageStateUrl is present, so a published build of this SDK
464
+ // talking to a fixed gateway seeds the same profile twice (one
465
+ // extra GCS fetch, harmless). DO NOT remove this seed on the
466
+ // assumption the server already did it — this SDK version may
467
+ // be talking to an OLDER gateway that doesn't carry the
468
+ // server-side fix, and the server has no way to tell the SDK
469
+ // whether it already seeded. Version skew means the SDK must
470
+ // assume the server did nothing.
442
471
  const ctxOptions = {};
443
472
  const tz = session.proxy?.timezoneId;
444
473
  if (tz)
@@ -550,10 +579,13 @@ export class Solari {
550
579
  clearTimeout(timer);
551
580
  if (res.ok)
552
581
  return res;
553
- const retryThis = isRetryableStatus(res.status) ||
582
+ const retryThis = (isRetryableStatus(res.status) && !(await responseIsTerminal(res))) ||
554
583
  (isSafeToReplay(method, opts?.idempotencyKey) &&
555
584
  (await responseSaysRetryable(res)));
556
- if (!retryThis)
585
+ // Out of attempts: hand the last response back so the caller maps its
586
+ // status and `code` like any other error, instead of a status-less
587
+ // "exhausted" error that hides why the gateway refused.
588
+ if (!retryThis || attempt === this.maxAttempts)
557
589
  return res;
558
590
  await res.text().catch(() => { });
559
591
  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.5",
3
+ "version": "0.1.7",
4
4
  "description": "Official client SDK for Solari Browser.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",