@solarisdk/browser 0.4.0 → 0.5.1

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
@@ -88,6 +88,23 @@ seconds for the gateway (1001 ms is 2 s); the gateway refuses anything under 60
88
88
  zero or unset leaves the plan maximum, and a negative, `NaN`, `Infinity` or
89
89
  fractional value throws before any request. `launch()` accepts the same options.
90
90
 
91
+ ## Edit a profile
92
+
93
+ `solari.profiles.edit(id)` opens the profile editor, where a person signs in once and the result is saved into the profile. It returns a handle; give its `url` to whoever should sign in, or open it yourself.
94
+
95
+ ```ts
96
+ const editor = await solari.profiles.edit("prof_123", { reason: "Sign in to Acme" });
97
+ console.log(editor.url); // open this in a browser
98
+
99
+ await editor.waitForSave({ timeoutMs: 10 * 60_000 }); // the person clicks Save in the editor page
100
+ // or drive it from code once they are done:
101
+ await editor.saveAndClose(); // save and close the editor
102
+ await editor.exit(); // close it WITHOUT saving
103
+ console.log(await editor.status());
104
+ ```
105
+
106
+ The link works for 30 minutes and the editor starts when the person clicks "Open editor" on the page. After that the editor stops by itself and these calls fail with a clear 404 (link expired) or 409 (editor not running). There is no save-and-keep-editing: `saveAndClose()` saves and closes.
107
+
91
108
  ## Regions
92
109
 
93
110
  Solari runs in `us-west` (N. California) today, with additional regions
package/dist/index.d.ts CHANGED
@@ -96,6 +96,18 @@ export interface Profile {
96
96
  name: string;
97
97
  [k: string]: unknown;
98
98
  }
99
+ /** Options for `profiles.edit()`. */
100
+ export interface EditProfileOptions {
101
+ /** Shown to whoever opens the link ("Sign in once so your agent can reuse this login" by default). Max 300 chars. */
102
+ reason?: string;
103
+ }
104
+ /** Options for `ProfileEditor.waitForSave()`. */
105
+ export interface WaitForSaveOptions {
106
+ /** Give up after this many ms. Default: until the link expires. */
107
+ timeoutMs?: number;
108
+ /** How often to check. Default 2000. */
109
+ pollMs?: number;
110
+ }
99
111
  /** What `blockHeavy` skips. Every field is optional. */
100
112
  export interface BlockHeavyOptions {
101
113
  /** Block video and audio. Default true. */
@@ -218,6 +230,62 @@ export declare class SolariError extends Error {
218
230
  readonly code?: (SolariErrorCode | string) | undefined;
219
231
  constructor(message: string, status?: number | undefined, cause?: unknown | undefined, code?: (SolariErrorCode | string) | undefined);
220
232
  }
233
+ /**
234
+ * `text` with each of `secrets` (the session's ws / cdp endpoints) replaced by its redacted form, then any other
235
+ * URL-looking token cut to `scheme://host/…`: the Playwright driver prints the URL it dialled ("Call log: <ws
236
+ * connecting> ws://…/ws/<id>"), and that URL carries the session's credential.
237
+ */
238
+ declare function scrubUrls(text: string, secrets?: string[]): string;
239
+ /** @internal Exposed for tests only. */
240
+ export declare const __testing: {
241
+ scrubUrls: typeof scrubUrls;
242
+ redactSessionPath: typeof redactSessionPath;
243
+ };
244
+ declare function redactSessionPath(text: string): string;
245
+ /**
246
+ * A profile editor launched by `solari.profiles.edit(id)`. Give `url` to the person who should
247
+ * sign in (or open it yourself). The editor starts when the person clicks "Open editor" on that page; it is only usable
248
+ * while the link is valid (30 minutes from `edit()`), after which the editor stops by itself.
249
+ */
250
+ export declare class ProfileEditor {
251
+ private readonly client;
252
+ readonly profileId: string;
253
+ /** Open this in a browser. */
254
+ readonly url: string;
255
+ readonly handoffId: string;
256
+ /** ISO time after which the link stops working. */
257
+ readonly expiresAt: string;
258
+ /** The profile's version when the editor was launched; a save raises it. */
259
+ readonly startVersion: number;
260
+ constructor(client: Solari, profileId: string,
261
+ /** Open this in a browser. */
262
+ url: string, handoffId: string,
263
+ /** ISO time after which the link stops working. */
264
+ expiresAt: string,
265
+ /** The profile's version when the editor was launched; a save raises it. */
266
+ startVersion: number);
267
+ private path;
268
+ private headers;
269
+ /** The editor's state as the console reports it (`editorStatus`: launching | editing | saving | idle | error, or null before it was ever opened, meaning idle). */
270
+ status(): Promise<{
271
+ editorStatus: string;
272
+ version: number;
273
+ [k: string]: unknown;
274
+ }>;
275
+ /** Save what was signed in and close the editor. Resolves with the new profile version. */
276
+ saveAndClose(): Promise<{
277
+ version: number;
278
+ }>;
279
+ /** Close the editor WITHOUT saving; the profile keeps its previous version. */
280
+ exit(): Promise<void>;
281
+ /**
282
+ * Wait until the person saves in the editor page (the profile's version rises). Resolves with the
283
+ * new version; throws a `SolariError` with status 408 on timeout or when the link expires first.
284
+ */
285
+ waitForSave(opts?: WaitForSaveOptions): Promise<{
286
+ version: number;
287
+ }>;
288
+ }
221
289
  declare class ProfilesResource {
222
290
  private readonly client;
223
291
  constructor(client: Solari);
@@ -225,6 +293,11 @@ declare class ProfilesResource {
225
293
  name: string;
226
294
  }): Promise<Profile>;
227
295
  list(): Promise<Profile[]>;
296
+ /**
297
+ * Open the profile editor for an existing (or newly created) profile, to sign in or fix its
298
+ * login. Returns a handle whose `url` is the link to the editor page; see `ProfileEditor`.
299
+ */
300
+ edit(id: string, opts?: EditProfileOptions): Promise<ProfileEditor>;
228
301
  delete(id: string): Promise<void>;
229
302
  save(id: string, storageState: StorageState): Promise<{
230
303
  version: number;
@@ -290,5 +363,7 @@ export declare class Solari {
290
363
  _unregisterSession(sessionId: string): void;
291
364
  request(method: string, path: string, body?: unknown, opts?: {
292
365
  idempotencyKey?: string;
366
+ headers?: Record<string, string>;
367
+ singleAttempt?: boolean;
293
368
  }): Promise<Response>;
294
369
  }
package/dist/index.js CHANGED
@@ -69,6 +69,51 @@ export class SolariError extends Error {
69
69
  * `code`. Every REST call uses this so a caller can branch on `e.code` no matter
70
70
  * which endpoint failed.
71
71
  */
72
+ /**
73
+ * `text` with the session id in every `/sessions/<id>` cut to its first 8 characters. The session id is
74
+ * the credential inside the ws/cdp URLs, so it must not appear in error messages or logs.
75
+ */
76
+ /** Field names of a gateway response, never its values (they hold the session id and the ws / cdp URLs). */
77
+ function responseFields(data) {
78
+ return data && typeof data === "object" ? Object.keys(data).join(", ") || "none" : typeof data;
79
+ }
80
+ function redactUrl(url) {
81
+ try {
82
+ const u = new URL(url);
83
+ return `${u.protocol}//${u.host}/\u2026`;
84
+ }
85
+ catch {
86
+ return "<url>";
87
+ }
88
+ }
89
+ /**
90
+ * `text` with each of `secrets` (the session's ws / cdp endpoints) replaced by its redacted form, then any other
91
+ * URL-looking token cut to `scheme://host/…`: the Playwright driver prints the URL it dialled ("Call log: <ws
92
+ * connecting> ws://…/ws/<id>"), and that URL carries the session's credential.
93
+ */
94
+ function scrubUrls(text, secrets = []) {
95
+ let out = text;
96
+ for (const s of secrets)
97
+ if (s)
98
+ out = out.split(s).join(redactUrl(s));
99
+ return out.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>)\],]+/gi, (m) => redactUrl(m));
100
+ }
101
+ /** @internal Exposed for tests only. */
102
+ export const __testing = { scrubUrls, redactSessionPath };
103
+ /** The error to surface from launch(): a foreign error (patchright's connect failure) gets its message scrubbed and its
104
+ * cause dropped, since both print the endpoint; SolariErrors only get a scrub of their message. */
105
+ function scrubbedLaunchError(err, secrets) {
106
+ if (err instanceof SolariError) {
107
+ err.message = scrubUrls(err.message, secrets);
108
+ return err;
109
+ }
110
+ if (err instanceof Error)
111
+ return new SolariError(scrubUrls(err.message, secrets));
112
+ return err;
113
+ }
114
+ function redactSessionPath(text) {
115
+ return text.replace(/(\/sessions\/)([^/?#\s]+)/g, (_m, prefix, id) => `${prefix}${id.length > 8 ? `${id.slice(0, 8)}\u2026` : id}`);
116
+ }
72
117
  async function apiError(what, res) {
73
118
  const body = await res.text().catch(() => "");
74
119
  let code;
@@ -78,7 +123,7 @@ async function apiError(what, res) {
78
123
  code = parsed.code;
79
124
  }
80
125
  catch { }
81
- return new SolariError(`Solari ${what} failed: ${res.status} ${body}`, res.status, undefined, code);
126
+ return new SolariError(`Solari ${redactSessionPath(what)} failed: ${res.status} ${body}`, res.status, undefined, code);
82
127
  }
83
128
  const REGION_URLS = {
84
129
  "us-west": "https://api.getsolari.com",
@@ -221,6 +266,78 @@ function deriveCdpFromWs(wsEndpoint) {
221
266
  catch { }
222
267
  return wsEndpoint;
223
268
  }
269
+ /**
270
+ * A profile editor launched by `solari.profiles.edit(id)`. Give `url` to the person who should
271
+ * sign in (or open it yourself). The editor starts when the person clicks "Open editor" on that page; it is only usable
272
+ * while the link is valid (30 minutes from `edit()`), after which the editor stops by itself.
273
+ */
274
+ export class ProfileEditor {
275
+ client;
276
+ profileId;
277
+ url;
278
+ handoffId;
279
+ expiresAt;
280
+ startVersion;
281
+ constructor(client, profileId,
282
+ /** Open this in a browser. */
283
+ url, handoffId,
284
+ /** ISO time after which the link stops working. */
285
+ expiresAt,
286
+ /** The profile's version when the editor was launched; a save raises it. */
287
+ startVersion) {
288
+ this.client = client;
289
+ this.profileId = profileId;
290
+ this.url = url;
291
+ this.handoffId = handoffId;
292
+ this.expiresAt = expiresAt;
293
+ this.startVersion = startVersion;
294
+ }
295
+ path(suffix = "") {
296
+ return `/profiles/${encodeURIComponent(this.profileId)}/editor${suffix}`;
297
+ }
298
+ headers(singleAttempt = false) {
299
+ return { headers: { "X-Handoff-Id": this.handoffId }, singleAttempt };
300
+ }
301
+ /** The editor's state as the console reports it (`editorStatus`: launching | editing | saving | idle | error, or null before it was ever opened, meaning idle). */
302
+ async status() {
303
+ const res = await this.client.request("GET", this.path(), undefined, this.headers());
304
+ if (!res.ok)
305
+ throw await apiError(`GET ${this.path()}`, res);
306
+ return (await res.json());
307
+ }
308
+ /** Save what was signed in and close the editor. Resolves with the new profile version. */
309
+ async saveAndClose() {
310
+ const res = await this.client.request("POST", this.path("/save"), undefined, this.headers(true));
311
+ if (!res.ok)
312
+ throw await apiError(`POST ${this.path("/save")}`, res);
313
+ const data = (await res.json());
314
+ return { version: data.version ?? this.startVersion + 1 };
315
+ }
316
+ /** Close the editor WITHOUT saving; the profile keeps its previous version. */
317
+ async exit() {
318
+ const res = await this.client.request("DELETE", this.path(), undefined, this.headers(true));
319
+ if (!res.ok)
320
+ throw await apiError(`DELETE ${this.path()}`, res);
321
+ }
322
+ /**
323
+ * Wait until the person saves in the editor page (the profile's version rises). Resolves with the
324
+ * new version; throws a `SolariError` with status 408 on timeout or when the link expires first.
325
+ */
326
+ async waitForSave(opts = {}) {
327
+ const pollMs = opts.pollMs ?? 2000;
328
+ const deadline = Math.min(Date.parse(this.expiresAt) || Number.POSITIVE_INFINITY, opts.timeoutMs === undefined ? Number.POSITIVE_INFINITY : Date.now() + opts.timeoutMs);
329
+ for (;;) {
330
+ const p = (await this.client.profiles.list()).find((x) => x.id === this.profileId);
331
+ const v = typeof p?.version === "number" ? p.version : this.startVersion;
332
+ if (v > this.startVersion)
333
+ return { version: v };
334
+ if (Date.now() >= deadline) {
335
+ throw new SolariError(`Profile ${this.profileId} was not saved before the editor link expired or the wait timed out`, 408);
336
+ }
337
+ await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
338
+ }
339
+ }
340
+ }
224
341
  class ProfilesResource {
225
342
  client;
226
343
  constructor(client) {
@@ -247,6 +364,17 @@ class ProfilesResource {
247
364
  throw await apiError("GET /profiles", res);
248
365
  return (await res.json());
249
366
  }
367
+ /**
368
+ * Open the profile editor for an existing (or newly created) profile, to sign in or fix its
369
+ * login. Returns a handle whose `url` is the link to the editor page; see `ProfileEditor`.
370
+ */
371
+ async edit(id, opts = {}) {
372
+ const res = await this.client.request("POST", `/profiles/${encodeURIComponent(id)}/login-handoff`, opts.reason === undefined ? {} : { reason: opts.reason });
373
+ if (!res.ok)
374
+ throw await apiError(`POST /profiles/${id}/login-handoff`, res);
375
+ const d = (await res.json());
376
+ return new ProfileEditor(this.client, id, d.url, d.handoffId, d.expiresAt, d.version ?? 0);
377
+ }
250
378
  async delete(id) {
251
379
  const res = await this.client.request("DELETE", `/profiles/${encodeURIComponent(id)}`);
252
380
  if (!res.ok && res.status !== 404)
@@ -354,7 +482,7 @@ class SessionsResource {
354
482
  }
355
483
  const data = (await res.json());
356
484
  if (!data.sessionId || !data.wsEndpoint) {
357
- throw new SolariError(`Solari: unexpected session response: ${JSON.stringify(data)}`);
485
+ throw new SolariError(`Solari: unexpected session response (fields: ${responseFields(data)})`);
358
486
  }
359
487
  const cdpUpstream = data.cdpEndpoint ?? deriveCdpFromWs(data.wsEndpoint);
360
488
  const localWsEndpoint = await this.client._wrapSessionEndpoint(data.sessionId, data.wsEndpoint, "ws");
@@ -389,12 +517,12 @@ class SessionsResource {
389
517
  void this.client.request("DELETE", `/sessions/${encodeURIComponent(id)}`).then(async (res) => {
390
518
  const rejection = await releaseRejection(res);
391
519
  if (rejection) {
392
- console.warn(`[Solari] DELETE /sessions/${id} returned ${res.status}: ` +
520
+ console.warn(`[Solari] ${redactSessionPath(`DELETE /sessions/${id}`)} returned ${res.status}: ` +
393
521
  `${rejection.body} — session was NOT released; its pool ` +
394
522
  `slot stays held until the server-side grace timer reaps it`);
395
523
  }
396
524
  }, (err) => {
397
- console.warn(`[Solari] DELETE /sessions/${id} failed (fire-and-forget):`, err);
525
+ console.warn(`[Solari] ${redactSessionPath(`DELETE /sessions/${id}`)} failed (fire-and-forget):`, err);
398
526
  });
399
527
  }
400
528
  async releaseAndWait(id) {
@@ -402,7 +530,7 @@ class SessionsResource {
402
530
  const res = await this.client.request("DELETE", `/sessions/${encodeURIComponent(id)}`);
403
531
  const rejection = await releaseRejection(res);
404
532
  if (rejection) {
405
- throw new SolariError(`Solari DELETE /sessions/${id} failed: ${res.status} ${rejection.body}`, res.status, undefined, rejection.code);
533
+ throw new SolariError(`Solari ${redactSessionPath(`DELETE /sessions/${id}`)} failed: ${res.status} ${rejection.body}`, res.status, undefined, rejection.code);
406
534
  }
407
535
  }
408
536
  /** The gateway's current view of one session (`GET /sessions/:id`). */
@@ -441,7 +569,7 @@ class SessionsResource {
441
569
  throw await apiError(`GET /sessions/${id}/replay-url`, res);
442
570
  const data = (await res.json());
443
571
  if (!data.url) {
444
- throw new SolariError(`Solari: unexpected replay-url response: ${JSON.stringify(data)}`);
572
+ throw new SolariError(`Solari: unexpected replay-url response (fields: ${responseFields(data)})`);
445
573
  }
446
574
  return {
447
575
  url: data.url,
@@ -579,7 +707,7 @@ export class Solari {
579
707
  await sleep(100 * attempt);
580
708
  continue;
581
709
  }
582
- throw err;
710
+ throw scrubbedLaunchError(err, [session.wsEndpoint, session.cdpEndpoint]);
583
711
  }
584
712
  }
585
713
  throw lastErr ?? new SolariError("Solari.launch: exhausted attempts");
@@ -621,7 +749,7 @@ export class Solari {
621
749
  upstream = new URL(upstreamEndpoint);
622
750
  }
623
751
  catch (e) {
624
- throw new SolariError(`Solari: invalid upstream ${kind}Endpoint: ${upstreamEndpoint}`, undefined, e);
752
+ throw new SolariError(`Solari: invalid upstream ${kind}Endpoint: ${redactUrl(upstreamEndpoint)}`, undefined, e);
625
753
  }
626
754
  await this.localProxy.start();
627
755
  this.localProxy.register(sessionId, upstream, kind);
@@ -640,10 +768,14 @@ export class Solari {
640
768
  ...(opts?.idempotencyKey
641
769
  ? { "Idempotency-Key": opts.idempotencyKey }
642
770
  : {}),
771
+ ...(opts?.headers ?? {}),
643
772
  };
644
773
  const payload = body === undefined ? undefined : JSON.stringify(body);
645
774
  let lastErr;
646
- for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
775
+ // singleAttempt: a state-changing call that must not be replayed (the first try may have done
776
+ // its work; a replay would then answer 409 and hide the real result).
777
+ const maxAttempts = opts?.singleAttempt ? 1 : this.maxAttempts;
778
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
647
779
  const ac = new AbortController();
648
780
  const timer = setTimeout(() => ac.abort(), this.timeoutMs);
649
781
  try {
@@ -662,10 +794,10 @@ export class Solari {
662
794
  // Out of attempts: hand the last response back so the caller maps its
663
795
  // status and `code` like any other error, instead of a status-less
664
796
  // "exhausted" error that hides why the gateway refused.
665
- if (!retryThis || attempt === this.maxAttempts)
797
+ if (!retryThis || attempt === maxAttempts)
666
798
  return res;
667
799
  await res.text().catch(() => { });
668
- lastErr = new SolariError(`Solari ${method} ${path}: ${res.status}`, res.status);
800
+ lastErr = new SolariError(`Solari ${method} ${redactSessionPath(path)}: ${res.status}`, res.status);
669
801
  }
670
802
  catch (e) {
671
803
  clearTimeout(timer);
@@ -673,10 +805,10 @@ export class Solari {
673
805
  throw e;
674
806
  lastErr = e;
675
807
  }
676
- if (attempt < this.maxAttempts) {
808
+ if (attempt < maxAttempts) {
677
809
  await sleep(this.backoffMs);
678
810
  }
679
811
  }
680
- throw new SolariError(`Solari ${method} ${path}: exhausted ${this.maxAttempts} attempts`, undefined, lastErr);
812
+ throw new SolariError(`Solari ${method} ${redactSessionPath(path)}: exhausted ${maxAttempts} attempts`, undefined, lastErr);
681
813
  }
682
814
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/browser",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Official client SDK for Solari Browser.",
5
5
  "license": "MIT",
6
6
  "type": "module",