Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1import type { Source } from 'wonka';
2import {
3 pipe,
4 map,
5 filter,
6 onStart,
7 take,
8 makeSubject,
9 toPromise,
10 merge,
11} from 'wonka';
12
13import type {
14 Operation,
15 OperationContext,
16 OperationResult,
17 CombinedError,
18 Exchange,
19 DocumentInput,
20 AnyVariables,
21 OperationInstance,
22} from '@urql/core';
23import { createRequest, makeOperation, makeErrorResult } from '@urql/core';
24
25/** Utilities to use while refreshing authentication tokens. */
26export interface AuthUtilities {
27 /** Sends a mutation to your GraphQL API, bypassing earlier exchanges and authentication.
28 *
29 * @param query - a GraphQL document containing the mutation operation that will be executed.
30 * @param variables - the variables used to execute the operation.
31 * @param context - {@link OperationContext} options that'll be used in future exchanges.
32 * @returns A `Promise` of an {@link OperationResult} for the GraphQL mutation.
33 *
34 * @remarks
35 * The `mutation()` utility method is useful when your authentication requires you to make a GraphQL mutation
36 * request to update your authentication tokens. In these cases, you likely wish to bypass prior exchanges and
37 * the authentication in the `authExchange` itself.
38 *
39 * This method bypasses the usual mutation flow of the `Client` and instead issues the mutation as directly
40 * as possible. This also means that it doesn’t carry your `Client`'s default {@link OperationContext}
41 * options, so you may have to pass them again, if needed.
42 */
43 mutate<Data = any, Variables extends AnyVariables = AnyVariables>(
44 query: DocumentInput<Data, Variables>,
45 variables: Variables,
46 context?: Partial<OperationContext>
47 ): Promise<OperationResult<Data>>;
48
49 /** Adds additional HTTP headers to an `Operation`.
50 *
51 * @param operation - An {@link Operation} to add headers to.
52 * @param headers - The HTTP headers to add to the `Operation`.
53 * @returns The passed {@link Operation} with the headers added to it.
54 *
55 * @remarks
56 * The `appendHeaders()` utility method is useful to add additional HTTP headers
57 * to an {@link Operation}. It’s a simple convenience function that takes
58 * `operation.context.fetchOptions` into account, since adding headers for
59 * authentication is common.
60 */
61 appendHeaders(
62 operation: Operation,
63 headers: Record<string, string>
64 ): Operation;
65}
66
67/** Configuration for the `authExchange` returned by the initializer function you write. */
68export interface AuthConfig {
69 /** Called for every operation to add authentication data to your operation.
70 *
71 * @param operation - An {@link Operation} that needs authentication tokens added.
72 * @returns a new {@link Operation} with added authentication tokens.
73 *
74 * @remarks
75 * The {@link authExchange} will call this function you provide and expects that you
76 * add your authentication tokens to your operation here, on the {@link Operation}
77 * that is returned.
78 *
79 * Hint: You likely want to modify your `fetchOptions.headers` here, for instance to
80 * add an `Authorization` header.
81 */
82 addAuthToOperation(operation: Operation): Operation;
83
84 /** Called before an operation is forwaded onwards to make a request.
85 *
86 * @param operation - An {@link Operation} that needs authentication tokens added.
87 * @returns a boolean, if true, authentication must be refreshed.
88 *
89 * @remarks
90 * The {@link authExchange} will call this function before an {@link Operation} is
91 * forwarded onwards to your following exchanges.
92 *
93 * When this function returns `true`, the `authExchange` will call
94 * {@link AuthConfig.refreshAuth} before forwarding more operations
95 * to prompt you to update your authentication tokens.
96 *
97 * Hint: If you define this function, you can use it to check whether your authentication
98 * tokens have expired.
99 */
100 willAuthError?(operation: Operation): boolean;
101
102 /** Called after receiving an operation result to check whether it has failed with an authentication error.
103 *
104 * @param error - A {@link CombinedError} that a result has come back with.
105 * @param operation - The {@link Operation} of that has failed.
106 * @returns a boolean, if true, authentication must be refreshed.
107 *
108 * @remarks
109 * The {@link authExchange} will call this function if it sees an {@link OperationResult}
110 * with a {@link CombinedError} on it, implying that it may have failed due to an authentication
111 * error.
112 *
113 * When this function returns `true`, the `authExchange` will call
114 * {@link AuthConfig.refreshAuth} before forwarding more operations
115 * to prompt you to update your authentication tokens.
116 * Afterwards, this operation will be retried once.
117 *
118 * Hint: You should define a function that detects your API’s authentication
119 * errors, e.g. using `result.extensions`.
120 */
121 didAuthError(error: CombinedError, operation: Operation): boolean;
122
123 /** Called to refresh the authentication state.
124 *
125 * @remarks
126 * The {@link authExchange} will call this function if either {@link AuthConfig.willAuthError}
127 * or {@link AuthConfig.didAuthError} have returned `true` prior, which indicates that the
128 * authentication state you hold has expired or is out-of-date.
129 *
130 * When this function is called, you should refresh your authentication state.
131 * For instance, if you have a refresh token and an access token, you should rotate
132 * these tokens with your API by sending the refresh token.
133 *
134 * Hint: You can use the {@link fetch} API here, or use {@link AuthUtilities.mutate}
135 * if your API requires a GraphQL mutation to refresh your authentication state.
136 */
137 refreshAuth(): Promise<void>;
138}
139
140const addAuthAttemptToOperation = (
141 operation: Operation,
142 authAttempt: boolean
143) =>
144 makeOperation(operation.kind, operation, {
145 ...operation.context,
146 authAttempt,
147 });
148
149/** Creates an `Exchange` handling control flow for authentication.
150 *
151 * @param init - An initializer function that returns an {@link AuthConfig} wrapped in a `Promise`.
152 * @returns the created authentication {@link Exchange}.
153 *
154 * @remarks
155 * The `authExchange` is used to create an exchange handling authentication and
156 * the control flow of refresh authentication.
157 *
158 * You must pass an initializer function, which receives {@link AuthUtilities} and
159 * must return an {@link AuthConfig} wrapped in a `Promise`.
160 * When this exchange is used in your `Client`, it will first call your initializer
161 * function, which gives you an opportunity to get your authentication state, e.g.
162 * from local storage.
163 *
164 * You may then choose to validate this authentication state and update it, and must
165 * then return an {@link AuthConfig}.
166 *
167 * This configuration defines how you add authentication state to {@link Operation | Operations},
168 * when your authentication state expires, when an {@link OperationResult} has errored
169 * with an authentication error, and how to refresh your authentication state.
170 *
171 * @example
172 * ```ts
173 * authExchange(async (utils) => {
174 * let token = localStorage.getItem('token');
175 * let refreshToken = localStorage.getItem('refreshToken');
176 * return {
177 * addAuthToOperation(operation) {
178 * return utils.appendHeaders(operation, {
179 * Authorization: `Bearer ${token}`,
180 * });
181 * },
182 * didAuthError(error) {
183 * return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
184 * },
185 * async refreshAuth() {
186 * const result = await utils.mutate(REFRESH, { token });
187 * if (result.data?.refreshLogin) {
188 * token = result.data.refreshLogin.token;
189 * refreshToken = result.data.refreshLogin.refreshToken;
190 * localStorage.setItem('token', token);
191 * localStorage.setItem('refreshToken', refreshToken);
192 * }
193 * },
194 * };
195 * });
196 * ```
197 */
198export function authExchange(
199 init: (utilities: AuthUtilities) => Promise<AuthConfig>
200): Exchange {
201 return ({ client, forward }) => {
202 const bypassQueue = new Set<OperationInstance | undefined>();
203 const retries = makeSubject<Operation>();
204 const errors = makeSubject<OperationResult>();
205
206 let retryQueue = new Map<number, Operation>();
207
208 function flushQueue() {
209 authPromise = undefined;
210 const queue = retryQueue;
211 retryQueue = new Map();
212 queue.forEach(retries.next);
213 }
214
215 function errorQueue(error: Error) {
216 authPromise = undefined;
217 const queue = retryQueue;
218 retryQueue = new Map();
219 queue.forEach(operation => {
220 errors.next(makeErrorResult(operation, error));
221 });
222 }
223
224 let authPromise: Promise<void> | void;
225 let config: AuthConfig | null = null;
226
227 return operations$ => {
228 function initAuth() {
229 authPromise = Promise.resolve()
230 .then(() =>
231 init({
232 mutate<Data = any, Variables extends AnyVariables = AnyVariables>(
233 query: DocumentInput<Data, Variables>,
234 variables: Variables,
235 context?: Partial<OperationContext>
236 ): Promise<OperationResult<Data>> {
237 const baseOperation = client.createRequestOperation(
238 'mutation',
239 createRequest(query, variables),
240 context
241 );
242 return pipe(
243 result$,
244 onStart(() => {
245 const operation = addAuthToOperation(baseOperation);
246 bypassQueue.add(
247 operation.context._instance as OperationInstance
248 );
249 retries.next(operation);
250 }),
251 filter(
252 result =>
253 result.operation.key === baseOperation.key &&
254 baseOperation.context._instance ===
255 result.operation.context._instance
256 ),
257 take(1),
258 toPromise
259 );
260 },
261 appendHeaders(
262 operation: Operation,
263 headers: Record<string, string>
264 ) {
265 const fetchOptions =
266 typeof operation.context.fetchOptions === 'function'
267 ? operation.context.fetchOptions()
268 : operation.context.fetchOptions || {};
269 return makeOperation(operation.kind, operation, {
270 ...operation.context,
271 fetchOptions: {
272 ...fetchOptions,
273 headers: {
274 ...fetchOptions.headers,
275 ...headers,
276 },
277 },
278 });
279 },
280 })
281 )
282 .then((_config: AuthConfig) => {
283 if (_config) config = _config;
284 flushQueue();
285 })
286 .catch((error: Error) => {
287 if (process.env.NODE_ENV !== 'production') {
288 console.warn(
289 'authExchange()’s initialization function has failed, which is unexpected.\n' +
290 'If your initialization function is expected to throw/reject, catch this error and handle it explicitly.\n' +
291 'Unless this error is handled it’ll be passed onto any `OperationResult` instantly and authExchange() will block further operations and retry.',
292 error
293 );
294 }
295
296 errorQueue(error);
297 });
298 }
299
300 initAuth();
301
302 function refreshAuth(operation: Operation) {
303 // add to retry queue to try again later
304 retryQueue.set(
305 operation.key,
306 addAuthAttemptToOperation(operation, true)
307 );
308
309 // check that another operation isn't already doing refresh
310 if (config && !authPromise) {
311 authPromise = config.refreshAuth().then(flushQueue).catch(errorQueue);
312 }
313 }
314
315 function willAuthError(operation: Operation) {
316 return (
317 !operation.context.authAttempt &&
318 config &&
319 config.willAuthError &&
320 config.willAuthError(operation)
321 );
322 }
323
324 function didAuthError(result: OperationResult) {
325 return (
326 config &&
327 config.didAuthError &&
328 config.didAuthError(result.error!, result.operation)
329 );
330 }
331
332 function addAuthToOperation(operation: Operation) {
333 return config ? config.addAuthToOperation(operation) : operation;
334 }
335
336 const opsWithAuth$ = pipe(
337 merge([retries.source, operations$]),
338 map(operation => {
339 if (operation.kind === 'teardown') {
340 retryQueue.delete(operation.key);
341 return operation;
342 } else if (
343 operation.context._instance &&
344 bypassQueue.has(operation.context._instance)
345 ) {
346 return operation;
347 } else if (operation.context.authAttempt) {
348 return addAuthToOperation(operation);
349 } else if (authPromise || !config) {
350 if (!authPromise) initAuth();
351
352 if (!retryQueue.has(operation.key))
353 retryQueue.set(
354 operation.key,
355 addAuthAttemptToOperation(operation, false)
356 );
357
358 return null;
359 } else if (willAuthError(operation)) {
360 refreshAuth(operation);
361 return null;
362 }
363
364 return addAuthToOperation(
365 addAuthAttemptToOperation(operation, false)
366 );
367 }),
368 filter(Boolean)
369 ) as Source<Operation>;
370
371 const result$ = pipe(opsWithAuth$, forward);
372
373 return merge([
374 errors.source,
375 pipe(
376 result$,
377 filter(result => {
378 if (
379 !bypassQueue.has(result.operation.context._instance) &&
380 result.error &&
381 didAuthError(result) &&
382 !result.operation.context.authAttempt
383 ) {
384 refreshAuth(result.operation);
385 return false;
386 }
387
388 if (bypassQueue.has(result.operation.context._instance)) {
389 bypassQueue.delete(result.operation.context._instance);
390 }
391
392 return true;
393 })
394 ),
395 ]);
396 };
397 };
398}