UNPKG

flighty

Version:

Fetch wrapper. Polyfills optional. Aborts, retries, intercepts all in 5kb

427 lines (367 loc) 10.7 kB
import 'promise-polyfill'; import 'cross-fetch/polyfill'; import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'; import qs from 'qs'; import urlJoin from 'url-join'; const teardownAbort = (token, map) => { if (!token) { return; } const val = map.get(token); if (!val || --val.count) { return; } map.delete(token); }; const setupAbort = ({ abortToken, signal }, controller, map) => { // if there is no token or signal, use Flighty abortController if (!abortToken && !signal) { return controller.signal; } // otherwise, use an abortController local to this request let abortController = new AbortController(); if (abortToken) { // allow to use a single token to cancel multiple requests const mapValue = map.get(abortToken) || { controller: abortController, count: 0, }; mapValue.count++; map.set(abortToken, mapValue); abortController = mapValue.controller; } // the user has defined their own signal. We won't use it directly, but we'll listen to it if (signal) { if (signal.aborted) { abortController.abort(); } else { signal.addEventListener('abort', () => abortController.abort()); } } // when the Flighty abortController aborts, also abort this request controller.signal.addEventListener('abort', () => abortController.abort()); return abortController.signal; }; const retryDelayFn = delay => new Promise(res => setTimeout(() => res(), delay)); const checkFn = (fn, err) => { if (typeof fn !== 'function') { throw new Error(err); } }; const asyncRetry = async (asyncFnToRetry, { retries = 0, retryDelay = 1000, retryFn }) => { checkFn(asyncFnToRetry, 'retry function is not a function'); if (typeof retries !== 'number' || !Number.isInteger(retries) || retries < 0) { throw new Error('retries must be a number greater than or equal to 0'); } if (retryDelay && (typeof retryDelay !== 'number' || !Number.isFinite(retryDelay))) { throw new Error('retryDelay must be a number (milliseconds)'); } if (retryFn && typeof retryFn !== 'function') { throw new Error('retryFn must be callable'); } const localRetryFn = async (...args) => (retryFn ? retryFn(...args) : retryDelayFn(retryDelay)); let count = -1; const wrap = async (remaining) => { try { count++; return await asyncFnToRetry(count); } catch (err) { if (!remaining) { throw err; } await localRetryFn(count + 1, err); return wrap(remaining - 1); } }; const res = await wrap(retries); return { count, res }; }; const fetchRetry = async (fetchToRetry, { retries, retryDelay, retryFn, retryOn = [], signal, }) => { checkFn(fetchToRetry, 'retry function is not a function'); if (retryOn && !Array.isArray(retryOn)) { throw new Error('retryOn must be an array of response statii'); } if (signal != null && typeof signal.aborted !== 'boolean') { throw new Error('signal must have boolean "aborted" property'); } return asyncRetry( async (retryCount) => { const res = await fetchToRetry(); if (retryOn.indexOf(res.status) === -1 || retries === retryCount) { return res; } throw new Error(res); }, { retries, retryFn: async (count, err) => { if (signal && signal.aborted) { throw err; } if (retryFn) { return retryFn(count, err); } return retryDelayFn(retryDelay); }, }, ); }; if (typeof fetch === 'undefined') { throw new Error("You're missing a fetch implementation. Try var Flighty = require('flighty/fetch') or import Flighty from 'flighty/fetch'"); } if (typeof AbortController === 'undefined') { throw new Error("You're missing an AbortController implementation. Try var Flighty = require('flighty/abort') or import Flighty from 'flighty/abort'"); } const isApplicationJSON = headers => Object.keys(headers).some((key) => { const header = headers[key]; if (key.toLowerCase() === 'content-type') { return header.toLowerCase().split(';')[0] === 'application/json'; } return false; }); const METHODS = ['GET', 'POST', 'PUT', 'HEAD', 'OPTIONS', 'DEL', 'PATCH']; const doFetch = (method, context, path, options) => { const opts = { ...options, method: method === 'del' ? 'DELETE' : method.toUpperCase(), headers: { ...(context.headers || {}), ...options.headers, }, }; // remove any nil or blank headers opts.headers = Object.keys(opts.headers).reduce((carry, key) => { const value = opts.headers[key]; return value ? { [key]: value, ...carry } : carry; }, {}); if (!opts.body && method === 'POST') { opts.body = ''; } let fetchPath = path; if (method === 'GET' && opts.body) { fetchPath += `?${qs.stringify(opts.body, { arrayFormat: context.arrayFormat })}`; delete opts.body; } if (isApplicationJSON(opts.headers) && opts.body) { opts.body = JSON.stringify(opts.body); } const fullUri = context.baseURI ? urlJoin(context.baseURI, fetchPath) : fetchPath; return fetch(fullUri, opts); }; const call = async ( method, context, { path, options }, extra, retryCount = 0, ) => { // strip out interceptor-immutable or non-fetch options const { retries = 0, retryDelay = 1000, retryOn = [], retryFn, abortToken, signal, ...fetchOptions } = options; const flightyAbortSignal = setupAbort( { abortToken, signal }, context.abortController, context.abortTokenMap, ); let numRetries = retryCount; // flighty object const flighty = { method, // the values flighty was called with call: { path, options: { ...options }, extra: { ...extra }, }, // retry method retry: () => { numRetries += 1; return call( method, context, { path, options: { ...options } }, { ...extra }, numRetries, ); }, }; const interceptors = Array.from(context.interceptors); const req = interceptors.reduce((last, next) => { let p = last; // add in extra and retryCount to each interceptor p = p.then(args => args.slice(0, 2).concat([{ ...extra }, retryCount])); if (next.request) { p = p.then(args => next.request(...args)); } if (next.requestError) { p = p.catch(next.requestError); } return p; }, Promise.resolve([path, fetchOptions])); const flightyRes = interceptors.reverse().reduce((last, next) => { let p = last; if (next.response) { p = p.then(next.response); } if (next.responseError) { p = p.catch(next.responseError); } return p; }, (async () => { // stuff from the interceptors const [reqPath, reqOptions] = await req; const { count, res } = await fetchRetry( () => doFetch(method, context, reqPath, { ...reqOptions, signal: flightyAbortSignal }), { retries, retryDelay, retryOn, retryFn, signal: flightyAbortSignal, }, ); numRetries += count; res.flighty = flighty; let json; let text; try { json = await res.clone().json(); } catch (err) { // stub - don't care } try { text = await res.clone().text(); } catch (err) { // stub - don't care } res.flighty = { ...flighty, retryCount: numRetries, json, text, }; return res; })()); try { return await flightyRes; } catch (err) { throw err; } finally { teardownAbort(abortToken, context.abortTokenMap); } }; class Flighty { constructor(baseOptions = {}) { // add the methods let localOptions = { ...baseOptions }; METHODS.forEach((method) => { this[method.toLowerCase()] = (path = '/', options = {}, extra = {}) => call(method, this, { path, options }, extra); }); let localAbortController; const interceptors = new Set(); const abortTokenMap = new Map(); Object.defineProperties(this, { headers: { get() { return localOptions.headers; }, set(headers = {}) { localOptions = { ...localOptions, headers, }; }, }, arrayFormat: { get() { return localOptions.arrayFormat || 'indicies'; }, }, baseURI: { get() { return localOptions.baseURI; }, set(baseURI) { localOptions = { ...localOptions, baseURI, }; }, }, interceptors: { get() { return interceptors; }, }, interceptor: { get() { return { register: interceptor => this.registerInterceptor(interceptor), unregister: interceptor => this.removeInterceptor(interceptor), clear: () => this.clearInterceptors(), }; }, }, abortController: { get() { if (!localAbortController) { localAbortController = new AbortController(); localAbortController.signal.addEventListener('abort', () => { // when this is aborted, null out the localAbortController // so we'll create a new one next time we need it localAbortController = null; }); } return localAbortController; }, }, abortTokenMap: { get() { return abortTokenMap; }, }, }); } abort(token) { const val = this.abortTokenMap.get(token); return val && val.controller.abort(); } abortAll() { this.abortController.abort(); } registerInterceptor(interceptor) { if (!interceptor) { throw new Error('cannot register a null interceptor'); } this.interceptors.add(interceptor); return () => this.interceptors.delete(interceptor); } clearInterceptors() { this.interceptors.clear(); } removeInterceptor(interceptor) { this.interceptors.delete(interceptor); } auth(username, password) { const base64Implementation = (string) => { if (typeof btoa !== 'undefined') { return btoa(string); } return Buffer.from(string).toString('base64'); }; this.headers = { ...this.headers, Authorization: username && password ? `Basic ${base64Implementation(`${username}:${password}`)}` : null, }; } jwt(token) { this.headers = { ...this.headers, Authorization: token ? `Bearer ${token}` : null, }; return this; } } const flighty = new Flighty(); flighty.create = baseOptions => new Flighty(baseOptions); export default flighty;