From 1be7841024d9b4bc0fafa5419ba4dbec40916c10 Mon Sep 17 00:00:00 2001 From: Ali Mihandoost Date: Thu, 3 Nov 2022 16:56:10 +0330 Subject: [PATCH 1/4] refactor(fetch): separate handle methods --- packages/core/fetch/src/fetch.ts | 308 ++++++++++++++++++------------- 1 file changed, 177 insertions(+), 131 deletions(-) diff --git a/packages/core/fetch/src/fetch.ts b/packages/core/fetch/src/fetch.ts index 71ba74b54..e6e96722b 100644 --- a/packages/core/fetch/src/fetch.ts +++ b/packages/core/fetch/src/fetch.ts @@ -15,6 +15,7 @@ declare global { } export type CacheStrategy = 'network_only' | 'network_first' | 'cache_only' | 'cache_first' | 'stale_while_revalidate'; +export type CacheDuplicate = 'never' | 'always' | 'until_load' | 'auto'; // @TODO: docs for all options export interface FetchOptions extends RequestInit { @@ -39,6 +40,12 @@ export interface FetchOptions extends RequestInit { /** * Strategies for caching. * + * - `network_only`: Only network request without any cache. + * - `network_first`: Network first, falling back to cache. + * - `cache_only`: Cache only without any network request. + * - `cache_first`: Cache first, falling back to network. + * - `stale_while_revalidate`: Fastest strategy, Use cached first but always request network to update the cache. + * * @default 'network_only' */ cacheStrategy: CacheStrategy; @@ -50,6 +57,18 @@ export interface FetchOptions extends RequestInit { */ cacheStorageName: string; + /** + * Simple memory caching for duplicate requests by url (include query parameters). + * + * - `never`: Never cache. + * - `always`: Always cache. + * - `until_load`: Cache parallel requests until request completed (it will be removed after the promise resolved). + * - `auto`: If CacheStorage was supported use `until_load` strategy else use `always`. + * + * @default 'never' + */ + cacheDuplicate: CacheDuplicate; + /** * Body as JS Object. */ @@ -61,11 +80,58 @@ export interface FetchOptions extends RequestInit { queryParameters?: Record; } -let cacheStorage: Cache; -const cacheSupported = 'caches' in self; +/** + * It fetches a JSON file from a URL, and returns the parsed data. + * + * Example: + * + * ```ts + * const productList = await getJson({ + * url: '/api/products', + * queryParameters: {limit: 10}, + * timeout: 5_000, + * retry: 3, + * cacheStrategy: 'stale_while_revalidate', + * cacheDuplicate: 'auto', + * }); + * ``` + */ +export async function getJson>( + options: Partial & {url: string}, +): Promise { + logger.logMethodArgs('getJson', {options}); + + const response = await fetch(options); + + let data: ResponseType; + + try { + if (!response.ok) { + throw new Error('fetch_nok'); + } + data = (await response.json()) as ResponseType; + } + catch (err) { + logger.accident('getJson', 'response_json', 'response json error', { + retry: options.retry, + err, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (options.retry! > 1) { + data = await getJson(options); + } + else { + throw err; + } + } + + return data; +} /** - * It's a wrapper around the browser's `fetch` function that adds retry pattern with timeout and cacheStrategy. + * It's a wrapper around the browser's `fetch` function that adds retry pattern, timeout, cacheStrategy, + * remove duplicates, etc. * * Example: * @@ -76,16 +142,76 @@ const cacheSupported = 'caches' in self; * timeout: 5_000, * retry: 3, * cacheStrategy: 'stale_while_revalidate', + * cacheDuplicate: 'auto', * }); * ``` */ -export async function fetch(_options: Partial & {url: string}): Promise { +export function fetch(_options: Partial & {url: string}): Promise { const options = _processOptions(_options); - logger.logMethodArgs('fetch', {options}); + return _handleCacheStrategy(options); +} + +/** + * Process fetch options and set defaults, etc. + */ +function _processOptions(options: Partial & {url: string}): FetchOptions { + options.method ??= 'GET'; + options.window ??= null; + + options.timeout ??= 5_000; + options.retry ??= 3; + options.cacheStrategy ??= 'network_only'; + options.cacheStorageName ??= 'alwatr_fetch_cache'; + options.cacheDuplicate ??= 'never'; + + if (options.cacheStrategy !== 'network_only' && cacheSupported !== true) { + logger.accident('fetch', 'fetch_cache_strategy_ignore', 'Cache storage not support in this browser', { + cacheSupported, + }); + options.cacheStrategy = 'network_only'; + } + + if (options.cacheDuplicate === 'auto') { + options.cacheDuplicate = cacheSupported ? 'until_load' : 'always'; + } + + if (options.url.lastIndexOf('?') === -1 && options.queryParameters != null) { + // prettier-ignore + const queryArray = Object + .keys(options.queryParameters) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map((key) => `${key}=${String(options.queryParameters![key])}`); + + if (queryArray.length > 0) { + options.url += '?' + queryArray.join('&'); + } + } + + if (options.body != null && options.bodyJson != null) { + options.body = JSON.stringify(options.bodyJson); + options.headers = { + ...options.headers, + 'Content-Type': 'application/json', + }; + } + + return options as FetchOptions; +} + +let cacheStorage: Cache; +const cacheSupported = 'caches' in self; + +// const duplicateRequestStorage: Record> = {}; + +/** + * Handle Cache Strategy over `_handleRetryPattern`. + */ +export async function _handleCacheStrategy(options: FetchOptions): Promise { + logger.logMethodArgs('_handleCacheStorage', {options}); if (options.cacheStrategy === 'network_only') { - return _fetch(options); + return _handleRetryPattern(options); } // else handle cache strategies! @@ -99,7 +225,7 @@ export async function fetch(_options: Partial & {url: string}): Pr case 'cache_first': { const cachedResponse = await cacheStorage.match(request); if (cachedResponse != null) return cachedResponse; - const response = await _fetch(options); + const response = await _handleRetryPattern(options); if (response.ok) { cacheStorage.put(request, response.clone()); } @@ -114,7 +240,7 @@ export async function fetch(_options: Partial & {url: string}): Pr case 'network_first': { try { - const networkResponse = await _fetch(options); + const networkResponse = await _handleRetryPattern(options); if (networkResponse.ok) { cacheStorage.put(request, networkResponse.clone()); } @@ -129,7 +255,7 @@ export async function fetch(_options: Partial & {url: string}): Pr case 'stale_while_revalidate': { const cachedResponse = await cacheStorage.match(request); - const fetchedResponsePromise = _fetch(options).then((networkResponse) => { + const fetchedResponsePromise = _handleRetryPattern(options).then((networkResponse) => { if (networkResponse.ok) { cacheStorage.put(request, networkResponse.clone()); } @@ -139,162 +265,82 @@ export async function fetch(_options: Partial & {url: string}): Pr } default: { - return _fetch(options); + return _handleRetryPattern(options); } } } -function _processOptions(options: Partial & {url: string}): FetchOptions { - options.method ??= 'GET'; - options.window ??= null; - - options.timeout ??= 5_000; - options.retry ??= 3; - options.cacheStrategy ??= 'network_only'; - options.cacheStorageName ??= 'alwatr_fetch_cache'; - - if (options.cacheStrategy !== 'network_only' && cacheSupported !== true) { - logger.accident('fetch', 'fetch_cache_strategy_ignore', 'Cache storage not support in this browser', { - cacheSupported, - }); - options.cacheStrategy = 'network_only'; - } - - if (options.url.lastIndexOf('?') === -1 && options.queryParameters != null) { - // prettier-ignore - const queryArray = Object - .keys(options.queryParameters) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .map((key) => `${key}=${String(options.queryParameters![key])}`); - - if (queryArray.length > 0) { - options.url += '?' + queryArray.join('&'); - } - } - - if (options.body != null && options.bodyJson != null) { - options.body = JSON.stringify(options.bodyJson); - options.headers = { - ...options.headers, - 'Content-Type': 'application/json', - }; - } - - return options as FetchOptions; -} - /** - * It's a wrapper around the browser's `fetch` function that adds retry pattern with timeout. + * Handle retry pattern over `_handleTimeout`. */ -async function _fetch(options: FetchOptions): Promise { - logger.logMethodArgs('_fetch', {options}); +async function _handleRetryPattern(options: FetchOptions): Promise { + logger.logMethod('_handleRetryPattern'); - // @TODO: AbortController polyfill - const abortController = new AbortController(); const externalAbortSignal = options.signal; - options.signal = abortController.signal; - - let timedOut = false; - const timeoutId = setTimeout(() => { - abortController.abort('fetch_timeout'); - timedOut = true; - }, options.timeout); - - if (externalAbortSignal != null) { - // Respect external abort signal - externalAbortSignal.addEventListener('abort', () => { - abortController.abort(`external abort signal: ${externalAbortSignal.reason}`); - clearTimeout(timeoutId); - }); - } - - abortController.signal.addEventListener('abort', () => { - logger.incident('fetch', 'fetch_abort_signal', 'fetch abort signal received', { - reason: abortController.signal.reason, - }); - }); const retryFetch = (): Promise => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - options.retry!--; + options.retry--; options.signal = externalAbortSignal; - return fetch(options); + return _handleRetryPattern(options); }; try { - // @TODO: browser fetch polyfill - const response = await window.fetch(options.url, options); - clearTimeout(timeoutId); + const response = await _handleTimeout(options); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (options.retry! > 1 && response.status >= 502 && response.status <= 504) { + if (options.retry > 1 && response.status >= 502 && response.status <= 504) { logger.accident('fetch', 'fetch_not_valid', 'fetch not valid and retry', { response, }); return retryFetch(); } - + // else return response; } catch (reason) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (timedOut && options.retry! > 1) { + if ((reason as Error)?.message === 'fetch_timeout' && options.retry > 1) { logger.incident('fetch', 'fetch_timeout', 'fetch timeout and retry', { reason, }); return retryFetch(); } - else { - clearTimeout(timeoutId); - throw reason; - } + // else + throw reason; } } /** - * It fetches a JSON file from a URL, and returns the parsed data. - * - * Example: - * - * ```ts - * const productList = await getJson({ - * url: '/api/products', - * queryParameters: {limit: 10}, - * timeout: 5_000, - * retry: 3, - * cacheStrategy: 'stale_while_revalidate', - * }); - * ``` + * It's a wrapper around the browser's `fetch` with timeout. */ -export async function getJson>( - options: Partial & {url: string}, -): Promise { - logger.logMethodArgs('getJson', {options}); - - const response = await fetch(options); - - let data: ResponseType; - - try { - if (!response.ok) { - throw new Error('fetch_nok'); +async function _handleTimeout(options: FetchOptions): Promise { + logger.logMethod('_handleTimeout'); + return new Promise((resolved, reject) => { + // @TODO: AbortController polyfill + const abortController = new AbortController(); + const externalAbortSignal = options.signal; + options.signal = abortController.signal; + + const timeoutId = setTimeout(() => { + reject(new Error('fetch_timeout')); + abortController.abort('fetch_timeout'); + }, options.timeout); + + if (externalAbortSignal != null) { + // Respect external abort signal + externalAbortSignal.addEventListener('abort', () => { + abortController.abort(`external abort signal: ${externalAbortSignal.reason}`); + clearTimeout(timeoutId); + }); } - data = (await response.json()) as ResponseType; - } - catch (err) { - logger.accident('getJson', 'response_json', 'response json error', { - retry: options.retry, - err, - }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (options.retry! > 1) { - data = await getJson(options); - } - else { - throw err; - } - } + abortController.signal.addEventListener('abort', () => { + logger.incident('fetch', 'fetch_abort_signal', 'fetch abort signal received', { + reason: abortController.signal.reason, + }); + }); - return data; + window.fetch(options.url, options) + .then((response) => resolved(response)) + .catch((reason) => reject(reason)) + .finally(() => clearTimeout(timeoutId)); + }); } From a0f1684787ae88dbcd250598934aa71cb4afc920 Mon Sep 17 00:00:00 2001 From: Ali Mihandoost Date: Fri, 4 Nov 2022 11:27:21 +0330 Subject: [PATCH 2/4] refactor(cache): rename removeDuplicate --- packages/core/fetch/src/fetch.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/core/fetch/src/fetch.ts b/packages/core/fetch/src/fetch.ts index e6e96722b..7a2e842d0 100644 --- a/packages/core/fetch/src/fetch.ts +++ b/packages/core/fetch/src/fetch.ts @@ -17,7 +17,6 @@ declare global { export type CacheStrategy = 'network_only' | 'network_first' | 'cache_only' | 'cache_first' | 'stale_while_revalidate'; export type CacheDuplicate = 'never' | 'always' | 'until_load' | 'auto'; -// @TODO: docs for all options export interface FetchOptions extends RequestInit { /** * Request URL. @@ -58,16 +57,16 @@ export interface FetchOptions extends RequestInit { cacheStorageName: string; /** - * Simple memory caching for duplicate requests by url (include query parameters). + * Simple memory caching for remove duplicate/parallel requests. * - * - `never`: Never cache. - * - `always`: Always cache. + * - `never`: Never use memory caching. + * - `always`: Always use memory caching and remove all duplicate requests. * - `until_load`: Cache parallel requests until request completed (it will be removed after the promise resolved). * - `auto`: If CacheStorage was supported use `until_load` strategy else use `always`. * * @default 'never' */ - cacheDuplicate: CacheDuplicate; + removeDuplicate: CacheDuplicate; /** * Body as JS Object. @@ -163,7 +162,7 @@ function _processOptions(options: Partial & {url: string}): FetchO options.retry ??= 3; options.cacheStrategy ??= 'network_only'; options.cacheStorageName ??= 'alwatr_fetch_cache'; - options.cacheDuplicate ??= 'never'; + options.removeDuplicate ??= 'never'; if (options.cacheStrategy !== 'network_only' && cacheSupported !== true) { logger.accident('fetch', 'fetch_cache_strategy_ignore', 'Cache storage not support in this browser', { @@ -172,8 +171,8 @@ function _processOptions(options: Partial & {url: string}): FetchO options.cacheStrategy = 'network_only'; } - if (options.cacheDuplicate === 'auto') { - options.cacheDuplicate = cacheSupported ? 'until_load' : 'always'; + if (options.removeDuplicate === 'auto') { + options.removeDuplicate = cacheSupported ? 'until_load' : 'always'; } if (options.url.lastIndexOf('?') === -1 && options.queryParameters != null) { From aa16ebfb22eaff449a978a7669ea44b1eb829255 Mon Sep 17 00:00:00 2001 From: Ali Mihandoost Date: Fri, 4 Nov 2022 11:28:37 +0330 Subject: [PATCH 3/4] lint(icon): make lint happy --- packages/ui/icon/src/icon.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ui/icon/src/icon.ts b/packages/ui/icon/src/icon.ts index c826820b1..c30d492b2 100644 --- a/packages/ui/icon/src/icon.ts +++ b/packages/ui/icon/src/icon.ts @@ -33,7 +33,9 @@ export class AlwatrIcon extends AlwatrElement { return html` Book Date: Fri, 4 Nov 2022 11:36:27 +0330 Subject: [PATCH 4/4] refactor(fetch): getJson use direct _handleCacheStrategy --- packages/core/fetch/src/fetch.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/fetch/src/fetch.ts b/packages/core/fetch/src/fetch.ts index 7a2e842d0..6217a8b44 100644 --- a/packages/core/fetch/src/fetch.ts +++ b/packages/core/fetch/src/fetch.ts @@ -96,11 +96,12 @@ export interface FetchOptions extends RequestInit { * ``` */ export async function getJson>( - options: Partial & {url: string}, + _options: Partial & {url: string}, ): Promise { + const options = _processOptions(_options); logger.logMethodArgs('getJson', {options}); - const response = await fetch(options); + const response = await _handleCacheStrategy(options); let data: ResponseType; @@ -116,8 +117,7 @@ export async function getJson 1) { + if (options.retry > 1) { data = await getJson(options); } else {