@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.
- package/dist/captcha.d.ts +66 -0
- package/dist/captcha.js +229 -0
- package/dist/index.d.ts +27 -3
- package/dist/index.js +117 -10
- package/package.json +3 -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,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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
447
|
-
this.
|
|
448
|
-
return this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": {
|