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