UNPKG

@hyper-fetch/core

Version:

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

478 lines (415 loc) 16.3 kB
import { AdapterInstance, ResponseType } from "adapter"; import { HttpAdapterType, parseResponse, HttpAdapter } from "http-adapter"; import { ClientErrorType, ClientInstance, ClientOptionsType, RequestGenericType, RequestInterceptorType, ResponseInterceptorType, } from "client"; import { Cache } from "cache"; import { Dispatcher } from "dispatcher"; import { PluginInstance, PluginMethodParameters, PluginMethods } from "plugin"; import { getRequestKey, getSimpleKey, Request, RequestInstance, RequestOptionsType } from "request"; import { AppManager, LoggerManager, RequestManager, LogLevel } from "managers"; import { interceptRequest, interceptResponse } from "./client.utils"; import { 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; public debug: boolean; // Private unstable_onErrorCallbacks: ResponseInterceptorType<ClientInstance>[] = []; unstable_onSuccessCallbacks: ResponseInterceptorType<ClientInstance>[] = []; unstable_onResponseCallbacks: ResponseInterceptorType<ClientInstance>[] = []; unstable_onAuthCallbacks: RequestInterceptorType[] = []; unstable_onRequestCallbacks: RequestInterceptorType[] = []; // Managers loggerManager: LoggerManager = new LoggerManager(); requestManager: RequestManager = new RequestManager(); appManager: AppManager; // Config adapter: Adapter; cache: Cache<Adapter>; fetchDispatcher: Dispatcher<Adapter>; submitDispatcher: Dispatcher<Adapter>; 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<Client<GlobalErrorType, Adapter>>) { const { url, appManager, cache, fetchDispatcher, submitDispatcher } = this.options; this.url = url; 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<GlobalErrorType, Adapter> => { this.debug = enabled; return this; }; /** * Set the logger severity of the messages displayed to the console */ setLogLevel = (severity: LogLevel): Client<GlobalErrorType, Adapter> => { this.loggerManager.setSeverity(severity); return this; }; /** * Set the new logger instance to the Client */ setLogger = (callback: (Client: ClientInstance) => LoggerManager): Client<GlobalErrorType, Adapter> => { 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 = <NewAdapter extends AdapterInstance>(adapter: NewAdapter): Client<GlobalErrorType, NewAdapter> => { this.adapter = adapter as unknown as Adapter; this.adapter.initialize(this); return this as unknown as Client<GlobalErrorType, NewAdapter>; }; /** * 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<GlobalErrorType, Adapter> => { this.unstable_onAuthCallbacks.push(callback); return this; }; /** * Method for removing listeners on auth. * */ removeOnAuthInterceptors = (callbacks: RequestInterceptorType[]): Client<GlobalErrorType, Adapter> => { 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 = <ErrorType = null>( callback: ResponseInterceptorType<ClientInstance, any, ErrorType | GlobalErrorType>, ): Client<GlobalErrorType, Adapter> => { this.unstable_onErrorCallbacks.push(callback); return this; }; /** * Method for removing listeners on error. * */ removeOnErrorInterceptors = ( callbacks: ResponseInterceptorType<ClientInstance, any, null | GlobalErrorType>[], ): Client<GlobalErrorType, Adapter> => { this.unstable_onErrorCallbacks = this.unstable_onErrorCallbacks.filter((callback) => !callbacks.includes(callback)); return this; }; /** * Method for intercepting success responses. */ onSuccess = <ErrorType = null>( callback: ResponseInterceptorType<ClientInstance, any, ErrorType | GlobalErrorType>, ): Client<GlobalErrorType, Adapter> => { this.unstable_onSuccessCallbacks.push(callback); return this; }; /** * Method for removing listeners on success. * */ removeOnSuccessInterceptors = ( callbacks: ResponseInterceptorType<ClientInstance, any, null | GlobalErrorType>[], ): Client<GlobalErrorType, Adapter> => { 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<GlobalErrorType, Adapter> => { this.unstable_onRequestCallbacks.push(callback); return this; }; /** * Method for removing listeners on request. * */ removeOnRequestInterceptors = (callbacks: RequestInterceptorType[]): Client<GlobalErrorType, Adapter> => { this.unstable_onRequestCallbacks = this.unstable_onRequestCallbacks.filter( (callback) => !callbacks.includes(callback), ); return this; }; /** * Method for intercepting any responses. */ onResponse = <ErrorType = null>( callback: ResponseInterceptorType<ClientInstance, any, ErrorType | GlobalErrorType>, ): Client<GlobalErrorType, Adapter> => { this.unstable_onResponseCallbacks.push(callback); return this; }; /** * Method for removing listeners on request. * */ removeOnResponseInterceptors = ( callbacks: ResponseInterceptorType<ClientInstance, any, null | GlobalErrorType>[], ): Client<GlobalErrorType, Adapter> => { 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 extends keyof PluginMethods<Client>>(key: Key, data: PluginMethodParameters<Key, Client>) => { 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<any, GlobalErrorType, Adapter>, request: RequestInstance, ) => interceptResponse<GlobalErrorType, ClientInstance>(this.unstable_onErrorCallbacks, response, request); /** * Private helper to run async on-success response processing * @private */ unstable_modifySuccessResponse = async ( response: ResponseType<any, GlobalErrorType, Adapter>, request: RequestInstance, ) => interceptResponse<GlobalErrorType, ClientInstance>(this.unstable_onSuccessCallbacks, response, request); /** * Private helper to run async response processing * @private */ unstable_modifyResponse = async (response: ResponseType<any, GlobalErrorType, Adapter>, request: RequestInstance) => interceptResponse<GlobalErrorType, ClientInstance>(this.unstable_onResponseCallbacks, response, request); /** * 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<HydrationOptions> | ((item: HydrateDataType) => Partial<HydrationOptions>), ) => { hydrationData?.forEach((item) => { if (!item) return; const { cacheKey, response, ...fallbackOptions } = item; const defaults = { cache: true, override: true, } satisfies Partial<HydrationOptions>; const config = typeof options === "function" ? { ...defaults, ...fallbackOptions, ...options(item) } : { ...defaults, ...fallbackOptions, ...options }; if (!config.override) { const cachedData = this.cache.get(cacheKey); if (cachedData) { return; } } const parsedData = parseResponse(response); this.cache.set({ ...config, cacheKey }, parsedData); }); }; /** * Create requests based on the Client setup * * @template Response Your response */ createRequest = <RequestProperties extends RequestGenericType<ExtractAdapterQueryParamsType<Adapter>> = {}>( /** * `createRequest` must be initialized twice(currying). * * ✅ Good: * ```ts * const request = createRequest<RequestProperties>()(params) * ``` * ⛔ Bad: * ```ts * const request = createRequest<RequestProperties>(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<Adapter>; type Response = TypeWithDefaults<RequestProperties, "response", undefined>; type Payload = TypeWithDefaults<RequestProperties, "payload", undefined>; type LocalError = TypeWithDefaults<RequestProperties, "error", GlobalErrorType>; /** we pass never to prevent the type from being empty, but we allow it to be undefined (optional) */ type QueryParams = TypeWithDefaults<RequestProperties, "queryParams", DefaultQueryParams, never>; return < EndpointType extends ExtractAdapterEndpointType<Adapter>, AdapterOptions extends ExtractAdapterOptionsType<Adapter>, MethodType extends ExtractAdapterMethodType<Adapter>, >( params: RequestOptionsType<EndpointType, AdapterOptions, MethodType>, ) => { type Endpoint = TypeWithDefaults<RequestProperties, "endpoint", EndpointType>; 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<GlobalErrorType, ExtractedAdapterType> >(this as unknown as Client<GlobalErrorType, ExtractedAdapterType>, mappedParams); this.plugins.forEach((plugin) => plugin.trigger("onRequestCreate", { request })); return request; }; }; }