Created
March 4, 2025 16:04
-
-
Save vslipchenko/9af8cec4e2eea780e4039a7360918a1a to your computer and use it in GitHub Desktop.
This utility provides a flexible request management system for asynchronous API calls. It is designed to optimize API request performance, avoid redundant requests, and manage stale data or errors in dynamic web applications.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Symbols are used in order to not confuse these special values with the real request response values | |
// Unique primitive value used as the stale request response value | |
const staleRequestSymbol = Symbol('staleRequest'); | |
// Unique primitive value used as a response value of non-distinct payload requests | |
const nonDistinctPayloadSymbol = Symbol('nonDistinctPayload'); | |
// Map are used to guarantee the integrity of request specific context if a single request | |
// is re-created more than once in a process | |
const requestsCache = new Map(); | |
const requestsContext = new Map(); | |
// Enum | |
const cacheScopes = { | |
// Page scope means that the request cache will be available until the user leaves a page | |
Page: 'page', | |
Global: 'global' | |
}; | |
const invalidRequest = (results) => results === nonDistinctPayloadSymbol || results === staleRequestSymbol; | |
const containsInvalidRequest = (results) => results.some(invalidRequest); | |
const cacheEntryExpired = (maxAge, cachedAt) => { | |
const expiresAt = cachedAt + maxAge; | |
const now = new Date().getTime(); | |
return now > expiresAt; | |
}; | |
const removeInvalidCacheEntries = () => { | |
requestsCache.entries().forEach(([key, value]) => { | |
const pageScopedCache = value.scope === cacheScopes.Page; | |
const removeCacheEntry = pageScopedCache || cacheEntryExpired(value.maxAge, value.cachedAt); | |
if (removeCacheEntry) return requestsCache.delete(key); | |
}); | |
}; | |
// Usage: const requestData = request(getData, {...options}); | |
// Where 'getData' is a method which returns a pending request (be it $.ajax/Promise/fetch or whatever) | |
const request = (apiCall, {cache, cacheScope, cacheMaxAge, distinctPayload, disregardStaleRequestResults, disregardStaleRequestError} = {}) => { | |
cache = cache ?? false; | |
cacheScope = cacheScope ?? cacheScopes.Page; | |
cacheMaxAge = cacheMaxAge ?? Infinity; | |
distinctPayload = distinctPayload ?? false; | |
disregardStaleRequestResults = disregardStaleRequestResults ?? false; | |
disregardStaleRequestError = disregardStaleRequestError ?? false; | |
const useCache = cache; | |
const useContext = distinctPayload || disregardStaleRequestResults || disregardStaleRequestError; | |
const initializeCacheEntry = useCache && !requestsCache.has(apiCall); | |
const initializeContextEntry = useContext && !requestsContext.has(apiCall); | |
if (initializeCacheEntry) requestsCache.set(apiCall, {results: [], scope: cacheScope, maxAge: cacheMaxAge}); | |
if (initializeContextEntry) requestsContext.set(apiCall, {}); | |
return (...payload) => { | |
let proceedWithApiCall = true; | |
const requestContext = requestsContext.get(apiCall); | |
// Compare current and the previous payload | |
if (distinctPayload) proceedWithApiCall = !_.isEqual(payload, requestContext.recentPayload); | |
return new Promise(async (resolve, reject) => { | |
if (useCache) { | |
const {results, maxAge, cachedAt} = requestsCache.get(apiCall); | |
// The default/current cache strategy is based on the request idempotence principle meaning | |
// that for the given payload the same cached entry will be resolved at all times | |
const cachedEntry = results.find((item) => _.isEqual(payload, item.payload)); | |
const fetchFromCache = cachedEntry && !cacheEntryExpired(maxAge, cachedAt); | |
// Resolve cached results if exist | |
if (fetchFromCache) return resolve(cachedEntry.results); | |
} | |
// Try to resolve cached results before resolving nonDistinctPayloadSymbol if payload is not distinct | |
if (!proceedWithApiCall) return resolve(nonDistinctPayloadSymbol); | |
const requestTimestamp = new Date().getTime(); | |
// Memoize the current context | |
if (useContext) requestsContext.set(apiCall, {recentPayload: payload, recentRequestTimestamp: requestTimestamp}); | |
try { | |
const results = await apiCall(...payload); | |
if (useCache) { | |
const requestCache = requestsCache.get(apiCall); | |
// Check if cache was not cleared before the request completed. | |
// E.g., if navigated away before the request completed. | |
// If so, it's considered a stale request | |
if (!requestCache) return resolve(staleRequestSymbol); | |
const updatedCachedResults = [...requestCache.results, {payload, results}]; | |
const now = new Date().getTime(); | |
// Update cache entry with new results | |
requestsCache.set(apiCall, { | |
...requestCache, | |
results: updatedCachedResults, | |
cachedAt: now | |
}); | |
} | |
if (disregardStaleRequestResults) { | |
const requestContext = requestsContext.get(apiCall); | |
// Check if context was cleared before the request completed. | |
// E.g., if navigated away before the request completes. | |
// If so, it's considered a stale request | |
if (!requestContext) return resolve(staleRequestSymbol); | |
const staleRequestResults = requestTimestamp !== requestContext.recentRequestTimestamp; | |
if (staleRequestResults) return resolve(staleRequestSymbol); | |
} | |
resolve(results); | |
} catch (error) { | |
if (disregardStaleRequestError) { | |
const requestContext = requestsContext.get(apiCall); | |
// Check if context was cleared before the request completed. | |
// E.g., if navigated away before the request completed. | |
// If so, it's considered a stale error | |
if (!requestContext) return resolve(staleRequestSymbol); | |
const staleRequestError = requestTimestamp !== requestContext.recentRequestTimestamp; | |
if (staleRequestError) return resolve(staleRequestSymbol); | |
} | |
reject(error); | |
} | |
}); | |
}; | |
}; | |
// Logic to be run when navigating away to perform Maps clean-up | |
const onNavigateAway = () => { | |
// Clean up maps when leaving/entering a page | |
requestsContext.clear(); | |
removeInvalidCacheEntries(); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment