UNPKG

@hyper-fetch/core

Version:

Cache, Queue and Persist your requests no matter if you are online or offline!

768 lines (688 loc) 27 kB
/* 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); }; }