///
///
import type { Dispatcher } from 'undici'
import type { ErrorData } from '../error/error.model.js'
import type { CommonLogger } from '../log/commonLogger.js'
import type {
AnyObject,
NumberOfMilliseconds,
Promisable,
Reviver,
UnixTimestampMillis,
} from '../types.js'
import type { HttpMethod, HttpStatusFamily } from './http.model.js'
export interface FetcherNormalizedCfg
extends
Required>,
Omit<
FetcherRequest,
| 'started'
| 'fullUrl'
| 'logRequest'
| 'logRequestBody'
| 'logResponse'
| 'logResponseBody'
| 'debug'
| 'redirect'
| 'credentials'
| 'throwHttpErrors'
| 'errorData'
> {
logger: CommonLogger
searchParams: Record
name?: string
}
export type FetcherBeforeRequestHook = (req: FetcherRequest) => Promisable
export type FetcherAfterResponseHook = (
res: FetcherResponse,
) => Promisable
export type FetcherBeforeRetryHook = (
res: FetcherResponse,
) => Promisable
/**
* Allows to mutate the error.
* Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
*/
export type FetcherOnErrorHook = (err: Error) => Promisable
/**
* Init hooks run lazily, once per Fetcher instance, before the first request.
* All requests wait for the init hooks to complete, their result is cached,
* so they run at most once.
* If a hook throws - the error is re-thrown to the caller of the request,
* and init hooks are re-attempted on the next request.
*
* Receives the Fetcher's normalized cfg, which is allowed (and expected)
* to be mutated. Allows to do async initialization, e.g fetch an auth token
* and set `cfg.init.headers` based on it.
*/
export type FetcherInitHook = (cfg: FetcherNormalizedCfg) => Promisable
/**
* Decides, based on the error response, if the Fetcher init state is "stale",
* e.g the auth token acquired in the init hook has expired.
* Returning true triggers `resetInit()`, so the init hooks re-run,
* and the request is then retried (at most once per request).
*
* Only consulted when init hooks are defined and the request resulted in an error.
*/
export type FetcherShouldReinitFn = (res: FetcherErrorResponse) => Promisable
/**
* FetcherCfg: configuration of the Fetcher instance. One per instance.
* FetcherOptions: options for a single request. One per request.
*/
export interface FetcherCfg {
/**
* Should **not** contain trailing slash.
*/
baseUrl?: string
/**
* "Name" of the fetcher.
* Accessible inside HttpRequestError, to be able to construct a good fingerprint.
* If name is not provided - baseUrl is used to identify a Fetcher.
*/
name?: string
/**
* Default rule is that you **are allowed** to mutate req, res, res.retryStatus
* properties of hook function arguments.
* If you throw an error from the hook - it will be re-thrown as-is.
*/
hooks?: {
/**
* Runs lazily, once per Fetcher instance, before the first request.
* See FetcherInitHook docs.
*/
init?: FetcherInitHook[]
/**
* Allows to detect "stale init" (e.g the auth token acquired in the init hook
* has expired) based on the error response, and trigger reinit+retry.
* Unlike other hooks, it's a single decision predicate, not an array.
* See FetcherShouldReinitFn docs.
*/
shouldReinit?: FetcherShouldReinitFn
/**
* Allows to mutate req.
*/
beforeRequest?: FetcherBeforeRequestHook[]
/**
* Allows to mutate res.
* If you set `res.err` - it will be thrown.
*/
afterResponse?: FetcherAfterResponseHook[]
/**
* Allows to mutate res.retryStatus to override retry behavior.
*/
beforeRetry?: FetcherBeforeRetryHook[]
onError?: FetcherOnErrorHook[]
}
/**
* If Fetcher has an error - `errorData` object will be appended to the error data.
* Like this:
*
* _errorDataAppend(err, cfg.errorData)
*
* So you, for example, can append a `fingerprint` to any error thrown from this fetcher.
*/
errorData?: ErrorData | undefined
/**
* If true - enables all possible logging.
*/
debug?: boolean
logRequest?: boolean
logRequestBody?: boolean
logResponse?: boolean
logResponseBody?: boolean
/**
* Controls if `baseUrl` should be included in logs (both success and error).
*
* Defaults to `true` on ServerSide and `false` on ClientSide.
*
* Reasoning.
*
* ClientSide often uses one main "backend host".
* Not including baseUrl improves Sentry error grouping.
*
* ServerSide often uses one Fetcher instance per 3rd-party API.
* Not including baseUrl can introduce confusion of "which API is it?".
*/
logWithBaseUrl?: boolean
/**
* Default to true.
* Set to false to strip searchParams from url when logging (both success and error)
*/
logWithSearchParams?: boolean
/**
* Defaults to `console`.
*/
logger?: CommonLogger
throwHttpErrors?: boolean
/**
* Pass an Undici Dispatcher.
* (Node.js only)
*
* @experimental
*/
dispatcher?: Dispatcher
}
export interface FetcherRetryStatus {
retryAttempt: number
retryTimeout: NumberOfMilliseconds
retryStopped: boolean
}
export interface FetcherRetryOptions {
count: number
timeout: NumberOfMilliseconds
timeoutMax: NumberOfMilliseconds
timeoutMultiplier: number
/**
* Upper limit for the server-indicated delay (`retry-after` and similar headers).
* Server-indicated delays within the limit are honored as-is (not affected by `timeoutMax`,
* which only limits the exponential backoff).
* If the server indicates a delay larger than this - the retry is NOT attempted,
* and the error is returned/thrown right away.
*
* Defaults to 10 minutes.
*/
maxRetryAfter: NumberOfMilliseconds
}
export interface FetcherRequest extends Omit<
FetcherOptions,
'method' | 'headers' | 'baseUrl' | 'url'
> {
/**
* inputUrl is only the part that was passed in the request,
* without baseUrl or searchParams.
*/
inputUrl: string
/**
* fullUrl includes baseUrl and searchParams.
*/
fullUrl: string
init: RequestInitNormalized
responseType: FetcherResponseType
timeoutSeconds: number
retry: FetcherRetryOptions
retryPost: boolean
retry3xx: boolean
retry4xx: boolean
retry5xx: boolean
started: UnixTimestampMillis
}
export interface FetcherGraphQLOptions extends FetcherOptions {
query: string
variables?: AnyObject
/**
* When querying singular entities, it may be convenient to specify 1st level object to unwrap.
* Example:
* {
* homePage: { ... }
* }
*
* unwrapObject: 'homePage'
*
* would return the contents of `{ ... }`
*/
unwrapObject?: string
}
/**
* FetcherCfg: configuration of the Fetcher instance. One per instance.
* FetcherOptions: options for a single request. One per request.
*/
export interface FetcherOptions {
method?: HttpMethod
/**
* If defined - this `url` will override the original given `url`.
* baseUrl (and searchParams) will still modify it.
*/
url?: string
baseUrl?: string
/**
* Default: 30.
*
* Timeout applies to both get the response and retrieve the body (e.g `await res.json()`),
* so both should finish within this single timeout (not each).
*/
timeoutSeconds?: number
/**
* AbortSignal to allow the caller to abort the request.
* If `timeoutSeconds` is also set, the signals are combined via `AbortSignal.any()`,
* so the request aborts on whichever fires first.
*/
signal?: AbortSignal
/**
* Supports all the types that RequestInit.body supports.
*
* Useful when you want to e.g pass FormData.
*/
body?: Blob | BufferSource | FormData | URLSearchParams | string
/**
* Same as `body`, but also conveniently sets the
* Content-Type header to `text/plain`
*/
text?: string
/**
* Same as `body`, but:
* 1. JSON.stringifies the passed variable
* 2. Conveniently sets the Content-Type header to `application/json`
*/
json?: any
/**
* Same as `body`, but:
* 1. Transforms the passed plain js object into URLSearchParams and passes it to `body`
* 2. Conveniently sets the Content-Type header to `application/x-www-form-urlencoded`
*/
form?: FormData | URLSearchParams | AnyObject
credentials?: RequestCredentials
/**
* Default to 'follow'.
* 'error' would throw on redirect.
* 'manual' will not throw, but return !ok response with 3xx status.
*/
redirect?: RequestRedirect
/**
* Default to false.
* When set to true, the request will not be aborted when the page is unloaded.
* Useful for sending analytics or tracking events that need to complete even if the user navigates away.
*/
keepalive?: boolean
// Removing RequestInit from options to simplify FetcherOptions interface.
// Will instead only add hand-picked useful options, such as `credentials`.
// init?: Partial
headers?: Record
responseType?: FetcherResponseType // default to 'json'
searchParams?: Record
/**
* Default is 2 retries (3 tries in total).
* Pass `retry: { count: 0 }` to disable retries.
*/
retry?: Partial
/**
* Defaults to false.
* Set to true to allow retrying `post` requests.
*/
retryPost?: boolean
/**
* Defaults to false.
*/
retry3xx?: boolean
/**
* Defaults to false.
*/
retry4xx?: boolean
/**
* Defaults to true.
*/
retry5xx?: boolean
jsonReviver?: Reviver
logRequest?: boolean
logRequestBody?: boolean
logResponse?: boolean
logResponseBody?: boolean
/**
* If true - enables all possible logging.
*/
debug?: boolean
/**
* If provided - will be used instead of `globalThis.fetch`.
* Can be used e.g to pass a `fetch` function from `undici` (in Node.js).
*
* This function IS called from `Fetcher.callNativeFetch`, so
* when `callNativeFetch` is mocked - fetchFn is NOT called.
*/
fetchFn?: FetchFunction
/**
* Allows to provide a fetch function that is NOT mocked by `Fetcher.callNativeFetch`.
*
* By default - consider `fetchFn`, that's what you would need most of the time.
*
* If you want to pass a fetch function that is NOT mockable - use `overrideFetchFn`.
* Example of where it is useful: in backend resourceTestService, which still needs to call
* native fetch, while allowing unit tests' fetch calls to be mocked.
*/
overrideFetchFn?: FetchFunction
/**
* Default to true.
* Set to false to not throw on http errors (`!Response.ok`), but simply return the response body as-is (json parsed, etc).
* Retries still apply as usual.
* Non-http errors (network failure, timeout, body parse error) are still thrown.
*/
throwHttpErrors?: boolean
/**
* If Fetcher has an error - `errorData` object will be appended to the error data.
* Like this:
*
* _errorDataAppend(err, cfg.errorData)
*
* So you, for example, can append a `fingerprint` to any error thrown from this fetcher.
*/
errorData?: ErrorData
/**
* Allows to mutate the error.
* Cannot cancel/prevent the error - AfterResponseHook can be used for that instead.
*/
onError?: FetcherOnErrorHook
/**
* If provided - will be passed further to HttpRequestError if error happens,
* allowing to construct an errorGroup/fingerprint to be able to group errors
* related to "this type of request".
*/
requestName?: string
}
export type RequestInitNormalized = Omit & {
method: HttpMethod
headers: Record
dispatcher?: Dispatcher
keepalive?: boolean
}
export interface FetcherSuccessResponse {
ok: true
err: undefined
fetchResponse: Response
body: BODY
req: FetcherRequest
statusCode: number
statusFamily?: HttpStatusFamily
retryStatus: FetcherRetryStatus
signature: string
}
export interface FetcherErrorResponse {
ok: false
err: Error
fetchResponse?: Response
body?: BODY
req: FetcherRequest
statusCode?: number
statusFamily?: HttpStatusFamily
retryStatus: FetcherRetryStatus
signature: string
}
export type FetcherResponse =
| FetcherSuccessResponse
| FetcherErrorResponse
export type FetcherResponseType =
| 'json'
| 'text'
| 'void'
| 'arrayBuffer'
| 'bytes'
| 'blob'
| 'readableStream'
/**
* Signature for the `fetch` function.
* Used to be able to override and provide a different implementation,
* e.g when mocking.
*/
export type FetchFunction = (url: string, init: RequestInit) => Promise
/**
* A subset of RequestInit that would match both:
*
* 1. RequestInit from dom types
* 2. RequestInit from undici types
*/
export interface RequestInitLike {
method?: string
referrer?: string
keepalive?: boolean
}
/**
* A subset of Response type that matches both dom and undici types.
*/
export interface ResponseLike {
ok: boolean
status: number
statusText: string
}
export type GraphQLResponse = GraphQLSuccessResponse | GraphQLErrorResponse
export interface GraphQLSuccessResponse {
data: DATA
errors: never
}
export interface GraphQLErrorResponse {
data: never
errors: GraphQLFormattedError[]
}
/**
* Copy-pasted from `graphql` package, slimmed down.
* See: https://spec.graphql.org/draft/#sec-Errors
*/
export interface GraphQLFormattedError {
message: string
}