@ethlete/query 6.0.0-next.42 → 6.0.0-next.44
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/CHANGELOG.md +21 -0
- package/fesm2022/ethlete-query.mjs +88 -8
- package/fesm2022/ethlete-query.mjs.map +1 -1
- package/migrations.json +44 -0
- package/package.json +4 -1
- package/types/ethlete-query.d.ts +32 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @ethlete/query
|
|
2
2
|
|
|
3
|
+
## 6.0.0-next.44
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#3077](https://github.com/ethlete-io/ethdk/pull/3077) [`8c2cb3a`](https://github.com/ethlete-io/ethdk/commit/8c2cb3a7b8a311363255d66b0f3c07fcfc4f6bdb) Thanks [@github-actions](https://github.com/apps/github-actions)! - Auth: `onRefreshFailure` and the `logout()` it triggers now run outside the reactive context of the effect that detects the failure. A consumer reacting to the session ending could otherwise throw `NG0602` instead of handling the logout.
|
|
8
|
+
|
|
9
|
+
- [`f7d6bb1`](https://github.com/ethlete-io/ethdk/commit/f7d6bb12a799b680ce72c90cbdcfd96ae3ea2fc0) Thanks [@TomTomB](https://github.com/TomTomB)! - A secure query now waits for the token refresh instead of sending an access token that is already expired, so a session seeded by SSO no longer costs a `401` and a retry.
|
|
10
|
+
|
|
11
|
+
## 6.0.0-next.43
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- [#3075](https://github.com/ethlete-io/ethdk/pull/3075) [`48488fb`](https://github.com/ethlete-io/ethdk/commit/48488fbdc0d61bafe5e8117844e5efa2c31c330a) Thanks [@github-actions](https://github.com/apps/github-actions)! - Auth: a rejected cookie auto-login now ends the session through `onRefreshFailure` with `sessionEndCause()` `'expired'`, and `setTokens()` supersedes an auto-login still in flight so its late `401` no longer overwrites the seeded session.
|
|
16
|
+
|
|
17
|
+
- [`a533863`](https://github.com/ethlete-io/ethdk/commit/a533863ce73cbb9e3454b6016be39487aa965c7a) Thanks [@TomTomB](https://github.com/TomTomB)! - Query devtools: a session that ends as expired is dropped from the auth vault, so a tab seeded with it no longer meets the same dead token on every reload.
|
|
18
|
+
|
|
19
|
+
- [#3075](https://github.com/ethlete-io/ethdk/pull/3075) [`1d38e7e`](https://github.com/ethlete-io/ethdk/commit/1d38e7e0a025769f065d8ca7d506cb75ad89139a) Thanks [@github-actions](https://github.com/apps/github-actions)! - `et update` moves the `@ethlete/*` packages to a newer version, runs the codemods those versions
|
|
20
|
+
declare in their own `migrations.json`, and reports what needs a decision or an agent.
|
|
21
|
+
|
|
22
|
+
- [#3075](https://github.com/ethlete-io/ethdk/pull/3075) [`f9fbd3a`](https://github.com/ethlete-io/ethdk/commit/f9fbd3a6959a99af7c6de60d7ceaf2781e8a58d2) Thanks [@github-actions](https://github.com/apps/github-actions)! - Re-export types explicitly with `export type` so the barrels type-check under `isolatedModules`.
|
|
23
|
+
|
|
3
24
|
## 6.0.0-next.42
|
|
4
25
|
|
|
5
26
|
### Minor Changes
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { inject, Injector, effect, DestroyRef, InjectionToken, EnvironmentInjector, ElementRef, ErrorHandler, createEnvironmentInjector, untracked, signal, linkedSignal, computed, runInInjectionContext, isDevMode, PLATFORM_ID, resource, DOCUMENT, VERSION, makeEnvironmentProviders, provideEnvironmentInitializer, ApplicationRef, assertInInjectionContext, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, input, Pipe, ViewEncapsulation, Component, NgZone } from '@angular/core';
|
|
3
3
|
import { UrlTree, Router, RedirectCommand, ActivatedRoute } from '@angular/router';
|
|
4
|
-
import { Observable, defer, from, filter, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, EMPTY as EMPTY$1, take, takeUntil, finalize, mergeMap, toArray, map, merge, takeWhile, firstValueFrom,
|
|
4
|
+
import { Observable, defer, from, filter, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, EMPTY as EMPTY$1, take, takeUntil, finalize, mergeMap, toArray, map, race, NEVER, merge, takeWhile, firstValueFrom, concatMap, fromEvent, throttleTime, interval, startWith, pairwise, BehaviorSubject, combineLatest, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
|
|
5
5
|
import { defineStaticProvider, RuntimeError, clamp, randomId, defineRootProvider, isObject, injectUnsavedChangesCoordinator, injectRoute, getCookie, deleteCookie, setCookie, CORE_VERSION, createDestroy, computedTillTruthy, defineProvider, toProvideFn, toInjectFn, nextFrame, injectQueryParams, injectQueryParamChanges, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
|
|
6
6
|
import { HttpClient, HttpEventType, HttpErrorResponse, HttpResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http';
|
|
7
7
|
import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
|
@@ -4389,6 +4389,12 @@ const createQueryClient = (options) => defineRootProvider(() => {
|
|
|
4389
4389
|
});
|
|
4390
4390
|
|
|
4391
4391
|
const AUTH_HEADER = 'Authorization';
|
|
4392
|
+
/**
|
|
4393
|
+
* How long a request waits for the refresh of an expired access token before it sends the expired one
|
|
4394
|
+
* anyway. Nothing guarantees the refresh arrives - the refresh token can be spent, and the request can
|
|
4395
|
+
* fail or never come back - and a request that 401s and retries beats one that never goes out.
|
|
4396
|
+
*/
|
|
4397
|
+
const EXPIRED_TOKEN_WAIT_MS = 5000;
|
|
4392
4398
|
const createSecureExecuteFactory = (options) => {
|
|
4393
4399
|
const executeState = setupQueryExecuteState();
|
|
4394
4400
|
const circularChecker = circularQueryDependencyChecker();
|
|
@@ -4479,16 +4485,25 @@ const createSecureExecuteFactory = (options) => {
|
|
|
4479
4485
|
// Calling `authAndExec` purely because the auth query is "done" therefore races
|
|
4480
4486
|
// the token and can run while it is still null. Gate on the token signal instead:
|
|
4481
4487
|
// execute immediately if it is already available, otherwise wait for it.
|
|
4488
|
+
//
|
|
4489
|
+
// A token that is present but already expired is no better than a missing one: it can only earn a
|
|
4490
|
+
// 401, and the refresh that answers that 401 is the one already in flight (a token seed hands over
|
|
4491
|
+
// an expired access token, and the refresh query's schedule fires on it at once). Waiting for it
|
|
4492
|
+
// spends one round trip instead of two.
|
|
4482
4493
|
const authAndExecWhenTokenReady = (executeArgs) => {
|
|
4483
|
-
|
|
4494
|
+
const tokenAtWait = options.authProvider.accessToken();
|
|
4495
|
+
if (tokenAtWait && !options.authProvider.isAccessTokenExpired()) {
|
|
4484
4496
|
authAndExec(executeArgs);
|
|
4485
4497
|
return;
|
|
4486
4498
|
}
|
|
4499
|
+
// The query is on its way out, not idle - without this a consumer renders an empty state for as
|
|
4500
|
+
// long as the wait lasts.
|
|
4501
|
+
options.state.loading.set({ executeTime: Date.now(), progress: null });
|
|
4487
4502
|
tokenWaitSubscription.unsubscribe();
|
|
4488
|
-
tokenWaitSubscription = toObservable(options.authProvider.accessToken, {
|
|
4503
|
+
tokenWaitSubscription = race(toObservable(options.authProvider.accessToken, {
|
|
4489
4504
|
injector: options.deps.injector,
|
|
4490
|
-
})
|
|
4491
|
-
.pipe(
|
|
4505
|
+
}).pipe(filter((token) => !!token && token !== tokenAtWait)), tokenAtWait ? timer(EXPIRED_TOKEN_WAIT_MS) : NEVER)
|
|
4506
|
+
.pipe(take(1), tap(() => authAndExec(executeArgs)))
|
|
4492
4507
|
.subscribe();
|
|
4493
4508
|
};
|
|
4494
4509
|
const exec = (executeArgs) => {
|
|
@@ -5279,6 +5294,10 @@ const createBearerAuthProviderImpl = (config) => {
|
|
|
5279
5294
|
// executionState (e.g. `{ type: 'login', state: 'success' }`) via setupBearerQueryRegistry's
|
|
5280
5295
|
// execute(), so that internal path calls applyTokens directly to avoid stomping it with this.
|
|
5281
5296
|
const setTokens = (access, refresh) => {
|
|
5297
|
+
// A seed supersedes what is in flight, the way a logout does: the tokens are here, so a cookie
|
|
5298
|
+
// auto-login or a refresh still out with an older token can only report a failure that no longer
|
|
5299
|
+
// means anything - or apply the pair it comes back with over this one.
|
|
5300
|
+
invalidateTokenIssuingExecutions();
|
|
5282
5301
|
applyTokens(access, refresh);
|
|
5283
5302
|
executionState.set({ type: 'tokenSeed', state: 'success' });
|
|
5284
5303
|
};
|
|
@@ -5327,6 +5346,7 @@ const createBearerAuthProviderImpl = (config) => {
|
|
|
5327
5346
|
sessionEndCause: sessionEndCause.asReadonly(),
|
|
5328
5347
|
isTabLocalSession: isTabLocalSession.asReadonly(),
|
|
5329
5348
|
});
|
|
5349
|
+
let readAccessTokenExpiry = null;
|
|
5330
5350
|
const querySetupContext = {
|
|
5331
5351
|
name: config.name,
|
|
5332
5352
|
accessToken,
|
|
@@ -5339,8 +5359,12 @@ const createBearerAuthProviderImpl = (config) => {
|
|
|
5339
5359
|
logout,
|
|
5340
5360
|
afterTokenRefresh$,
|
|
5341
5361
|
queries: queries,
|
|
5362
|
+
latestExecutedQuery: latestExecutedQuery.asReadonly(),
|
|
5342
5363
|
executionState: executionState.asReadonly(),
|
|
5343
5364
|
hasTokenIssuingExecutionInFlight,
|
|
5365
|
+
reportAccessTokenExpiry: (isExpired) => {
|
|
5366
|
+
readAccessTokenExpiry = isExpired;
|
|
5367
|
+
},
|
|
5344
5368
|
};
|
|
5345
5369
|
for (const builder of config.queries) {
|
|
5346
5370
|
builder.setup?.(querySetupContext);
|
|
@@ -5391,6 +5415,7 @@ const createBearerAuthProviderImpl = (config) => {
|
|
|
5391
5415
|
refreshToken: refreshToken.asReadonly(),
|
|
5392
5416
|
bearerData,
|
|
5393
5417
|
isAuthenticated,
|
|
5418
|
+
isAccessTokenExpired: () => readAccessTokenExpiry?.() ?? false,
|
|
5394
5419
|
latestExecutedQuery: latestExecutedQuery.asReadonly(),
|
|
5395
5420
|
latestNonInternalQuery: latestNonInternalQuery.asReadonly(),
|
|
5396
5421
|
executionState: executionState.asReadonly(),
|
|
@@ -5746,6 +5771,24 @@ const withRefreshQuery = (key, config) => {
|
|
|
5746
5771
|
const remainingMs = msUntilAccessTokenIsStale();
|
|
5747
5772
|
return remainingMs !== null && remainingMs <= 0;
|
|
5748
5773
|
};
|
|
5774
|
+
/**
|
|
5775
|
+
* Whether the access token this tab holds is already past its expiry, so sending it can only earn
|
|
5776
|
+
* a 401. Unlike {@link isAccessTokenStale} this has no grace period: a token with seconds of life
|
|
5777
|
+
* left is still worth sending.
|
|
5778
|
+
*/
|
|
5779
|
+
const isAccessTokenExpired = () => {
|
|
5780
|
+
const token = context.accessToken();
|
|
5781
|
+
if (!token)
|
|
5782
|
+
return false;
|
|
5783
|
+
const expiresAt = tokenExpiresAt(token);
|
|
5784
|
+
return expiresAt !== null && expiresAt <= Date.now();
|
|
5785
|
+
};
|
|
5786
|
+
// Only reported when an expired token actually gets refreshed. With `refreshIfExpired: false` the
|
|
5787
|
+
// application asked for the request to go out and fail, so a secure query must not wait for a
|
|
5788
|
+
// refresh that never comes.
|
|
5789
|
+
if (refreshIfExpired) {
|
|
5790
|
+
context.reportAccessTokenExpiry(isAccessTokenExpired);
|
|
5791
|
+
}
|
|
5749
5792
|
/**
|
|
5750
5793
|
* The scheduled refresh, re-armed for as long as it keeps declining to spend the refresh token.
|
|
5751
5794
|
* Without the re-arm a single declined attempt strands the session on a token nothing renews until
|
|
@@ -5804,9 +5847,23 @@ const withRefreshQuery = (key, config) => {
|
|
|
5804
5847
|
});
|
|
5805
5848
|
effect(() => {
|
|
5806
5849
|
const state = context.executionState();
|
|
5807
|
-
if (state?.
|
|
5850
|
+
if (state?.state !== 'error')
|
|
5808
5851
|
return;
|
|
5809
|
-
|
|
5852
|
+
// `withPersistentAuth` spends the cookie's refresh token through this very query, and that
|
|
5853
|
+
// execution reports as `autoLogin` rather than as `tokenRefresh`. Judged by its type alone it
|
|
5854
|
+
// would end nothing: the tab keeps a rejected cookie's error state for good, `sessionEndCause`
|
|
5855
|
+
// never says `expired`, and an app gated on either never leaves its startup screen. The token
|
|
5856
|
+
// check keeps a session another tab handed over mid-request out of it - that session is not
|
|
5857
|
+
// this restore's to end.
|
|
5858
|
+
const isRejectedRestore = state.type === 'autoLogin' &&
|
|
5859
|
+
untracked(context.latestExecutedQuery)?.key === key &&
|
|
5860
|
+
!untracked(context.accessToken);
|
|
5861
|
+
if (state.type !== 'tokenRefresh' && !isRejectedRestore)
|
|
5862
|
+
return;
|
|
5863
|
+
// `logout` tears down every secure cache entry and emits on `events$`, so a consumer that reacts
|
|
5864
|
+
// to a session ending runs inside this effect. Angular refuses `effect()` from a reactive context,
|
|
5865
|
+
// so a listener that creates a query would throw NG0602 instead of handling the logout.
|
|
5866
|
+
untracked(() => onRefreshFailure({ error: state.error, logout: () => context.logout('expired') }));
|
|
5810
5867
|
});
|
|
5811
5868
|
// Auto-retry on 401: Listen to repository events and trigger refresh on 401 errors
|
|
5812
5869
|
const autoRetryOn401 = config.autoRetryOn401 ?? true;
|
|
@@ -8099,9 +8156,12 @@ const trackQueryDevtoolsAuthProvider = (registration) => {
|
|
|
8099
8156
|
const stop = effect(() => {
|
|
8100
8157
|
const accessToken = handle.accessToken();
|
|
8101
8158
|
const refreshToken = handle.refreshToken();
|
|
8159
|
+
const endCause = handle.sessionEndCause();
|
|
8102
8160
|
untracked(() => {
|
|
8103
8161
|
if (accessToken && refreshToken)
|
|
8104
8162
|
remember({ provider: name, accessToken, refreshToken });
|
|
8163
|
+
else if (endCause === 'expired')
|
|
8164
|
+
forgetExpired(name);
|
|
8105
8165
|
else
|
|
8106
8166
|
forgetActive(name);
|
|
8107
8167
|
});
|
|
@@ -8212,6 +8272,26 @@ const forgetActive = (provider) => {
|
|
|
8212
8272
|
persist();
|
|
8213
8273
|
syncPill();
|
|
8214
8274
|
};
|
|
8275
|
+
/**
|
|
8276
|
+
* Drops the session a provider was on because it expired, tokens and all - unlike every other way a
|
|
8277
|
+
* session ends, this pair is spent. Keeping it offers a switch back that can only fail, and a tab-local
|
|
8278
|
+
* seed would re-apply it on every reload, so the tab meets the same dead token until someone clears the
|
|
8279
|
+
* storage by hand.
|
|
8280
|
+
*/
|
|
8281
|
+
const forgetExpired = (provider) => {
|
|
8282
|
+
const sessionId = active()[provider] ?? null;
|
|
8283
|
+
const { [provider]: _dropped, ...seeds } = readSeeds();
|
|
8284
|
+
writeSeeds(seeds);
|
|
8285
|
+
if (!sessionId)
|
|
8286
|
+
return;
|
|
8287
|
+
store.update((current) => ({
|
|
8288
|
+
...current,
|
|
8289
|
+
sessions: current.sessions.filter((session) => session.id !== sessionId),
|
|
8290
|
+
}));
|
|
8291
|
+
setActive(provider, null);
|
|
8292
|
+
persist();
|
|
8293
|
+
syncPill();
|
|
8294
|
+
};
|
|
8215
8295
|
/** The sessions offered for one provider, which are the ones this backend issued. */
|
|
8216
8296
|
const queryDevtoolsAuthSessionsFor = (provider) => {
|
|
8217
8297
|
const scope = scopeOf();
|
|
@@ -9582,7 +9662,7 @@ const armQueryDevtoolsOverrideTransfer = (recorder, ops) => {
|
|
|
9582
9662
|
|
|
9583
9663
|
// Generated from package.json by tools/scripts/generate-version.js - do not edit.
|
|
9584
9664
|
/** The version of `@ethlete/query` this build was cut from. */
|
|
9585
|
-
const QUERY_VERSION = '6.0.0-next.
|
|
9665
|
+
const QUERY_VERSION = '6.0.0-next.44';
|
|
9586
9666
|
|
|
9587
9667
|
/** How deep a schema is expanded before a nested branch is given up on. */
|
|
9588
9668
|
const MAX_DEPTH = 12;
|