@solarisdk/browser 0.1.7 → 0.3.0

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/README.md CHANGED
@@ -3,6 +3,9 @@
3
3
  Official client SDK for [Solari Browser](https://getsolari.com) — managed,
4
4
  stealth-hardened browsers as an API.
5
5
 
6
+ Feature coverage across all five languages is on the
7
+ [SDK feature matrix](https://docs.getsolari.com/sdk-capabilities).
8
+
6
9
  ## Install
7
10
 
8
11
  ```bash
@@ -55,6 +58,36 @@ const session = await solari.sessions.create();
55
58
  const browser = await chromium.connectOverCDP(session.cdpEndpoint);
56
59
  ```
57
60
 
61
+ ### Get and list sessions
62
+
63
+ ```ts
64
+ const view = await solari.sessions.get(session.id); // status, kind, userMetadata, endpoints while active
65
+ let page = await solari.sessions.list({ limit: 50, metadata: { job: "nightly-42" } });
66
+ // page.sessions, page.nextCursor (absent on the last page)
67
+ ```
68
+
69
+ `list` never returns the websocket endpoints (they are bearer capabilities): call `get`
70
+ for a single session's. Pass `page.nextCursor` as `cursor` for the next page.
71
+
72
+ Every REST error carries the gateway's `code` when it sends one, so you can branch on
73
+ it whichever call failed: `e.code === "ReplayPending"` (a replay still uploading; wait and
74
+ call again), `"SessionNotFound"`, `"InvalidSessionId"`, `"PlanLimitExceeded"` and so on.
75
+
76
+ ### Timeout, keep-alive and metadata
77
+
78
+ ```ts
79
+ const session = await solari.sessions.create({
80
+ sessionTimeoutMs: 600_000, // end earlier than the plan maximum, in ms (at least 60 000)
81
+ keepAlive: true, // hold the session if the client drops, until its deadline or release
82
+ userMetadata: { job: "nightly-42" }, // stored with the session
83
+ });
84
+ ```
85
+
86
+ Every duration option is in milliseconds. `sessionTimeoutMs` is rounded up to whole
87
+ seconds for the gateway (1001 ms is 2 s); the gateway refuses anything under 60 s;
88
+ zero or unset leaves the plan maximum, and a negative, `NaN`, `Infinity` or
89
+ fractional value throws before any request. `launch()` accepts the same options.
90
+
58
91
  ## Regions
59
92
 
60
93
  Solari runs in `us-west` (N. California) today, with additional regions
package/dist/index.d.ts CHANGED
@@ -88,6 +88,8 @@ export interface Session {
88
88
  /** Present only when a managed proxy was requested. Confirmation only
89
89
  * (country/tier/timezone) — egress is applied server-side. */
90
90
  proxy?: ResolvedProxyConfig;
91
+ /** Echo of the `userMetadata` sent at create; absent when none was sent. */
92
+ userMetadata?: Record<string, unknown>;
91
93
  }
92
94
  export interface Profile {
93
95
  id: string;
@@ -114,12 +116,59 @@ export interface CreateSessionOptions {
114
116
  * proxy: "off"
115
117
  */
116
118
  proxy?: string | ProxyRequest | "off" | "smart";
119
+ /** End the session earlier than the plan maximum, in milliseconds. The gateway
120
+ * counts whole seconds, so the value is rounded up (1001 ms is 2 s), and it
121
+ * refuses anything under 60 s (400). Zero/undefined leaves the plan maximum;
122
+ * a negative, NaN, Infinity or non-integer value throws. */
123
+ sessionTimeoutMs?: number;
124
+ /** Hold the session after the client disconnects, until its deadline
125
+ * (`sessionTimeoutMs`, else an hour, never past the plan maximum) or until
126
+ * it is released, so a dropped client can reconnect. */
127
+ keepAlive?: boolean;
128
+ /** Free-form JSON object stored with the session (labels, job ids, ...). */
129
+ userMetadata?: Record<string, unknown>;
117
130
  }
118
131
  export interface ReplayUrl {
119
132
  url: string;
120
133
  expiresInSeconds: number;
121
134
  contentEncoding: string;
122
135
  }
136
+ /** `GET /sessions/:id`. Fields the gateway adds later are kept (index signature). */
137
+ export interface SessionView {
138
+ id: string;
139
+ status: string;
140
+ kind?: string | null;
141
+ org?: string;
142
+ createdAt?: string | null;
143
+ expiresAt?: string | null;
144
+ userMetadata?: Record<string, unknown>;
145
+ /** Present while the session is active. */
146
+ wsEndpoint?: string;
147
+ cdpEndpoint?: string;
148
+ [k: string]: unknown;
149
+ }
150
+ /** One row of `sessions.list()`. Never carries the websocket endpoints. */
151
+ export interface SessionSummary {
152
+ id: string;
153
+ status: string;
154
+ kind?: string | null;
155
+ createdAt?: string | null;
156
+ expiresAt?: string | null;
157
+ userMetadata?: Record<string, unknown>;
158
+ }
159
+ export interface SessionList {
160
+ sessions: SessionSummary[];
161
+ /** Absent on the last page; pass it as `ListSessionsOptions.cursor`. */
162
+ nextCursor?: string;
163
+ }
164
+ export interface ListSessionsOptions {
165
+ /** Page size; unset uses the gateway default. */
166
+ limit?: number;
167
+ /** Continue from a previous page's `nextCursor`. */
168
+ cursor?: string;
169
+ /** Keep sessions whose `userMetadata` has all these string values. */
170
+ metadata?: Record<string, string>;
171
+ }
123
172
  /** Options for `Solari.launch()`. Strict superset of `CreateSessionOptions`. */
124
173
  export interface LaunchOptions extends CreateSessionOptions {
125
174
  /** Additional re-launch attempts after the first failure. Default 0. */
@@ -178,6 +227,15 @@ declare class SessionsResource {
178
227
  /** Fire-and-forget release. Use `releaseAndWait` for confirmation. */
179
228
  release(id: string): void;
180
229
  releaseAndWait(id: string): Promise<void>;
230
+ /** The gateway's current view of one session (`GET /sessions/:id`). */
231
+ get(id: string): Promise<SessionView>;
232
+ /**
233
+ * One page of the organisation's sessions (`GET /sessions`). Rows never carry the
234
+ * websocket endpoints (they are bearer capabilities): call `get(id)` for those.
235
+ * `metadata` keeps sessions whose `userMetadata` has all these string values
236
+ * (at most 10 pairs; keys and values cannot contain a single quote).
237
+ */
238
+ list(options?: ListSessionsOptions): Promise<SessionList>;
181
239
  /** Presigned URL for the session's replay. Available ~1-3s after `releaseAndWait`. */
182
240
  getReplayUrl(id: string): Promise<ReplayUrl>;
183
241
  /** Download the replay as NDJSON bytes. */
package/dist/index.js CHANGED
@@ -35,6 +35,21 @@ function toPlaywrightStorageState(state) {
35
35
  })),
36
36
  };
37
37
  }
38
+ /** The gateway's list filter grammar, in key order; throws for a pair it cannot express. */
39
+ function metadataFilter(m) {
40
+ const keys = Object.keys(m).sort();
41
+ if (keys.length > 10)
42
+ throw new SolariError("Solari: at most 10 metadata filter pairs");
43
+ return keys
44
+ .map((k) => {
45
+ const v = m[k];
46
+ if (k.length < 1 || k.length > 128 || k.includes("'") || v.includes("'")) {
47
+ throw new SolariError(`Solari: metadata filter key ${JSON.stringify(k)} or its value cannot be expressed (1-128 characters, no single quote)`);
48
+ }
49
+ return `user_metadata['${k}']:'${v}'`;
50
+ })
51
+ .join(" AND ");
52
+ }
38
53
  export class SolariError extends Error {
39
54
  status;
40
55
  cause;
@@ -47,6 +62,24 @@ export class SolariError extends Error {
47
62
  this.name = "SolariError";
48
63
  }
49
64
  }
65
+ /**
66
+ * Build the error for a non-ok API response: the status, the body text in the
67
+ * message, and the gateway's `code` (for example `ReplayPending`,
68
+ * `SessionNotFound`, `InvalidSessionId`) when the body is JSON with a string
69
+ * `code`. Every REST call uses this so a caller can branch on `e.code` no matter
70
+ * which endpoint failed.
71
+ */
72
+ async function apiError(what, res) {
73
+ const body = await res.text().catch(() => "");
74
+ let code;
75
+ try {
76
+ const parsed = JSON.parse(body);
77
+ if (typeof parsed?.code === "string")
78
+ code = parsed.code;
79
+ }
80
+ catch { }
81
+ return new SolariError(`Solari ${what} failed: ${res.status} ${body}`, res.status, undefined, code);
82
+ }
50
83
  const REGION_URLS = {
51
84
  "us-west": "https://api.getsolari.com",
52
85
  };
@@ -60,6 +93,19 @@ const DEFAULT_TIMEOUT_MS = 90_000;
60
93
  // transient, and a client's correctness must not depend on which gateway
61
94
  // build it reaches. Inert today, and deliberately so — do not delete it as
62
95
  // dead code.
96
+ /**
97
+ * The gateway's `timeoutSeconds` for a `sessionTimeoutMs`: rounded UP to whole
98
+ * seconds (1001 -> 2). Zero/undefined means "omit". Throws for a negative, NaN,
99
+ * Infinity or non-integer value. Not clamped: the gateway still answers 400 under 60 s.
100
+ */
101
+ function sessionTimeoutSeconds(ms) {
102
+ if (ms === undefined || ms === 0)
103
+ return undefined;
104
+ if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 0) {
105
+ throw new SolariError(`Solari: sessionTimeoutMs must be a non-negative integer number of milliseconds, got ${String(ms)}`);
106
+ }
107
+ return Math.ceil(ms / 1000);
108
+ }
63
109
  function isRetryableStatus(status) {
64
110
  return status === 502 || status === 503 || status === 504 || status === 507;
65
111
  }
@@ -197,25 +243,19 @@ class ProfilesResource {
197
243
  }
198
244
  async list() {
199
245
  const res = await this.client.request("GET", "/profiles");
200
- if (!res.ok) {
201
- const body = await res.text().catch(() => "");
202
- throw new SolariError(`Solari GET /profiles failed: ${res.status} ${body}`, res.status);
203
- }
246
+ if (!res.ok)
247
+ throw await apiError("GET /profiles", res);
204
248
  return (await res.json());
205
249
  }
206
250
  async delete(id) {
207
251
  const res = await this.client.request("DELETE", `/profiles/${encodeURIComponent(id)}`);
208
- if (!res.ok && res.status !== 404) {
209
- const body = await res.text().catch(() => "");
210
- throw new SolariError(`Solari DELETE /profiles/${id} failed: ${res.status} ${body}`, res.status);
211
- }
252
+ if (!res.ok && res.status !== 404)
253
+ throw await apiError(`DELETE /profiles/${id}`, res);
212
254
  }
213
255
  async save(id, storageState) {
214
256
  const res = await this.client.request("POST", `/profiles/${encodeURIComponent(id)}/save`, { storageState });
215
- if (!res.ok) {
216
- const body = await res.text().catch(() => "");
217
- throw new SolariError(`Solari POST /profiles/${id}/save failed: ${res.status} ${body}`, res.status);
218
- }
257
+ if (!res.ok)
258
+ throw await apiError(`POST /profiles/${id}/save`, res);
219
259
  const data = (await res.json());
220
260
  return {
221
261
  version: data.version ?? 0,
@@ -266,10 +306,8 @@ class ProxyResource {
266
306
  * `proxy.countries()` and C++ `proxyCountries()`. */
267
307
  async countries() {
268
308
  const res = await this.client.request("GET", "/proxy/countries");
269
- if (!res.ok) {
270
- const body = await res.text().catch(() => "");
271
- throw new SolariError(`Solari GET /proxy/countries failed: ${res.status} ${body}`, res.status);
272
- }
309
+ if (!res.ok)
310
+ throw await apiError("GET /proxy/countries", res);
273
311
  return (await res.json());
274
312
  }
275
313
  }
@@ -290,6 +328,13 @@ class SessionsResource {
290
328
  body.captcha = true;
291
329
  if (options?.proxy !== undefined)
292
330
  body.proxy = options.proxy;
331
+ const timeoutSeconds = sessionTimeoutSeconds(options?.sessionTimeoutMs);
332
+ if (timeoutSeconds !== undefined)
333
+ body.timeoutSeconds = timeoutSeconds;
334
+ if (options?.keepAlive === true)
335
+ body.keepAlive = true;
336
+ if (options?.userMetadata !== undefined)
337
+ body.userMetadata = options.userMetadata;
293
338
  // A create allocates a slot and launches a browser before it can answer,
294
339
  // so a response lost after that point makes a retry a SECOND session.
295
340
  // The key lets the gateway answer the retry from the first result.
@@ -331,6 +376,9 @@ class SessionsResource {
331
376
  if (data.proxy) {
332
377
  session.proxy = data.proxy;
333
378
  }
379
+ if (data.userMetadata) {
380
+ session.userMetadata = data.userMetadata;
381
+ }
334
382
  return session;
335
383
  }
336
384
  /** Fire-and-forget release. Use `releaseAndWait` for confirmation. */
@@ -355,13 +403,40 @@ class SessionsResource {
355
403
  throw new SolariError(`Solari DELETE /sessions/${id} failed: ${res.status} ${rejection.body}`, res.status, undefined, rejection.code);
356
404
  }
357
405
  }
406
+ /** The gateway's current view of one session (`GET /sessions/:id`). */
407
+ async get(id) {
408
+ const res = await this.client.request("GET", `/sessions/${encodeURIComponent(id)}`);
409
+ if (!res.ok)
410
+ throw await apiError(`GET /sessions/${id}`, res);
411
+ return (await res.json());
412
+ }
413
+ /**
414
+ * One page of the organisation's sessions (`GET /sessions`). Rows never carry the
415
+ * websocket endpoints (they are bearer capabilities): call `get(id)` for those.
416
+ * `metadata` keeps sessions whose `userMetadata` has all these string values
417
+ * (at most 10 pairs; keys and values cannot contain a single quote).
418
+ */
419
+ async list(options) {
420
+ const params = new URLSearchParams();
421
+ if (options?.limit !== undefined && options.limit > 0)
422
+ params.set("limit", String(options.limit));
423
+ if (options?.cursor)
424
+ params.set("cursor", options.cursor);
425
+ if (options?.metadata && Object.keys(options.metadata).length > 0) {
426
+ params.set("q", metadataFilter(options.metadata));
427
+ }
428
+ const qs = params.toString();
429
+ const res = await this.client.request("GET", `/sessions${qs ? `?${qs}` : ""}`);
430
+ if (!res.ok)
431
+ throw await apiError("GET /sessions", res);
432
+ const data = (await res.json());
433
+ return { sessions: data.sessions ?? [], ...(data.nextCursor ? { nextCursor: data.nextCursor } : {}) };
434
+ }
358
435
  /** Presigned URL for the session's replay. Available ~1-3s after `releaseAndWait`. */
359
436
  async getReplayUrl(id) {
360
437
  const res = await this.client.request("GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
361
- if (!res.ok) {
362
- const body = await res.text().catch(() => "");
363
- throw new SolariError(`Solari GET /sessions/${id}/replay-url failed: ${res.status} ${body}`, res.status);
364
- }
438
+ if (!res.ok)
439
+ throw await apiError(`GET /sessions/${id}/replay-url`, res);
365
440
  const data = (await res.json());
366
441
  if (!data.url) {
367
442
  throw new SolariError(`Solari: unexpected replay-url response: ${JSON.stringify(data)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/browser",
3
- "version": "0.1.7",
3
+ "version": "0.3.0",
4
4
  "description": "Official client SDK for Solari Browser.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",