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);