/* 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 = ExtractAdapterOptionsType>; type ClientAdapterMethod = ExtractAdapterMethodType>; type ClientRequestOptions = RequestOptionsType< E, ClientAdapterOptions, ClientAdapterMethod >; /** * 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; params: ExtractUrlParams | EmptyTypes; payload: PayloadType; queryParams: QueryParams | EmptyTypes; options?: ClientAdapterOptions | 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 = createRequestHooks(); isMockerEnabled = false; unstable_mock?: { fn: (options: { request: RequestInstance; requestId: string; }) => MockResponseType, ExtractClientAdapterType>; config: MockerConfigType; }; /** @internal */ unstable_payloadMapper?: PayloadMapperType; /** @internal */ unstable_requestMapper?: RequestMapper; /** @internal */ unstable_responseMapper?: ResponseMapper | ResponseErrorType>; /** @internal */ retryOnError?: RetryOnErrorCallbackType; /** @internal */ optimistic?: OptimisticCallback; 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, readonly initialRequestConfiguration?: | RequestConfigurationType< Payload, Endpoint extends string ? ExtractUrlParams : never, QueryParams, Endpoint, ClientAdapterOptions, ClientAdapterMethod > | undefined, ) { const configuration: ClientRequestOptions = { ...(this.client.adapter.unstable_getRequestDefaults?.(requestOptions) as ClientRequestOptions), ...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>; 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; } public setHeaders = (headers: HeadersInit) => { return this.clone({ headers }); }; public setAuth = (auth: boolean) => { return this.clone({ auth }); }; public setParams =

>(params: P) => { return this.clone({ params }); }; public setPayload =

(payload: P) => { return this.clone

({ payload, }); }; public setQueryParams = (queryParams: QueryParams) => { return this.clone({ queryParams }); }; public setOptions = (options: ClientAdapterOptions) => { return this.clone({ 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(); cloned.scope = scopeId; return cloned; }; public setCancelable = (cancelable: boolean) => { return this.clone({ cancelable }); }; public setRetry = (retry: ClientRequestOptions["retry"]) => { return this.clone({ retry }); }; public setRetryTime = (retryTime: ClientRequestOptions["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 >, ) => { const cloned = this.clone(); cloned.retryOnError = callback as RetryOnErrorCallbackType; 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 = (callback: OptimisticCallback) => { const cloned = this.clone(); cloned.optimistic = callback as OptimisticCallback; return cloned as unknown as Request< Response, Payload, QueryParams, LocalError, Endpoint, Client, HasPayload, HasParams, HasQuery, Ctx >; }; public setCacheTime = (cacheTime: ClientRequestOptions["cacheTime"]) => { return this.clone({ cacheTime }); }; public setCache = (cache: ClientRequestOptions["cache"]) => { return this.clone({ cache }); }; public setStaleTime = (staleTime: ClientRequestOptions["staleTime"]) => { return this.clone({ staleTime }); }; public setQueued = (queued: boolean) => { return this.clone({ queued }); }; public setAbortKey = (abortKey: string) => { this.updatedAbortKey = true; return this.clone({ abortKey }); }; public setCacheKey = (cacheKey: string) => { this.updatedCacheKey = true; return this.clone({ cacheKey }); }; public setQueryKey = (queryKey: string) => { this.updatedQueryKey = true; return this.clone({ queryKey }); }; public setDeduplicate = (deduplicate: boolean) => { return this.clone({ deduplicate }); }; public setDeduplicateTime = (deduplicateTime: number) => { return this.clone({ deduplicateTime }); }; public setUsed = (used: boolean) => { return this.clone({ used }); }; public setOffline = (offline: boolean) => { return this.clone({ offline }); }; public setMock = ( fn: (options: { request: Request; requestId: string; }) => SyncOrAsync< MockResponseType, ExtractClientAdapterType> >, config: MockerConfigType = {}, ) => { this.unstable_mock = { fn, config } as typeof this.unstable_mock; this.isMockerEnabled = true; return this; }; public clearMock = () => { this.unstable_mock = undefined; this.isMockerEnabled = false; return this; }; public setMockingEnabled = (isMockerEnabled: boolean) => { this.isMockerEnabled = isMockerEnabled; return this; }; /** * Map data before it gets send to the server * @param payloadMapper * @returns */ public setPayloadMapper = >( payloadMapper: (data: Payload) => MappedPayload, ) => { const cloned = this.clone(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 = (requestMapper: RequestMapper) => { const cloned = this.clone(undefined); cloned.unstable_requestMapper = requestMapper; return cloned; }; /** * Map the response to the new interface * @param responseMapper our mapping callback * @returns new response */ public setResponseMapper = | ResponseErrorType>( responseMapper?: ResponseMapper, ) => { const cloned = this.clone(); cloned.unstable_responseMapper = responseMapper as typeof cloned.unstable_responseMapper; return cloned as unknown as Request< MappedResponse extends ResponseType ? R : Response, Payload, QueryParams, MappedResponse extends ResponseType ? 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; }; public toJSON(): RequestJSON { return { requestOptions: this.requestOptions as unknown as RequestOptionsType< ExtractEndpointType, ExtractAdapterOptionsType>, ExtractAdapterMethodType> >, endpoint: this.endpoint as ExtractEndpointType, headers: this.headers, auth: this.auth, // TODO: fix this type method: this.method as any, params: this.params as ExtractParamsType, payload: this.payload as ExtractPayloadType, queryParams: this.queryParams as ExtractQueryParamsType, 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, }; } 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, ClientAdapterMethod >, ) { const json = this.toJSON(); const initialRequestConfiguration: RequestConfigurationType< Payload, Endpoint extends string ? ExtractUrlParams : never, QueryParams, Endpoint, ClientAdapterOptions, ClientAdapterMethod > = { ...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 : 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; } public abort = () => { const { requestManager } = this.client; requestManager.abortByKey(scopeKey(this.abortKey, this.scope)); return this.clone(); }; public dehydrate = (config?: { /** in case of using adapter without cache we can provide response to dehydrate */ response?: ResponseType, ExtractClientAdapterType>; /** override cache data */ override?: boolean; }): | HydrateDataType, ExtractClientAdapterType> | 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>( 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, ExtractClientAdapterType> | undefined { const cacheData = this.client.cache.get>( 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 = async (options?: RequestSendOptionsType) => { 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 = async (options?: RequestSendOptionsType) => { 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); }; 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); }; }