@runappsai/sdk 0.7.6 → 0.7.8
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 +14 -0
- package/dist/browser-auth.d.ts +22 -2
- package/dist/browser-auth.d.ts.map +1 -1
- package/dist/browser-auth.js +123 -4
- package/dist/browser-auth.js.map +1 -1
- package/dist/client.d.ts +9 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -1
- package/dist/client.js.map +1 -1
- package/dist/sdk.umd.js +3 -3
- package/dist/sdk.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/browser-auth.ts +118 -5
- package/src/client.ts +11 -2
package/package.json
CHANGED
package/src/browser-auth.ts
CHANGED
|
@@ -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,17 @@ 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. */
|
|
119
|
+
const POPUP_NAME = "runapps-signin";
|
|
120
|
+
/** Query parameter marking the app URL the popup lands on when the host
|
|
121
|
+
* has no callback page (window.name is reset by a COOP swap, so it
|
|
122
|
+
* cannot carry the mark). */
|
|
123
|
+
const POPUP_PARAM = "runapps_popup";
|
|
124
|
+
/** The callback page every app host on the platform serves: it stores the
|
|
125
|
+
* token for the app and closes, without loading the app. */
|
|
126
|
+
const POPUP_CALLBACK_PATH = "/__runapps/signin.html";
|
|
127
|
+
/** Hosts whose backend serves POPUP_CALLBACK_PATH. */
|
|
128
|
+
const hasCallbackPage = (hostname: string) => /\.runapps\.dev$/i.test(hostname) || /\.localhost$/i.test(hostname);
|
|
108
129
|
// Written by SDK 0.3 and earlier; read once and migrated, so an upgrade
|
|
109
130
|
// keeps the user signed in.
|
|
110
131
|
const LEGACY_STORAGE_KEY_BASE = "__runjobs_auth_v1__";
|
|
@@ -168,6 +189,9 @@ export class BrowserAuth {
|
|
|
168
189
|
private readonly badgePosition: BadgePosition;
|
|
169
190
|
private token: string | null = null;
|
|
170
191
|
private expiresAt = 0;
|
|
192
|
+
private signInMode: "redirect" | "popup";
|
|
193
|
+
private popupWait: Promise<void> | null = null;
|
|
194
|
+
private popupOpenedAt = 0;
|
|
171
195
|
private userInfo: BrowserUser | null = null;
|
|
172
196
|
private listeners: Array<(token: string) => void> = [];
|
|
173
197
|
private parentHandshake: Promise<void> | null = null;
|
|
@@ -179,6 +203,7 @@ export class BrowserAuth {
|
|
|
179
203
|
this.project = opts.project ?? null;
|
|
180
204
|
this.events = opts.events ?? null;
|
|
181
205
|
this.badgePosition = opts.badgePosition ?? "bottom-right";
|
|
206
|
+
this.signInMode = opts.signInMode ?? "redirect";
|
|
182
207
|
if (this.events && !this.hideBadge) {
|
|
183
208
|
this.tracker = new ActivityTracker();
|
|
184
209
|
this.tracker.attach(this.events);
|
|
@@ -201,6 +226,13 @@ export class BrowserAuth {
|
|
|
201
226
|
// sticky flag — the user explicitly signed in again.
|
|
202
227
|
if (this.consumeFragment()) {
|
|
203
228
|
this.clearSignedOut();
|
|
229
|
+
if (new URLSearchParams(location.search).has(POPUP_PARAM)) {
|
|
230
|
+
// We are the sign-in popup, landed on the app itself (no callback
|
|
231
|
+
// page on this host): the token is already in localStorage, which
|
|
232
|
+
// the opener (same origin) sees through its `storage` event.
|
|
233
|
+
this.finishPopup();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
204
236
|
}
|
|
205
237
|
|
|
206
238
|
if (this.isSignedOut()) {
|
|
@@ -277,6 +309,11 @@ export class BrowserAuth {
|
|
|
277
309
|
// No fresh token + can't get one silently → redirect to grant.
|
|
278
310
|
// The page is about to unload; return a never-resolving Promise so
|
|
279
311
|
// the caller doesn't proceed with a half-issued request.
|
|
312
|
+
if (this.signInMode === "popup") {
|
|
313
|
+
await this.signInPopup();
|
|
314
|
+
if (this.tokenIsFresh()) return this.token as string;
|
|
315
|
+
throw new Error("RunApps: sign-in did not complete");
|
|
316
|
+
}
|
|
280
317
|
this.signIn();
|
|
281
318
|
return new Promise(() => {});
|
|
282
319
|
};
|
|
@@ -360,25 +397,101 @@ export class BrowserAuth {
|
|
|
360
397
|
this.clearPersisted();
|
|
361
398
|
}
|
|
362
399
|
|
|
363
|
-
/**
|
|
364
|
-
|
|
365
|
-
|
|
400
|
+
/** Start the grant flow: a redirect (default) or, in popup mode, a
|
|
401
|
+
* popup window — then the returned promise resolves once the token
|
|
402
|
+
* has arrived (and rejects if the popup is closed first). */
|
|
403
|
+
signIn(): Promise<void> | void {
|
|
404
|
+
if (typeof window === "undefined") return;
|
|
405
|
+
if (this.signInMode === "popup") return this.signInPopup();
|
|
406
|
+
if (this.signingIn) return;
|
|
366
407
|
// Explicit signIn clears the "signed out" flag so getToken() can
|
|
367
408
|
// proceed with the redirect-grant flow.
|
|
368
409
|
this.clearSignedOut();
|
|
369
410
|
this.signingIn = true;
|
|
370
|
-
|
|
411
|
+
location.href = this.grantUrlForThisPage();
|
|
412
|
+
}
|
|
413
|
+
private grantUrlForThisPage(popup = false): string {
|
|
414
|
+
let ret = location.href.split("#")[0] ?? location.href;
|
|
415
|
+
if (popup) {
|
|
416
|
+
if (hasCallbackPage(location.hostname)) {
|
|
417
|
+
const q = new URLSearchParams({ key: storageKey(this.project), origin: this.origin });
|
|
418
|
+
ret = location.origin + POPUP_CALLBACK_PATH + "?" + q.toString();
|
|
419
|
+
} else {
|
|
420
|
+
const u = new URL(ret);
|
|
421
|
+
u.searchParams.set(POPUP_PARAM, "1");
|
|
422
|
+
ret = u.toString();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
371
425
|
const scheme = window.matchMedia?.("(prefers-color-scheme: dark)")
|
|
372
426
|
.matches
|
|
373
427
|
? "dark"
|
|
374
428
|
: "light";
|
|
375
|
-
|
|
429
|
+
return this.buildGrantUrl({
|
|
376
430
|
pageOrigin: location.origin,
|
|
377
431
|
app: location.host,
|
|
378
432
|
redirectTo: ret,
|
|
379
433
|
scheme,
|
|
380
434
|
});
|
|
381
435
|
}
|
|
436
|
+
/** Popup grant flow. One popup at a time; concurrent callers share it. */
|
|
437
|
+
private signInPopup(): Promise<void> {
|
|
438
|
+
// One popup at a time; a click a few seconds later (the user closed
|
|
439
|
+
// the first one, say) opens a fresh one and joins the same wait.
|
|
440
|
+
const now = Date.now();
|
|
441
|
+
if (this.popupWait && now - this.popupOpenedAt < 3000) return this.popupWait;
|
|
442
|
+
this.clearSignedOut();
|
|
443
|
+
const key = storageKey(this.project);
|
|
444
|
+
const win = window.open(this.grantUrlForThisPage(true), POPUP_NAME, "popup=yes,width=520,height=700");
|
|
445
|
+
if (!win) {
|
|
446
|
+
// Popup blocked: the redirect is the only way left.
|
|
447
|
+
this.signingIn = true;
|
|
448
|
+
location.href = this.grantUrlForThisPage();
|
|
449
|
+
return new Promise(() => {});
|
|
450
|
+
}
|
|
451
|
+
this.popupOpenedAt = now;
|
|
452
|
+
if (this.popupWait) return this.popupWait;
|
|
453
|
+
// Under COOP the popup's handle is cut off as soon as it leaves this
|
|
454
|
+
// origin (`win.closed` reads true), so a closed popup cannot be told
|
|
455
|
+
// from one still signing in: the promise stays pending until a token
|
|
456
|
+
// arrives, like Puter's own sign-in.
|
|
457
|
+
this.popupWait = new Promise<void>((resolve) => {
|
|
458
|
+
let timer = 0;
|
|
459
|
+
const adopt = (): boolean => {
|
|
460
|
+
const before = this.token;
|
|
461
|
+
this.loadPersisted();
|
|
462
|
+
if (!this.tokenIsFresh()) return false;
|
|
463
|
+
if (this.token !== before) this.setToken(this.token as string, this.expiresAt, this.userInfo ?? undefined);
|
|
464
|
+
else if (this.userInfo && !this.hideBadge) this.renderBadge();
|
|
465
|
+
window.removeEventListener("storage", onStorage);
|
|
466
|
+
window.clearInterval(timer);
|
|
467
|
+
this.popupWait = null;
|
|
468
|
+
resolve();
|
|
469
|
+
return true;
|
|
470
|
+
};
|
|
471
|
+
const onStorage = (e: StorageEvent) => {
|
|
472
|
+
if (e.key === key && e.newValue) adopt();
|
|
473
|
+
};
|
|
474
|
+
window.addEventListener("storage", onStorage);
|
|
475
|
+
timer = window.setInterval(adopt, 1000);
|
|
476
|
+
});
|
|
477
|
+
return this.popupWait;
|
|
478
|
+
}
|
|
479
|
+
/** Inside the popup, after the token landed: close, or say so. */
|
|
480
|
+
private finishPopup() {
|
|
481
|
+
try { window.close(); } catch { /* not script-closable */ }
|
|
482
|
+
if (window.closed) return;
|
|
483
|
+
// Still open: stop the app from loading and show a plain page instead.
|
|
484
|
+
try { window.stop(); } catch { /* ignore */ }
|
|
485
|
+
try {
|
|
486
|
+
document.open();
|
|
487
|
+
document.write(
|
|
488
|
+
'<!doctype html><html><head><meta charset="utf-8"><title>Signed in</title></head>' +
|
|
489
|
+
'<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;text-align:center">' +
|
|
490
|
+
'<div><p>Signed in. You can go back to the app.</p><button onclick="window.close()" style="padding:8px 16px;font:inherit;border:1px solid #ccc;border-radius:8px;background:#fff;cursor:pointer">Close this window</button></div></body></html>',
|
|
491
|
+
);
|
|
492
|
+
document.close();
|
|
493
|
+
} catch { /* ignore */ }
|
|
494
|
+
}
|
|
382
495
|
|
|
383
496
|
/**
|
|
384
497
|
* 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
|
/**
|