@oxy.so/core 1.8.1 → 1.9.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +56 -0
- package/dist/cjs/i18n/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/es-ES.json +3 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +3 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +3 -1
- package/dist/cjs/session/accountDialogController.js +73 -4
- package/dist/cjs/session/refresh.js +39 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +56 -0
- package/dist/esm/i18n/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/es-ES.json +3 -1
- package/dist/esm/i18n/locales/locales/en-US.json +3 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +3 -1
- package/dist/esm/session/accountDialogController.js +72 -4
- package/dist/esm/session/refresh.js +38 -4
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +28 -0
- package/dist/types/models/interfaces.d.ts +6 -1
- package/dist/types/session/accountDialogController.d.ts +50 -3
- package/dist/types/session/refresh.d.ts +14 -0
- package/package.json +1 -1
- package/src/HttpService.ts +57 -0
- package/src/__tests__/nearExpiryMintStorm.test.ts +165 -0
- package/src/i18n/locales/en-US.json +3 -1
- package/src/i18n/locales/es-ES.json +3 -1
- package/src/models/interfaces.ts +6 -1
- package/src/session/__tests__/accountDialogController.test.ts +105 -0
- package/src/session/__tests__/accountDialogShape.test.ts +1 -0
- package/src/session/accountDialogController.ts +87 -5
- package/src/session/refresh.ts +39 -4
package/dist/cjs/HttpService.js
CHANGED
|
@@ -116,6 +116,24 @@ const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60000;
|
|
|
116
116
|
* expired so the request carries a fresh bearer rather than racing the clock.
|
|
117
117
|
*/
|
|
118
118
|
const TOKEN_REFRESH_LEAD_SECONDS = 60;
|
|
119
|
+
/**
|
|
120
|
+
* Whether `token` is a JWT that is still valid but already inside the refresh
|
|
121
|
+
* lead window. `false` for an expired, opaque or no-`exp` token.
|
|
122
|
+
*/
|
|
123
|
+
function isInsideRefreshLeadWindow(token) {
|
|
124
|
+
try {
|
|
125
|
+
const decoded = (0, jwt_decode_1.jwtDecode)(token);
|
|
126
|
+
if (typeof decoded.exp !== 'number') {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const now = Math.floor(Date.now() / 1000);
|
|
130
|
+
// `<=`: the proactive scheduler fires at exactly `exp - lead`.
|
|
131
|
+
return decoded.exp > now && decoded.exp - now <= TOKEN_REFRESH_LEAD_SECONDS;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
119
137
|
/**
|
|
120
138
|
* Soft ceiling on the number of live entries in the identity-scoped GET
|
|
121
139
|
* response cache. Crossing it does NOT evict anything (the {@link TTLCache}
|
|
@@ -237,6 +255,27 @@ class HttpService {
|
|
|
237
255
|
* it, and there is exactly one timestamp to reason about.
|
|
238
256
|
*/
|
|
239
257
|
this.lastRefreshWasRateLimited = false;
|
|
258
|
+
/**
|
|
259
|
+
* The access token the last SUCCESSFUL refresh handed back while it was still
|
|
260
|
+
* inside the refresh lead window, or `null`.
|
|
261
|
+
*
|
|
262
|
+
* The device mint (`POST /session/device/token`) answers with the session's
|
|
263
|
+
* STORED access token for as long as that token has not expired — it only
|
|
264
|
+
* mints a new one once the old one is past `exp`. A client that re-mints
|
|
265
|
+
* {@link TOKEN_REFRESH_LEAD_SECONDS} before `exp` therefore gets the SAME
|
|
266
|
+
* token back, and because that counts as a success (no cooldown), every
|
|
267
|
+
* following request re-ran the preflight and minted again: one mint per
|
|
268
|
+
* request for the last minute of every token, which exhausted the server's
|
|
269
|
+
* 30/min mint budget. The 429 that followed imposed a 60s cooldown that
|
|
270
|
+
* straddled the token's real expiry, so the next request went out with no
|
|
271
|
+
* bearer, drew a 401, found the refresh cooling down, and signed the user out.
|
|
272
|
+
*
|
|
273
|
+
* Remembering the answer ends that: while the current token IS this value and
|
|
274
|
+
* has not yet expired, the server has already said it has nothing fresher, so
|
|
275
|
+
* the preflight sends the still-valid token instead of asking again. Once the
|
|
276
|
+
* token crosses `exp` the preflight asks once more and the server rotates.
|
|
277
|
+
*/
|
|
278
|
+
this.noFresherTokenThan = null;
|
|
240
279
|
this.authRefreshHandler = null;
|
|
241
280
|
this.accessTokenProvider = null;
|
|
242
281
|
this.deviceSecretMintInFlight = null;
|
|
@@ -1028,6 +1067,12 @@ class HttpService {
|
|
|
1028
1067
|
const currentTime = Math.floor(Date.now() / 1000);
|
|
1029
1068
|
// If the token expires within the refresh lead window, refresh it.
|
|
1030
1069
|
if (decoded.exp && decoded.exp - currentTime < TOKEN_REFRESH_LEAD_SECONDS) {
|
|
1070
|
+
// The server already answered this token's refresh with this very token:
|
|
1071
|
+
// it has nothing fresher until `exp`. Use it rather than asking again
|
|
1072
|
+
// (see `noFresherTokenThan`).
|
|
1073
|
+
if (decoded.exp > currentTime && accessToken === this.noFresherTokenThan) {
|
|
1074
|
+
return `Bearer ${accessToken}`;
|
|
1075
|
+
}
|
|
1031
1076
|
const refreshed = await this.refreshAccessToken('preflight');
|
|
1032
1077
|
if (refreshed)
|
|
1033
1078
|
return `Bearer ${refreshed}`;
|
|
@@ -1080,6 +1125,7 @@ class HttpService {
|
|
|
1080
1125
|
this.tokenStore.setTokens(newToken);
|
|
1081
1126
|
this.notifyTokenChange();
|
|
1082
1127
|
}
|
|
1128
|
+
this.noFresherTokenThan = isInsideRefreshLeadWindow(newToken) ? newToken : null;
|
|
1083
1129
|
// A success clears the failure timestamp so the next refresh is never
|
|
1084
1130
|
// throttled by a stale cooldown.
|
|
1085
1131
|
this.lastRefreshFailureAt = 0;
|
|
@@ -1113,6 +1159,16 @@ class HttpService {
|
|
|
1113
1159
|
noteRefreshRateLimited() {
|
|
1114
1160
|
this.lastRefreshWasRateLimited = true;
|
|
1115
1161
|
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Whether the server has already answered a refresh of the CURRENT access
|
|
1164
|
+
* token with that same, still-valid token — i.e. it has nothing fresher to
|
|
1165
|
+
* give until the token's `exp`. The proactive scheduler reads this so it waits
|
|
1166
|
+
* for expiry instead of re-asking at its 1s floor.
|
|
1167
|
+
*/
|
|
1168
|
+
isAwaitingCurrentTokenExpiry() {
|
|
1169
|
+
const token = this.tokenStore.getAccessToken();
|
|
1170
|
+
return token !== null && token === this.noFresherTokenThan && isInsideRefreshLeadWindow(token);
|
|
1171
|
+
}
|
|
1116
1172
|
/**
|
|
1117
1173
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1118
1174
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Switch account",
|
|
1852
1852
|
"count": "{{count}} accounts",
|
|
1853
|
+
"countOne": "{{count}} account",
|
|
1853
1854
|
"empty": "Accounts you own or share"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Loading sessions…",
|
|
1971
1972
|
"empty": "No active sessions",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Sign out of all other devices",
|
|
1973
|
-
"signOutAllSubtitle": "End {{count}} other device session(s)"
|
|
1974
|
+
"signOutAllSubtitle": "End {{count}} other device session(s)",
|
|
1975
|
+
"otherSession": "Another session"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Remove device",
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Cambiar de cuenta",
|
|
1852
1852
|
"count": "{{count}} cuentas",
|
|
1853
|
+
"countOne": "{{count}} cuenta",
|
|
1853
1854
|
"empty": "Cuentas que posees o compartes"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Cargando sesiones…",
|
|
1971
1972
|
"empty": "No hay sesiones activas",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Cerrar sesión en los demás dispositivos",
|
|
1973
|
-
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos"
|
|
1974
|
+
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos",
|
|
1975
|
+
"otherSession": "Otra sesión"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Eliminar dispositivo",
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Switch account",
|
|
1852
1852
|
"count": "{{count}} accounts",
|
|
1853
|
+
"countOne": "{{count}} account",
|
|
1853
1854
|
"empty": "Accounts you own or share"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Loading sessions…",
|
|
1971
1972
|
"empty": "No active sessions",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Sign out of all other devices",
|
|
1973
|
-
"signOutAllSubtitle": "End {{count}} other device session(s)"
|
|
1974
|
+
"signOutAllSubtitle": "End {{count}} other device session(s)",
|
|
1975
|
+
"otherSession": "Another session"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Remove device",
|
|
@@ -1850,6 +1850,7 @@
|
|
|
1850
1850
|
"switch": {
|
|
1851
1851
|
"title": "Cambiar de cuenta",
|
|
1852
1852
|
"count": "{{count}} cuentas",
|
|
1853
|
+
"countOne": "{{count}} cuenta",
|
|
1853
1854
|
"empty": "Cuentas que posees o compartes"
|
|
1854
1855
|
}
|
|
1855
1856
|
},
|
|
@@ -1970,7 +1971,8 @@
|
|
|
1970
1971
|
"loading": "Cargando sesiones…",
|
|
1971
1972
|
"empty": "No hay sesiones activas",
|
|
1972
1973
|
"signOutAllOnThisDevice": "Cerrar sesión en los demás dispositivos",
|
|
1973
|
-
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos"
|
|
1974
|
+
"signOutAllSubtitle": "Finalizar {{count}} sesión(es) de otros dispositivos",
|
|
1975
|
+
"otherSession": "Otra sesión"
|
|
1974
1976
|
},
|
|
1975
1977
|
"confirms": {
|
|
1976
1978
|
"removeDeviceTitle": "Eliminar dispositivo",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
*/
|
|
59
59
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
60
60
|
exports.AccountDialogController = void 0;
|
|
61
|
+
exports.backViewOf = backViewOf;
|
|
61
62
|
exports.createAccountDialogController = createAccountDialogController;
|
|
62
63
|
const logger_1 = require("../logger");
|
|
63
64
|
const errorUtils_1 = require("../utils/errorUtils");
|
|
@@ -333,16 +334,25 @@ class AccountDialogController {
|
|
|
333
334
|
}
|
|
334
335
|
// Signed out. The directory is bearer-read and `SessionClient` drops the one
|
|
335
336
|
// it holds when the device empties, so there is nothing to clear here — only
|
|
336
|
-
// the in-flight bookkeeping, which no longer describes anything.
|
|
337
|
+
// the in-flight bookkeeping, which no longer describes anything. The account
|
|
338
|
+
// menu described the account that just left, so it gives way to the entry.
|
|
337
339
|
this.error = null;
|
|
338
340
|
this.loading = false;
|
|
341
|
+
this.view = this.resolveView(this.view);
|
|
339
342
|
this.emit();
|
|
340
343
|
}
|
|
341
344
|
// =========================================================================
|
|
342
345
|
// View actions
|
|
343
346
|
// =========================================================================
|
|
344
|
-
/**
|
|
345
|
-
|
|
347
|
+
/**
|
|
348
|
+
* Set the dialog view directly.
|
|
349
|
+
*
|
|
350
|
+
* `'accounts'` is only honoured with a session. Signed out it lands on the
|
|
351
|
+
* sign-in entry instead — the menu it would open holds "Switch account",
|
|
352
|
+
* storage and "Sign out" for an account that does not exist.
|
|
353
|
+
*/
|
|
354
|
+
setView(requested) {
|
|
355
|
+
const view = this.resolveView(requested);
|
|
346
356
|
if (this.view === view)
|
|
347
357
|
return;
|
|
348
358
|
this.view = view;
|
|
@@ -355,6 +365,37 @@ class AccountDialogController {
|
|
|
355
365
|
}
|
|
356
366
|
this.emit();
|
|
357
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Go back one step: `signup` and `qr` return to the sign-in entry, and the
|
|
370
|
+
* entry returns to the account menu when somebody is signed in.
|
|
371
|
+
*
|
|
372
|
+
* Returns `false` — changing nothing — when the current view is the dialog's
|
|
373
|
+
* first one, so the host closes the dialog instead. Leaving an active
|
|
374
|
+
* request withdraws it; otherwise its authorize code stays approvable until
|
|
375
|
+
* it expires.
|
|
376
|
+
*/
|
|
377
|
+
back() {
|
|
378
|
+
const target = backViewOf(this.view, this.hasSession());
|
|
379
|
+
if (target === null)
|
|
380
|
+
return false;
|
|
381
|
+
if (this.signIn.phase === 'starting' || this.signIn.phase === 'waiting') {
|
|
382
|
+
this.cancelSignIn();
|
|
383
|
+
}
|
|
384
|
+
this.setView(target);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
/** `'accounts'` needs a session; without one the sign-in entry stands in for it. */
|
|
388
|
+
resolveView(view) {
|
|
389
|
+
return view === 'accounts' && !this.hasSession() ? 'signin' : view;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Whether an account is signed in here — the precondition of the account
|
|
393
|
+
* menu. The bearer, not the directory: the directory is bearer-read, and the
|
|
394
|
+
* menu's content (hero, storage, sign-out) is all about the bearer's account.
|
|
395
|
+
*/
|
|
396
|
+
hasSession() {
|
|
397
|
+
return this.isAuthenticated();
|
|
398
|
+
}
|
|
358
399
|
/** Switch to the "add account" view (the sign-in entry chooser). */
|
|
359
400
|
add() {
|
|
360
401
|
this.setView('add');
|
|
@@ -1289,8 +1330,13 @@ class AccountDialogController {
|
|
|
1289
1330
|
}
|
|
1290
1331
|
computeSnapshot() {
|
|
1291
1332
|
const directory = this.sessionClient.getDirectory();
|
|
1333
|
+
const hasSession = this.hasSession();
|
|
1334
|
+
// Resolved here as well as in `setView`: the controller is constructed on
|
|
1335
|
+
// `'accounts'` before anyone knows whether a bearer exists.
|
|
1336
|
+
const view = this.resolveView(this.view);
|
|
1292
1337
|
return {
|
|
1293
|
-
view
|
|
1338
|
+
view,
|
|
1339
|
+
backView: backViewOf(view, hasSession),
|
|
1294
1340
|
directory,
|
|
1295
1341
|
activeContext: (0, deviceDirectory_1.resolveActiveContext)(directory),
|
|
1296
1342
|
loading: this.loading,
|
|
@@ -1316,6 +1362,29 @@ class AccountDialogController {
|
|
|
1316
1362
|
}
|
|
1317
1363
|
}
|
|
1318
1364
|
exports.AccountDialogController = AccountDialogController;
|
|
1365
|
+
/**
|
|
1366
|
+
* The view one step back from `view`, or `null` when `view` is the dialog's
|
|
1367
|
+
* first view.
|
|
1368
|
+
*
|
|
1369
|
+
* The sign-in entry is `'add'` ("add another account") when somebody is signed
|
|
1370
|
+
* in and `'signin'` when nobody is; `signup` and `qr` are reached FROM that
|
|
1371
|
+
* entry, so they return to it. The entry itself returns to the account menu —
|
|
1372
|
+
* which exists only with a session. This used to be a host-side
|
|
1373
|
+
* "back = accounts" that assumed a signed-in origin, so a signed-out Back from
|
|
1374
|
+
* "Create your account" opened a menu holding "Sign out" for nobody.
|
|
1375
|
+
*/
|
|
1376
|
+
function backViewOf(view, hasSession) {
|
|
1377
|
+
switch (view) {
|
|
1378
|
+
case 'signup':
|
|
1379
|
+
case 'qr':
|
|
1380
|
+
return hasSession ? 'add' : 'signin';
|
|
1381
|
+
case 'add':
|
|
1382
|
+
return hasSession ? 'accounts' : null;
|
|
1383
|
+
case 'signin':
|
|
1384
|
+
case 'accounts':
|
|
1385
|
+
return null;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1319
1388
|
/** Factory mirroring `createSessionClient`, for ergonomic wiring by consumers. */
|
|
1320
1389
|
function createAccountDialogController(options) {
|
|
1321
1390
|
return new AccountDialogController(options);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.TOKEN_REFRESH_LEAD_MS = void 0;
|
|
3
|
+
exports.REMINT_AFTER_EXPIRY_MS = exports.TOKEN_REFRESH_LEAD_MS = void 0;
|
|
4
4
|
exports.refreshDeviceSecretArm = refreshDeviceSecretArm;
|
|
5
5
|
exports.refreshPersistedSession = refreshPersistedSession;
|
|
6
6
|
exports.createAuthRefreshHandler = createAuthRefreshHandler;
|
|
@@ -39,6 +39,20 @@ const MIN_SCHEDULE_DELAY_MS = 1000;
|
|
|
39
39
|
*/
|
|
40
40
|
const MIN_FAILURE_BACKOFF_MS = 5000;
|
|
41
41
|
const MAX_FAILURE_BACKOFF_MS = 5 * 60000;
|
|
42
|
+
/**
|
|
43
|
+
* How long past `exp` to re-mint when a successful refresh handed back a token
|
|
44
|
+
* that is STILL inside the lead window.
|
|
45
|
+
*
|
|
46
|
+
* The device mint returns the session's stored access token until that token
|
|
47
|
+
* has actually expired, so asking again before `exp` only returns the same
|
|
48
|
+
* token. Re-arming from expiry in that state computed a negative delay, hit the
|
|
49
|
+
* {@link MIN_SCHEDULE_DELAY_MS} floor and re-minted once a second for the last
|
|
50
|
+
* minute of every token — 30+ mints, the server's whole per-minute budget, and
|
|
51
|
+
* the 429 that followed straddled the real expiry. Waiting until just past
|
|
52
|
+
* `exp` asks exactly once more, when the server will rotate. The margin covers
|
|
53
|
+
* the server's strict `exp < now` comparison and a little clock skew.
|
|
54
|
+
*/
|
|
55
|
+
exports.REMINT_AFTER_EXPIRY_MS = 2000;
|
|
42
56
|
/**
|
|
43
57
|
* Arm 1 — the device-secret mint, run under the owning client's PROCESS-WIDE
|
|
44
58
|
* single-flight (`httpService.runSingleFlightDeviceSecretMint`).
|
|
@@ -334,12 +348,26 @@ function startTokenRefreshScheduler(oxy) {
|
|
|
334
348
|
if (expSeconds === null) {
|
|
335
349
|
return;
|
|
336
350
|
}
|
|
337
|
-
|
|
351
|
+
const untilExpiryMs = expSeconds * 1000 - Date.now();
|
|
352
|
+
// The server already answered this token's refresh with this very token:
|
|
353
|
+
// asking again before `exp` only gets it back again. Ask just after expiry,
|
|
354
|
+
// when the server rotates (see REMINT_AFTER_EXPIRY_MS).
|
|
355
|
+
if (untilExpiryMs > 0 && oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
356
|
+
armTimer(untilExpiryMs + exports.REMINT_AFTER_EXPIRY_MS);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
armTimer(untilExpiryMs - exports.TOKEN_REFRESH_LEAD_MS);
|
|
338
360
|
};
|
|
339
361
|
const runRefresh = () => {
|
|
340
362
|
// Clear any pending timer up front so an out-of-band trigger (focus) plus
|
|
341
363
|
// a fired timer can never double-run.
|
|
342
364
|
clearTimer();
|
|
365
|
+
// Another lane (a request-time preflight) already asked, and the server
|
|
366
|
+
// answered with this same token: re-arm for just past expiry instead.
|
|
367
|
+
if (oxy.httpService.isAwaitingCurrentTokenExpiry?.()) {
|
|
368
|
+
scheduleFromExpiry();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
343
371
|
void oxy.httpService.refreshAccessToken('preflight')
|
|
344
372
|
.then((token) => Boolean(token))
|
|
345
373
|
.catch(() => false)
|
|
@@ -382,10 +410,16 @@ function startTokenRefreshScheduler(oxy) {
|
|
|
382
410
|
schedule();
|
|
383
411
|
}
|
|
384
412
|
};
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
413
|
+
// Re-arm only on a token that actually CHANGED. The mint plants the token it
|
|
414
|
+
// returns even when that is the token already held, and treating the repeat
|
|
415
|
+
// as new re-armed from an in-lead-window expiry, i.e. at the 1s floor.
|
|
416
|
+
let lastSeenToken = oxy.getAccessToken();
|
|
417
|
+
const unsubscribeTokens = oxy.onTokensChanged((token) => {
|
|
418
|
+
if (disposed || token === lastSeenToken) {
|
|
419
|
+
return;
|
|
388
420
|
}
|
|
421
|
+
lastSeenToken = token;
|
|
422
|
+
schedule();
|
|
389
423
|
});
|
|
390
424
|
let removeFocusListener = null;
|
|
391
425
|
if (typeof document !== 'undefined') {
|