UNPKG

@hyper-fetch/core

Version:

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

345 lines (303 loc) 11.7 kB
import { AdapterInstance, ResponseType } from "adapter"; import { ResponseDetailsType, LoggerMethods } from "managers"; import { ClientInstance } from "client"; import { CacheOptionsType, CacheAsyncStorageType, CacheStorageType, getCacheData, getCacheEvents, CacheValueType, CacheSetState, RequestCacheType, } from "cache"; import { Request, RequestInstance } from "request"; import { ExtractAdapterType, ExtractErrorType, ExtractResponseType } from "types"; import { EventEmitter } from "utils"; /** * Cache class handles the data exchange with the dispatchers. * * @note * Keys used to save the values are created dynamically on the Request class * */ export class Cache<Adapter extends AdapterInstance> { public emitter = new EventEmitter(); public events: ReturnType<typeof getCacheEvents>; public storage: CacheStorageType; public lazyStorage?: CacheAsyncStorageType; public version: string; public garbageCollectors = new Map<string, ReturnType<typeof setTimeout>>(); private logger: LoggerMethods; private client: ClientInstance<{ adapter: Adapter }>; constructor(public options?: CacheOptionsType) { const { storage = new Map<string, CacheValueType>(), lazyStorage, version = "0.0.1" } = options ?? {}; this.emitter?.setMaxListeners(1000); this.events = getCacheEvents(this.emitter); this.storage = storage; this.version = version; this.lazyStorage = lazyStorage; } initialize = (client: ClientInstance<{ adapter: Adapter }>) => { this.client = client; this.logger = client.loggerManager.initialize(client, "Cache"); // Going back from offline should re-trigger garbage collection client.appManager.events.onOnline(() => { [...this.storage.keys()].forEach(this.scheduleGarbageCollector); }); [...this.storage.keys()].forEach(this.scheduleGarbageCollector); return this; }; /** * Set the cache data to the storage * @param request * @param response * @returns */ set = <Request extends RequestCacheType<RequestInstance>>( request: Request, response: CacheSetState< ResponseType<ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request>> & ResponseDetailsType > & { hydrated?: boolean }, ): void => { this.logger.debug({ title: "Processing cache response", type: "system", extra: { request, response } }); const { cacheKey, cache, staleTime, cacheTime } = request; const previousCacheData = this.storage.get< ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request> >(cacheKey); // Once refresh error occurs we don't want to override already valid data in our cache with the thrown error // We need to check it against cache and return last valid data we have const processedResponse = typeof response === "function" ? response(previousCacheData || null) : response; const data = getCacheData(previousCacheData, processedResponse); const newCacheData: CacheValueType<any, any, ExtractAdapterType<Request>> = { ...data, staleTime, version: this.version, cacheKey, cacheTime, cached: !!request.cache, }; // Only success data is valid for the cache store if (processedResponse.success && cache) { this.logger.debug({ title: "Saving response to cache storage", type: "system", extra: { request, data } }); this.storage.set<Response, Error, ExtractAdapterType<Request>>(cacheKey, newCacheData); this.lazyStorage?.set<Response, Error, ExtractAdapterType<Request>>(cacheKey, newCacheData); this.client.triggerPlugins("onCacheItemChange", { cache: this, cacheKey, prevData: (previousCacheData || null) as CacheValueType<any, any, AdapterInstance>, newData: newCacheData as CacheValueType<any, any, AdapterInstance>, }); this.scheduleGarbageCollector(cacheKey); } else { // If request should not use cache - just emit response data this.logger.debug({ title: "Prevented saving response to cache", type: "system", extra: { request, data } }); } this.logger.debug({ title: "Emitting cache response", type: "system", extra: { request, data } }); this.events.emitCacheData<ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request>>( newCacheData, ); }; /** * Update the cache data with partial response data * @param request * @param partialResponse * @param isTriggeredExtrenally - informs whether an update was triggered due to internal logic or externally, e.g. * via plugin. * @returns */ update = <Request extends RequestCacheType<RequestInstance>>( request: Request, partialResponse: CacheSetState< Partial< ResponseType<ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request>> & ResponseDetailsType > >, ): void => { this.logger.debug({ title: "Processing cache update", type: "system", extra: { request, partialResponse } }); const { cacheKey } = request; const cachedData = this.storage.get< ExtractResponseType<Request>, ExtractErrorType<Request>, ExtractAdapterType<Request> >(cacheKey); const processedResponse = typeof partialResponse === "function" ? partialResponse(cachedData || null) : partialResponse; if (cachedData) { this.set(request, { ...cachedData, ...processedResponse }); } }; /** * Get particular record from storage by cacheKey. It will trigger lazyStorage to emit lazy load event for reading it's data. * @param cacheKey * @returns */ get = <Response, Error>(cacheKey: string): CacheValueType<Response, Error, Adapter> | undefined => { this.getLazyResource<Response, Error>(cacheKey); const cachedData = this.storage.get<Response, Error, Adapter>(cacheKey); return cachedData; }; /** * Get sync storage keys, lazyStorage keys will not be included * @returns */ keys = (): string[] => { const values = this.storage.keys(); return Array.from(values); }; /** * Delete record from storages and trigger invalidation event * @param cacheKey */ delete = (cacheKey: string): void => { this.logger.debug({ title: "Deleting cache element", type: "system", extra: { cacheKey } }); this.storage.delete(cacheKey); this.lazyStorage?.delete(cacheKey); this.client.triggerPlugins("onCacheItemDelete", { cache: this, cacheKey, }); this.events.emitDelete(cacheKey); }; /** * Invalidate cache by cacheKey or partial matching with RegExp * It emits invalidation event for each matching cacheKey and sets staleTime to 0 to indicate out of time cache * @param key - cacheKey or Request instance or RegExp for partial matching */ invalidate = (cacheKeys: string | RegExp | RequestInstance | Array<string | RegExp | RequestInstance>) => { this.logger.debug({ title: "Revalidating cache element", type: "system", extra: { cacheKeys } }); const onInvalidate = (key: string | RegExp | RequestInstance) => { const keys = Array.from(this.storage.keys()); const handleInvalidation = (cacheKey: string) => { const value = this.storage.get(cacheKey); if (value) { this.storage.set(cacheKey, { ...value, staleTime: 0 }); } this.client.triggerPlugins("onCacheItemInvalidate", { cache: this, cacheKey, }); this.events.emitInvalidation(cacheKey); }; if (key instanceof Request) { handleInvalidation(key.cacheKey); } else if (typeof key === "string") { handleInvalidation(key); } else if (keys.length) { keys.forEach((entityKey) => { if (key.test(entityKey)) { handleInvalidation(entityKey); } }); } }; if (Array.isArray(cacheKeys)) { cacheKeys.forEach(onInvalidate.bind(this)); } else { onInvalidate.bind(this)(cacheKeys); } }; /** * Used to receive data from lazy storage * @param cacheKey */ getLazyResource = async <Response, Error>( cacheKey: string, ): Promise<CacheValueType<Response, Error, Adapter> | undefined> => { const data = await this.lazyStorage?.get<Response, Error, Adapter>(cacheKey); const syncData = this.storage.get<Response, Error, Adapter>(cacheKey); // No data in lazy storage const hasLazyData = this.lazyStorage && data; if (hasLazyData) { const now = +new Date(); const isNewestData = syncData ? syncData.responseTimestamp < data.responseTimestamp : true; const isStaleData = data.staleTime <= now - data.responseTimestamp; const isValidLazyData = data.version === this.version; if (!isValidLazyData) { this.lazyStorage?.delete(cacheKey); } if (isNewestData && !isStaleData && isValidLazyData) { this.storage.set<Response, Error, Adapter>(cacheKey, data); this.events.emitCacheData<Response, Error, Adapter>({ ...data, cacheKey, cached: true }); return data; } } const isValidData = syncData?.version === this.version; if (syncData && !isValidData) { this.delete(cacheKey); } return syncData; }; /** * Used to receive keys from sync storage and lazy storage * @param cacheKey */ getLazyKeys = async () => { const keys = (await this.lazyStorage?.keys()) || []; return [...new Set(keys)]; }; /** * Used to receive keys from sync storage and lazy storage * @param cacheKey */ getAllKeys = async () => { const keys = await this.lazyStorage?.keys(); const asyncKeys = Array.from(keys || []); const syncKeys = Array.from(this.storage.keys()); return [...new Set([...asyncKeys, ...syncKeys])]; }; /** * Schedule garbage collection for given key * @param cacheKey * @returns */ scheduleGarbageCollector = (cacheKey: string) => { // We need to make sure that all of the values will be removed, also that we have the proper data const cacheData = this.storage.get(cacheKey); // Clear running garbage collectors for given key clearTimeout(this.garbageCollectors.get(cacheKey)); // Garbage collect if (cacheData) { const timeLeft = cacheData.cacheTime + cacheData.responseTimestamp - +new Date(); // null if (cacheData.cacheTime === null) { this.logger.debug({ title: "Cache time is null", type: "system", extra: { cacheKey } }); } // Infinity else if ( (cacheData.cacheTime !== null && JSON.stringify(cacheData.cacheTime) === "null") || cacheData.cacheTime === Infinity ) { this.logger.debug({ title: "Cache time is Infinite", type: "system", extra: { cacheKey } }); } // Run garbage collector else if (timeLeft >= 0) { this.garbageCollectors.set( cacheKey, setTimeout(() => { if (this.client.appManager.isOnline) { this.logger.info({ title: "Garbage collecting cache data", type: "system", extra: { cacheKey } }); this.delete(cacheKey); } }, timeLeft), ); } // Delete if value is stale and we are online else if (this.client.appManager.isOnline) { this.logger.info({ title: "Garbage collecting cache data", type: "system", extra: { cacheKey } }); this.delete(cacheKey); } } }; /** * Clear cache storages */ clear = async (): Promise<void> => { this.garbageCollectors.forEach((timeout) => clearTimeout(timeout)); this.storage.clear(); }; }