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