@ethlete/query 6.0.0-next.43 → 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 CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 6.0.0-next.43
4
12
 
5
13
  ### Patch 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, race, concatMap, fromEvent, throttleTime, interval, startWith, pairwise, BehaviorSubject, combineLatest, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
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
- if (options.authProvider.accessToken()) {
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(filter(Boolean), take(1), tap(() => authAndExec(executeArgs)))
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) => {
@@ -5331,6 +5346,7 @@ const createBearerAuthProviderImpl = (config) => {
5331
5346
  sessionEndCause: sessionEndCause.asReadonly(),
5332
5347
  isTabLocalSession: isTabLocalSession.asReadonly(),
5333
5348
  });
5349
+ let readAccessTokenExpiry = null;
5334
5350
  const querySetupContext = {
5335
5351
  name: config.name,
5336
5352
  accessToken,
@@ -5346,6 +5362,9 @@ const createBearerAuthProviderImpl = (config) => {
5346
5362
  latestExecutedQuery: latestExecutedQuery.asReadonly(),
5347
5363
  executionState: executionState.asReadonly(),
5348
5364
  hasTokenIssuingExecutionInFlight,
5365
+ reportAccessTokenExpiry: (isExpired) => {
5366
+ readAccessTokenExpiry = isExpired;
5367
+ },
5349
5368
  };
5350
5369
  for (const builder of config.queries) {
5351
5370
  builder.setup?.(querySetupContext);
@@ -5396,6 +5415,7 @@ const createBearerAuthProviderImpl = (config) => {
5396
5415
  refreshToken: refreshToken.asReadonly(),
5397
5416
  bearerData,
5398
5417
  isAuthenticated,
5418
+ isAccessTokenExpired: () => readAccessTokenExpiry?.() ?? false,
5399
5419
  latestExecutedQuery: latestExecutedQuery.asReadonly(),
5400
5420
  latestNonInternalQuery: latestNonInternalQuery.asReadonly(),
5401
5421
  executionState: executionState.asReadonly(),
@@ -5751,6 +5771,24 @@ const withRefreshQuery = (key, config) => {
5751
5771
  const remainingMs = msUntilAccessTokenIsStale();
5752
5772
  return remainingMs !== null && remainingMs <= 0;
5753
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
+ }
5754
5792
  /**
5755
5793
  * The scheduled refresh, re-armed for as long as it keeps declining to spend the refresh token.
5756
5794
  * Without the re-arm a single declined attempt strands the session on a token nothing renews until
@@ -5822,7 +5860,10 @@ const withRefreshQuery = (key, config) => {
5822
5860
  !untracked(context.accessToken);
5823
5861
  if (state.type !== 'tokenRefresh' && !isRejectedRestore)
5824
5862
  return;
5825
- onRefreshFailure({ error: state.error, logout: () => context.logout('expired') });
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') }));
5826
5867
  });
5827
5868
  // Auto-retry on 401: Listen to repository events and trigger refresh on 401 errors
5828
5869
  const autoRetryOn401 = config.autoRetryOn401 ?? true;
@@ -9621,7 +9662,7 @@ const armQueryDevtoolsOverrideTransfer = (recorder, ops) => {
9621
9662
 
9622
9663
  // Generated from package.json by tools/scripts/generate-version.js - do not edit.
9623
9664
  /** The version of `@ethlete/query` this build was cut from. */
9624
- const QUERY_VERSION = '6.0.0-next.43';
9665
+ const QUERY_VERSION = '6.0.0-next.44';
9625
9666
 
9626
9667
  /** How deep a schema is expanded before a nested branch is given up on. */
9627
9668
  const MAX_DEPTH = 12;