Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 9.3 kB view raw
1import { 2 map, 3 makeSubject, 4 fromPromise, 5 filter, 6 merge, 7 mergeMap, 8 takeUntil, 9 pipe, 10} from 'wonka'; 11 12import type { 13 PersistedRequestExtensions, 14 TypedDocumentNode, 15 OperationResult, 16 CombinedError, 17 Exchange, 18 Operation, 19 OperationContext, 20} from '@urql/core'; 21import { makeOperation, stringifyDocument } from '@urql/core'; 22 23import { hash } from './sha256'; 24 25const isPersistedMiss = (error: CombinedError): boolean => 26 error.graphQLErrors.some(x => x.message === 'PersistedQueryNotFound'); 27 28const isPersistedUnsupported = (error: CombinedError): boolean => 29 error.graphQLErrors.some(x => x.message === 'PersistedQueryNotSupported'); 30 31/** Input parameters for the {@link persistedExchange}. */ 32export interface PersistedExchangeOptions { 33 /** Controls whether GET method requests will be made for Persisted Queries. 34 * 35 * @remarks 36 * When set to `true` or `'within-url-limit'`, the `persistedExchange` 37 * will use GET requests on persisted queries when the request URL 38 * doesn't exceed the 2048 character limit. 39 * 40 * When set to `force`, the `persistedExchange` will set 41 * `OperationContext.preferGetMethod` to `'force'` on persisted queries, 42 * which will force requests to be made using a GET request. 43 * 44 * GET requests are frequently used to make GraphQL requests more 45 * cacheable on CDNs. 46 * 47 * @defaultValue `within-url-limit` - Use GET requests for persisted queries within the URL limit. 48 */ 49 preferGetForPersistedQueries?: OperationContext['preferGetMethod']; 50 /** Enforces non-automatic persisted queries by ignoring APQ errors. 51 * 52 * @remarks 53 * When enabled, the `persistedExchange` will ignore `PersistedQueryNotFound` 54 * and `PersistedQueryNotSupported` errors and assume that all persisted 55 * queries are already known to the API. 56 * 57 * This is used to switch from Automatic Persisted Queries to 58 * Persisted Queries. This is commonly used to obfuscate GraphQL 59 * APIs. 60 */ 61 enforcePersistedQueries?: boolean; 62 /** Custom hashing function for persisted queries. 63 * 64 * @remarks 65 * By default, `persistedExchange` will create a SHA-256 hash for 66 * persisted queries automatically. If you're instead generating 67 * hashes at compile-time, or need to use a custom SHA-256 function, 68 * you may pass one here. 69 * 70 * If `generateHash` returns either `null` or `undefined`, the 71 * operation will not be treated as a persisted operation, which 72 * essentially skips this exchange’s logic for a given operation. 73 * 74 * Hint: The default SHA-256 function uses the WebCrypto API. This 75 * API is unavailable on React Native, which may require you to 76 * pass a custom function here. 77 */ 78 generateHash?( 79 query: string, 80 document: TypedDocumentNode<any, any> 81 ): Promise<string | undefined | null>; 82 /** Enables persisted queries to be used for mutations. 83 * 84 * @remarks 85 * When enabled, the `persistedExchange` will also use the persisted queries 86 * logic for mutation operations. 87 * 88 * This is disabled by default, but often used on APIs that obfuscate 89 * their GraphQL APIs. 90 */ 91 enableForMutation?: boolean; 92 /** Enables persisted queries to be used for subscriptions. 93 * 94 * @remarks 95 * When enabled, the `persistedExchange` will also use the persisted queries 96 * logic for subscription operations. 97 * 98 * This is disabled by default, but often used on APIs that obfuscate 99 * their GraphQL APIs. 100 */ 101 enableForSubscriptions?: boolean; 102} 103 104/** Exchange factory that adds support for Persisted Queries. 105 * 106 * @param options - A {@link PersistedExchangeOptions} configuration object. 107 * @returns the created persisted queries {@link Exchange}. 108 * 109 * @remarks 110 * The `persistedExchange` adds support for (Automatic) Persisted Queries 111 * to any `fetchExchange`, `subscriptionExchange`, or other API exchanges 112 * following it. 113 * 114 * It does so by adding the `persistedQuery` extensions field to GraphQL 115 * requests and handles `PersistedQueryNotFound` and 116 * `PersistedQueryNotSupported` errors. 117 * 118 * @example 119 * ```ts 120 * import { Client, cacheExchange, fetchExchange } from '@urql/core'; 121 * import { persistedExchange } from '@urql/exchange-persisted'; 122 * 123 * const client = new Client({ 124 * url: 'URL', 125 * exchanges: [ 126 * cacheExchange, 127 * persistedExchange({ 128 * preferGetForPersistedQueries: true, 129 * }), 130 * fetchExchange 131 * ], 132 * }); 133 * ``` 134 */ 135export const persistedExchange = 136 (options?: PersistedExchangeOptions): Exchange => 137 ({ forward }) => { 138 if (!options) options = {}; 139 140 const preferGetForPersistedQueries = 141 options.preferGetForPersistedQueries != null 142 ? options.preferGetForPersistedQueries 143 : 'within-url-limit'; 144 const enforcePersistedQueries = !!options.enforcePersistedQueries; 145 const hashFn = options.generateHash || hash; 146 const enableForMutation = !!options.enableForMutation; 147 const enableForSubscriptions = !!options.enableForSubscriptions; 148 let supportsPersistedQueries = true; 149 150 const operationFilter = (operation: Operation) => 151 supportsPersistedQueries && 152 !operation.context.persistAttempt && 153 ((enableForMutation && operation.kind === 'mutation') || 154 (enableForSubscriptions && operation.kind === 'subscription') || 155 operation.kind === 'query'); 156 157 const getPersistedOperation = async (operation: Operation) => { 158 const persistedOperation = makeOperation(operation.kind, operation, { 159 ...operation.context, 160 persistAttempt: true, 161 }); 162 163 const sha256Hash = await hashFn( 164 stringifyDocument(operation.query), 165 operation.query 166 ); 167 if (sha256Hash) { 168 persistedOperation.extensions = { 169 ...persistedOperation.extensions, 170 persistedQuery: { 171 version: 1, 172 sha256Hash, 173 }, 174 }; 175 if (persistedOperation.kind === 'query') { 176 persistedOperation.context.preferGetMethod = 177 preferGetForPersistedQueries; 178 } 179 } 180 181 return persistedOperation; 182 }; 183 184 return operations$ => { 185 const retries = makeSubject<Operation>(); 186 187 const forwardedOps$ = pipe( 188 operations$, 189 filter(operation => !operationFilter(operation)) 190 ); 191 192 const persistedOps$ = pipe( 193 operations$, 194 filter(operationFilter), 195 mergeMap(operation => { 196 const persistedOperation$ = getPersistedOperation(operation); 197 return pipe( 198 fromPromise(persistedOperation$), 199 takeUntil( 200 pipe( 201 operations$, 202 filter(op => op.kind === 'teardown' && op.key === operation.key) 203 ) 204 ) 205 ); 206 }) 207 ); 208 209 return pipe( 210 merge([persistedOps$, forwardedOps$, retries.source]), 211 forward, 212 map(result => { 213 if ( 214 !enforcePersistedQueries && 215 result.operation.extensions && 216 result.operation.extensions.persistedQuery 217 ) { 218 if (result.error && isPersistedUnsupported(result.error)) { 219 // Disable future persisted queries if they're not enforced 220 supportsPersistedQueries = false; 221 // Update operation with unsupported attempt 222 const followupOperation = makeOperation( 223 result.operation.kind, 224 result.operation 225 ); 226 if (followupOperation.extensions) 227 delete followupOperation.extensions.persistedQuery; 228 retries.next(followupOperation); 229 return null; 230 } else if (result.error && isPersistedMiss(result.error)) { 231 if (result.operation.extensions.persistedQuery.miss) { 232 if (process.env.NODE_ENV !== 'production') { 233 console.warn( 234 'persistedExchange()’s results include two misses for the same operation.\n' + 235 'This is not expected as it means a persisted error has been delivered for a non-persisted query!\n' + 236 'Another exchange with a cache may be delivering an outdated result. For example, a server-side ssrExchange() may be caching an errored result.\n' + 237 'Try moving the persistedExchange() in past these exchanges, for example in front of your fetchExchange.' 238 ); 239 } 240 241 return result; 242 } 243 // Update operation with unsupported attempt 244 const followupOperation = makeOperation( 245 result.operation.kind, 246 result.operation 247 ); 248 // Mark as missed persisted query 249 followupOperation.extensions = { 250 ...followupOperation.extensions, 251 persistedQuery: { 252 ...(followupOperation.extensions || {}).persistedQuery, 253 miss: true, 254 } as PersistedRequestExtensions, 255 }; 256 retries.next(followupOperation); 257 return null; 258 } 259 } 260 return result; 261 }), 262 filter((result): result is OperationResult => !!result) 263 ); 264 }; 265 };