From 6636cc66b12c058d0a932df976d827315ea4277e Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 15 Oct 2025 02:50:43 +0200 Subject: [PATCH] Prevent sending requests that are known to fail In case it's still unknown if auth is set up on the server, only the initial request to detect this should be sent. Since all other requests will also just fail, they should be blocked until the auth initialization is completed. While the access token is getting refreshed, no request will succeed since the access token is expired; thus, they should not be sent. --- src/App.tsx | 18 ++-- src/features/authentication/AuthManager.ts | 24 +++++ .../authentication/components/AuthGuard.tsx | 20 ++--- src/lib/requests/RequestManager.ts | 13 +++ src/lib/requests/client/BaseClient.ts | 61 +++++++++++++ src/lib/requests/client/GraphQLClient.ts | 27 +++++- src/lib/requests/client/RestClient.ts | 87 ++++++++++--------- 7 files changed, 186 insertions(+), 64 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 26155fe4..69d831af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -298,16 +298,16 @@ export const App: React.FC = () => ( - - - - - - - - - + + + + + + + + + diff --git a/src/features/authentication/AuthManager.ts b/src/features/authentication/AuthManager.ts index 380e946b..feb579d3 100644 --- a/src/features/authentication/AuthManager.ts +++ b/src/features/authentication/AuthManager.ts @@ -18,6 +18,10 @@ export class AuthManager { private static accessToken: string | null = null; + private static authInitialized: boolean = false; + + private static refreshingToken: boolean = false; + static isAuthRequired(): boolean | null { return AppStorage.session.getItemParsed(AuthManager.AUTH_REQUIRED_KEY, null); } @@ -90,4 +94,24 @@ export class AuthManager { static useListenToReactSessionContextRefreshEvent(): void { useSessionStorage(AuthManager.REACT_SESSION_REFRESH_KEY, 0); } + + static isAuthInitialized(): boolean { + return AuthManager.authInitialized; + } + + static setAuthInitialized(value: boolean): void { + AuthManager.authInitialized = value; + } + + static isRefreshingToken(): boolean { + return AuthManager.refreshingToken; + } + + static setIsRefreshingToken(value: boolean): void { + AuthManager.refreshingToken = value; + } + + static shouldQueueRequests(): boolean { + return !AuthManager.isAuthInitialized() || AuthManager.isRefreshingToken(); + } } diff --git a/src/features/authentication/components/AuthGuard.tsx b/src/features/authentication/components/AuthGuard.tsx index 38efc91d..584708d8 100644 --- a/src/features/authentication/components/AuthGuard.tsx +++ b/src/features/authentication/components/AuthGuard.tsx @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { ReactNode, useEffect } from 'react'; +import { ReactNode } from 'react'; import { useSessionContext } from '@/features/authentication/SessionContext.tsx'; import { SplashScreen } from '@/features/authentication/components/SplashScreen.tsx'; import { requestManager } from '@/lib/requests/RequestManager.ts'; @@ -17,17 +17,17 @@ export const AuthGuard = ({ children }: { children: ReactNode }) => { requestManager.useGetAbout({ skip: isAuthRequired !== null, - onCompleted: () => AuthManager.setAuthRequired(false), + onCompleted: () => { + if (AuthManager.isAuthInitialized()) { + return; + } + + AuthManager.setAuthRequired(false); + AuthManager.setAuthInitialized(true); + requestManager.processQueues(); + }, }); - useEffect(() => { - const onUnload = () => AuthManager.setAuthRequired(null); - - window.addEventListener('beforeunload', onUnload); - - return () => window.removeEventListener('beforeunload', onUnload); - }, []); - if (isAuthRequired === null) { return ; } diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index e86d788a..fe5ce1d4 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -29,6 +29,7 @@ import { MaybeMasked, OperationVariables, Reference } from '@apollo/client/core' import { useEffect, useMemo, useRef, useState } from 'react'; import { IRestClient, RestClient } from '@/lib/requests/client/RestClient.ts'; import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts'; +import { BaseClient } from '@/lib/requests/client/BaseClient.ts'; import { CategoryOrderBy, ChapterConditionInput, @@ -452,6 +453,12 @@ export class RequestManager { private readonly imageQueue = new Queue(5); + constructor() { + BaseClient.setTokenRefreshCompleteCallback(() => { + this.processQueues(); + }); + } + public getClient(): IRestClient { return this.restClient; } @@ -463,6 +470,7 @@ export class RequestManager { public reset(): void { AuthManager.setAuthRequired(null); + AuthManager.setAuthInitialized(false); AuthManager.removeTokens(); this.graphQLClient.client.resetStore(); this.graphQLClient.terminateSubscriptions(); @@ -470,6 +478,11 @@ export class RequestManager { this.imageQueue.clear(); } + public processQueues(): void { + this.graphQLClient.processQueue(); + this.restClient.processQueue(); + } + public getBaseUrl(): string { return this.restClient.getBaseUrl(); } diff --git a/src/lib/requests/client/BaseClient.ts b/src/lib/requests/client/BaseClient.ts index e7a87502..259ec43c 100644 --- a/src/lib/requests/client/BaseClient.ts +++ b/src/lib/requests/client/BaseClient.ts @@ -11,6 +11,13 @@ import { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts'; import { AuthManager } from '@/features/authentication/AuthManager.ts'; import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts'; import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts'; +import { ControlledPromise } from '@/lib/ControlledPromise.ts'; + +interface QueuedRequest { + execute: () => void; + resolve: (value: any) => void; + reject: (error: any) => void; +} export abstract class BaseClient { protected abstract client: Client; @@ -19,6 +26,14 @@ export abstract class BaseClient { private static activeTokenRefreshPromise: Promise | null = null; + private static onTokenRefreshComplete: (() => void) | null = null; + + protected requestQueue: QueuedRequest[] = []; + + public static setTokenRefreshCompleteCallback(callback: (() => void) | null): void { + BaseClient.onTokenRefreshComplete = callback; + } + protected static async refreshAccessToken( refreshFn: (refreshToken: string) => AbortableApolloMutationResponse, ): Promise { @@ -36,6 +51,8 @@ export abstract class BaseClient { return this.activeTokenRefreshPromise; } + AuthManager.setIsRefreshingToken(true); + const refreshRequest = refreshFn(refreshToken).response; this.activeTokenRefreshPromise = refreshRequest.then((result) => result.data); @@ -48,6 +65,9 @@ export abstract class BaseClient { } AuthManager.setAccessToken(data.refreshToken.accessToken); + AuthManager.setAuthInitialized(true); + + BaseClient.onTokenRefreshComplete?.(); return data; } catch (e) { @@ -55,6 +75,7 @@ export abstract class BaseClient { throw e; } finally { this.activeTokenRefreshPromise = null; + AuthManager.setIsRefreshingToken(false); } } @@ -75,5 +96,45 @@ export abstract class BaseClient { return SubpathUtil.getApiBaseUrl(serverBaseURL); } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldQueueRequest(operationName?: string): boolean { + return AuthManager.shouldQueueRequests(); + } + + protected enqueueRequest(executor: () => Promise, operationName?: string): Promise { + if (!this.shouldQueueRequest(operationName)) { + return executor(); + } + + const { promise: requestPromise, reject, resolve } = new ControlledPromise(); + this.requestQueue.push({ + execute: () => { + executor().then(resolve).catch(reject); + }, + resolve, + reject, + }); + + return requestPromise; + } + + public processQueue(): void { + const queue = [...this.requestQueue]; + this.requestQueue = []; + + queue.forEach((request) => { + request.execute(); + }); + } + + protected clearQueue(error?: Error): void { + const queue = [...this.requestQueue]; + this.requestQueue = []; + + queue.forEach((request) => { + request.reject(error ?? new Error('Request queue cleared')); + }); + } + public abstract updateConfig(config: Partial): void; } diff --git a/src/lib/requests/client/GraphQLClient.ts b/src/lib/requests/client/GraphQLClient.ts index 923efdd0..9489559b 100644 --- a/src/lib/requests/client/GraphQLClient.ts +++ b/src/lib/requests/client/GraphQLClient.ts @@ -12,11 +12,12 @@ import { ApolloClient, ApolloClientOptions, ApolloLink, + from, + fromPromise, InMemoryCache, NormalizedCacheObject, split, - from, - fromPromise, + toPromise, } from '@apollo/client'; import createUploadLink from 'apollo-upload-client/createUploadLink.mjs'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; @@ -210,6 +211,27 @@ export class GraphQLClient extends BaseClient< this.wsClient.terminate(); } + protected override shouldQueueRequest(operationName: string | undefined): boolean { + const authOperations = ['GET_ABOUT', 'USER_LOGIN', 'USER_REFRESH']; + if (authOperations.includes(operationName!)) { + return false; + } + + return super.shouldQueueRequest(); + } + + private createAuthGuardLink() { + return new ApolloLink((operation, forward) => { + const { operationName } = operation; + + if (this.shouldQueueRequest(operationName)) { + return fromPromise(this.enqueueRequest(() => toPromise(forward(operation)), operationName)); + } + + return forward(operation); + }); + } + private createErrorLink() { return onError(({ graphQLErrors, operation, forward }) => { if (!graphQLErrors) { @@ -262,6 +284,7 @@ export class GraphQLClient extends BaseClient< }, this.createWSLink(), from([ + this.createAuthGuardLink(), this.createErrorLink(), this.createAuthLink(), removeTypenameLink, diff --git a/src/lib/requests/client/RestClient.ts b/src/lib/requests/client/RestClient.ts index ebc1e7c3..52ae0cb2 100644 --- a/src/lib/requests/client/RestClient.ts +++ b/src/lib/requests/client/RestClient.ts @@ -49,56 +49,57 @@ export class RestClient config?: RequestInit; checkResponseIsJson?: boolean; } = {}, - ): Promise => { - const updatedUrl = url.startsWith('http') ? url : `${this.getBaseUrl()}${url}`; - const isAuthRequired = AuthManager.isAuthRequired(); - const accessToken = AuthManager.getAccessToken(); + ): Promise => + this.enqueueRequest(async () => { + const updatedUrl = url.startsWith('http') ? url : `${this.getBaseUrl()}${url}`; + const isAuthRequired = AuthManager.isAuthRequired(); + const accessToken = AuthManager.getAccessToken(); - let result: Response; + let result: Response; - switch (httpMethod) { - case HttpMethod.GET: - result = await this.client(updatedUrl, { - ...this.config, - ...config, - method: httpMethod, - headers: { + switch (httpMethod) { + case HttpMethod.GET: + result = await this.client(updatedUrl, { + ...this.config, + ...config, + method: httpMethod, + headers: { + ...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + ...this.config.headers, + ...config?.headers, + }, + }); + break; + case HttpMethod.POST: + case HttpMethod.PATCH: + case HttpMethod.DELETE: + result = await this.client(updatedUrl, { ...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), - ...this.config.headers, - ...config?.headers, - }, - }); - break; - case HttpMethod.POST: - case HttpMethod.PATCH: - case HttpMethod.DELETE: - result = await this.client(updatedUrl, { - ...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), - ...this.config, - ...config, - method: httpMethod, - body: JSON.stringify(data), - }); - break; - default: - throw new Error(`Unexpected HttpMethod "${httpMethod}"`); - } + ...this.config, + ...config, + method: httpMethod, + body: JSON.stringify(data), + }); + break; + default: + throw new Error(`Unexpected HttpMethod "${httpMethod}"`); + } - if (result.status === 401) { - await BaseClient.refreshAccessToken(this.handleRefreshToken); - return this.fetcher(url, { data, httpMethod, config, checkResponseIsJson }); - } + if (result.status === 401) { + await BaseClient.refreshAccessToken(this.handleRefreshToken); + return this.fetcher(url, { data, httpMethod, config, checkResponseIsJson }); + } - if (result.status !== 200) { - throw new Error(`status ${result.status}: ${result.statusText}`); - } + if (result.status !== 200) { + throw new Error(`status ${result.status}: ${result.statusText}`); + } - if (checkResponseIsJson && result.headers.get('content-type') !== 'application/json') { - throw new Error('Response is not json'); - } + if (checkResponseIsJson && result.headers.get('content-type') !== 'application/json') { + throw new Error('Response is not json'); + } - return result; - }; + return result; + }); constructor(handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse) { super(handleRefreshToken);