import type { AdapterInstance, ResponseType } from "adapter"; import type { HttpAdapterType } from "http-adapter"; import { parseResponse, HttpAdapter } from "http-adapter"; import type { ClientErrorType, ClientInstance, ClientMode, ClientOptionsType, RequestGenericType, RequestInterceptorType, ResponseInterceptorType, } from "client"; import { Cache } from "cache"; import { Dispatcher } from "dispatcher"; import type { PluginInstance, PluginMethodParameters, PluginMethods } from "plugin"; import type { RequestInstance, RequestJSON, RequestOptionsType } from "request"; import { getRequestKey, getSimpleKey, Request, scopeKey } from "request"; import type { LogLevel } from "managers"; import { AppManager, LoggerManager, RequestManager } from "managers"; import { interceptRequest, interceptResponse, resolveClientMode } from "./client.utils"; import type { EmptyTypes, TypeWithDefaults, ExtractAdapterMethodType, ExtractAdapterOptionsType, ExtractAdapterQueryParamsType, ExtractAdapterEndpointType, ExtractUnionAdapter, HydrateDataType, HydrationOptions, ExtractAdapterDefaultQueryParamsType, } from "types"; import { getUniqueRequestId } from "utils"; /** * **Client** is a class that allows you to configure the connection with the server and then use it to create * requests. It allows you to set global defaults for the requests configuration, query params configuration. * It is also orchestrator for all of the HyperFetch modules like Cache, Dispatcher, AppManager, LoggerManager, * RequestManager and more. */ export class Client< GlobalErrorType extends ClientErrorType = Error, Adapter extends AdapterInstance = HttpAdapterType, > { readonly url: string; readonly mode: ClientMode; public debug: boolean; // Private unstable_onErrorCallbacks: ResponseInterceptorType[] = []; unstable_onSuccessCallbacks: ResponseInterceptorType[] = []; unstable_onResponseCallbacks: ResponseInterceptorType[] = []; unstable_onAuthCallbacks: RequestInterceptorType[] = []; unstable_onRequestCallbacks: RequestInterceptorType[] = []; // Managers loggerManager: LoggerManager = new LoggerManager(); requestManager: RequestManager = new RequestManager(); appManager: AppManager; // Config adapter: Adapter; cache: Cache; fetchDispatcher: Dispatcher; submitDispatcher: Dispatcher; isMockerEnabled = true; // Registered requests effect plugins: PluginInstance[] = []; /** @internal */ unstable_abortKeyMapper: (request: RequestInstance) => string = getSimpleKey; /** @internal */ unstable_cacheKeyMapper: (request: RequestInstance) => string = getRequestKey; /** @internal */ unstable_queryKeyMapper: (request: RequestInstance) => string = getRequestKey; /** @internal */ unstable_requestIdMapper: (request: RequestInstance) => string = getUniqueRequestId; // Logger logger = this.loggerManager.initialize(this, "Client"); constructor(public options: ClientOptionsType>) { const { url, appManager, cache, fetchDispatcher, submitDispatcher, mode: modeOption } = this.options; this.url = url; this.mode = resolveClientMode(modeOption); this.adapter = HttpAdapter() as Adapter; this.appManager = appManager?.() || new AppManager(); this.cache = cache?.() || new Cache(); this.fetchDispatcher = fetchDispatcher?.() || new Dispatcher(); this.submitDispatcher = submitDispatcher?.() || new Dispatcher(); // IMPORTANT: Do not change initialization order as it's crucial for dependencies injection this.adapter.initialize(this); this.appManager.initialize(); this.cache.initialize(this as unknown as ClientInstance<{ adapter: Adapter }>); this.fetchDispatcher.initialize(this as unknown as ClientInstance<{ adapter: Adapter }>); this.submitDispatcher.initialize(this as unknown as ClientInstance<{ adapter: Adapter }>); } /** * This method enables the logger usage and display the logs in console */ setDebug = (enabled: boolean): Client => { this.debug = enabled; return this; }; /** * Set the logger severity of the messages displayed to the console */ setLogLevel = (severity: LogLevel): Client => { this.loggerManager.setSeverity(severity); return this; }; /** * Set the new logger instance to the Client */ setLogger = (callback: (Client: ClientInstance) => LoggerManager): Client => { this.loggerManager = callback(this); this.loggerManager.initialize(this, "Client"); return this; }; /** * Set globally if mocking should be enabled or disabled for all client requests. * @param isMockerEnabled */ setEnableGlobalMocking = (isMockerEnabled: boolean) => { this.isMockerEnabled = isMockerEnabled; return this; }; /** * Set custom http adapter to handle graphql, rest, firebase or others */ setAdapter = (adapter: NewAdapter): Client => { this.adapter = adapter as unknown as Adapter; this.adapter.initialize(this); return this as unknown as Client; }; /** * Method of manipulating requests before sending the request. We can for example add custom header with token to the request which request had the auth set to true. */ onAuth = (callback: RequestInterceptorType): Client => { this.unstable_onAuthCallbacks.push(callback); return this; }; /** * Method for removing listeners on auth. * */ removeOnAuthInterceptors = (callbacks: RequestInterceptorType[]): Client => { this.unstable_onAuthCallbacks = this.unstable_onAuthCallbacks.filter((callback) => !callbacks.includes(callback)); return this; }; /** * Method for intercepting error responses. It can be used for example to refresh tokens. */ onError = ( callback: ResponseInterceptorType, ): Client => { this.unstable_onErrorCallbacks.push(callback); return this; }; /** * Method for removing listeners on error. * */ removeOnErrorInterceptors = ( callbacks: ResponseInterceptorType[], ): Client => { this.unstable_onErrorCallbacks = this.unstable_onErrorCallbacks.filter((callback) => !callbacks.includes(callback)); return this; }; /** * Method for intercepting success responses. */ onSuccess = ( callback: ResponseInterceptorType, ): Client => { this.unstable_onSuccessCallbacks.push(callback); return this; }; /** * Method for removing listeners on success. * */ removeOnSuccessInterceptors = ( callbacks: ResponseInterceptorType[], ): Client => { this.unstable_onSuccessCallbacks = this.unstable_onSuccessCallbacks.filter( (callback) => !callbacks.includes(callback), ); return this; }; /** * Method of manipulating requests before sending the request. */ onRequest = (callback: RequestInterceptorType): Client => { this.unstable_onRequestCallbacks.push(callback); return this; }; /** * Method for removing listeners on request. * */ removeOnRequestInterceptors = (callbacks: RequestInterceptorType[]): Client => { this.unstable_onRequestCallbacks = this.unstable_onRequestCallbacks.filter( (callback) => !callbacks.includes(callback), ); return this; }; /** * Method for intercepting any responses. */ onResponse = ( callback: ResponseInterceptorType, ): Client => { this.unstable_onResponseCallbacks.push(callback); return this; }; /** * Method for removing listeners on request. * */ removeOnResponseInterceptors = ( callbacks: ResponseInterceptorType[], ): Client => { this.unstable_onResponseCallbacks = this.unstable_onResponseCallbacks.filter( (callback) => !callbacks.includes(callback), ); return this; }; /** * Add persistent plugins which trigger on the request lifecycle */ addPlugin = (plugin: PluginInstance) => { this.plugins.push(plugin); plugin.initialize(this); plugin.trigger("onMount", { client: this }); return this; }; /** * Remove plugins from Client */ removePlugin = (plugin: PluginInstance) => { const pluginCount = this.plugins.length; this.plugins = this.plugins.filter((p) => p !== plugin); if (this.plugins.length !== pluginCount) { plugin.trigger("onUnmount", { client: this }); } return this; }; triggerPlugins = >(key: Key, data: PluginMethodParameters) => { if (!this.plugins.length) { return this; } this.plugins.forEach((plugin) => { plugin.trigger(key, data); }); return this; }; /** * Key setters */ setAbortKeyMapper = (callback: (request: RequestInstance) => string) => { this.unstable_abortKeyMapper = callback; return this; }; setCacheKeyMapper = (callback: (request: RequestInstance) => string) => { this.unstable_cacheKeyMapper = callback; return this; }; setQueryKeyMapper = (callback: (request: RequestInstance) => string) => { this.unstable_queryKeyMapper = callback; return this; }; setRequestIdMapper = (callback: (request: RequestInstance) => string) => { this.unstable_requestIdMapper = callback; return this; }; /** * Helper used by http adapter to apply the modifications on response error * @private */ unstable_modifyAuth = async (request: RequestInstance) => interceptRequest(this.unstable_onAuthCallbacks, request); /** * Private helper to run async pre-request processing * @private */ unstable_modifyRequest = async (request: RequestInstance) => interceptRequest(this.unstable_onRequestCallbacks, request); /** * Private helper to run async on-error response processing * @private */ unstable_modifyErrorResponse = async ( response: ResponseType, request: RequestInstance, ) => interceptResponse(this.unstable_onErrorCallbacks, response, request); /** * Private helper to run async on-success response processing * @private */ unstable_modifySuccessResponse = async ( response: ResponseType, request: RequestInstance, ) => interceptResponse(this.unstable_onSuccessCallbacks, response, request); /** * Private helper to run async response processing * @private */ unstable_modifyResponse = async (response: ResponseType, request: RequestInstance) => interceptResponse(this.unstable_onResponseCallbacks, response, request); /** * Reconstruct a Request class instance from its serialized JSON form. * Useful when dispatcher storage serializes queue data (e.g. MMKV, AsyncStorage) * and the deserialized request loses its class identity. */ fromJSON = > = {}>( json: RequestJSON, ) => { type DefaultQueryParams = ExtractAdapterDefaultQueryParamsType; type Response = TypeWithDefaults; type Payload = TypeWithDefaults; type LocalError = TypeWithDefaults; type QueryParams = TypeWithDefaults; type Endpoint = TypeWithDefaults; return Request.fromJSON(this as unknown as ClientInstance, json) as unknown as Request< Response, Payload, QueryParams, LocalError, Endpoint extends string ? Endpoint : string, Client >; }; /** * Clears the Client instance and remove all listeners on it's dependencies */ clear = () => { const { appManager, cache, fetchDispatcher, submitDispatcher } = this.options; this.requestManager.abortControllers.clear(); this.fetchDispatcher.clear(); this.submitDispatcher.clear(); this.cache.clear(); this.requestManager.emitter.removeAllListeners(); this.fetchDispatcher.emitter.removeAllListeners(); this.submitDispatcher.emitter.removeAllListeners(); this.cache.emitter.removeAllListeners(); this.cache = cache?.() || new Cache(); this.appManager = appManager?.() || new AppManager(); this.fetchDispatcher = fetchDispatcher?.() || new Dispatcher(); this.submitDispatcher = submitDispatcher?.() || new Dispatcher(); // DO NOT CHANGE INITIALIZATION ORDER this.appManager.initialize(); this.cache.initialize(this as unknown as ClientInstance<{ adapter: Adapter }>); this.fetchDispatcher.initialize(this as unknown as ClientInstance<{ adapter: Adapter }>); this.submitDispatcher.initialize(this as unknown as ClientInstance<{ adapter: Adapter }>); }; /** * Hydrate your SSR cache data * @param hydrationData * @param options */ hydrate = ( hydrationData: (HydrateDataType | EmptyTypes)[], options?: Partial | ((item: HydrateDataType) => Partial), ) => { hydrationData?.forEach((item) => { if (!item) return; const { cacheKey, scope, response, ...fallbackOptions } = item; const defaults = { cache: true, override: true, } satisfies Partial; const config = typeof options === "function" ? { ...defaults, ...fallbackOptions, ...options(item) } : { ...defaults, ...fallbackOptions, ...options }; if (!config.override) { const cachedData = this.cache.get(scopeKey(cacheKey, scope)); if (cachedData) { return; } } const parsedData = parseResponse(response); this.cache.set({ ...config, cacheKey, scope }, parsedData); }); }; /** * Create requests based on the Client setup * * @template Response Your response */ createRequest = > = {}>( /** * `createRequest` must be initialized twice(currying). * * ✅ Good: * ```ts * const request = createRequest()(params) * ``` * ⛔ Bad: * ```ts * const request = createRequest(params) * ``` * * We are using currying to achieve auto generated types for the endpoint string. * * This solution will be removed once https://github.com/microsoft/TypeScript/issues/10571 get resolved. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _USE_DOUBLE_INITIALIZATION?: never, ) => { type DefaultQueryParams = ExtractAdapterDefaultQueryParamsType; type Response = TypeWithDefaults; type Payload = TypeWithDefaults; type LocalError = TypeWithDefaults; /** we pass never to prevent the type from being empty, but we allow it to be undefined (optional) */ type QueryParams = TypeWithDefaults; return < EndpointType extends ExtractAdapterEndpointType, AdapterOptions extends ExtractAdapterOptionsType, MethodType extends ExtractAdapterMethodType, >( params: RequestOptionsType, ) => { type Endpoint = TypeWithDefaults; const endpoint = this.adapter.unstable_endpointMapper(params.endpoint); // Splitting this type prevents "Type instantiation is excessively deep and possibly infinite" error type ExtractedAdapter = ExtractUnionAdapter< Adapter, { method: MethodType; options: AdapterOptions; queryParams: QueryParams; } >; type ExtractedAdapterType = ExtractedAdapter extends EmptyTypes ? Adapter : ExtractedAdapter; const mappedParams: RequestOptionsType< Endpoint extends string ? Endpoint : typeof endpoint, AdapterOptions, MethodType > = { ...params, endpoint: String(endpoint) as Endpoint extends string ? Endpoint : typeof endpoint, }; const request = new Request< Response, Payload, QueryParams, LocalError, Endpoint extends string ? Endpoint : typeof endpoint, Client >(this as unknown as Client, mappedParams); this.plugins.forEach((plugin) => plugin.trigger("onRequestCreate", { request })); return request; }; }; }