Files
suwayomi-material-you-webui/src/lib/requests/client/GraphQLClient.ts

410 lines
14 KiB
TypeScript
Raw Normal View History

2023-09-01 20:21:59 +02:00
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
2025-07-27 00:03:51 +02:00
import { onError } from '@apollo/client/link/error';
import { setContext } from '@apollo/client/link/context';
import {
ApolloClient,
ApolloClientOptions,
ApolloLink,
from,
fromPromise,
InMemoryCache,
NormalizedCacheObject,
split,
toPromise,
} from '@apollo/client';
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
2023-10-04 20:50:11 +02:00
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { Client, createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
import { TypePolicies } from '@apollo/client/cache';
import { removeTypenameFromVariables } from '@apollo/client/link/remove-typename';
2025-10-11 00:39:44 +02:00
import { d } from 'koration';
import { useId } from '@mantine/hooks';
import { useEffect } from 'react';
2023-09-01 20:21:59 +02:00
import { BaseClient } from '@/lib/requests/client/BaseClient.ts';
import { StrictTypedTypePolicies } from '@/lib/graphql/generated/apollo-helpers.ts';
2025-07-27 00:03:51 +02:00
import { AuthManager } from '@/features/authentication/AuthManager.ts';
import { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts';
import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
2023-09-01 20:21:59 +02:00
/* eslint-disable no-underscore-dangle */
2023-09-01 20:21:59 +02:00
const typePolicies: StrictTypedTypePolicies = {
MangaType: {
fields: {
trackRecords: {
merge(existing, incoming) {
const nodes = incoming.nodes ?? existing?.nodes;
return {
...existing,
...incoming,
totalCount: nodes?.length ?? existing?.totalCount ?? incoming.totalCount,
nodes,
};
},
},
},
},
2023-09-01 20:21:59 +02:00
GlobalMetaType: { keyFields: ['key'] },
MangaMetaType: { keyFields: ['mangaId', 'key'] },
ChapterMetaType: { keyFields: ['chapterId', 'key'] },
CategoryMetaType: { keyFields: ['categoryId', 'key'] },
SourceMetaType: { keyFields: ['sourceId', 'key'] },
ExtensionType: { keyFields: ['pkgName'] },
AboutServerPayload: { keyFields: [] },
AboutWebUI: { keyFields: [] },
WebUIUpdateInfo: { keyFields: [] },
WebUIUpdateCheck: { keyFields: [] },
SettingsType: { keyFields: [] },
DownloadStatus: {
keyFields: [],
fields: {
queue: {
merge(_existing, incoming) {
return incoming;
},
},
},
},
2023-12-13 02:05:03 +01:00
DownloadType: { keyFields: ['chapter'] },
CategoryUpdateType: { keyFields: ['category'] },
MangaUpdateType: { keyFields: ['manga'] },
UpdaterJobsInfoType: { keyFields: [] },
WebUIUpdateStatus: { keyFields: [] },
UpdateStatus: { keyFields: [] },
Query: {
fields: {
manga(_, { args, toReference }) {
return toReference({
__typename: 'MangaType',
id: args?.id,
});
},
category(_, { args, toReference }) {
return toReference({
__typename: 'CategoryType',
id: args?.id,
});
},
source(_, { args, toReference }) {
return toReference({
__typename: 'SourceType',
id: args?.id,
});
},
extension(_, { args, toReference }) {
return toReference({
__typename: 'ExtensionType',
2024-07-01 20:18:48 +02:00
pkgName: args?.pkgName,
});
},
meta(_, { args, toReference }) {
return toReference({
__typename: 'GlobalMetaType',
key: args?.key,
});
},
downloadStatus: {
read(_, { toReference }) {
return toReference({
__typename: 'DownloadStatus',
key: {},
});
},
merge(_, incoming) {
return incoming;
},
},
getWebUIUpdateStatus(_, { toReference }) {
return toReference({
__typename: 'WebUIUpdateStatus',
key: {},
});
},
updateStatus(_, { toReference }) {
return toReference({ __typename: 'UpdateStatus', key: {} });
},
chapters: {
keyArgs: ['condition', 'filter', 'orderBy', 'orderByType', 'order'],
merge(existing, incoming) {
if (existing == null) {
return incoming;
}
const isReFetch = !incoming.pageInfo.hasPreviousPage;
const hasLessItems = existing.nodes.length > incoming.nodes.length;
const useIncomingResponse = isReFetch && !hasLessItems;
if (useIncomingResponse) {
return incoming;
}
const replaceExistingItems = isReFetch && hasLessItems;
if (replaceExistingItems) {
const existingWithReplacedIncoming: typeof incoming = {
...existing,
pageInfo: {
...existing.pageInfo,
startCursor: incoming.pageInfo.startCursor,
},
nodes: [...incoming.nodes, ...existing.nodes.slice(incoming.nodes.length)],
};
return existingWithReplacedIncoming;
}
const existingWithAppendedIncoming: typeof incoming = {
...existing,
pageInfo: {
...existing.pageInfo,
endCursor: incoming.pageInfo.endCursor,
hasNextPage: incoming.pageInfo.hasNextPage,
},
nodes: [...existing.nodes, ...incoming.nodes],
};
return existingWithAppendedIncoming;
},
},
settings: {
merge(existing, incoming) {
return {
...(existing ?? {}),
...(incoming ?? {}),
};
},
},
},
},
2023-09-01 20:21:59 +02:00
};
/* eslint-enable no-underscore-dangle */
2023-09-01 20:21:59 +02:00
export class GraphQLClient extends BaseClient<
ApolloClient<NormalizedCacheObject>,
ApolloClientOptions<NormalizedCacheObject>,
null
> {
readonly fetcher = null;
public client!: ApolloClient<NormalizedCacheObject>;
2023-09-01 20:21:59 +02:00
2023-10-04 20:50:11 +02:00
private wsClient!: Client;
private wsClientAliveCheckInterval: NodeJS.Timeout | undefined = undefined;
private activeConnectionSubscriptions = new Map<string, () => void>();
2025-07-27 00:03:51 +02:00
constructor(handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>) {
super(handleRefreshToken);
this.createClient();
}
2023-09-01 20:21:59 +02:00
public override getBaseUrl(): string {
return `${super.getBaseUrl()}/api/graphql`;
}
2025-11-07 01:25:36 +01:00
override reset(): void {
super.reset();
this.client.clearStore();
this.client.stop();
this.resetWsClient(false);
this.createClient();
}
private resetWsClient(recreateClient: boolean): void {
this.wsClient.dispose();
this.wsClient.terminate();
2025-11-07 01:25:36 +01:00
if (!recreateClient) {
return;
}
this.createWSClient(false);
this.client.setLink(this.createLink());
this.restartAllSubscriptions();
}
private restartAllSubscriptions(): void {
this.activeConnectionSubscriptions.forEach((callback) => callback());
}
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);
});
}
2025-07-27 00:03:51 +02:00
private createErrorLink() {
return onError(({ graphQLErrors, operation, forward }) => {
if (!graphQLErrors) {
return undefined;
}
const isAuthError = graphQLErrors.some((graphQLError) =>
graphQLError.message.includes('suwayomi.tachidesk.server.user.UnauthorizedException'),
);
if (!isAuthError) {
return undefined;
}
return fromPromise(BaseClient.refreshAccessToken(this.handleRefreshToken))
.filter(Boolean)
.flatMap(() => forward(operation));
});
}
private createAuthLink() {
return setContext((_, { headers }) => {
const isAuthRequired = AuthManager.isAuthRequired();
const accessToken = AuthManager.getAccessToken();
return {
credentials: 'include',
2025-07-27 00:03:51 +02:00
headers: {
...headers,
2025-08-27 00:22:17 +02:00
...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
2025-07-27 00:03:51 +02:00
},
};
});
}
2023-09-01 20:21:59 +02:00
private createUploadLink() {
return createUploadLink({ uri: () => this.getBaseUrl() });
2023-09-01 20:21:59 +02:00
}
2023-10-04 20:50:11 +02:00
private createWSLink() {
return new GraphQLWsLink(this.wsClient);
}
2023-09-01 20:21:59 +02:00
private createLink() {
const removeTypenameLink = removeTypenameFromVariables();
2023-10-04 20:50:11 +02:00
return split(
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
},
this.createWSLink(),
2025-07-27 00:03:51 +02:00
from([
this.createAuthGuardLink(),
2025-07-27 00:03:51 +02:00
this.createErrorLink(),
this.createAuthLink(),
removeTypenameLink,
// apollo-upload-client dependency is outdated (see 134e47763faae9e62db4d4e3a8387a74e32e5568) and thus types are not matching, but they are still correct
this.createUploadLink() as unknown as ApolloLink,
]),
2023-10-04 20:50:11 +02:00
);
2023-09-01 20:21:59 +02:00
}
private createWSClient(lazy: boolean = true): void {
2025-10-11 00:39:44 +02:00
const heartbeatInterval = d(20).seconds.inWholeMilliseconds;
2023-10-04 20:50:11 +02:00
this.wsClient = createClient({
lazy,
2023-10-04 20:50:11 +02:00
url: () => this.getBaseUrl().replace(/http(|s)/g, 'ws'),
keepAlive: heartbeatInterval,
retryAttempts: Number.MAX_SAFE_INTEGER,
shouldRetry: () => true,
retryWait: async (retries) => {
2025-10-11 00:39:44 +02:00
const delay = Math.min(d(1).seconds.inWholeMilliseconds * 2 ** retries, heartbeatInterval);
return new Promise((resolve) => {
setTimeout(resolve, delay);
});
},
2025-07-27 00:03:51 +02:00
connectionParams: () => {
const isAuthRequired = AuthManager.isAuthRequired();
const accessToken = AuthManager.getAccessToken();
return {
Authorization: isAuthRequired && accessToken ? accessToken : undefined,
};
},
});
let triedForcedReconnection = false;
let lastHeartbeat: number = Date.now();
this.wsClient.on('message', () => {
lastHeartbeat = Date.now();
triedForcedReconnection = false;
2023-10-04 20:50:11 +02:00
});
2025-10-11 00:39:44 +02:00
const checkHeartbeatInterval = heartbeatInterval + d(30).seconds.inWholeMilliseconds;
clearInterval(this.wsClientAliveCheckInterval);
this.wsClientAliveCheckInterval = setInterval(() => {
const isHeartbeatMissing = Date.now() - lastHeartbeat > checkHeartbeatInterval * 1.1;
if (!isHeartbeatMissing) {
return;
}
if (!triedForcedReconnection) {
triedForcedReconnection = true;
this.wsClient.terminate();
return;
}
2025-11-07 01:25:36 +01:00
this.resetWsClient(true);
}, checkHeartbeatInterval);
}
2025-11-07 01:25:36 +01:00
protected createClient(createWsClientLazily?: boolean) {
this.createWSClient(createWsClientLazily);
2023-09-01 20:21:59 +02:00
this.client = new ApolloClient({
cache: new InMemoryCache({
// for whatever reason there is some weird TypeError complaining that
// "FieldReadFunction<Reference, Reference, FieldFunctionOptions<SomeObject, Record<string, any>>"
// is not compatible with "FieldReadFunction<any, any, FieldFunctionOptions<Record<string, any, Record<string, any>>"
// Since "typePolicies" is correctly typed as StrictTypedTypePolicies, and it is working as expected,
// the TypeError can just be ignored
typePolicies: typePolicies as TypePolicies,
2023-09-01 20:21:59 +02:00
}),
connectToDevTools: true,
link: this.createLink(),
});
}
public override updateConfig() {}
public useRestartSubscription(restart: () => void) {
const id = useId();
this.activeConnectionSubscriptions.set(id, () => {
restart();
});
useEffect(
() => () => {
this.activeConnectionSubscriptions.delete(id);
},
[id],
);
}
2023-09-01 20:21:59 +02:00
}