@solarisdk/browserbase-compat 0.2.0 → 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/dist/client.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { gunzipSync } from "node:zlib";
2
- import { APIError, BrowserbaseError, SolariCompatUnsupported, apiErrorFor, connectionErrorFor } from "./errors.js";
3
- import { mapCreateParams, mapStatus } from "./mapping.js";
2
+ import { APIError, APIUserAbortError, BrowserbaseError, NotFoundError, SolariCompatUnsupported, apiErrorFor, connectionErrorFor } from "./errors.js";
3
+ import { KEEP_ALIVE_KEY, mapCreateParams, mapStatus } from "./mapping.js";
4
+ import { assertHeaders, compat, followUp, resolveOptions, setHeader, splitArgs } from "./options.js";
5
+ import { unsupportedMethods } from "./unsupported.js";
4
6
  export const DEFAULT_BASE_URL = "https://api.getsolari.com";
5
7
  const REGION = "us-west-2"; // reported for Browserbase-shaped objects; Solari's single region (us-west)
8
+ const CLIENT_OPTION_KEYS = new Set(["apiKey", "baseURL", "strict", "onWarning", "maxRetries", "timeout", "fetch", "defaultHeaders", "defaultQuery", "httpAgent"]);
6
9
  function resolveApiKey(explicit, warn) {
7
10
  const sources = [
8
11
  ["apiKey", explicit],
@@ -19,7 +22,9 @@ function resolveApiKey(explicit, warn) {
19
22
  }
20
23
  throw new BrowserbaseError("No Solari API key: pass apiKey or set SOLARI_API_KEY (keys start with slr_)");
21
24
  }
22
- /** Whether a base URL may receive a Solari key: https on getsolari.com or a subdomain, or loopback. */
25
+ /** The only Solari hosts that may receive a key (the API gateways). Not every *.getsolari.com host: preview hosts (<label>-<port>.preview.getsolari.com) serve customer code. */
26
+ const SOLARI_API_HOST = /^api(-sta(-gcp)?)?\.getsolari\.com$/;
27
+ /** Whether a base URL may receive a Solari key: https on a Solari API host, or loopback. */
23
28
  export function isSolariBaseUrl(raw) {
24
29
  let u;
25
30
  try {
@@ -31,7 +36,7 @@ export function isSolariBaseUrl(raw) {
31
36
  if (u.username || u.password)
32
37
  return false; // never a URL with credentials in it
33
38
  const host = u.hostname.toLowerCase().replace(/\.$/, "");
34
- if (u.protocol === "https:" && (host === "getsolari.com" || host.endsWith(".getsolari.com")))
39
+ if (u.protocol === "https:" && SOLARI_API_HOST.test(host))
35
40
  return true;
36
41
  const loopback = host === "localhost" || host === "[::1]" || /^127(\.\d{1,3}){3}$/.test(host);
37
42
  return loopback && (u.protocol === "http:" || u.protocol === "https:");
@@ -49,7 +54,7 @@ function resolveBaseUrl(explicit) {
49
54
  if (!given)
50
55
  return DEFAULT_BASE_URL;
51
56
  if (!isSolariBaseUrl(given)) {
52
- throw new BrowserbaseError(`Refusing to send a Solari API key to ${describeHost(given)}: it does not point at Solari (getsolari.com or loopback).`);
57
+ throw new BrowserbaseError(`Refusing to send a Solari API key to ${describeHost(given)}: it is not a Solari API host or loopback.`);
53
58
  }
54
59
  return given.replace(/\/+$/, "");
55
60
  }
@@ -59,21 +64,51 @@ function sleep(ms) {
59
64
  export class Browserbase {
60
65
  sessions;
61
66
  contexts;
62
- extensions;
63
- projects;
64
- agents;
65
- fetchAPI;
66
- search;
67
+ extensions = unsupportedMethods("extensions", "browser extensions cannot be loaded", ["create", "retrieve", "delete"]);
68
+ projects = unsupportedMethods("projects", "Solari keys are organisation-scoped; there are no projects", ["retrieve", "list", "usage"]);
69
+ agents = {
70
+ ...unsupportedMethods("agents", "Browserbase Agents is a separate product", ["create", "retrieve", "update", "list", "delete"]),
71
+ runs: unsupportedMethods("agents.runs", "Browserbase Agents is a separate product", ["create", "retrieve", "list", "listMessages"]),
72
+ };
73
+ fetchAPI = unsupportedMethods("fetchAPI", "Browserbase Fetch is a separate product", ["create"]);
74
+ search = unsupportedMethods("search", "Browserbase Search is a separate product", ["web"]);
75
+ certificates = unsupportedMethods("certificates", "custom CA certificates are not supported", ["create", "retrieve", "list", "delete"]);
76
+ secrets = unsupportedMethods("secrets", "Solari has no secret store for Browserbase Functions", ["create", "retrieve", "update", "list", "delete", "getPublicKey"]);
77
+ functions = {
78
+ secrets: unsupportedMethods("functions.secrets", "Browserbase Functions is a separate product", ["list", "attach", "detach"]),
79
+ };
80
+ webhooks = unsupportedMethods("webhooks", "Solari does not offer webhooks", ["create", "retrieve", "update", "list", "delete", "rotateSecret"]);
81
+ navigationPolicies = unsupportedMethods("navigationPolicies", "Solari has no stored navigation policies; pass browserSettings.allowedDomains for an egress allowlist", ["create", "retrieve", "update", "list", "delete"]);
67
82
  apiKey;
68
83
  baseURL;
84
+ /** Retries for a failed request (client default; a call can override it). */
85
+ maxRetries;
86
+ /** Request timeout in ms (client default; a call can override it). */
87
+ timeout;
69
88
  /** @internal */ strict;
70
89
  /** @internal */ warn;
71
90
  fetchImpl;
72
- maxRetries;
73
- timeoutMs;
91
+ defaultHeaders;
92
+ defaultQuery;
93
+ projectIdWarned = false;
74
94
  /** session id -> profile to save on release (browserSettings.context.persist). In-process only. */
75
95
  /** @internal */ persist = new Map();
76
96
  constructor(opts = {}) {
97
+ for (const k of Object.keys(opts)) {
98
+ if (opts[k] === undefined)
99
+ continue;
100
+ if (k === "httpAgent")
101
+ throw new SolariCompatUnsupported("client option httpAgent", "pass fetch with your own dispatcher instead");
102
+ if (!CLIENT_OPTION_KEYS.has(k))
103
+ throw new SolariCompatUnsupported(`client option ${k}`, "not an option of this client");
104
+ }
105
+ if (opts.maxRetries !== undefined && !(Number.isInteger(opts.maxRetries) && opts.maxRetries >= 0)) {
106
+ throw new BrowserbaseError("maxRetries must be a non-negative integer");
107
+ }
108
+ if (opts.timeout !== undefined && !(typeof opts.timeout === "number" && opts.timeout > 0)) {
109
+ throw new BrowserbaseError("timeout must be a positive number of milliseconds");
110
+ }
111
+ assertHeaders(opts.defaultHeaders, "client option defaultHeaders");
77
112
  this.warn = opts.onWarning ?? ((m) => console.warn(`[solari-browserbase-compat] ${m}`));
78
113
  // Non-enumerable so console.log(client) / JSON.stringify(client) never print the key.
79
114
  Object.defineProperty(this, "apiKey", { value: resolveApiKey(opts.apiKey, this.warn), enumerable: false });
@@ -81,61 +116,106 @@ export class Browserbase {
81
116
  this.strict = opts.strict === true;
82
117
  this.fetchImpl = opts.fetch ?? fetch;
83
118
  this.maxRetries = opts.maxRetries ?? 2;
84
- this.timeoutMs = opts.timeout ?? 60_000;
119
+ this.timeout = opts.timeout ?? 60_000;
120
+ this.defaultHeaders = { ...(opts.defaultHeaders ?? {}) };
121
+ this.defaultQuery = { ...(opts.defaultQuery ?? {}) };
85
122
  this.sessions = new Sessions(this);
86
123
  this.contexts = new Contexts(this);
87
- this.extensions = unsupported("extensions", "browser extensions cannot be loaded");
88
- this.projects = unsupported("projects", "Solari keys are organisation-scoped; there are no projects");
89
- this.agents = unsupported("agents", "Browserbase Agents is a separate product");
90
- this.fetchAPI = unsupported("fetchAPI", "Browserbase Fetch is a separate product");
91
- this.search = unsupported("search", "Browserbase Search is a separate product");
92
124
  }
93
- /** @internal GET a presigned URL (no Solari auth header). */
94
- async fetchPublic(url) {
125
+ // Browserbase's raw HTTP escape hatches address Browserbase's own API paths, which Solari does not serve.
126
+ get(..._a) { throw rawUnsupported("get"); }
127
+ post(..._a) { throw rawUnsupported("post"); }
128
+ put(..._a) { throw rawUnsupported("put"); }
129
+ patch(..._a) { throw rawUnsupported("patch"); }
130
+ delete(..._a) { throw rawUnsupported("delete"); }
131
+ getAPIList(..._a) { throw rawUnsupported("getAPIList"); }
132
+ request(..._a) { throw rawUnsupported("request"); }
133
+ /** @internal `projectId` has nothing to select on Solari: say so once per client (or refuse under `strict`). */
134
+ noteProjectId(where) {
135
+ if (this.strict)
136
+ throw new SolariCompatUnsupported(`${where} projectId`, "Solari keys belong to an organisation; there are no projects");
137
+ if (this.projectIdWarned)
138
+ return;
139
+ this.projectIdWarned = true;
140
+ this.warn("projectId is ignored: Solari keys belong to an organisation; there are no projects.");
141
+ }
142
+ /** @internal GET a presigned URL (no Solari auth header, no default headers). */
143
+ async fetchPublic(url, call = {}, defaultTimeoutMs = 15_000) {
95
144
  const ac = new AbortController();
96
- const timer = setTimeout(() => ac.abort(), 15_000);
145
+ const detach = linkSignal(ac, call.signal);
146
+ const timer = setTimeout(() => ac.abort(), call.timeout ?? defaultTimeoutMs);
97
147
  try {
98
148
  return await this.fetchImpl(url, { signal: ac.signal });
99
149
  }
150
+ catch (e) {
151
+ if (call.signal?.aborted)
152
+ throw new APIUserAbortError();
153
+ throw connectionErrorFor(e);
154
+ }
100
155
  finally {
101
156
  clearTimeout(timer);
157
+ detach();
102
158
  }
103
159
  }
104
160
  /** @internal */
105
- async request(method, path, body, opts) {
161
+ async send(method, path, body, call = {}) {
106
162
  const headers = { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" };
107
- if (opts?.idempotencyKey)
108
- headers["Idempotency-Key"] = opts.idempotencyKey;
109
- const replaySafe = method === "GET" || method === "DELETE" || opts?.idempotencyKey !== undefined;
163
+ for (const [k, v] of Object.entries(this.defaultHeaders))
164
+ setHeader(headers, k, v);
165
+ for (const [k, v] of Object.entries(call.headers ?? {}))
166
+ setHeader(headers, k, v);
167
+ if (call.idempotencyKey)
168
+ headers["Idempotency-Key"] = call.idempotencyKey;
169
+ // Extra query parameters are appended (percent-encoded) rather than re-serialising the path's own query.
170
+ const extra = [...Object.entries(this.defaultQuery), ...Object.entries(call.query ?? {})]
171
+ .filter(([, v]) => v !== undefined && v !== null)
172
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
173
+ const url = `${this.baseURL}${path}${extra.length > 0 ? (path.includes("?") ? "&" : "?") + extra.join("&") : ""}`;
174
+ if (call.body !== undefined) {
175
+ if (method === "GET")
176
+ throw new SolariCompatUnsupported("request option body", "a GET has no body");
177
+ body = { ...(body ?? {}), ...call.body };
178
+ }
179
+ const maxRetries = call.maxRetries ?? this.maxRetries;
180
+ const timeoutMs = call.timeout ?? this.timeout;
181
+ const replaySafe = method === "GET" || method === "DELETE" || call.idempotencyKey !== undefined;
110
182
  let lastErr;
111
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
183
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
184
+ if (call.signal?.aborted)
185
+ throw new APIUserAbortError();
112
186
  const ac = new AbortController();
113
- const timer = setTimeout(() => ac.abort(), this.timeoutMs);
187
+ const detach = linkSignal(ac, call.signal);
188
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
114
189
  try {
115
- const res = await this.fetchImpl(`${this.baseURL}${path}`, {
190
+ const res = await this.fetchImpl(url, {
116
191
  method,
117
192
  headers,
118
193
  body: body === undefined ? undefined : JSON.stringify(body),
119
194
  signal: ac.signal,
120
195
  });
121
196
  clearTimeout(timer);
122
- if (res.ok || !replaySafe || ![502, 503, 504].includes(res.status) || attempt === this.maxRetries)
197
+ if (res.ok || !replaySafe || ![502, 503, 504].includes(res.status) || attempt === maxRetries)
123
198
  return res;
124
199
  await res.text().catch(() => { });
125
200
  }
126
201
  catch (e) {
127
202
  clearTimeout(timer);
128
- if (!replaySafe || attempt === this.maxRetries)
203
+ if (call.signal?.aborted)
204
+ throw new APIUserAbortError();
205
+ if (!replaySafe || attempt === maxRetries)
129
206
  throw connectionErrorFor(e);
130
207
  lastErr = e;
131
208
  }
209
+ finally {
210
+ detach();
211
+ }
132
212
  await sleep(300 * (attempt + 1));
133
213
  }
134
214
  throw connectionErrorFor(lastErr ?? new Error("request failed"));
135
215
  }
136
216
  /** @internal */
137
- async json(method, path, body, opts) {
138
- const res = await this.request(method, path, body, opts);
217
+ async json(method, path, body, call = {}) {
218
+ const res = await this.send(method, path, body, call);
139
219
  if (res.status === 204)
140
220
  return undefined;
141
221
  const text = await res.text();
@@ -153,45 +233,51 @@ export class Browserbase {
153
233
  return parsed;
154
234
  }
155
235
  }
156
- class UnsupportedImpl {
157
- feature;
158
- hint;
159
- constructor(feature, hint) {
160
- this.feature = feature;
161
- this.hint = hint;
162
- // Every method on this object throws; a Proxy keeps the surface open-ended (create/list/retrieve/...).
163
- return new Proxy(this, {
164
- get: (target, prop) => {
165
- if (typeof prop === "symbol" || prop === "then" || prop in target)
166
- return Reflect.get(target, prop);
167
- return () => {
168
- throw new SolariCompatUnsupported(`${target.feature}.${prop}`, target.hint);
169
- };
170
- },
171
- });
172
- }
236
+ function rawUnsupported(method) {
237
+ return new SolariCompatUnsupported(`client.${method}()`, "Browserbase's raw HTTP methods address its own API paths, which Solari does not serve");
173
238
  }
174
- function unsupported(feature, hint) {
175
- return new UnsupportedImpl(feature, hint);
239
+ /** Abort `ac` when the caller's signal aborts. Returns the function that detaches the listener. */
240
+ function linkSignal(ac, signal) {
241
+ if (!signal)
242
+ return () => { };
243
+ if (signal.aborted) {
244
+ ac.abort();
245
+ return () => { };
246
+ }
247
+ const on = () => ac.abort();
248
+ signal.addEventListener("abort", on, { once: true });
249
+ return () => signal.removeEventListener("abort", on);
176
250
  }
177
251
  function newKey() {
178
252
  return globalThis.crypto?.randomUUID?.() ?? `bbc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
179
253
  }
254
+ /** The keepAlive flag and the user's own metadata, from what the gateway stored (see KEEP_ALIVE_KEY). */
255
+ function splitMetadata(md) {
256
+ if (!md || typeof md !== "object")
257
+ return { keepAlive: false };
258
+ const { [KEEP_ALIVE_KEY]: marker, ...rest } = md;
259
+ return { keepAlive: marker === true, ...(Object.keys(rest).length > 0 ? { userMetadata: rest } : {}) };
260
+ }
180
261
  function toSession(row, extra = {}) {
181
262
  const created = row.createdAt ?? new Date().toISOString();
182
263
  const status = extra.status ?? mapStatus(row.status);
264
+ const expiresAt = row.expiresAt ?? created;
265
+ const meta = splitMetadata(row.userMetadata);
266
+ // Only report an end time we know: the release call knows it (now); a timed-out session ended at its deadline; the
267
+ // gateway does not record when a released session ended, so that is left out rather than invented.
268
+ const endedAt = extra.endedAt ?? (status === "TIMED_OUT" && row.expiresAt ? row.expiresAt : undefined);
183
269
  return {
184
270
  id: row.id,
185
271
  createdAt: created,
186
272
  updatedAt: created,
187
273
  projectId: "",
188
274
  startedAt: created,
189
- ...(status === "COMPLETED" || status === "TIMED_OUT" ? { endedAt: new Date().toISOString() } : {}),
190
- expiresAt: row.expiresAt ?? created,
275
+ ...(endedAt ? { endedAt } : {}),
276
+ expiresAt,
191
277
  status,
192
278
  proxyBytes: 0,
193
- keepAlive: extra.keepAlive ?? false,
194
- ...(row.userMetadata ? { userMetadata: row.userMetadata } : {}),
279
+ keepAlive: extra.keepAlive ?? meta.keepAlive,
280
+ ...(meta.userMetadata ? { userMetadata: meta.userMetadata } : {}),
195
281
  ...(extra.contextId ? { contextId: extra.contextId } : {}),
196
282
  region: REGION,
197
283
  };
@@ -199,102 +285,151 @@ function toSession(row, extra = {}) {
199
285
  function withConnect(s, cdp) {
200
286
  return { ...s, connectUrl: cdp, seleniumRemoteUrl: "", signingKey: "" };
201
287
  }
288
+ function onlyParams(obj, known, feature) {
289
+ for (const k of Object.keys(obj ?? {})) {
290
+ if (!known.includes(k))
291
+ throw new SolariCompatUnsupported(`${feature}.${k}`, "this parameter is not supported");
292
+ }
293
+ }
294
+ const MAX_LIST_PAGES = 1000;
202
295
  class Sessions {
203
296
  c;
204
297
  recording;
205
- replays;
206
- logs;
207
- downloads;
208
- uploads;
298
+ replays = unsupportedMethods("sessions.replays", "Solari records DOM events, not video; use sessions.recording.retrieve", ["retrieve", "retrievePage"]);
299
+ logs = unsupportedMethods("sessions.logs", "session command logs are not available", ["list"]);
300
+ downloads = unsupportedMethods("sessions.downloads", "file downloads cannot be retrieved through the API", ["list"]);
301
+ uploads = unsupportedMethods("sessions.uploads", "use your automation client's setInputFiles", ["create"]);
209
302
  constructor(c) {
210
303
  this.c = c;
211
304
  this.recording = new Recording(c);
212
- this.replays = unsupported("sessions.replays", "Solari records DOM events, not video; use sessions.recording.retrieve");
213
- this.logs = unsupported("sessions.logs", "session command logs are not available");
214
- this.downloads = unsupported("sessions.downloads", "file downloads cannot be retrieved through the API");
215
- this.uploads = unsupported("sessions.uploads", "use your automation client's setInputFiles");
216
305
  }
217
- async create(params) {
218
- const mapped = mapCreateParams(params, this.c.strict);
219
- for (const w of mapped.warnings)
220
- this.c.warn(w);
221
- const data = await this.c.json("POST", "/sessions", Object.keys(mapped.body).length ? mapped.body : undefined, { idempotencyKey: newKey() });
222
- if (!data?.sessionId || !data.cdpEndpoint) {
223
- throw new BrowserbaseError(`Solari returned an unexpected session response: ${JSON.stringify(data)}`);
224
- }
225
- if (mapped.persistProfileId)
226
- this.c.persist.set(data.sessionId, mapped.persistProfileId);
227
- const ctx = params?.browserSettings?.context?.id;
228
- const s = toSession({
229
- id: data.sessionId,
230
- status: "active",
231
- createdAt: new Date().toISOString(),
232
- ...(data.expiresAt ? { expiresAt: data.expiresAt } : {}),
233
- ...(data.userMetadata ? { userMetadata: data.userMetadata } : {}),
234
- }, { keepAlive: params?.keepAlive === true, ...(ctx ? { contextId: ctx } : {}), status: "RUNNING" });
235
- const out = withConnect(s, data.cdpEndpoint);
236
- if (ctx) {
237
- let state = data.storageState ?? null;
238
- if (!state && data.storageStateUrl?.url) {
239
- const r = await this.c.fetchPublic(data.storageStateUrl.url).catch(() => undefined);
240
- if (r?.ok)
241
- state = (await r.json().catch(() => null));
242
- else
243
- this.c.warn("could not download the saved context; the session starts without it");
306
+ create(first, second) {
307
+ return compat(async () => {
308
+ const [params, options] = splitArgs(first, second);
309
+ const call = resolveOptions(options, { body: true, idempotencyKey: true }, "sessions.create");
310
+ const mapped = mapCreateParams(params, this.c.strict);
311
+ if (params?.projectId !== undefined)
312
+ this.c.noteProjectId("sessions.create");
313
+ for (const w of mapped.warnings)
314
+ this.c.warn(w);
315
+ const data = await this.c.json("POST", "/sessions", Object.keys(mapped.body).length ? mapped.body : undefined, { ...call, idempotencyKey: call.idempotencyKey ?? newKey() });
316
+ if (!data?.sessionId || !data.cdpEndpoint) {
317
+ throw new BrowserbaseError(`Solari returned an unexpected session response: ${JSON.stringify(data)}`);
244
318
  }
245
- out.storageState = state;
246
- }
247
- return out;
319
+ if (mapped.persistProfileId)
320
+ this.c.persist.set(data.sessionId, mapped.persistProfileId);
321
+ const ctx = params?.browserSettings?.context?.id;
322
+ const s = toSession({
323
+ id: data.sessionId,
324
+ status: "active",
325
+ createdAt: new Date().toISOString(),
326
+ ...(data.expiresAt ? { expiresAt: data.expiresAt } : {}),
327
+ ...(data.userMetadata ? { userMetadata: data.userMetadata } : {}),
328
+ }, { keepAlive: params?.keepAlive === true, ...(ctx ? { contextId: ctx } : {}), status: "RUNNING" });
329
+ const out = withConnect(s, data.cdpEndpoint);
330
+ if (ctx) {
331
+ let state = data.storageState ?? null;
332
+ if (!state && data.storageStateUrl?.url) {
333
+ const r = await this.c.fetchPublic(data.storageStateUrl.url, followUp(call)).catch(() => undefined);
334
+ if (r?.ok)
335
+ state = (await r.json().catch(() => null));
336
+ else
337
+ this.c.warn("could not download the saved context; the session starts without it");
338
+ }
339
+ out.storageState = state;
340
+ }
341
+ return out;
342
+ });
248
343
  }
249
- async retrieve(id) {
250
- const row = await this.c.json("GET", `/sessions/${encodeURIComponent(id)}`);
251
- return withConnect(toSession({ ...row, id: row.id ?? id }), row.cdpEndpoint ?? "");
344
+ retrieve(id, options) {
345
+ return compat(async () => {
346
+ const call = resolveOptions(options, {}, "sessions.retrieve");
347
+ const row = await this.c.json("GET", `/sessions/${encodeURIComponent(id)}`, undefined, call);
348
+ return withConnect(toSession({ ...row, id: row.id ?? id }), row.cdpEndpoint ?? "");
349
+ });
252
350
  }
253
- async list(query) {
254
- const data = await this.c.json("GET", query?.q ? `/sessions?q=${encodeURIComponent(query.q)}` : "/sessions");
255
- const all = (data?.sessions ?? []).map((r) => toSession(r));
256
- return query?.status ? all.filter((s) => s.status === query.status) : all;
351
+ list(first, second) {
352
+ return compat(async () => {
353
+ const [query, options] = splitArgs(first, second);
354
+ onlyParams(query, ["status", "q"], "sessions.list");
355
+ const call = resolveOptions(options, {}, "sessions.list");
356
+ // The gateway answers in pages (100 by default); Browserbase's list is the whole list, so follow the cursor.
357
+ const rows = [];
358
+ let cursor;
359
+ for (let page = 0;; page++) {
360
+ if (page >= MAX_LIST_PAGES)
361
+ throw new BrowserbaseError(`sessions.list: more than ${MAX_LIST_PAGES} pages; narrow it with q`);
362
+ const qs = [];
363
+ if (query?.q)
364
+ qs.push(`q=${encodeURIComponent(query.q)}`);
365
+ if (cursor)
366
+ qs.push(`cursor=${encodeURIComponent(cursor)}`);
367
+ const data = await this.c.json("GET", qs.length > 0 ? `/sessions?${qs.join("&")}` : "/sessions", undefined, call);
368
+ rows.push(...(data?.sessions ?? []));
369
+ if (!data?.nextCursor)
370
+ break;
371
+ if (data.nextCursor === cursor)
372
+ throw new BrowserbaseError("sessions.list: the gateway's cursor did not advance");
373
+ cursor = data.nextCursor;
374
+ }
375
+ const all = rows.map((r) => toSession(r));
376
+ return query?.status ? all.filter((s) => s.status === query.status) : all;
377
+ });
257
378
  }
258
379
  /** `{ status: "REQUEST_RELEASE" }` releases the session (saving the context first when it was created with persist: true). */
259
- async update(id, params) {
260
- if (params?.status !== "REQUEST_RELEASE")
261
- throw new SolariCompatUnsupported(`sessions.update status ${String(params?.status)}`, "only REQUEST_RELEASE exists");
262
- const profileId = this.c.persist.get(id);
263
- if (profileId) {
264
- await this.c.json("POST", `/sessions/${encodeURIComponent(id)}/save-profile`, { profileId });
265
- this.c.persist.delete(id);
266
- }
267
- await this.c.json("DELETE", `/sessions/${encodeURIComponent(id)}`);
268
- return toSession({ id, status: "released" }, { status: "COMPLETED" });
380
+ update(id, params, options) {
381
+ return compat(async () => {
382
+ onlyParams(params, ["status", "projectId"], "sessions.update");
383
+ const call = resolveOptions(options, {}, "sessions.update");
384
+ if (params?.status !== "REQUEST_RELEASE")
385
+ throw new SolariCompatUnsupported(`sessions.update status ${String(params?.status)}`, "only REQUEST_RELEASE exists");
386
+ if (params.projectId !== undefined)
387
+ this.c.noteProjectId("sessions.update");
388
+ const profileId = this.c.persist.get(id);
389
+ if (profileId) {
390
+ await this.c.json("POST", `/sessions/${encodeURIComponent(id)}/save-profile`, { profileId }, call);
391
+ this.c.persist.delete(id);
392
+ }
393
+ await this.c.json("DELETE", `/sessions/${encodeURIComponent(id)}`, undefined, call);
394
+ return toSession({ id, status: "released" }, { status: "COMPLETED", endedAt: new Date().toISOString() });
395
+ });
269
396
  }
270
- /** Live-view links. Both URLs are the same view-only share link; `pages` is empty. */
271
- async debug(id, query) {
272
- const share = await this.c.json("POST", `/sessions/${encodeURIComponent(id)}/shares`, query?.expiresIn !== undefined ? { ttlSeconds: query.expiresIn } : {});
273
- const row = await this.c.json("GET", `/sessions/${encodeURIComponent(id)}`);
274
- return { debuggerFullscreenUrl: share.url, debuggerUrl: share.url, wsUrl: row.cdpEndpoint ?? "", pages: [] };
397
+ debug(id, first, second) {
398
+ return compat(async () => {
399
+ const [query, options] = splitArgs(first, second);
400
+ onlyParams(query, ["expiresIn"], "sessions.debug");
401
+ const call = resolveOptions(options, {}, "sessions.debug");
402
+ const share = await this.c.json("POST", `/sessions/${encodeURIComponent(id)}/shares`, query?.expiresIn !== undefined ? { ttlSeconds: query.expiresIn } : {}, call);
403
+ const row = await this.c.json("GET", `/sessions/${encodeURIComponent(id)}`, undefined, call);
404
+ return { debuggerFullscreenUrl: share.url, debuggerUrl: share.url, wsUrl: row.cdpEndpoint ?? "", pages: [] };
405
+ });
275
406
  }
276
407
  }
277
408
  class Recording {
278
409
  c;
410
+ downloads = unsupportedMethods("sessions.recording.downloads", "Solari records DOM events, not video; use sessions.recording.retrieve", ["create", "list"]);
279
411
  constructor(c) {
280
412
  this.c = c;
281
413
  }
282
414
  /** The session's DOM recording as an array of rrweb events. Needs `browserSettings: { recordSession: true }`; available a few seconds after release. */
283
- async retrieve(id) {
284
- const meta = await this.c.json("GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
285
- if (!meta?.url)
286
- throw new BrowserbaseError("Solari returned no replay URL");
287
- const r = await fetch(meta.url);
288
- if (!r.ok)
289
- throw new APIError(r.status, `replay download failed: ${r.status}`);
290
- let buf = Buffer.from(await r.arrayBuffer());
291
- if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b)
292
- buf = gunzipSync(buf);
293
- return buf
294
- .toString("utf8")
295
- .split("\n")
296
- .filter((l) => l.trim() !== "")
297
- .map((l) => JSON.parse(l));
415
+ retrieve(id, options) {
416
+ return compat(async () => {
417
+ const call = resolveOptions(options, {}, "sessions.recording.retrieve");
418
+ const meta = await this.c.json("GET", `/sessions/${encodeURIComponent(id)}/replay-url`, undefined, call);
419
+ if (!meta?.url)
420
+ throw new BrowserbaseError("Solari returned no replay URL");
421
+ const r = await this.c.fetchPublic(meta.url, followUp(call), 60_000);
422
+ if (!r.ok)
423
+ throw new APIError(r.status, `replay download failed: ${r.status}`);
424
+ let buf = Buffer.from(await r.arrayBuffer());
425
+ if (buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b)
426
+ buf = gunzipSync(buf);
427
+ return buf
428
+ .toString("utf8")
429
+ .split("\n")
430
+ .filter((l) => l.trim() !== "")
431
+ .map((l) => JSON.parse(l));
432
+ });
298
433
  }
299
434
  }
300
435
  class Contexts {
@@ -302,22 +437,35 @@ class Contexts {
302
437
  constructor(c) {
303
438
  this.c = c;
304
439
  }
305
- /** A Context is a Solari profile (cookies + localStorage). */
306
- async create(params) {
307
- const p = await this.c.json("POST", "/profiles", { name: params?.name ?? `context-${newKey().slice(0, 8)}` });
308
- return { id: p.id, publicKey: "", cipherAlgorithm: "", initializationVectorSize: 0 };
440
+ create(first, second) {
441
+ return compat(async () => {
442
+ const [params, options] = splitArgs(first, second);
443
+ onlyParams(params, ["projectId", "name"], "contexts.create");
444
+ const call = resolveOptions(options, { body: true, idempotencyKey: true }, "contexts.create");
445
+ if (params?.projectId !== undefined)
446
+ this.c.noteProjectId("contexts.create");
447
+ const p = await this.c.json("POST", "/profiles", { name: params?.name ?? `context-${newKey().slice(0, 8)}` }, call);
448
+ return { id: p.id, publicKey: "", cipherAlgorithm: "", initializationVectorSize: 0, uploadUrl: "" };
449
+ });
309
450
  }
310
- async retrieve(id) {
311
- const list = await this.c.json("GET", "/profiles");
312
- const p = (list ?? []).find((x) => x.id === id);
313
- if (!p)
314
- throw new APIError(404, `context ${id} not found`, "NotFound");
315
- return { id: p.id, ...(p.name ? { name: p.name } : {}), ...(p.createdAt ? { createdAt: p.createdAt } : {}) };
451
+ retrieve(id, options) {
452
+ return compat(async () => {
453
+ const call = resolveOptions(options, {}, "contexts.retrieve");
454
+ const list = await this.c.json("GET", "/profiles", undefined, call);
455
+ const p = (list ?? []).find((x) => x.id === id);
456
+ if (!p)
457
+ throw new NotFoundError(404, `context ${id} not found`, "NotFound");
458
+ const createdAt = p.createdAt ?? "";
459
+ return { id: p.id, ...(p.name ? { name: p.name } : {}), createdAt, updatedAt: p.updatedAt ?? createdAt, projectId: "" };
460
+ });
316
461
  }
317
- update() {
462
+ update(..._a) {
318
463
  throw new SolariCompatUnsupported("contexts.update", "contexts are saved when a persist:true session is released");
319
464
  }
320
- async delete(id) {
321
- await this.c.json("DELETE", `/profiles/${encodeURIComponent(id)}`);
465
+ delete(id, options) {
466
+ return compat(async () => {
467
+ const call = resolveOptions(options, {}, "contexts.delete");
468
+ await this.c.json("DELETE", `/profiles/${encodeURIComponent(id)}`, undefined, call);
469
+ });
322
470
  }
323
471
  }
package/dist/errors.d.ts CHANGED
@@ -42,6 +42,10 @@ export declare class APIConnectionError extends BrowserbaseError {
42
42
  export declare class APIConnectionTimeoutError extends APIConnectionError {
43
43
  constructor(message?: string, cause?: unknown);
44
44
  }
45
+ /** The caller aborted the request (the `signal` passed in the request options). Same name as Browserbase's SDK. */
46
+ export declare class APIUserAbortError extends BrowserbaseError {
47
+ constructor(message?: string);
48
+ }
45
49
  /** The APIError subclass for an HTTP status, as Browserbase's SDK raises them. */
46
50
  export declare function apiErrorFor(status: number, message: string, code?: string, body?: unknown): APIError;
47
51
  /** A fetch failure as a Browserbase-style connection error (abort/timeout vs. everything else). */
package/dist/errors.js CHANGED
@@ -55,6 +55,12 @@ export class APIConnectionTimeoutError extends APIConnectionError {
55
55
  super(message, cause);
56
56
  }
57
57
  }
58
+ /** The caller aborted the request (the `signal` passed in the request options). Same name as Browserbase's SDK. */
59
+ export class APIUserAbortError extends BrowserbaseError {
60
+ constructor(message = "Request was aborted.") {
61
+ super(message);
62
+ }
63
+ }
58
64
  /** The APIError subclass for an HTTP status, as Browserbase's SDK raises them. */
59
65
  export function apiErrorFor(status, message, code, body) {
60
66
  const C = status === 400 ? BadRequestError
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { Browserbase } from "./client.js";
2
2
  export { Browserbase, DEFAULT_BASE_URL } from "./client.js";
3
- export type { BrowserbaseOptions, UnsupportedResource } from "./client.js";
4
- export { APIConnectionError, APIConnectionTimeoutError, APIError, AuthenticationError, BadRequestError, BrowserbaseError, ConflictError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, SolariCompatUnsupported, UnprocessableEntityError, } from "./errors.js";
3
+ export type { BrowserbaseOptions } from "./client.js";
4
+ export type { UnsupportedResource } from "./unsupported.js";
5
+ export type { RequestOptions } from "./options.js";
6
+ export { CompatPromise } from "./options.js";
7
+ export { APIConnectionError, APIConnectionTimeoutError, APIError, APIUserAbortError, AuthenticationError, BadRequestError, BrowserbaseError, ConflictError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, SolariCompatUnsupported, UnprocessableEntityError, } from "./errors.js";
5
8
  export { mapCreateParams, mapStatus } from "./mapping.js";
6
9
  export type * from "./types.js";
7
10
  export default Browserbase;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Browserbase } from "./client.js";
2
2
  export { Browserbase, DEFAULT_BASE_URL } from "./client.js";
3
- export { APIConnectionError, APIConnectionTimeoutError, APIError, AuthenticationError, BadRequestError, BrowserbaseError, ConflictError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, SolariCompatUnsupported, UnprocessableEntityError, } from "./errors.js";
3
+ export { CompatPromise } from "./options.js";
4
+ export { APIConnectionError, APIConnectionTimeoutError, APIError, APIUserAbortError, AuthenticationError, BadRequestError, BrowserbaseError, ConflictError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, SolariCompatUnsupported, UnprocessableEntityError, } from "./errors.js";
4
5
  export { mapCreateParams, mapStatus } from "./mapping.js";
5
6
  export default Browserbase;