@solarisdk/browser 0.1.5 → 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.
- package/dist/captcha.d.ts +66 -0
- package/dist/captcha.js +229 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +26 -1
- package/package.json +1 -1
|
@@ -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 {};
|
package/dist/captcha.js
ADDED
|
@@ -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,20 @@ async function responseSaysRetryable(res) {
|
|
|
109
109
|
return false;
|
|
110
110
|
}
|
|
111
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
|
+
}
|
|
125
|
+
}
|
|
112
126
|
function isRetryableError(_err) {
|
|
113
127
|
return true;
|
|
114
128
|
}
|
|
@@ -439,6 +453,17 @@ export class Solari {
|
|
|
439
453
|
// pre-creates one when a proxy was requested, so on a plain
|
|
440
454
|
// launch() browser.contexts() was empty and contexts()[0]
|
|
441
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.
|
|
442
467
|
const ctxOptions = {};
|
|
443
468
|
const tz = session.proxy?.timezoneId;
|
|
444
469
|
if (tz)
|
|
@@ -550,7 +575,7 @@ export class Solari {
|
|
|
550
575
|
clearTimeout(timer);
|
|
551
576
|
if (res.ok)
|
|
552
577
|
return res;
|
|
553
|
-
const retryThis = isRetryableStatus(res.status) ||
|
|
578
|
+
const retryThis = (isRetryableStatus(res.status) && !(await responseIsTerminal503(res))) ||
|
|
554
579
|
(isSafeToReplay(method, opts?.idempotencyKey) &&
|
|
555
580
|
(await responseSaysRetryable(res)));
|
|
556
581
|
if (!retryThis)
|