@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 +24 -2
- package/dist/index.js +92 -10
- package/package.json +3 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
447
|
-
this.
|
|
448
|
-
return this.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": {
|