/// /// 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 { 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 { /** * 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; headers?: Record; responseType?: FetcherResponseType; 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; }