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' ;
2024-04-14 15:10:34 +02:00
import {
ApolloClient ,
ApolloClientOptions ,
ApolloLink ,
2025-10-15 02:50:43 +02:00
from ,
fromPromise ,
2024-04-14 15:10:34 +02:00
InMemoryCache ,
NormalizedCacheObject ,
split ,
2025-10-15 02:50:43 +02:00
toPromise ,
2024-04-14 15:10:34 +02:00
} from '@apollo/client' ;
2024-12-24 14:19:40 +01:00
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' ;
2023-12-25 21:20:07 +01:00
import { TypePolicies } from '@apollo/client/cache' ;
2025-07-20 16:48:59 +02:00
import { removeTypenameFromVariables } from '@apollo/client/link/remove-typename' ;
2025-10-11 00:39:44 +02:00
import { d } from 'koration' ;
2025-11-06 02:41:58 +01:00
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
2023-09-30 16:55:35 +02:00
/* eslint-disable no-underscore-dangle */
2023-09-01 20:21:59 +02:00
const typePolicies : StrictTypedTypePolicies = {
2024-09-08 01:52:36 +02:00
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' ] } ,
2024-09-21 20:01:06 +02:00
MangaMetaType : { keyFields : [ 'mangaId' , 'key' ] } ,
ChapterMetaType : { keyFields : [ 'chapterId' , 'key' ] } ,
CategoryMetaType : { keyFields : [ 'categoryId' , 'key' ] } ,
SourceMetaType : { keyFields : [ 'sourceId' , 'key' ] } ,
2023-12-25 21:16:04 +01:00
ExtensionType : { keyFields : [ 'pkgName' ] } ,
2023-11-24 01:33:56 +01:00
AboutServerPayload : { keyFields : [ ] } ,
2023-12-09 23:08:27 +01:00
AboutWebUI : { keyFields : [ ] } ,
2023-11-24 01:33:56 +01:00
WebUIUpdateInfo : { keyFields : [ ] } ,
2023-12-09 23:08:27 +01:00
WebUIUpdateCheck : { keyFields : [ ] } ,
2023-10-27 23:39:05 +02:00
SettingsType : { keyFields : [ ] } ,
2025-01-12 20:55:08 +01:00
DownloadStatus : {
keyFields : [ ] ,
fields : {
queue : {
merge ( _existing , incoming ) {
return incoming ;
} ,
} ,
} ,
} ,
2023-12-13 02:05:03 +01:00
DownloadType : { keyFields : [ 'chapter' ] } ,
2025-03-16 21:01:24 +01:00
CategoryUpdateType : { keyFields : [ 'category' ] } ,
MangaUpdateType : { keyFields : [ 'manga' ] } ,
UpdaterJobsInfoType : { keyFields : [ ] } ,
2024-01-01 22:40:39 +01:00
WebUIUpdateStatus : { keyFields : [ ] } ,
UpdateStatus : { keyFields : [ ] } ,
2023-09-30 16:55:35 +02:00
Query : {
fields : {
2023-12-25 21:20:07 +01:00
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 ,
2023-12-25 21:20:07 +01:00
} ) ;
} ,
meta ( _ , { args , toReference } ) {
return toReference ( {
__typename : 'GlobalMetaType' ,
key : args?.key ,
} ) ;
} ,
2024-08-21 21:29:01 +02:00
downloadStatus : {
read ( _ , { toReference } ) {
return toReference ( {
__typename : 'DownloadStatus' ,
key : { } ,
} ) ;
} ,
merge ( _ , incoming ) {
return incoming ;
} ,
2024-01-01 22:40:39 +01:00
} ,
getWebUIUpdateStatus ( _ , { toReference } ) {
return toReference ( {
__typename : 'WebUIUpdateStatus' ,
key : { } ,
} ) ;
} ,
updateStatus ( _ , { toReference } ) {
return toReference ( { __typename : 'UpdateStatus' , key : { } } ) ;
} ,
2023-09-30 16:55:35 +02:00
chapters : {
2025-07-08 21:56:21 +02:00
keyArgs : [ 'condition' , 'filter' , 'orderBy' , 'orderByType' , 'order' ] ,
2023-09-30 16:55:35 +02:00
merge ( existing , incoming ) {
2023-11-25 15:35:26 +01:00
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 = {
2023-09-30 16:55:35 +02:00
. . . existing ,
2023-11-25 15:35:26 +01:00
pageInfo : {
. . . existing . pageInfo ,
endCursor : incoming.pageInfo.endCursor ,
hasNextPage : incoming.pageInfo.hasNextPage ,
} ,
nodes : [ . . . existing . nodes , . . . incoming . nodes ] ,
2023-09-30 16:55:35 +02:00
} ;
2023-11-25 15:35:26 +01:00
return existingWithAppendedIncoming ;
2023-09-30 16:55:35 +02:00
} ,
} ,
2024-09-26 13:59:37 +02:00
settings : {
merge ( existing , incoming ) {
return {
. . . ( existing ? ? { } ) ,
. . . ( incoming ? ? { } ) ,
} ;
} ,
} ,
2023-09-30 16:55:35 +02:00
} ,
} ,
2023-09-01 20:21:59 +02:00
} ;
2023-09-30 16:55:35 +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 ;
2024-08-25 22:34:01 +02:00
public client ! : ApolloClient < NormalizedCacheObject > ;
2023-09-01 20:21:59 +02:00
2023-10-04 20:50:11 +02:00
private wsClient ! : Client ;
2025-10-10 19:07:15 +02:00
private wsClientAliveCheckInterval : NodeJS.Timeout | undefined = undefined ;
2025-11-06 02:41:58 +01:00
private activeConnectionSubscriptions = new Map < string , ( ) = > void > ( ) ;
2025-07-27 00:03:51 +02:00
constructor ( handleRefreshToken : ( refreshToken : string ) = > AbortableApolloMutationResponse < UserRefreshMutation > ) {
super ( handleRefreshToken ) ;
2024-08-25 22:34:01 +02:00
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 ( ) ;
2024-08-25 22:34:01 +02:00
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 ( ) ) ;
2024-08-25 22:34:01 +02:00
}
2025-10-15 02:50:43 +02:00
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 )
2025-11-07 12:09:36 +01:00
. map ( ( result ) = > {
this . restartAllSubscriptions ( ) ;
return result ;
} )
2025-07-27 00:03:51 +02:00
. flatMap ( ( ) = > forward ( operation ) ) ;
} ) ;
}
private createAuthLink() {
return setContext ( ( _ , { headers } ) = > {
const isAuthRequired = AuthManager . isAuthRequired ( ) ;
const accessToken = AuthManager . getAccessToken ( ) ;
return {
2025-08-27 01:09:32 +02:00
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() {
2025-08-27 01:09:32 +02:00
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() {
2025-07-20 16:48:59 +02:00
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 ( [
2025-10-15 02:50:43 +02:00
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
}
2025-10-10 19:07:15 +02:00
private createWSClient ( lazy : boolean = true ) : void {
2025-10-11 00:39:44 +02:00
const heartbeatInterval = d ( 20 ) . seconds . inWholeMilliseconds ;
2024-01-26 20:55:14 +01:00
2023-10-04 20:50:11 +02:00
this . wsClient = createClient ( {
2025-10-10 19:07:15 +02:00
lazy ,
2023-10-04 20:50:11 +02:00
url : ( ) = > this . getBaseUrl ( ) . replace ( /http(|s)/g , 'ws' ) ,
2024-01-26 20:55:14 +01:00
keepAlive : heartbeatInterval ,
2025-10-10 19:07:15 +02:00
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 ) ;
2025-10-10 19:07:15 +02:00
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 ,
} ;
} ,
2024-01-26 20:55:14 +01:00
} ) ;
2024-01-21 22:31:20 +01:00
2025-11-06 02:42:33 +01:00
let triedForcedReconnection = false ;
2025-10-10 19:07:15 +02:00
let lastHeartbeat : number = Date . now ( ) ;
2025-11-06 02:42:33 +01:00
this . wsClient . on ( 'message' , ( ) = > {
2024-01-26 20:55:14 +01:00
lastHeartbeat = Date . now ( ) ;
2025-11-06 02:42:33 +01:00
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 ;
2025-10-10 19:07:15 +02:00
clearInterval ( this . wsClientAliveCheckInterval ) ;
this . wsClientAliveCheckInterval = setInterval ( ( ) = > {
2024-01-26 20:55:14 +01:00
const isHeartbeatMissing = Date . now ( ) - lastHeartbeat > checkHeartbeatInterval * 1.1 ;
2025-11-06 02:42:33 +01:00
if ( ! isHeartbeatMissing ) {
return ;
}
2025-10-10 19:07:15 +02:00
2025-11-06 02:42:33 +01:00
if ( ! triedForcedReconnection ) {
triedForcedReconnection = true ;
this . wsClient . terminate ( ) ;
2025-11-06 02:41:58 +01:00
2025-11-06 02:42:33 +01:00
return ;
2024-01-26 20:55:14 +01:00
}
2025-11-06 02:42:33 +01:00
2025-11-07 01:25:36 +01:00
this . resetWsClient ( true ) ;
2024-01-26 20:55:14 +01:00
} , checkHeartbeatInterval ) ;
2025-10-10 19:07:15 +02:00
}
2024-01-26 20:55:14 +01:00
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 ( {
2023-12-25 21:20:07 +01:00
// 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() { }
2025-11-06 02:41:58 +01:00
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
}