@runappsai/sdk 0.7.6 → 0.7.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runappsai/sdk",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "JavaScript / TypeScript client for the RunApps AI Gateway",
5
5
  "license": "MIT",
6
6
  "author": "RunApps",
@@ -41,6 +41,16 @@ export interface BrowserAuthOptions {
41
41
  * through `RunApps`.
42
42
  */
43
43
  hideBadge?: boolean;
44
+ /**
45
+ * How a missing token is obtained. `"redirect"` (default) navigates
46
+ * the page to the grant flow and back, which discards unsaved work in
47
+ * the page. `"popup"` opens the grant flow in a popup window and waits:
48
+ * the popup lands back on this origin with the token, which it stores,
49
+ * and this page picks it up through the `storage` event — so it works
50
+ * under COOP, where popups lose their `window.opener`. A blocked popup
51
+ * falls back to the redirect.
52
+ */
53
+ signInMode?: "redirect" | "popup";
44
54
  /**
45
55
  * Pin the grant flow to a named runapps.ai project. When set, the
46
56
  * SDK passes `project_id=<value>` to `/api/sdk/grant`, asking the
@@ -105,6 +115,8 @@ const TOKEN_REFRESH_MARGIN_S = 60;
105
115
  // unsuffixed key, which preserves back-compat for already-persisted
106
116
  // tokens after upgrade.
107
117
  const STORAGE_KEY_BASE = "__runapps_auth_v1__";
118
+ /** window.name of the sign-in popup; the page loaded in it closes itself. */
119
+ const POPUP_NAME = "runapps-signin";
108
120
  // Written by SDK 0.3 and earlier; read once and migrated, so an upgrade
109
121
  // keeps the user signed in.
110
122
  const LEGACY_STORAGE_KEY_BASE = "__runjobs_auth_v1__";
@@ -168,6 +180,8 @@ export class BrowserAuth {
168
180
  private readonly badgePosition: BadgePosition;
169
181
  private token: string | null = null;
170
182
  private expiresAt = 0;
183
+ private signInMode: "redirect" | "popup";
184
+ private popupWait: Promise<void> | null = null;
171
185
  private userInfo: BrowserUser | null = null;
172
186
  private listeners: Array<(token: string) => void> = [];
173
187
  private parentHandshake: Promise<void> | null = null;
@@ -179,6 +193,7 @@ export class BrowserAuth {
179
193
  this.project = opts.project ?? null;
180
194
  this.events = opts.events ?? null;
181
195
  this.badgePosition = opts.badgePosition ?? "bottom-right";
196
+ this.signInMode = opts.signInMode ?? "redirect";
182
197
  if (this.events && !this.hideBadge) {
183
198
  this.tracker = new ActivityTracker();
184
199
  this.tracker.attach(this.events);
@@ -201,6 +216,12 @@ export class BrowserAuth {
201
216
  // sticky flag — the user explicitly signed in again.
202
217
  if (this.consumeFragment()) {
203
218
  this.clearSignedOut();
219
+ if (window.name === POPUP_NAME) {
220
+ // We are the sign-in popup: the token is in localStorage, which
221
+ // the opener (same origin) sees through its `storage` event.
222
+ this.finishPopup();
223
+ return;
224
+ }
204
225
  }
205
226
 
206
227
  if (this.isSignedOut()) {
@@ -277,6 +298,11 @@ export class BrowserAuth {
277
298
  // No fresh token + can't get one silently → redirect to grant.
278
299
  // The page is about to unload; return a never-resolving Promise so
279
300
  // the caller doesn't proceed with a half-issued request.
301
+ if (this.signInMode === "popup") {
302
+ await this.signInPopup();
303
+ if (this.tokenIsFresh()) return this.token as string;
304
+ throw new Error("RunApps: sign-in did not complete");
305
+ }
280
306
  this.signIn();
281
307
  return new Promise(() => {});
282
308
  };
@@ -360,25 +386,84 @@ export class BrowserAuth {
360
386
  this.clearPersisted();
361
387
  }
362
388
 
363
- /** Force a redirect to the grant page. */
364
- signIn(): void {
365
- if (typeof window === "undefined" || this.signingIn) return;
389
+ /** Start the grant flow: a redirect (default) or, in popup mode, a
390
+ * popup window — then the returned promise resolves once the token
391
+ * has arrived (and rejects if the popup is closed first). */
392
+ signIn(): Promise<void> | void {
393
+ if (typeof window === "undefined") return;
394
+ if (this.signInMode === "popup") return this.signInPopup();
395
+ if (this.signingIn) return;
366
396
  // Explicit signIn clears the "signed out" flag so getToken() can
367
397
  // proceed with the redirect-grant flow.
368
398
  this.clearSignedOut();
369
399
  this.signingIn = true;
400
+ location.href = this.grantUrlForThisPage();
401
+ }
402
+ private grantUrlForThisPage(): string {
370
403
  const ret = location.href.split("#")[0] ?? location.href;
371
404
  const scheme = window.matchMedia?.("(prefers-color-scheme: dark)")
372
405
  .matches
373
406
  ? "dark"
374
407
  : "light";
375
- location.href = this.buildGrantUrl({
408
+ return this.buildGrantUrl({
376
409
  pageOrigin: location.origin,
377
410
  app: location.host,
378
411
  redirectTo: ret,
379
412
  scheme,
380
413
  });
381
414
  }
415
+ /** Popup grant flow. One popup at a time; concurrent callers share it. */
416
+ private signInPopup(): Promise<void> {
417
+ if (this.popupWait) return this.popupWait;
418
+ this.clearSignedOut();
419
+ const key = storageKey(this.project);
420
+ const win = window.open(this.grantUrlForThisPage(), POPUP_NAME, "popup=yes,width=520,height=700");
421
+ if (!win) {
422
+ // Popup blocked: the redirect is the only way left.
423
+ this.signingIn = true;
424
+ location.href = this.grantUrlForThisPage();
425
+ return new Promise(() => {});
426
+ }
427
+ this.popupWait = new Promise<void>((resolve, reject) => {
428
+ let timer = 0;
429
+ const done = (err?: Error) => {
430
+ window.removeEventListener("storage", onStorage);
431
+ window.clearInterval(timer);
432
+ this.popupWait = null;
433
+ if (err) reject(err); else resolve();
434
+ };
435
+ const adopt = (): boolean => {
436
+ const before = this.token;
437
+ this.loadPersisted();
438
+ if (!this.tokenIsFresh()) return false;
439
+ if (this.token !== before) this.setToken(this.token as string, this.expiresAt, this.userInfo ?? undefined);
440
+ else if (this.userInfo && !this.hideBadge) this.renderBadge();
441
+ try { win.close(); } catch { /* already gone */ }
442
+ done();
443
+ return true;
444
+ };
445
+ const onStorage = (e: StorageEvent) => {
446
+ if (e.key === key && e.newValue) adopt();
447
+ };
448
+ window.addEventListener("storage", onStorage);
449
+ timer = window.setInterval(() => {
450
+ if (adopt()) return;
451
+ if (win.closed) done(new Error("RunApps: sign-in was cancelled"));
452
+ }, 500);
453
+ });
454
+ return this.popupWait;
455
+ }
456
+ /** Inside the popup, after the token landed: close, or say so. */
457
+ private finishPopup() {
458
+ try { window.close(); } catch { /* not script-closable */ }
459
+ window.setTimeout(() => {
460
+ if (window.closed) return;
461
+ document.documentElement.innerHTML =
462
+ '<head><meta charset="utf-8"><title>Signed in</title></head>' +
463
+ '<body style="margin:0;display:flex;align-items:center;justify-content:center;height:100vh;font:15px/1.5 -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#333;background:#fafafa">' +
464
+ "<p>Signed in. You can close this window and go back to the app.</p></body>";
465
+ }, 400);
466
+ }
382
467
 
383
468
  /**
384
469
  * Build the `/api/sdk/grant?…` URL the user is redirected to. Split
package/src/client.ts CHANGED
@@ -60,6 +60,14 @@ export interface ClientOptions {
60
60
  * badge in the corner.
61
61
  */
62
62
  showIdentityBadge?: boolean;
63
+ /**
64
+ * How the browser auth flow obtains a token when there is none:
65
+ * `"redirect"` (default) navigates the page away and back, losing
66
+ * unsaved state; `"popup"` signs in through a popup window and
67
+ * resumes the pending call, so the page keeps its state. See
68
+ * `BrowserAuthOptions.signInMode`.
69
+ */
70
+ signInMode?: "redirect" | "popup";
63
71
  /**
64
72
  * Which corner the identity badge floats in. Defaults to
65
73
  * `"bottom-right"` — the canonical tray-app position. Switch to
@@ -213,6 +221,7 @@ export class RunApps {
213
221
  events: this.events,
214
222
  ...(options.badgePosition !== undefined && { badgePosition: options.badgePosition }),
215
223
  ...(options.project !== undefined && { project: options.project }),
224
+ ...(options.signInMode !== undefined && { signInMode: options.signInMode }),
216
225
  });
217
226
  (this as { auth: BrowserAuth | null }).auth = auth;
218
227
  // The browser auth resolver wins over any caller-supplied one
@@ -278,8 +287,8 @@ export class RunApps {
278
287
 
279
288
  /** Force a redirect to the runapps.ai grant page. No-op in static
280
289
  * auth mode or in Node. */
281
- signIn(): void {
282
- this.auth?.signIn();
290
+ signIn(): Promise<void> | void {
291
+ return this.auth?.signIn();
283
292
  }
284
293
 
285
294
  /**