iso-web
Version:
Isomorphic web apis utilities for fetch, event target, signals, crypto and doh.
542 lines (473 loc) • 13.4 kB
JavaScript
import delay from 'delay'
import pRetry from 'p-retry'
import { anySignal } from './signals.js'
const symbol = Symbol.for('request-error')
/**
* @typedef {NetworkError | TimeoutError | AbortError | HttpError | RetryError} Errors
*/
/**
* Check if a value is a RequestError
*
* @param {unknown} value
* @returns {value is RequestError}
*/
export function isRequestError(value) {
return value instanceof Error && symbol in value
}
export class RequestError extends Error {
/** @type {boolean} */
[symbol] = true
name = 'RequestError'
/** @type {unknown} */
cause
/**
*
* @param {string} message
* @param {ErrorOptions} [options]
*/
constructor(message, options = {}) {
super(message, options)
this.cause = options.cause
}
/**
* Check if a value is a RequestError
*
* @param {unknown} value
* @returns {value is RequestError}
*/
static is(value) {
return isRequestError(value) && value.name === 'RequestError'
}
}
export class JsonError extends RequestError {
name = 'JsonError'
/** @type {import('type-fest').JsonValue} */
cause
/**
*
* @param {{ cause: import('type-fest').JsonValue }} options
*/
constructor(options) {
super('Failed with a JSON error, see cause.', options)
this.cause = options.cause
}
/**
* Check if a value is a JsonError
*
* @param {unknown} value
* @returns {value is JsonError}
*/
static is(value) {
return isRequestError(value) && value.name === 'JsonError'
}
}
export class NetworkError extends RequestError {
name = 'NetworkError'
/**
* Check if a value is a NetworkError
*
* @param {unknown} value
* @returns {value is NetworkError}
*/
static is(value) {
return isRequestError(value) && value.name === 'NetworkError'
}
}
export class TimeoutError extends RequestError {
name = 'TimeoutError'
/**
*
* @param {number} timeout
* @param {ErrorOptions} [options]
*/
constructor(timeout, options = {}) {
super(`Request timed out after ${timeout}ms`, options)
}
/**
* Check if a value is a TimeoutError
*
* @param {unknown} value
* @returns {value is TimeoutError}
*/
static is(value) {
return isRequestError(value) && value.name === 'TimeoutError'
}
}
export class AbortError extends RequestError {
name = 'AbortError'
/** @type {AbortSignal} */
signal
/**
*
* @param {AbortSignal} signal
* @param {ErrorOptions} [options]
*/
constructor(signal, options = {}) {
super(`Request aborted: ${signal.reason ?? 'unknown'}`, options)
this.signal = signal
}
/**
* Check if a value is a AbortError
*
* @param {unknown} value
* @returns {value is AbortError}
*/
static is(value) {
return isRequestError(value) && value.name === 'AbortError'
}
}
export class RetryError extends RequestError {
name = 'RetryError'
/**
*
* @param {number} attempts
* @param {ErrorOptions} [options]
*/
constructor(attempts, options = {}) {
super(`Request failed after ${attempts} attempts`, options)
}
/**
* Check if a value is a RetryError
*
* @param {unknown} value
* @returns {value is RetryError}
*/
static is(value) {
return isRequestError(value) && value.name === 'RetryError'
}
}
export class HttpError extends RequestError {
name = 'HttpError'
/** @type {number} */
code = 0
/** @type {Response} */
response
/** @type {Request} */
request
/** @type {import('./types.js').RequestOptions} */
options
/**
*
* @param {ErrorOptions & {response: Response, request: Request, options: import('./types.js').RequestOptions}} options
*/
constructor(options) {
const msg = `HttpError: ${options.response.status} - ${options.response.statusText}`
super(msg)
this.code = options.response?.status ?? 0
this.response = options.response
this.request = options.request
this.options = options.options
}
/**
* Check if a value is a HttpError
*
* @param {unknown} value
* @returns {value is HttpError}
*/
static is(value) {
return isRequestError(value) && value.name === 'HttpError'
}
}
/**
* Request timeout
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
export async function request(resource, options = {}) {
const {
signal,
timeout = 5000,
retry,
fetch = globalThis.fetch.bind(globalThis),
json,
headers,
} = options
// validate resource type
if (typeof resource !== 'string' && !(resource instanceof URL)) {
return {
error: new RequestError('`resource` must be a string or URL object'),
}
}
const timeoutSignal = AbortSignal.timeout(timeout)
const combinedSignals = anySignal([signal, timeoutSignal])
const _headers = new Headers(headers)
if (json !== undefined) {
_headers.set(
'content-type',
_headers.get('content-type') ?? 'application/json'
)
options.body = JSON.stringify(json)
}
const request = new Request(resource, {
...options,
headers: _headers,
signal: combinedSignals,
})
try {
const response = await (retry
? pRetry(
async () => {
const rsp = await fetch(request)
if (!rsp.ok) {
// Delay if needed using Retry-After header
const delayValue = calculateRetryAfter(rsp)
if (delayValue > 0) {
await delay(delayValue, { signal: combinedSignals })
}
throw new HttpError({
response: rsp,
request,
options,
})
}
return rsp
},
{ ...retry, signal: combinedSignals }
)
: fetch(request))
return response.ok
? { result: response }
: {
error: new HttpError({
response,
request,
options,
}),
}
} catch (error) {
const err = /** @type {Error} */ (error)
if (timeoutSignal.aborted) {
return { error: new TimeoutError(timeout, { cause: err }) }
}
if (signal?.aborted) {
return { error: new AbortError(signal, { cause: err }) }
}
if ('attemptNumber' in err) {
return {
error: new RetryError(Number(err.attemptNumber), { cause: err }),
}
}
return {
error: new NetworkError(err.message, { cause: err.cause }),
}
}
}
/**
*
* @param {Response} response
*/
function calculateRetryAfter(response) {
const retryAfter = response.headers.get('Retry-After')
if (retryAfter === null) {
return 0
}
let after = Number(retryAfter)
if (Number.isNaN(after)) {
after = Date.parse(retryAfter) - Date.now()
} else {
after *= 1000
}
return after
}
/**
* Request GET
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.get = function get(resource, options = {}) {
return request(resource, { ...options, method: 'GET' })
}
/**
* Request POST
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.post = function post(resource, options = {}) {
return request(resource, { ...options, method: 'POST' })
}
/**
* Request PUT
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.put = function put(resource, options = {}) {
return request(resource, { ...options, method: 'PUT' })
}
/**
* Request DELETE
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.delete = function del(resource, options = {}) {
return request(resource, { ...options, method: 'DELETE' })
}
/**
* Request PATCH
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.patch = function patch(resource, options = {}) {
return request(resource, { ...options, method: 'PATCH' })
}
/**
* Request HEAD
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.head = function head(resource, options = {}) {
return request(resource, { ...options, method: 'HEAD' })
}
/**
* Request OPTIONS
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.options = function optionsFn(resource, options = {}) {
return request(resource, { ...options, method: 'OPTIONS' })
}
/**
* Request TRACE
*
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").RequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<Response, Errors>>}
*/
request.trace = function trace(resource, options = {}) {
return request(resource, { ...options, method: 'TRACE' })
}
/**
* Request Json GET
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json = async function json(resource, options = {}) {
const { error, result } = await request(resource, {
...options,
body: null,
json: options.body,
})
if (error) {
if (
HttpError.is(error) &&
error.response.headers.get('content-type')?.includes('json')
) {
return {
error: new JsonError({ cause: await error.response.json() }),
}
}
return { error }
}
if (result.ok && result.headers.get('content-type')?.includes('json')) {
return { result: /** @type {T} */ (await result.json()) }
}
return {
error: new RequestError('Response is not JSON', { cause: result }),
}
}
/**
* Request Json GET
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.get = function get(resource, options = {}) {
return request.json(resource, { ...options, method: 'GET' })
}
/**
* Request Json POST
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.post = function post(resource, options = {}) {
return request.json(resource, { ...options, method: 'POST' })
}
/**
* Request Json PUT
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.put = function put(resource, options = {}) {
return request.json(resource, { ...options, method: 'PUT' })
}
/**
* Request Json DELETE
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.delete = function del(resource, options = {}) {
return request.json(resource, { ...options, method: 'DELETE' })
}
/**
* Request Json PATCH
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.patch = function patch(resource, options = {}) {
return request.json(resource, { ...options, method: 'PATCH' })
}
/**
* Request Json HEAD
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.head = function head(resource, options = {}) {
return request.json(resource, { ...options, method: 'HEAD' })
}
/**
* Request Json OPTIONS
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.options = function optionsFn(resource, options = {}) {
return request.json(resource, { ...options, method: 'OPTIONS' })
}
/**
* Request Json TRACE
*
* @template T
* @param {import('./types.js').RequestInput} resource
* @param {import("./types.js").JSONRequestOptions} options
* @returns {Promise<import("./types.js").MaybeResult<T, Errors | JsonError>>}
*/
request.json.trace = function trace(resource, options = {}) {
return request.json(resource, { ...options, method: 'TRACE' })
}