qp-es-ui
Version:
Query Park UI Components for React
212 lines (190 loc) • 6.69 kB
JavaScript
/**
* Handles success for `FetchWithTimeout`.
*
* @name successHandler
* @function
* @param {...any} [values] Values to pass resolve
* @returns {Promise}
*/
/**
* Handles error for `FetchWithTimeout`.
*
* @name errorHandler
* @function
* @param {Error} [error] Error to pass reject
* @returns {Promise}
*/
/**
* Fetch... With a timeout :O.
*
* @name FetchWithTimeout
* @function
* @param {String} url
* @param {Object} options fetch options
* @param {Number} ms Time to wait in milliseconds
* @returns {Promise}
*/
/** An Error representing a timeout. */
class TimeoutError extends Error {
/**
* Creates a new instance of `TimeoutError`.
*
* @param {Object} options
* @param {string} [options.message] Classic `error.message`. It can be
* auto generated by providing `ms` and/or `url` while omitting `message`
* @param {Number} [options.ms] Timeout duration in milliseconds
* @param {String} [options.url] Url for which the timeout occured
* @returns {TimeoutError}
*/
constructor (options = {}) {
const message = options.message ||
(options.ms ? `Waited ${options.ms} milliseconds ` : '') +
(options.url ? `for ${options.url}` : '')
super(message)
this.name = this.constructor.name
this.url = options.url
this.ms = options.ms
}
}
/**
* Uses setTimeout to reject the returned promise after `ms` milliseconds.
*
* @async
* @param {String} [url='']
* @param {Number} [ms=5000] Time to wait in milliseconds
* @returns {Promise}
*/
export function promiseTimeout (url = '', ms = 5000) {
return new Promise((resolve, reject) =>
setTimeout(() =>
reject(new TimeoutError({ url, ms })), ms))
}
/**
* Give a timeout to fetch via `setTimeout`. A request queue of ongoing requests
* are tracked allowing for mutliple uses of an instance of `TimeoutFetch`.
* Requests are removed from that queue when both the request and timeout have
* completed or when the timeout has completed and the queue is larger than
* `options.maxLength`.
*
* @param {window.fetch} _fetch
* @param {Object} [_options={}] Options to configure TimeoutFallback
* @param {Number} [options.maxLength=20] Max length of the internal request
* tracker before it starts removing timedout but unresolved requests.
* @returns {FetchWithTimeout}
*/
export function TimeoutFallback (_fetch, _options = {}) {
let _requests = []
let _maxLength = _options.maxLength || 20
/**
* Using `Array.filter` remove requests where both `request` and `timeout`
* have completed or where `timeout` has completed and `requests.length` is
* larger than `_maxLength` or where `request` has completed and
* `requests.length` is larger than `_maxLength`.
*/
const _prune = () => {
_requests = _requests.filter((request, _, requests) =>
(request.request.promise === null && request.timeout.promise === null) ||
(request.timeout.promise === null && requests.length > _maxLength) ||
(request.request.promise === null && requests.length > _maxLength))
}
/**
* A higher order functin that provides reference to `self`. The
* `successHandler` will mark `self` as complete, prune the `requests` queue
* and resolve.
*
* @param {Object} self The errorer
* @returns {successHandler}
*/
const _handleSuccess = (self) => (...values) => {
self.promise = null
_prune()
return Promise.resolve(...values)
}
/**
* A higher order function that provides reference to `self` and
* `competition`. The `errorHandler` will mark `self` as complete, prune the
* `requests` queue and only reject if `competition` has not already completed.
*
* @param {Object} self The errorer
* @param {Object} competition Another potential errorer
* @returns {errorHandler}
*/
const _handleError = (self, competition) => (error) => {
self.promise = null
_prune()
return (competition.promise !== null)
? Promise.reject(error)
: Promise.resolve()
}
/** See FetchWithTimeout */
const fetch = (url, options, ms) => {
// ? Objects to keep reference when passed around (_handlers)
const request = { promise: _fetch(url, options) }
const timeout = { promise: promiseTimeout(url, ms) }
_requests.push({ url, options, ms, request, timeout })
return Promise.race([
request.promise.then(
_handleSuccess(request, timeout),
_handleError(request, timeout)
),
timeout.promise.catch(_handleError(timeout, request))
])
}
return fetch
}
/**
* Give fetch a timeout using `AbortController`. This method is perfered over
* the fallback since it cancels the fetch request entirely instead of allowing
* it to hang.
*
* @param {window.fetch} fetch
* @returns {FetchWithTimeout}
*/
export function Aborter (fetch) {
/**
* A higher order function that provides a reference to a `timeoutId`. The
* `successHandler` will clear the `timeoutId` and resolve.
*
* @param {Number} timeoutId Identifier returned by `setTimeout`
* @returns {successHandler}
*/
const _handleSuccess = (timeoutId) => (...values) => {
clearTimeout(timeoutId)
return Promise.resolve(...values)
}
/**
* A higher order function that provides a reference to a `url` and `ms`. The
* `errorHandler` will convert any non descriptive`AbortError` to a
* `TimeoutError` before rejecting.
*
* @param {String} url
* @param {Number} ms Milliseconds
* @returns {errorHandler}
*/
const _handleError = (url, ms) => (error) => /The operation was aborted\./.test(error.message)
? Promise.reject(new TimeoutError({ url, ms }))
: Promise.reject(error)
return (url, options = {}, ms = 5000) => {
/* eslint-disable-next-line no-undef */
const controller = new AbortController()
const signal = controller.signal
const timeoutId = setTimeout(() => controller.abort(), ms)
return fetch(url, { ...options, signal })
.then(_handleSuccess(timeoutId), _handleError(url, ms))
}
}
/**
* Get fetch with a timeout. This will use the AbortController api if available.
* The fallback is a timeout solution.
*
* @param {window.fetch|any} fetch Any fetch implementation supporting promises
* @returns {Aborter|TimeoutFallback}
*/
function FetchWithTimeout (fetch) {
if (typeof AbortController === 'function') {
return Aborter(fetch)
} else {
return TimeoutFallback(fetch)
}
}
export default FetchWithTimeout