UNPKG

@hyper-fetch/core

Version:

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

597 lines (511 loc) 19.3 kB
import EventEmitter from "events"; import { QueueDataType, getRequestType, canRetryRequest, getDispatcherEvents, DispatcherRequestType, DispatcherOptionsType, DispatcherStorageType, QueueElementType, RunningRequestValueType, } from "dispatcher"; import { ClientInstance } from "client"; import { getUniqueRequestId } from "utils"; import { ResponseDetailsType, LoggerType } from "managers"; import { RequestInstance } from "request"; import { getErrorMessage } from "adapter"; /** * Dispatcher class was made to store controlled request Fetches, and firing them all-at-once or one-by-one in request queue. * Generally requests should be flushed at the same time, the queue provide mechanism to fire them in the order. */ export class Dispatcher { public emitter = new EventEmitter(); public events = getDispatcherEvents(this.emitter); public storage: DispatcherStorageType = new Map<string, QueueDataType<any>>(); private requestCount = new Map<string, number>(); private runningRequests = new Map<string, RunningRequestValueType[]>(); private logger: LoggerType; constructor(public client: ClientInstance, public options?: DispatcherOptionsType) { this.logger = client.loggerManager.init("Dispatcher"); if (this.options?.storage) { this.storage = this.options.storage; } // Going back from offline should re-trigger all requests this.client.appManager.events.onOnline(() => { this.flush(); }); this.options?.onInitialization?.(this); } // ******************** // ******************** // Queue // ******************** // ******************** /** * Start request handling by queueKey */ start = (queueKey: string) => { // Change status to running const queue = this.getQueue(queueKey); // Start the queue when its stopped queue.stopped = false; this.setQueue(queueKey, queue); this.flushQueue(queueKey); this.events.setQueueStatus(queueKey, queue); }; /** * Pause request queue, but not cancel already started requests */ pause = (queueKey: string) => { // Change state to stopped const queue = this.getQueue(queueKey); queue.stopped = true; this.setQueue(queueKey, queue); this.events.setQueueStatus(queueKey, queue); }; /** * Stop request queue and cancel all started requests - those will be treated like not started */ stop = (queueKey: string) => { // Change state to stopped const queue = this.getQueue(queueKey); queue.stopped = true; this.setQueue(queueKey, queue); // Cancel running requests this.cancelRunningRequests(queueKey); this.events.setQueueStatus(queueKey, queue); }; /** * Return all */ getQueuesKeys = () => { return Array.from(this.storage.keys()); }; /** * Return queue state object */ getQueue = <Request extends RequestInstance = RequestInstance>(queueKey: string) => { const initialQueueState: QueueDataType<Request> = { requests: [], stopped: false }; const storedEntity = this.storage.get<Request>(queueKey); return storedEntity || initialQueueState; }; /** * Return request from queue state */ getRequest = <Request extends RequestInstance = RequestInstance>(queueKey: string, requestId: string) => { const initialQueueState: QueueDataType<Request> = { requests: [], stopped: false }; const storedEntity = this.storage.get<Request>(queueKey) || initialQueueState; return storedEntity.requests.find((req) => req.requestId === requestId); }; /** * Get value of the active queue status based on the stopped status */ getIsActiveQueue = (queueKey: string) => { const queue = this.getQueue(queueKey); const hasAvailableRequests = queue.requests.some((req) => !req.stopped); const isRunningQueue = !queue.stopped; return hasAvailableRequests && isRunningQueue; }; /** * Add new element to storage */ addQueueElement = <Request extends RequestInstance = RequestInstance>( queueKey: string, dispatcherDump: QueueElementType<Request>, ) => { const queue = this.getQueue<Request>(queueKey); queue.requests.push(dispatcherDump); this.setQueue<Request>(queueKey, queue); }; /** * Set new queue storage value */ setQueue = <Request extends RequestInstance = RequestInstance>(queueKey: string, queue: QueueDataType<Request>) => { this.storage.set<Request>(queueKey, queue); // Emit Queue Changes this.options?.onUpdateStorage?.(queueKey, queue); this.events.setQueueChanged(queueKey, queue); return queue; }; /** * Clear requests from queue cache */ clearQueue = (queueKey: string) => { const queue = this.getQueue(queueKey); const newQueue = { requests: [], stopped: queue.stopped }; this.storage.set(queueKey, newQueue); // Emit Queue Changes this.options?.onDeleteFromStorage?.(queueKey, newQueue); this.events.setQueueChanged(queueKey, newQueue); return newQueue; }; /** * Method used to flush the queue requests */ flushQueue = async (queueKey: string) => { const queue = this.getQueue(queueKey); const runningRequests = this.getRunningRequests(queueKey); const queueElement = queue.requests.find((request) => !request.stopped); const isStopped = queue && queue.stopped; const isOffline = !this.client.appManager.isOnline; const isConcurrent = !queueElement?.request.queued; const isInactive = !runningRequests.length; const isEmpty = !queueElement; // When there are no requests to flush, when its stopped, there is running request // or there is no request to trigger - we don't want to perform actions if (isStopped || isOffline || isEmpty) { this.logger.debug("Skipping queue trigger", { isStopped, isOffline, isEmpty }); } else if (isConcurrent) { queue.requests.forEach((element) => { if (!this.hasRunningRequest(queueKey, element.requestId)) { this.performRequest(element); } }); } else if (isInactive) { await this.performRequest(queueElement); this.flushQueue(queueKey); } }; /** * Flush all available requests from all queues */ flush = async () => { const keys = this.getQueuesKeys(); // eslint-disable-next-line no-restricted-syntax for (const key of keys) { const storageElement = this.getQueue(key); if (storageElement) { this.flushQueue(key); } } }; /** * Clear all running requests and storage */ clear = () => { const keys = this.getQueuesKeys(); keys.forEach((queueKey) => this.cancelRunningRequests(queueKey)); this.runningRequests.clear(); this.storage.clear(); this.options?.onClearStorage?.(this); }; // ******************** // ******************** // Requests // ******************** // ******************** /** * Start particular request */ startRequest = (queueKey: string, requestId: string) => { // Change status to running const queue = this.getQueue(queueKey); const request = queue.requests.find((element) => element.requestId === requestId); // Start the queue when its stopped if (request) { request.stopped = false; this.setQueue(queueKey, queue); this.flushQueue(queueKey); this.events.setQueueStatus(queueKey, queue); } }; /** * Stop particular request */ stopRequest = (queueKey: string, requestId: string) => { // Change state to stopped const queue = this.getQueue(queueKey); const request = queue.requests.find((element) => element.requestId === requestId); if (request) { request.stopped = true; this.setQueue(queueKey, queue); // Cancel running requests this.cancelRunningRequest(queueKey, requestId); this.events.setQueueStatus(queueKey, queue); } }; /** * Get currently running requests from all queueKeys */ getAllRunningRequest = () => { return Array.from(this.runningRequests.values()).flat(); }; /** * Get currently running requests */ getRunningRequests = (queueKey: string) => { return this.runningRequests.get(queueKey) || []; }; /** * Get running request by id */ getRunningRequest = (queueKey: string, requestId: string) => { const runningRequests = this.getRunningRequests(queueKey); return runningRequests.find((req) => req.requestId === requestId); }; /** * Add request to the running requests list */ addRunningRequest = (queueKey: string, requestId: string, request: RequestInstance) => { const runningRequests = this.getRunningRequests(queueKey); runningRequests.push({ requestId, request, timestamp: Date.now() }); this.runningRequests.set(queueKey, runningRequests); }; /** * Get the value based on the currently running requests */ hasRunningRequests = (queueKey: string) => { return !!this.getRunningRequests(queueKey).length; }; /** * Check if request is currently processing */ hasRunningRequest = (queueKey: string, requestId: string) => { const runningRequests = this.getRunningRequests(queueKey); return !!runningRequests.find((req) => req.requestId === requestId); }; /** * Cancel all started requests, but do NOT remove it from main storage */ cancelRunningRequests = (queueKey: string) => { this.runningRequests.get(queueKey)?.forEach((request) => { this.client.requestManager.abortByRequestId(request.request.abortKey, request.requestId); }); this.deleteRunningRequests(queueKey); }; /** * Cancel started request, but do NOT remove it from main storage */ cancelRunningRequest = (queueKey: string, requestId: string) => { const requests = this.getRunningRequests(queueKey).filter((request) => { if (request.requestId === requestId) { this.client.requestManager.abortByRequestId(request.request.abortKey, request.requestId); return false; } return true; }); this.runningRequests.set(queueKey, requests); }; /** * Delete all started requests, but do NOT clear it from queue and do NOT cancel them */ deleteRunningRequests = (queueKey: string) => { this.runningRequests.set(queueKey, []); }; /** * Delete request by id, but do NOT clear it from queue and do NOT cancel them */ deleteRunningRequest = (queueKey: string, requestId: string) => { const runningRequests = this.getRunningRequests(queueKey); this.runningRequests.set( queueKey, runningRequests.filter((req) => req.requestId !== requestId), ); }; /** * Get count of requests from the same queueKey */ getQueueRequestCount = (queueKey: string) => { return this.requestCount.get(queueKey) || 0; }; /** * Add request count to the queueKey */ incrementQueueRequestCount = (queueKey: string) => { const count = this.requestCount.get(queueKey) || 0; this.requestCount.set(queueKey, count + 1); }; /** * Create storage element from request */ // eslint-disable-next-line class-methods-use-this createStorageElement = <Request extends RequestInstance>(request: Request) => { const requestId = getUniqueRequestId(request.queueKey); const storageElement: QueueElementType<Request> = { requestId, timestamp: +new Date(), request, retries: 0, stopped: false, }; return storageElement; }; // ******************** // ******************** // Dispatching // ******************** // ******************** /** * Add request to the dispatcher handler */ add = (request: RequestInstance) => { const { queueKey } = request; // Create dump of the request to allow storing it in localStorage, AsyncStorage or any other // This way we don't save the Class but the instruction of the request to be done const storageElement = this.createStorageElement(request); const { requestId } = storageElement; const queue = this.getQueue(queueKey); const [latestRequest] = queue.requests.slice(-1); const requestType = getRequestType(request, latestRequest); this.logger.debug("Adding request to queue", { requestType, request, requestId }); switch (requestType) { case DispatcherRequestType.oneByOne: { // Requests will go one by one this.addQueueElement(queueKey, storageElement); this.flushQueue(queueKey); return requestId; } case DispatcherRequestType.previousCanceled: { // Cancel all previous on-going requests this.cancelRunningRequests(queueKey); this.clearQueue(queueKey); this.addQueueElement(queueKey, storageElement); this.flushQueue(queueKey); return requestId; } case DispatcherRequestType.deduplicated: { // Return the running requestId to fulfill the events return queue.requests[0].requestId; } default: { this.addQueueElement(queueKey, storageElement); this.flushQueue(queueKey); return requestId; } } }; /** * Delete from the storage and cancel request */ delete = (queueKey: string, requestId: string, abortKey: string) => { this.logger.debug("Deleting request", { queueKey, requestId, abortKey }); const queue = this.getQueue(queueKey); const queueElement = queue.requests.find((req) => req.requestId === requestId); if (!queueElement) return; queue.requests = queue.requests.filter((req) => req.requestId !== requestId); this.storage.set(queueKey, queue); // Clean controllers if (this.hasRunningRequest(queueKey, requestId)) { this.deleteRunningRequest(queueKey, requestId); this.client.requestManager.abortByRequestId(abortKey, requestId); } // Emit Queue Changes this.options?.onDeleteFromStorage?.(queueKey, queue); this.events.setQueueChanged(queueKey, queue); this.client.requestManager.events.emitRemove(queueKey, requestId, { requestId, request: queueElement.request }); if (!queue.requests.length) { this.events.setDrained(queueKey, queue); } return queue; }; /** * Request can run for some time, once it's done, we have to check if it's successful or if it was aborted * It can be different once the previous call was set as cancelled and removed from queue before this request got resolved */ performRequest = async (storageElement: QueueElementType) => { const { request, requestId } = storageElement; this.logger.info("Performing request", { request, requestId }); const { retry, retryTime, queueKey, cacheKey, abortKey, offline } = request; const { adapter, requestManager, cache, appManager } = this.client; const canRetry = canRetryRequest(storageElement.retries, retry); // When offline not perform any request const isOffline = !appManager.isOnline && offline; // When request with this id was triggered again const isAlreadyRunning = this.hasRunningRequest(queueKey, requestId); const isStopped = storageElement.stopped; if (isOffline || isAlreadyRunning || isStopped) { return this.logger.warning("Unable to perform request", { isOffline, isAlreadyRunning, isStopped }); } // Additionally keep the running request to possibly abort it later this.addRunningRequest(queueKey, requestId, request); // Propagate the loading to all connected hooks requestManager.events.emitLoading(queueKey, requestId, { queueKey, requestId, loading: true, isRetry: !!storageElement.retries, isOffline, }); // Trigger Request this.incrementQueueRequestCount(queueKey); // Listen for aborting requestManager.addAbortController(abortKey, requestId); const response = await adapter(request, requestId); // Stop listening for aborting requestManager.removeAbortController(abortKey, requestId); // Do not continue the request handling when it got stopped and request was unsuccessful // Or when the request was aborted/canceled const isOfflineResponseStatus = !appManager.isOnline; // If there is no running request with this id, it means it was cancelled and removed during send const isCancelMessage = getErrorMessage("abort").message === response.error?.message; const isCanceled = !this.hasRunningRequest(queueKey, requestId) || isCancelMessage; // Remove running request, must be called after isCancelled this.deleteRunningRequest(queueKey, requestId); const requestDetails: ResponseDetailsType = { isCanceled, isOffline: isOfflineResponseStatus, retries: storageElement.retries, timestamp: +new Date(), }; // Global response emitter to handle request execution requestManager.events.emitResponse(cacheKey, requestId, response, requestDetails); // Turn off loading requestManager.events.emitLoading(queueKey, requestId, { queueKey, requestId, loading: false, isRetry: !!storageElement.retries, isOffline, }); // Cache event to emit the data inside and store it cache.set(request, { ...response, ...requestDetails }); this.logger.info("Request finished", { requestId, request, response, requestDetails }); // On cancelled if (isCanceled) { const queue = this.getQueue(queueKey); const queueElement = queue.requests.find((req) => req.requestId === requestId); // do not remove cancelled request as it may be result of manual queue pause // if abort was done without stop action we can remove request if (!queue.stopped && !queueElement?.stopped) { this.logger.debug("Request paused", { response, requestDetails, request }); return this.delete(queueKey, requestId, abortKey); } return this.logger.debug("Request canceled", { response, requestDetails, request }); } // On offline if (!response.success && isOfflineResponseStatus) { // if we don't want to keep offline request - just delete them if (!offline) { this.logger.warning("Removing non-offline request", { response, requestDetails, request }); return this.delete(queueKey, requestId, abortKey); } // do not remove request from store as we want to re-send it later return this.logger.debug("Awaiting for network restoration", { response, requestDetails, request }); } // On success if (response.success) { this.delete(queueKey, requestId, abortKey); return this.logger.debug("Successful response, removing request from queue.", { response, requestDetails, request, }); } // On retry if (!response.success && canRetry) { this.logger.debug("Waiting for retry", { response, requestDetails, request }); // Perform retry once request is failed setTimeout(() => { this.logger.warning("Error response, performing retry"); this.performRequest({ ...storageElement, retries: storageElement.retries + 1, }); }, retryTime || 0); } else { this.logger.error("All retries have been used. Removing request from queue."); this.delete(queueKey, requestId, abortKey); } }; }