flighty
Version:
Fetch wrapper. Polyfills optional. Aborts, retries, intercepts all in 5kb
464 lines (380 loc) • 10.7 kB
JavaScript
;
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var qs = _interopDefault(require('qs'));
var urlJoin = _interopDefault(require('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);
module.exports = flighty;