///
///
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;
}