///
///
///
import type { ReadableStream as WebReadableStream } from 'node:stream/web'
import { abortSignalAnyOrUndefined, abortSignalTimeoutOrUndefined } from '../abort.js'
import { _ms, _since } from '../datetime/time.util.js'
import { isServerSide } from '../env.js'
import { _assertErrorClassOrRethrow, _assertIsError } from '../error/assert.js'
import type { ErrorLike, ErrorObject } from '../error/error.model.js'
import {
_anyToError,
_anyToErrorObject,
_errorDataAppend,
_errorLikeToErrorObject,
HttpRequestError,
UnexpectedPassError,
} from '../error/error.util.js'
import { _clamp } from '../number/number.util.js'
import {
_filterFalsyValues,
_filterNullishValues,
_filterUndefinedValues,
_mapKeys,
_merge,
_omit,
_pick,
} from '../object/object.util.js'
import { pDelaySignal } from '../promise/pDelay.js'
import { pTimeout } from '../promise/pTimeout.js'
import { _toUrlOrNull } from '../string/index.js'
import { _jsonParse, _jsonParseIfPossible } from '../string/json.util.js'
import { _stringify } from '../string/stringify.js'
import type {
AnyObject,
ErrorDataTuple,
NumberOfMilliseconds,
UnixTimestampMillis,
} from '../types.js'
import type {
FetcherAfterResponseHook,
FetcherBeforeRequestHook,
FetcherBeforeRetryHook,
FetcherCfg,
FetcherErrorResponse,
FetcherGraphQLOptions,
FetcherInitHook,
FetcherNormalizedCfg,
FetcherOnErrorHook,
FetcherOptions,
FetcherRequest,
FetcherResponse,
FetcherResponseType,
FetcherRetryOptions,
FetcherSuccessResponse,
FetchFunction,
GraphQLResponse,
RequestInitNormalized,
} from './fetcher.model.js'
import type { HttpStatusFamily } from './http.model.js'
import { HTTP_METHODS } from './http.model.js'
/**
* Experimental wrapper around Fetch.
* Works in both Browser and Node, using `globalThis.fetch`.
*/
export class Fetcher {
/**
* Included in UserAgent when run in Node.
* In the browser it's not included, as we want "browser own" UserAgent to be included instead.
*
* Version is to be incremented every time a difference in behaviour (or a bugfix) is done.
*/
static readonly VERSION = 5
/**
* userAgent is statically exposed as Fetcher.userAgent.
* It can be modified globally, and will be used (read) at the start of every request.
*/
static userAgent = isServerSide() ? `fetcher/${this.VERSION}` : undefined
private constructor(cfg: FetcherCfg & FetcherOptions = {}) {
if (typeof globalThis.fetch !== 'function') {
throw new TypeError('globalThis.fetch is not available')
}
this.cfg = this.normalizeCfg(cfg)
// Dynamically create all helper methods
for (const method of HTTP_METHODS) {
const m = method.toLowerCase()
// responseType=void
;(this as any)[`${m}Void`] = async (url: string, opt?: FetcherOptions): Promise => {
return await this.fetch({
url,
method,
responseType: 'void',
...opt,
})
}
if (method === 'HEAD') {
continue // responseType=text
}
;(this as any)[`${m}Text`] = async (url: string, opt?: FetcherOptions): Promise => {
return await this.fetch({
url,
method,
responseType: 'text',
...opt,
})
}
// Default responseType=json, but overridable
;(this as any)[m] = async (url: string, opt?: FetcherOptions): Promise => {
return await this.fetch({
url,
method,
responseType: 'json',
...opt,
})
}
}
}
/**
* Add BeforeRequest hook at the end of the hooks list.
*/
onBeforeRequest(hook: FetcherBeforeRequestHook): this {
;(this.cfg.hooks.beforeRequest ||= []).push(hook)
return this
}
onAfterResponse(hook: FetcherAfterResponseHook): this {
;(this.cfg.hooks.afterResponse ||= []).push(hook)
return this
}
onBeforeRetry(hook: FetcherBeforeRetryHook): this {
;(this.cfg.hooks.beforeRetry ||= []).push(hook)
return this
}
onError(hook: FetcherOnErrorHook): this {
;(this.cfg.hooks.onError ||= []).push(hook)
return this
}
/**
* Init hooks run lazily, once per Fetcher instance, before the first request.
* See FetcherInitHook docs.
*/
onInit(hook: FetcherInitHook): this {
;(this.cfg.hooks.init ||= []).push(hook)
return this
}
/**
* Clears the cached result of the init hooks, so they re-run before the next request.
* Useful when the state acquired during init (e.g an auth token) becomes stale.
* See also `cfg.hooks.shouldReinit`, which calls this automatically.
*/
resetInit(): void {
this.initPromise = undefined
this.initGeneration++
}
cfg: FetcherNormalizedCfg
private initPromise?: Promise
private initGeneration = 0
static create(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
return new Fetcher(cfg)
}
// These methods are generated dynamically in the constructor
// These default methods use responseType=json
get!: (url: string, opt?: FetcherOptions) => Promise
post!: (url: string, opt?: FetcherOptions) => Promise
put!: (url: string, opt?: FetcherOptions) => Promise
patch!: (url: string, opt?: FetcherOptions) => Promise
delete!: (url: string, opt?: FetcherOptions) => Promise
// responseType=text
getText!: (url: string, opt?: FetcherOptions) => Promise
postText!: (url: string, opt?: FetcherOptions) => Promise
putText!: (url: string, opt?: FetcherOptions) => Promise
patchText!: (url: string, opt?: FetcherOptions) => Promise
deleteText!: (url: string, opt?: FetcherOptions) => Promise
// responseType=void (no body fetching/parsing)
getVoid!: (url: string, opt?: FetcherOptions) => Promise
postVoid!: (url: string, opt?: FetcherOptions) => Promise
putVoid!: (url: string, opt?: FetcherOptions) => Promise
patchVoid!: (url: string, opt?: FetcherOptions) => Promise
deleteVoid!: (url: string, opt?: FetcherOptions) => Promise
headVoid!: (url: string, opt?: FetcherOptions) => Promise
/**
* Small convenience wrapper that allows to issue GraphQL queries.
* In practice, all it does is:
* - Defines convenience `query` input option
* - Unwraps `response.data`
* - Unwraps `response.errors` and throws, if it's defined (as GQL famously returns http 200 even for errors)
*
* Currently it only unwraps and uses the first error from the `errors` array, for simplicity.
*/
async queryGraphQL(opt: FetcherGraphQLOptions): Promise {
opt = { ...opt } // avoid mutating the input options
opt.method ||= this.cfg.init.method // defaults to GET
const payload: AnyObject = _filterFalsyValues({
query: opt.query,
variables: opt.variables,
})
// Checking the query length, and not allowing to use GET if above 1900
if (opt.method === 'GET' && opt.query.length > 1900) {
opt.method = 'POST'
}
if (opt.method === 'GET') {
opt.searchParams = {
...opt.searchParams,
...payload,
}
} else {
opt.json = payload
}
const res = await this.doFetch>(opt)
if (res.err) {
throw res.err
}
if (res.body.errors) {
// unwrap errors and throw
const err = res.body.errors[0]!
// todo: consider creating a new GraphQLError class for this
throw new HttpRequestError(err.message, {
...payload, // query and variables
errors: res.body.errors, // full errors payload returned
response: res.fetchResponse,
responseStatusCode: res.statusCode,
requestUrl: res.req.fullUrl,
requestBaseUrl: this.cfg.baseUrl,
requestMethod: res.req.init.method,
requestSignature: res.signature,
requestName: res.req.requestName,
fetcherName: this.cfg.name,
requestDuration: Date.now() - res.req.started,
})
}
const { data } = res.body
if (opt.unwrapObject) {
return (data as any)[opt.unwrapObject]
}
return data
}
// responseType=bytes
/**
* Returns response body as Uint8Array.
*/
async getBytes(url: string, opt?: FetcherOptions): Promise {
return await this.fetch({
url,
responseType: 'bytes',
...opt,
})
}
// responseType=readableStream
/**
* Returns raw fetchResponse.body, which is a ReadableStream
*
* More on streams and Node interop:
* https://css-tricks.com/web-streams-everywhere-and-fetch-for-node-js/
*/
async getReadableStream(
url: string,
opt?: FetcherOptions,
): Promise> {
return await this.fetch({
url,
responseType: 'readableStream',
...opt,
})
}
async fetch(opt: FetcherOptions): Promise {
const res = await this.doFetch(opt)
if (res.err && (res.req.throwHttpErrors || res.fetchResponse?.ok !== false)) {
// With throwHttpErrors=false only http errors (response received, but !ok) are returned instead of thrown.
// Other errors (network failure, timeout, body parsing) are still thrown.
throw res.err
}
return res.body as T
}
/**
* Like `fetch`, but returns the whole FetcherSuccessResponse, not just the body.
* Allows to access response metadata, e.g `fetchResponse.headers` and `statusCode`,
* while still throwing on errors (unlike `doFetch`).
*
* Note: `throwHttpErrors: false` is ignored here, http errors are always thrown -
* otherwise the returned FetcherSuccessResponse type would lie.
* Use `doFetch` if you don't want throwing.
*/
async fetchWithMeta(opt: FetcherOptions): Promise> {
const res = await this.doFetch(opt)
if (res.err) {
throw res.err
}
return res
}
/**
* Execute fetch and expect/assert it to return an Error (which will be wrapped in
* HttpRequestError as it normally would).
* If fetch succeeds, which is unexpected, it'll throw an UnexpectedPass error.
* Useful in unit testing.
*/
async expectError(opt: FetcherOptions): Promise {
const res = await this.doFetch(opt)
if (!res.err) {
throw new UnexpectedPassError('Fetch was expected to error')
}
_assertIsError(res.err, HttpRequestError)
return res.err
}
/**
* Like pTry - returns a [err, data] tuple (aka ErrorDataTuple).
* err, if defined, is strictly HttpRequestError.
* UPD: actually not, err is typed as Error, as it feels unsafe to guarantee error type.
* UPD: actually yes - it will return HttpRequestError, and throw if there's an error
* of any other type.
*/
async tryFetch(opt: FetcherOptions): Promise> {
const res = await this.doFetch(opt)
if (res.err) {
_assertErrorClassOrRethrow(res.err, HttpRequestError)
return [res.err, null]
}
return [null, res.body]
}
/**
* Returns FetcherResponse.
* Never throws, returns `err` property in the response instead.
* (Exception: errors thrown from init/beforeRequest hooks are re-thrown as-is.)
* Use this method instead of `throwHttpErrors: false` or try-catching.
*
* Note: responseType defaults to the Fetcher's cfg responseType (`json`, unless overridden).
*/
async doFetch(opt: FetcherOptions): Promise> {
let initGeneration = 0
if (this.cfg.hooks.init) {
try {
await (this.initPromise ??= this.runInitHooks())
} catch (err) {
// Reset, so init hooks are re-attempted on the next request
this.resetInit()
throw err
}
initGeneration = this.initGeneration
}
const req = this.normalizeOptions(opt)
const { logger } = this.cfg
const {
init: { method },
} = req
const timeoutMillis = req.timeoutSeconds ? req.timeoutSeconds * 1000 : undefined
for (const hook of this.cfg.hooks.beforeRequest || []) {
await hook(req)
}
const isFullUrl = req.fullUrl.includes('://')
const fullUrl = isFullUrl ? new URL(req.fullUrl) : undefined
const shortUrl = fullUrl ? this.getShortUrl(fullUrl) : req.fullUrl
const signature = [method, shortUrl].join(' ')
const res = {
req,
retryStatus: {
retryAttempt: 0,
retryStopped: false,
retryTimeout: req.retry.timeout,
},
signature,
} as FetcherResponse
while (!res.retryStatus.retryStopped) {
req.started = Date.now() as UnixTimestampMillis
res.body = undefined
req.init.signal = abortSignalAnyOrUndefined([
abortSignalTimeoutOrUndefined(timeoutMillis),
opt.signal,
])
if (req.logRequest) {
const { retryAttempt } = res.retryStatus
logger.log(
[' >>', signature, retryAttempt && `try#${retryAttempt + 1}/${req.retry.count + 1}`]
.filter(Boolean)
.join(' '),
)
if (req.logRequestBody && req.init.body) {
logger.log(req.init.body) // todo: check if we can _inspect it
}
}
try {
res.fetchResponse = await (req.overrideFetchFn || Fetcher.callNativeFetch)(
req.fullUrl,
req.init,
req.fetchFn,
)
res.ok = res.fetchResponse.ok
// important to set it to undefined, otherwise it can keep the previous value (from previous try)
res.err = undefined
} catch (err) {
// For example, CORS error would result in "TypeError: failed to fetch" here
// or, `fetch failed` with the cause of `unexpected redirect`
// AbortSignal.timeout() throws a DOMException with name "TimeoutError"
res.err = _anyToError(err)
res.ok = false
// important to set it to undefined, otherwise it can keep the previous value (from previous try)
res.fetchResponse = undefined
}
res.statusFamily = this.getStatusFamily(res)
res.statusCode = res.fetchResponse?.status
if (res.fetchResponse?.ok) {
try {
// We are applying a separate Timeout (as long as original Timeout for now) to "download and parse the body"
await pTimeout(
async () =>
await this.onOkResponse(res as FetcherResponse & { fetchResponse: Response }),
{
// 0 means "no timeout" for pTimeout
timeout: timeoutMillis ?? 0,
name: 'Fetcher.downloadBody',
},
)
} catch (err) {
// Important to cancel the original request to not keep it running (and occupying resources)
// UPD: no, we probably don't need to, because "request" has already completed, it's just the "body" is pending
// if (err instanceof TimeoutError) {}
// onOkResponse can still fail, e.g when loading/parsing json, text or doing other response manipulation
res.err = _anyToError(err)
res.ok = false
await this.onNotOkResponse(res)
}
} else {
// !res.ok
await this.onNotOkResponse(res)
}
}
if (res.err) {
_errorDataAppend(res.err, req.errorData)
req.onError?.(res.err)
for (const hook of this.cfg.hooks.onError || []) {
await hook(res.err)
}
}
for (const hook of this.cfg.hooks.afterResponse || []) {
await hook(res)
}
if (res.err && (await this.detectStaleInit(opt, res, initGeneration))) {
// Stale init (e.g expired auth token) was detected and reset - retry the request once
return await this.doFetch({ ...opt, [reinitAttempted]: true } as FetcherOptions)
}
return res
}
/**
* Consults cfg.hooks.shouldReinit to detect "stale init" (e.g expired auth token).
* If detected - resets the init (so it re-runs) and returns true,
* telling the caller to retry the request. At most once per request.
*/
private async detectStaleInit(
opt: FetcherOptions,
res: FetcherErrorResponse,
initGeneration: number,
): Promise {
const { shouldReinit, init } = this.cfg.hooks
if (!shouldReinit || !init) return false
if ((opt as any)[reinitAttempted]) return false
if (!(await shouldReinit(res))) return false
// Generation check prevents concurrent stale requests from resetting the already-fresh init
if (initGeneration === this.initGeneration) {
this.resetInit()
}
return true
}
private async runInitHooks(): Promise {
for (const hook of this.cfg.hooks.init || []) {
await hook(this.cfg)
}
}
private async onOkResponse(
res: FetcherResponse & { fetchResponse: Response },
): Promise {
const { req } = res
const { responseType } = res.req
// This function is subject to a separate timeout to "download and parse the data"
if (responseType === 'json') {
if (res.fetchResponse.body) {
const text = await res.fetchResponse.text()
if (text) {
res.body = text
res.body = _jsonParse(text, req.jsonReviver)
// Error while parsing json can happen - it'll be handled upstream
} else {
// Body had a '' (empty string)
res.body = {}
}
} else {
// if no body: set responseBody as {}
// do not throw a "cannot parse null as Json" error
res.body = {}
}
} else if (responseType === 'text') {
res.body = res.fetchResponse.body ? await res.fetchResponse.text() : ''
} else if (responseType === 'arrayBuffer') {
res.body = res.fetchResponse.body ? await res.fetchResponse.arrayBuffer() : new ArrayBuffer(0)
} else if (responseType === 'bytes') {
// Not using fetchResponse.bytes(), as it's unavailable in older browsers (e.g Safari <18.4)
res.body = res.fetchResponse.body
? new Uint8Array(await res.fetchResponse.arrayBuffer())
: new Uint8Array()
} else if (responseType === 'blob') {
res.body = res.fetchResponse.body ? await res.fetchResponse.blob() : new Blob()
} else if (responseType === 'void') {
// Cancel the body (without downloading it), to free the underlying connection for reuse
await res.fetchResponse.body?.cancel()
} else if (responseType === 'readableStream') {
res.body = res.fetchResponse.body
if (res.body === null) {
// Error is to be handled upstream
throw new Error('fetchResponse.body is null')
}
}
res.retryStatus.retryStopped = true
if (req.logResponse) {
const { retryAttempt } = res.retryStatus
const { logger } = this.cfg
logger.log(
[
' <<',
res.fetchResponse.status,
res.signature,
retryAttempt && `try#${retryAttempt + 1}/${req.retry.count + 1}`,
_since(res.req.started),
]
.filter(Boolean)
.join(' '),
)
if (req.logResponseBody && res.body !== undefined) {
logger.log(res.body)
}
}
}
/**
* This method exists to be able to easily mock it.
* It is static, so mocking applies to ALL instances (even future ones) of Fetcher at once.
*/
static async callNativeFetch(
url: string,
init: RequestInitNormalized,
fetchFn?: FetchFunction,
): Promise {
return await (fetchFn || globalThis.fetch)(url, init)
}
private async onNotOkResponse(res: FetcherResponse): Promise {
let cause: ErrorObject
// Try to fetch body and attach to res.body
// (but don't fail if it doesn't work)
if (!res.body && res.fetchResponse) {
try {
res.body = _jsonParseIfPossible(await res.fetchResponse.text())
} catch {
// ignore body fetching/parsing errors at this point
}
}
if (res.err) {
// This is only possible on JSON.parse error, or CORS error,
// or `unexpected redirect`
// This check should go first, to avoid calling .text() twice (which will fail)
cause = _errorLikeToErrorObject(res.err)
} else if (res.body) {
cause = _anyToErrorObject(res.body)
} else {
cause = {
name: 'Error',
message: 'Fetch failed',
data: {},
}
}
let responseStatusCode = res.fetchResponse?.status || 0
if (res.statusFamily === 2) {
// important to reset responseStatusCode to 0 in this case, as status 2xx can be misleading
res.statusFamily = undefined
res.statusCode = undefined
responseStatusCode = 0
}
const message = [res.statusCode, res.signature].filter(Boolean).join(' ')
res.err = new HttpRequestError(
message,
_filterNullishValues({
response: res.fetchResponse,
responseStatusCode,
// These properties are provided to be used in e.g custom Sentry error grouping
// Actually, disabled now, to avoid unnecessary error printing when both msg and data are printed
// Enabled, cause `data` is not printed by default when error is HttpError
// method: req.method,
// tryCount: req.tryCount,
requestUrl: res.req.fullUrl,
requestBaseUrl: this.cfg.baseUrl || undefined,
requestMethod: res.req.init.method,
requestSignature: res.signature,
requestName: res.req.requestName,
fetcherName: this.cfg.name,
requestDuration: Date.now() - res.req.started,
}),
{
cause,
},
)
await this.processRetry(res)
}
private async processRetry(res: FetcherResponse): Promise {
const { retryStatus } = res
if (!this.shouldRetry(res)) {
retryStatus.retryStopped = true
}
for (const hook of this.cfg.hooks.beforeRetry || []) {
await hook(res)
}
const { count, timeoutMultiplier, timeoutMax } = res.req.retry
if (retryStatus.retryAttempt >= count) {
retryStatus.retryStopped = true
}
// We don't log "last error", because it will be thrown and logged by consumer,
// but we should log all previous errors, otherwise they are lost.
// Here is the right place where we know it's not the "last error".
// lastError = retryStatus.retryStopped
// We need to log the response "anyway" if logResponse is true
if (res.err && (!retryStatus.retryStopped || res.req.logResponse)) {
this.cfg.logger.error(
[
' <<',
res.fetchResponse?.status || 0,
res.signature,
count &&
(retryStatus.retryAttempt || !retryStatus.retryStopped) &&
`try#${retryStatus.retryAttempt + 1}/${count + 1}`,
_since(res.req.started),
]
.filter(Boolean)
.join(' ') + '\n',
// We're stringifying the error here, otherwise Sentry shows it as [object Object]
_stringify(res.err.cause || res.err),
)
}
if (retryStatus.retryStopped) return
retryStatus.retryAttempt++
const timeout = this.getRetryTimeout(res)
if (timeout === null) {
this.cfg.logger.warn(
`${res.signature} server-indicated delay exceeds maxRetryAfter, will not retry`,
)
retryStatus.retryStopped = true
return
}
// Increase the timeout for the next possible retry
retryStatus.retryTimeout = _clamp(retryStatus.retryTimeout * timeoutMultiplier, 0, timeoutMax)
if (res.req.debug) {
this.cfg.logger.log(` .. ${res.signature} waiting ${_ms(timeout)}`)
}
await pDelaySignal(timeout, res.req.signal)
if (res.req.signal?.aborted) {
// Aborted while waiting for the retry - stop, without issuing another request
retryStatus.retryStopped = true
}
}
/**
* Returns the delay before the next retry attempt.
* Returns null if the server-indicated delay exceeds `retry.maxRetryAfter`,
* meaning the retry should not be attempted at all.
*/
private getRetryTimeout(res: FetcherResponse): NumberOfMilliseconds | null {
let timeout: NumberOfMilliseconds = 0
// Handling http 429 with specific retry headers
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
if (res.fetchResponse && [429, 503].includes(res.fetchResponse.status)) {
const retryAfterStr =
res.fetchResponse.headers.get('retry-after') ??
res.fetchResponse.headers.get('x-ratelimit-reset')
if (retryAfterStr) {
const retryAfterNumber = Number(retryAfterStr)
if (retryAfterNumber) {
if (retryAfterNumber > 10 ** 9) {
// Value is too large to be a "seconds from now" delay,
// treat it as a UnixTimestamp instead (e.g GitHub sends `x-ratelimit-reset` like that)
timeout = retryAfterNumber * 1000 - Date.now()
} else {
timeout = retryAfterNumber * 1000
}
} else {
const date = new Date(retryAfterStr)
if (!Number.isNaN(date.getTime())) {
timeout = date.getTime() - Date.now()
}
}
this.cfg.logger.log(`retry-after: ${retryAfterStr}`)
if (!timeout) {
this.cfg.logger.warn('retry-after could not be parsed')
}
}
}
if (timeout) {
if (timeout > res.req.retry.maxRetryAfter) {
return null
}
// Server-indicated delay is honored as-is (a negative value means "retry now")
return Math.max(0, timeout)
}
const noise = Math.random() * 500
return res.retryStatus.retryTimeout + noise
}
/**
* Default is yes,
* unless there's reason not to (e.g method is POST).
*
* statusCode of 0 (or absense of it) will BE retried.
*/
private shouldRetry(res: FetcherResponse): boolean {
// Don't retry if the input AbortSignal was aborted
if (res.req.signal?.aborted) return false
const { retryPost, retry3xx, retry4xx, retry5xx } = res.req
const { method } = res.req.init
if (method === 'POST' && !retryPost) return false
const { statusFamily } = res
const statusCode = res.fetchResponse?.status || 0
if (statusFamily === 5 && !retry5xx) return false
if ([408, 429].includes(statusCode)) {
// these codes are always retried
return true
}
if (statusFamily === 4 && !retry4xx) return false
if (statusFamily === 3 && !retry3xx) return false
// should not retry on `unexpected redirect` in error.cause.cause
if (
(res.err?.cause as ErrorLike | undefined)?.cause?.message?.includes('unexpected redirect')
) {
return false
}
return true // default is true
}
private getStatusFamily(res: FetcherResponse): HttpStatusFamily | undefined {
const status = res.fetchResponse?.status
if (!status) return
if (status >= 500) return 5
if (status >= 400) return 4
if (status >= 300) return 3
if (status >= 200) return 2
if (status >= 100) return 1
}
/**
* Returns url without baseUrl and before ?queryString
*/
private getShortUrl(url: URL): string {
const { baseUrl } = this.cfg
if (url.password) {
url = new URL(url.toString()) // prevent original url mutation
url.password = '[redacted]'
}
let shortUrl = url.toString()
if (!this.cfg.logWithSearchParams) {
shortUrl = shortUrl.split('?')[0]!
}
if (!this.cfg.logWithBaseUrl && baseUrl && shortUrl.startsWith(baseUrl)) {
shortUrl = shortUrl.slice(baseUrl.length)
}
return shortUrl
}
private normalizeCfg(cfg: FetcherCfg & FetcherOptions): FetcherNormalizedCfg {
const { debug = false, logger = console } = cfg
if (cfg.baseUrl?.endsWith('/')) {
logger.warn(`Fetcher: baseUrl should not end with slash: ${cfg.baseUrl}`)
cfg.baseUrl = cfg.baseUrl.slice(0, cfg.baseUrl.length - 1)
}
const norm: FetcherNormalizedCfg = _merge(
{
baseUrl: '',
name: this.getFetcherName(cfg),
inputUrl: '',
responseType: 'json',
searchParams: {},
timeoutSeconds: 30,
retryPost: false,
retry3xx: false,
retry4xx: false,
retry5xx: true,
logger,
debug,
logRequest: debug,
logRequestBody: debug,
logResponse: debug,
logResponseBody: debug,
logWithBaseUrl: isServerSide(),
logWithSearchParams: true,
retry: { ...defaultRetryOptions },
init: {
method: cfg.method || 'GET',
headers: _filterNullishValues({
'user-agent': Fetcher.userAgent,
...cfg.headers,
}),
credentials: cfg.credentials,
redirect: cfg.redirect,
dispatcher: cfg.dispatcher,
keepalive: cfg.keepalive,
},
hooks: {},
throwHttpErrors: true,
errorData: {},
},
_omit(cfg, ['method', 'credentials', 'headers', 'redirect', 'logger', 'name', 'keepalive']),
)
norm.init.headers = _mapKeys(norm.init.headers, k => k.toLowerCase())
return norm
}
private getFetcherName(cfg: FetcherCfg): string | undefined {
let { name } = cfg
if (!name && cfg.baseUrl) {
// derive FetcherName from baseUrl
const url = _toUrlOrNull(cfg.baseUrl)
if (url) {
name = url.hostname
}
}
return name
}
private normalizeOptions(opt: FetcherOptions): FetcherRequest {
const req: FetcherRequest = {
..._pick(this.cfg, [
'timeoutSeconds',
'retryPost',
'retry3xx',
'retry4xx',
'retry5xx',
'responseType',
'jsonReviver',
'logRequest',
'logRequestBody',
'logResponse',
'logResponseBody',
'debug',
'throwHttpErrors',
'errorData',
'fetchFn',
'overrideFetchFn',
]),
started: Date.now() as UnixTimestampMillis,
..._omit(opt, ['method', 'headers', 'credentials']),
inputUrl: opt.url || '',
fullUrl: opt.url || '',
retry: {
...this.cfg.retry,
..._filterUndefinedValues(opt.retry || {}),
},
init: _merge(
{
...this.cfg.init,
headers: {
...this.cfg.init.headers, // this avoids mutation
'user-agent': Fetcher.userAgent, // re-load it here, to support setting it globally post-fetcher-creation
},
method: opt.method || this.cfg.init.method,
credentials: opt.credentials || this.cfg.init.credentials,
redirect: opt.redirect || this.cfg.init.redirect || 'follow',
keepalive: opt.keepalive ?? this.cfg.init.keepalive,
},
{
headers: _mapKeys(opt.headers || {}, k => k.toLowerCase()),
} satisfies RequestInit,
),
}
// Because all header values are stringified, so `a: undefined` becomes `undefined` as a string
_filterNullishValues(req.init.headers, { mutate: true })
// setup url
const baseUrl = opt.baseUrl || this.cfg.baseUrl
if (baseUrl) {
let { inputUrl } = req
if (inputUrl.startsWith('/')) {
this.cfg.logger.warn('Fetcher: url should not start with / when baseUrl is specified')
inputUrl = inputUrl.slice(1)
}
req.fullUrl = `${baseUrl}/${inputUrl}`
}
const searchParams = _filterUndefinedValues({
...this.cfg.searchParams,
...opt.searchParams,
})
if (Object.keys(searchParams).length) {
const qs = new URLSearchParams(searchParams).toString()
req.fullUrl += (req.fullUrl.includes('?') ? '&' : '?') + qs
}
// setup request body
// Unless it's a well-defined input type (json, text) - content-type is set automatically by the native fetch
// Explicitly passed `content-type` header always wins
if (opt.json !== undefined) {
req.init.body = JSON.stringify(opt.json)
req.init.headers['content-type'] ||= 'application/json'
} else if (opt.text !== undefined) {
req.init.body = opt.text
req.init.headers['content-type'] ||= 'text/plain'
} else if (opt.form) {
if (opt.form instanceof URLSearchParams || opt.form instanceof FormData) {
req.init.body = opt.form
} else {
req.init.body = new URLSearchParams(opt.form)
req.init.headers['content-type'] ||= 'application/x-www-form-urlencoded'
}
} else if (opt.body !== undefined) {
req.init.body = opt.body
}
// Unless `accept` header was already set - set it based on responseType
req.init.headers['accept'] ||= acceptByResponseType[req.responseType]
return req
}
}
export function getFetcher(cfg: FetcherCfg & FetcherOptions = {}): Fetcher {
return Fetcher.create(cfg)
}
// Marks FetcherOptions of a request that was already retried due to a reinit,
// to guarantee at most one reinit per request
const reinitAttempted = Symbol('reinitAttempted')
const acceptByResponseType: Record = {
text: 'text/plain',
json: 'application/json',
void: '*/*',
readableStream: 'application/octet-stream',
arrayBuffer: 'application/octet-stream',
bytes: 'application/octet-stream',
blob: 'application/octet-stream',
}
const defaultRetryOptions: FetcherRetryOptions = {
count: 2,
timeout: 1000,
timeoutMax: 30_000,
timeoutMultiplier: 2,
maxRetryAfter: 600_000, // 10 minutes
}