Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 3e4bee3e9d344f738eb9c179921f75ccd8fb79c5 398 lines 14 kB view raw
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}