@hyper-fetch/core
Version:
Cache, Queue and Persist your requests no matter if you are online or offline!
768 lines (688 loc) • 27 kB
text/typescript
/* eslint-disable max-lines */
import type {
RequestSendOptionsType,
ParamsType,
RequestSendType,
PayloadType,
RequestJSON,
RequestOptionsType,
RequestConfigurationType,
PayloadMapperType,
RequestInstance,
RequestMapper,
ResponseMapper,
ExtractUrlParams,
RetryOnErrorCallbackType,
OptimisticCallback,
} from "./request.types";
import type { RequestHooks } from "./request.hooks";
import { createRequestHooks } from "./request.hooks";
import { mapResponseForSend, sendRequest, scopeKey } from "./request.utils";
import type { ClientInstance } from "client";
import type { ResponseErrorType, ResponseSuccessType, ResponseType } from "adapter";
import type {
ExtractAdapterType,
ExtractClientAdapterType,
ExtractClientGlobalError,
ExtractEndpointType,
ExtractParamsType,
ExtractPayloadType,
ExtractQueryParamsType,
EmptyTypes,
ExtractAdapterMethodType,
ExtractAdapterOptionsType,
HydrateDataType,
SyncOrAsync,
} from "types";
import { Time } from "constants/time.constants";
import type { MockerConfigType, MockResponseType } from "mocker";
type ClientAdapterOptions<C extends ClientInstance> = ExtractAdapterOptionsType<ExtractClientAdapterType<C>>;
type ClientAdapterMethod<C extends ClientInstance> = ExtractAdapterMethodType<ExtractClientAdapterType<C>>;
type ClientRequestOptions<E, C extends ClientInstance> = RequestOptionsType<
E,
ClientAdapterOptions<C>,
ClientAdapterMethod<C>
>;
/**
* Request is a class that represents a request sent to the server. It contains all the necessary information to make a request, like endpoint, method, headers, data, and much more.
* It is executed at any time via methods like `send` or `exec`.
*
* We can set it up with options like endpoint, method, headers and more.
* We can choose some of advanced settings like cache, invalidation patterns, concurrency, retries and much, much more.
*
* @info We should not use this class directly in the standard development flow.
* We can initialize it using the `createRequest` method on the **Client** class.
*
* @attention The most important thing about the request is that it keeps data in the format that can be dumped.
* This is necessary for the persistence and different dispatcher storage types.
* This class doesn't have any callback methods by design and communicate with dispatcher and cache by events.
*
* It should be serializable to JSON and deserializable back to the class.
* Serialization should not affect the result of the request, so it's methods and functional part should be only syntax sugar for given runtime.
*/
export class Request<
Response,
Payload,
QueryParams,
LocalError,
Endpoint extends string,
Client extends ClientInstance,
HasPayload extends true | false = false,
HasParams extends true | false = false,
HasQuery extends true | false = false,
MutationContext = undefined,
> {
endpoint: Endpoint;
headers?: HeadersInit;
auth: boolean;
method: ClientAdapterMethod<Client>;
params: ExtractUrlParams<Endpoint> | EmptyTypes;
payload: PayloadType<Payload>;
queryParams: QueryParams | EmptyTypes;
options?: ClientAdapterOptions<Client> | undefined;
cancelable: boolean;
retry: number;
retryTime: number;
cacheTime: number;
cache: boolean;
staleTime: number;
queued: boolean;
offline: boolean;
abortKey: string;
cacheKey: string;
queryKey: string;
used: boolean;
deduplicate: boolean;
deduplicateTime: number | null;
scope: string | null;
/**
* Instance-level lifecycle hooks. These callbacks fire for every `send()` / `exec()` call
* made on this request instance (and its clones), without needing to pass them to `send()` each time.
* Useful for cross-cutting concerns like logging, analytics, or toast notifications.
*
* Each method registers a callback and returns an unsubscribe function.
* Multiple listeners per hook are supported.
*/
$hooks: RequestHooks<RequestInstance> = createRequestHooks();
isMockerEnabled = false;
unstable_mock?: {
fn: (options: {
request: RequestInstance;
requestId: string;
}) => MockResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>;
config: MockerConfigType;
};
/** @internal */
unstable_payloadMapper?: PayloadMapperType<Payload>;
/** @internal */
unstable_requestMapper?: RequestMapper<any, any>;
/** @internal */
unstable_responseMapper?: ResponseMapper<this, ResponseSuccessType<any, any> | ResponseErrorType<any, any>>;
/** @internal */
retryOnError?: RetryOnErrorCallbackType<RequestInstance>;
/** @internal */
optimistic?: OptimisticCallback<RequestInstance, any>;
unstable_hasParams: HasParams = false as HasParams;
unstable_hasPayload: HasPayload = false as HasPayload;
unstable_hasQuery: HasQuery = false as HasQuery;
unstable_hasMutationContext: MutationContext = undefined as MutationContext;
private updatedAbortKey: boolean;
private updatedCacheKey: boolean;
private updatedQueryKey: boolean;
constructor(
readonly client: Client,
readonly requestOptions: ClientRequestOptions<Endpoint, Client>,
readonly initialRequestConfiguration?:
| RequestConfigurationType<
Payload,
Endpoint extends string ? ExtractUrlParams<Endpoint> : never,
QueryParams,
Endpoint,
ClientAdapterOptions<Client>,
ClientAdapterMethod<Client>
>
| undefined,
) {
const configuration: ClientRequestOptions<Endpoint, Client> = {
...(this.client.adapter.unstable_getRequestDefaults?.(requestOptions) as ClientRequestOptions<Endpoint, Client>),
...requestOptions,
};
const {
endpoint,
headers,
auth = true,
method = client.adapter.defaultMethod,
options,
cancelable = false,
retry = 0,
retryTime = 500,
cacheTime = Time.MIN * 5,
cache = true,
staleTime = Time.MIN * 5,
queued = false,
offline = true,
abortKey,
cacheKey,
queryKey,
deduplicate = false,
deduplicateTime = null,
} = configuration;
this.endpoint = initialRequestConfiguration?.endpoint ?? endpoint;
this.headers = initialRequestConfiguration?.headers ?? headers;
this.auth = initialRequestConfiguration?.auth ?? auth;
this.method = method as ExtractAdapterMethodType<ExtractClientAdapterType<Client>>;
this.params = initialRequestConfiguration?.params;
this.payload = initialRequestConfiguration?.payload;
this.queryParams = initialRequestConfiguration?.queryParams;
this.options = initialRequestConfiguration?.options ?? options;
this.cancelable = initialRequestConfiguration?.cancelable ?? cancelable;
this.retry = initialRequestConfiguration?.retry ?? retry;
this.retryTime = initialRequestConfiguration?.retryTime ?? retryTime;
this.cacheTime = initialRequestConfiguration?.cacheTime ?? cacheTime;
this.cache = initialRequestConfiguration?.cache ?? cache;
this.staleTime = initialRequestConfiguration?.staleTime ?? staleTime;
this.queued = initialRequestConfiguration?.queued ?? queued;
this.offline = initialRequestConfiguration?.offline ?? offline;
this.abortKey = initialRequestConfiguration?.abortKey ?? abortKey ?? this.client.unstable_abortKeyMapper(this);
this.cacheKey = initialRequestConfiguration?.cacheKey ?? cacheKey ?? this.client.unstable_cacheKeyMapper(this);
this.queryKey = initialRequestConfiguration?.queryKey ?? queryKey ?? this.client.unstable_queryKeyMapper(this);
this.used = initialRequestConfiguration?.used ?? false;
this.deduplicate = initialRequestConfiguration?.deduplicate ?? deduplicate;
this.deduplicateTime = initialRequestConfiguration?.deduplicateTime ?? deduplicateTime;
this.scope = initialRequestConfiguration?.scope ?? null;
this.updatedAbortKey = initialRequestConfiguration?.updatedAbortKey ?? false;
this.updatedCacheKey = initialRequestConfiguration?.updatedCacheKey ?? false;
this.updatedQueryKey = initialRequestConfiguration?.updatedQueryKey ?? false;
}
/** Set the request headers. Returns a cloned request with the new headers applied. */
public setHeaders = (headers: HeadersInit) => {
return this.clone({ headers });
};
/** Set whether authentication interceptors should run for this request. */
public setAuth = (auth: boolean) => {
return this.clone({ auth });
};
/** Set the URL path parameters (e.g., `:userId`). */
public setParams = <P extends ExtractParamsType<this>>(params: P) => {
return this.clone<HasPayload, P extends null ? false : true, HasQuery>({ params });
};
/** Set the request body payload. */
public setPayload = <P extends Payload>(payload: P) => {
return this.clone<P extends null ? false : true, HasParams, HasQuery>({
payload,
});
};
/** Set the query parameters appended to the URL. */
public setQueryParams = (queryParams: QueryParams) => {
return this.clone<HasPayload, HasParams, true>({ queryParams });
};
/** Set adapter-specific options (e.g., axios config, fetch init). */
public setOptions = (options: ClientAdapterOptions<Client>) => {
return this.clone<HasPayload, HasParams, true>({ options });
};
/**
* Set a scope identifier for this request.
* All keys (cache, queue, abort) are prefixed with this scope, isolating
* the request from other scopes. In "server" client mode, setting a scope
* also enables caching (which is otherwise disabled to prevent cross-request leaks).
*/
public setScope = (scopeId: string) => {
const cloned = this.clone<HasPayload, HasParams, HasQuery>();
cloned.scope = scopeId;
return cloned;
};
/** Set whether in-flight requests with the same abort key are cancelled when a new one is dispatched. */
public setCancelable = (cancelable: boolean) => {
return this.clone({ cancelable });
};
/** Set the number of retry attempts on failure. */
public setRetry = (retry: ClientRequestOptions<Endpoint, Client>["retry"]) => {
return this.clone({ retry });
};
/** Set the delay in milliseconds between retry attempts. */
public setRetryTime = (retryTime: ClientRequestOptions<Endpoint, Client>["retryTime"]) => {
return this.clone({ retryTime });
};
/**
* Set a callback that controls whether a failed request should be retried.
* Called on each failed attempt before scheduling the next retry.
* Return `true` to allow the retry, `false` to stop retrying immediately.
*/
public setRetryOnError = (
callback: RetryOnErrorCallbackType<
Request<Response, Payload, QueryParams, LocalError, Endpoint, Client, HasPayload, HasParams, HasQuery>
>,
) => {
const cloned = this.clone<HasPayload, HasParams, HasQuery>();
cloned.retryOnError = callback as RetryOnErrorCallbackType<RequestInstance>;
return cloned;
};
/**
* Configure optimistic update behavior for this request.
* The callback runs before the request is sent (in React's `useSubmit`) and receives
* the request, client, and payload. Return `context` (available in submit callbacks),
* `rollback` (called automatically on failure/abort), and `invalidate` (cache keys
* invalidated on success).
*/
public setOptimistic = <Ctx>(callback: OptimisticCallback<this, Ctx>) => {
const cloned = this.clone<HasPayload, HasParams, HasQuery>();
cloned.optimistic = callback as OptimisticCallback<RequestInstance, any>;
return cloned as unknown as Request<
Response,
Payload,
QueryParams,
LocalError,
Endpoint,
Client,
HasPayload,
HasParams,
HasQuery,
Ctx
>;
};
/** Set how long (in ms) the response remains in cache before being garbage collected. */
public setCacheTime = (cacheTime: ClientRequestOptions<Endpoint, Client>["cacheTime"]) => {
return this.clone({ cacheTime });
};
/** Set whether this request should use the cache. */
public setCache = (cache: ClientRequestOptions<Endpoint, Client>["cache"]) => {
return this.clone({ cache });
};
/** Set how long (in ms) cached data is considered fresh before triggering a revalidation. */
public setStaleTime = (staleTime: ClientRequestOptions<Endpoint, Client>["staleTime"]) => {
return this.clone({ staleTime });
};
/** Set whether the request should be queued and executed sequentially rather than immediately. */
public setQueued = (queued: boolean) => {
return this.clone({ queued });
};
/** Override the abort key used to cancel this request. */
public setAbortKey = (abortKey: string) => {
this.updatedAbortKey = true;
return this.clone({ abortKey });
};
/** Override the cache key used to store/retrieve the response. */
public setCacheKey = (cacheKey: string) => {
this.updatedCacheKey = true;
return this.clone({ cacheKey });
};
/** Override the query key that identifies this request in the dispatcher queue. */
public setQueryKey = (queryKey: string) => {
this.updatedQueryKey = true;
return this.clone({ queryKey });
};
/** Enable or disable request deduplication. When enabled, concurrent identical requests share a single response. */
public setDeduplicate = (deduplicate: boolean) => {
return this.clone({ deduplicate });
};
/** Set the time window (in ms) during which identical requests are deduplicated. */
public setDeduplicateTime = (deduplicateTime: number) => {
return this.clone({ deduplicateTime });
};
/** Mark the request as used (prevents automatic refetching in hooks). */
public setUsed = (used: boolean) => {
return this.clone({ used });
};
/** Set whether the request should be stored in the offline queue when there is no network connection. */
public setOffline = (offline: boolean) => {
return this.clone({ offline });
};
/** Attach a mock handler to this request. When mocking is enabled, the handler is called instead of the real adapter. */
public setMock = (
fn: (options: {
request: Request<Response, Payload, QueryParams, LocalError, Endpoint, Client, HasPayload, HasParams, HasQuery>;
requestId: string;
}) => SyncOrAsync<
MockResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>
>,
config: MockerConfigType = {},
) => {
this.unstable_mock = { fn, config } as typeof this.unstable_mock;
this.isMockerEnabled = true;
return this;
};
/** Remove any attached mock handler and disable mocking for this request. */
public clearMock = () => {
this.unstable_mock = undefined;
this.isMockerEnabled = false;
return this;
};
/** Enable or disable the mocker for this request without removing the mock handler. */
public setMockingEnabled = (isMockerEnabled: boolean) => {
this.isMockerEnabled = isMockerEnabled;
return this;
};
/**
* Map data before it gets send to the server
* @param payloadMapper
* @returns
*/
public setPayloadMapper = <MappedPayload extends any | Promise<any>>(
payloadMapper: (data: Payload) => MappedPayload,
) => {
const cloned = this.clone<HasPayload, HasParams, HasQuery>(undefined);
cloned.unstable_payloadMapper = payloadMapper as typeof this.unstable_payloadMapper;
return cloned;
};
/**
* Map request before it gets send to the server
* @param requestMapper mapper of the request
* @returns new request
*/
public setRequestMapper = <NewRequest extends RequestInstance>(requestMapper: RequestMapper<this, NewRequest>) => {
const cloned = this.clone<HasPayload, HasParams, HasQuery>(undefined);
cloned.unstable_requestMapper = requestMapper;
return cloned;
};
/**
* Map the response to the new interface
* @param responseMapper our mapping callback
* @returns new response
*/
public setResponseMapper = <MappedResponse extends ResponseSuccessType<any, any> | ResponseErrorType<any, any>>(
responseMapper?: ResponseMapper<this, MappedResponse>,
) => {
const cloned = this.clone<HasPayload, HasParams, HasQuery>();
cloned.unstable_responseMapper = responseMapper as typeof cloned.unstable_responseMapper;
return cloned as unknown as Request<
MappedResponse extends ResponseType<infer R, any, any> ? R : Response,
Payload,
QueryParams,
MappedResponse extends ResponseType<any, infer E, any> ? E : LocalError,
Endpoint,
Client,
HasPayload,
HasParams,
HasQuery,
MutationContext
>;
};
private paramsMapper = (params: ParamsType | null | undefined): Endpoint => {
const { endpoint } = this.requestOptions;
let stringEndpoint = String(endpoint);
if (params) {
Object.entries(params).forEach(([key, value]) => {
stringEndpoint = stringEndpoint.replace(new RegExp(`:${key}`, "g"), String(value));
});
}
return stringEndpoint as Endpoint;
};
/** Serialize the request configuration into a plain JSON object for persistence or transfer. */
public toJSON(): RequestJSON<this> {
return {
requestOptions: this.requestOptions as unknown as RequestOptionsType<
ExtractEndpointType<this>,
ExtractAdapterOptionsType<ExtractAdapterType<this>>,
ExtractAdapterMethodType<ExtractAdapterType<this>>
>,
endpoint: this.endpoint as ExtractEndpointType<this>,
headers: this.headers,
auth: this.auth,
// TODO: fix this type
method: this.method as any,
params: this.params as ExtractParamsType<this>,
payload: this.payload as ExtractPayloadType<this>,
queryParams: this.queryParams as ExtractQueryParamsType<this>,
options: this.options,
cancelable: this.cancelable,
retry: this.retry,
retryTime: this.retryTime,
cacheTime: this.cacheTime,
cache: this.cache,
staleTime: this.staleTime,
queued: this.queued,
offline: this.offline,
abortKey: this.abortKey,
cacheKey: this.cacheKey,
queryKey: this.queryKey,
used: this.used,
disableResponseInterceptors: this.requestOptions.disableResponseInterceptors,
disableRequestInterceptors: this.requestOptions.disableRequestInterceptors,
updatedAbortKey: this.updatedAbortKey,
updatedCacheKey: this.updatedCacheKey,
updatedQueryKey: this.updatedQueryKey,
deduplicate: this.deduplicate,
deduplicateTime: this.deduplicateTime,
scope: this.scope,
isMockerEnabled: this.isMockerEnabled,
hasMock: !!this.unstable_mock,
};
}
/** Create a new request instance with optional configuration overrides. Used internally by all setter methods. */
public clone<
NewData extends true | false = HasPayload,
NewParams extends true | false = HasParams,
NewQueryParams extends true | false = HasQuery,
>(
configuration?: RequestConfigurationType<
Payload,
(typeof this)["params"],
QueryParams,
Endpoint,
ClientAdapterOptions<Client>,
ClientAdapterMethod<Client>
>,
) {
const json = this.toJSON();
const initialRequestConfiguration: RequestConfigurationType<
Payload,
Endpoint extends string ? ExtractUrlParams<Endpoint> : never,
QueryParams,
Endpoint,
ClientAdapterOptions<Client>,
ClientAdapterMethod<Client>
> = {
...json,
...configuration,
options: configuration?.options || this.options,
abortKey: this.updatedAbortKey ? configuration?.abortKey || this.abortKey : undefined,
cacheKey: this.updatedCacheKey ? configuration?.cacheKey || this.cacheKey : undefined,
queryKey: this.updatedQueryKey ? configuration?.queryKey || this.queryKey : undefined,
endpoint: this.paramsMapper(configuration?.params || this.params),
queryParams: configuration?.queryParams || this.queryParams,
payload: configuration?.payload || this.payload,
params: (configuration?.params || this.params) as
| EmptyTypes
| (Endpoint extends string ? ExtractUrlParams<Endpoint> : never),
};
const cloned = new Request<
Response,
Payload,
QueryParams,
LocalError,
Endpoint,
Client,
NewData,
NewParams,
NewQueryParams,
MutationContext
>(this.client, this.requestOptions, initialRequestConfiguration);
// Inherit methods
cloned.unstable_payloadMapper = this.unstable_payloadMapper;
cloned.unstable_responseMapper = this.unstable_responseMapper as typeof cloned.unstable_responseMapper;
cloned.unstable_requestMapper = this.unstable_requestMapper;
cloned.retryOnError = this.retryOnError;
cloned.unstable_mock = this.unstable_mock;
cloned.isMockerEnabled = this.isMockerEnabled;
cloned.optimistic = this.optimistic;
cloned.$hooks = createRequestHooks(this.$hooks.__snapshot());
return cloned;
}
/** Abort all in-flight requests sharing this request's abort key. */
public abort = () => {
const { requestManager } = this.client;
requestManager.abortByKey(scopeKey(this.abortKey, this.scope));
return this.clone();
};
/** Extract the current cache data for this request into a portable hydration payload (useful for SSR). */
public dehydrate = (config?: {
/** in case of using adapter without cache we can provide response to dehydrate */
response?: ResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>;
/** override cache data */
override?: boolean;
}):
| HydrateDataType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>
| undefined => {
const { response, override = true } = config || {};
if (response) {
return {
override,
cacheTime: this.cacheTime,
staleTime: this.staleTime,
cacheKey: this.cacheKey,
scope: this.scope,
timestamp: +new Date(),
hydrated: true,
cache: true,
response,
};
}
const cacheData = this.client.cache.get<Response, LocalError | ExtractClientGlobalError<Client>>(
scopeKey(this.cacheKey, this.scope),
);
if (!cacheData) {
return undefined;
}
return {
override,
cacheTime: this.cacheTime,
staleTime: this.staleTime,
cacheKey: this.cacheKey,
scope: this.scope,
timestamp: +new Date(),
hydrated: true,
cache: true,
response: {
data: cacheData.data,
error: cacheData.error,
status: cacheData.status,
success: cacheData.success,
extra: cacheData.extra,
requestTimestamp: cacheData.requestTimestamp,
responseTimestamp: cacheData.responseTimestamp,
},
};
};
/**
* Read the response from cache data
*
* If it returns error and data at the same time, it means that latest request was failed
* and we show previous data from cache together with error received from actual request
*/
public read():
| ResponseType<Response, LocalError | ExtractClientGlobalError<Client>, ExtractClientAdapterType<Client>>
| undefined {
const cacheData = this.client.cache.get<Response, LocalError | ExtractClientGlobalError<Client>>(
scopeKey(this.cacheKey, this.scope),
);
if (cacheData) {
return {
data: cacheData.data,
error: cacheData.error,
status: cacheData.status,
success: cacheData.success,
extra: cacheData.extra,
requestTimestamp: cacheData.requestTimestamp,
responseTimestamp: cacheData.responseTimestamp,
};
}
return undefined;
}
/**
* Method to use the request WITHOUT adding it to cache and queues. This mean it will make simple request without queue side effects.
* @param options
* @disableReturns
* @returns
* ```tsx
* Promise<[Data | null, Error | null, HttpStatus]>
* ```
*/
public exec: RequestSendType<this> = async (options?: RequestSendOptionsType<this>) => {
const { adapter, requestManager } = this.client;
const request = this.clone(options);
const requestId = this.client.unstable_requestIdMapper(this);
const scopedAbortKey = scopeKey(this.abortKey, this.scope);
// Listen for aborting
requestManager.addAbortController(scopedAbortKey, requestId);
const response = await adapter.fetch(request, requestId);
// Stop listening for aborting
requestManager.removeAbortController(scopedAbortKey, requestId);
if (request.unstable_responseMapper) {
return request.unstable_responseMapper(response);
}
return response;
};
/**
* Method used to perform requests with usage of cache and queues
* @param options
* @param requestCallback
* @disableReturns
* @returns
* ```tsx
* Promise<{ data: Data | null, error: Error | null, status: HttpStatus, ... }>
* ```
*/
public send: RequestSendType<this> = async (options?: RequestSendOptionsType<this>) => {
const { dispatcherType, cachePolicy = "network-only", ...configuration } = options || {};
const request = this.clone(configuration) as unknown as this;
const sendRequestOptions = options === undefined ? undefined : { ...options, cachePolicy: undefined };
if (cachePolicy === "network-only") {
return sendRequest(request, sendRequestOptions);
}
const cached = request.read();
if (cachePolicy === "cache-first") {
if (cached) {
return mapResponseForSend(request, cached);
}
return sendRequest(request, sendRequestOptions);
}
// revalidate
if (cached) {
const resolved = await mapResponseForSend(request, cached);
// Background revalidation; promise from send() already resolved with cache snapshot
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendRequest(request, sendRequestOptions);
return resolved;
}
return sendRequest(request, sendRequestOptions);
};
/** Reconstruct a Request instance from a previously serialized JSON representation. */
static fromJSON = <
NewResponse,
NewPayload,
NewQueryParams,
NewLocalError,
NewEndpoint extends string,
NewClient extends ClientInstance,
NewHasPayload extends true | false = false,
NewHasParams extends true | false = false,
NewHasQuery extends true | false = false,
>(
client: NewClient,
json: RequestJSON<
Request<
NewResponse,
NewPayload,
NewQueryParams,
NewLocalError,
NewEndpoint,
NewClient,
NewHasPayload,
NewHasParams,
NewHasQuery
>
>,
) => {
return new Request<
NewResponse,
NewPayload,
NewQueryParams,
NewLocalError,
NewEndpoint,
NewClient,
NewHasPayload,
NewHasParams,
NewHasQuery
>(client, json.requestOptions, json);
};
}