@hyper-fetch/core
Version:
Cache, Queue and Persist your requests no matter if you are online or offline!
217 lines (186 loc) • 7.64 kB
text/typescript
import { ProgressType, RequestResponseType, ResponseType, getErrorMessage } from "adapter";
import { ProgressEventType, RequestInstance, RequestJSON, RequestSendOptionsType } from "request";
import { HttpMethods } from "constants/http.constants";
import { canRetryRequest, Dispatcher } from "dispatcher";
import { ExtractAdapterType, ExtractErrorType } from "types";
export const stringifyKey = (value: unknown): string => {
try {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
const data = JSON.stringify(value);
if (typeof data !== "string") throw new Error();
return data;
} catch (_) {
return "";
}
};
export const getProgressValue = ({ loaded, total }: ProgressEventType): number => {
if (!loaded || !total) return 0;
return Number(((loaded * 100) / total).toFixed(0));
};
export const getRequestEta = (
startDate: Date,
progressDate: Date,
{ total, loaded }: ProgressEventType,
): { sizeLeft: number; timeLeft: number | null } => {
const timeElapsed = +progressDate - +startDate || 1;
const uploadSpeed = loaded / timeElapsed;
const totalValue = Math.max(total, loaded);
const sizeLeft = totalValue - loaded;
const estimatedTimeValue = uploadSpeed ? sizeLeft / uploadSpeed : null;
const timeLeft = totalValue === loaded ? 0 : estimatedTimeValue;
return { timeLeft, sizeLeft };
};
export const getProgressData = (
requestStartTime: Date,
progressDate: Date,
progressEvent: ProgressEventType,
): ProgressType => {
const { total, loaded } = progressEvent;
if (Number.isNaN(total) || Number.isNaN(loaded)) {
return {
progress: 0,
timeLeft: 0,
sizeLeft: 0,
total: 0,
loaded: 0,
startTimestamp: +requestStartTime,
};
}
const { timeLeft, sizeLeft } = getRequestEta(requestStartTime, progressDate, progressEvent);
return {
progress: getProgressValue(progressEvent),
timeLeft,
sizeLeft,
total,
loaded,
startTimestamp: +requestStartTime,
};
};
// Keys
export const getSimpleKey = (request: RequestInstance | RequestJSON<RequestInstance>): string => {
return `${request.method}_${request.requestOptions.endpoint}_${request.cancelable}`;
};
/**
* Cache instance for individual request that collects individual requests responses from
* the same endpoint (they may differ base on the custom key, endpoint params etc)
* @param request
* @param useInitialValues
* @returns
*/
export const getRequestKey = (
request: RequestInstance | RequestJSON<RequestInstance>,
useInitialValues?: boolean,
): string => {
/**
* Below stringified values allow to match the response by method, endpoint and query params.
* That's because we have shared endpoint, but data with queryParams '?user=1' will not match regular request without queries.
* We want both results to be cached in separate places to not override each other.
*
* Values to be stringified:
*
* endpoint: string;
* queryParams: string;
* params: string;
*/
const methodKey = stringifyKey(request.method);
const endpointKey = useInitialValues ? request.requestOptions.endpoint : stringifyKey(request.endpoint);
const queryParamsKey = useInitialValues ? "" : stringifyKey(request.queryParams);
return `${methodKey}_${endpointKey}_${queryParamsKey}`;
};
export const getRequestDispatcher = <Request extends RequestInstance>(
request: Request,
dispatcherType: "auto" | "fetch" | "submit" = "auto",
): [Dispatcher<ExtractAdapterType<Request>>, isFetchDispatcher: boolean] => {
const { fetchDispatcher, submitDispatcher } = request.client;
const isGet = request.method === HttpMethods.GET;
const isFetchDispatcher = (dispatcherType === "auto" && isGet) || dispatcherType === "fetch";
const dispatcher = isFetchDispatcher ? fetchDispatcher : submitDispatcher;
return [dispatcher, isFetchDispatcher];
};
export const sendRequest = <Request extends RequestInstance>(
request: Request,
options?: RequestSendOptionsType<Request>,
): Promise<RequestResponseType<Request>> => {
const { client } = request;
const { requestManager } = client;
const [dispatcher] = getRequestDispatcher(request, options?.dispatcherType);
return new Promise<RequestResponseType<Request>>((resolve) => {
let isResolved = false;
const requestId = dispatcher.add(request);
options?.onBeforeSent?.({ requestId, request });
const unmountRequestStart = requestManager.events.onRequestStartById<Request>(requestId, (data) =>
options?.onRequestStart?.(data),
);
const unmountResponseStart = requestManager.events.onResponseStartById<Request>(requestId, (data) =>
options?.onResponseStart?.(data),
);
const unmountUpload = requestManager.events.onUploadProgressById<Request>(requestId, (data) =>
options?.onUploadProgress?.(data),
);
const unmountDownload = requestManager.events.onDownloadProgressById<Request>(requestId, (data) =>
options?.onDownloadProgress?.(data),
);
// When resolved
const unmountResponse = requestManager.events.onResponseById<Request>(requestId, (values) => {
const { details, response } = values;
isResolved = true;
const mapping = request.unstable_responseMapper?.(
response as ResponseType<any, any, ExtractAdapterType<Request>>,
);
const isOfflineStatus = request.offline && details.isOffline;
const willRetry = canRetryRequest(details.retries, request.retry);
const handleResponse = (success: boolean, data: ResponseType<any, any, ExtractAdapterType<Request>>) => {
// When going offline we can't handle the request as it will be postponed to later resolve
if (!success && isOfflineStatus) return;
// When request is in retry mode we need to listen for retries end
if (!success && willRetry) return;
options?.onResponse?.(values);
resolve(data);
// Unmount Listeners
// eslint-disable-next-line @typescript-eslint/no-use-before-define
umountAll();
};
// Create async await ONLY when we make a promise mapper
if (mapping instanceof Promise) {
(async () => {
const responseData = await mapping;
const { success } = responseData;
handleResponse(success, responseData as ResponseType<any, any, ExtractAdapterType<Request>>);
})();
}
// For sync mapping operations we should not use async actions
else {
const data = mapping || response;
const { success } = data;
handleResponse(success, data as ResponseType<any, any, ExtractAdapterType<Request>>);
}
});
// When removed from queue storage we need to clean event listeners and return proper error
const unmountRemoveQueueElement = requestManager.events.onRemoveById<Request>(requestId, (...props) => {
if (!isResolved) {
options?.onRemove?.(...props);
resolve({
data: null,
status: null,
success: false,
error: getErrorMessage("deleted") as unknown as ExtractErrorType<Request>,
extra: request.client.adapter.defaultExtra,
requestTimestamp: +new Date(),
responseTimestamp: +new Date(),
});
// Unmount Listeners
// eslint-disable-next-line @typescript-eslint/no-use-before-define
umountAll();
}
});
function umountAll() {
unmountRequestStart();
unmountResponseStart();
unmountUpload();
unmountDownload();
unmountResponse();
unmountRemoveQueueElement();
}
});
};