UNPKG

@irrelon/cache-express

Version:

Express Cache Middleware: Effortlessly cache responses with custom timeouts, dependencies, pooling, support for cache-control

510 lines (503 loc) 17.7 kB
import { Emitter } from '@irrelon/emitter'; const emitter = new Emitter(); /** * A map of keys and booleans to determine if a particular request (represented * by a cache key string) is currently being processed / loaded or not. */ const inFlight = {}; /** * Hashes a string to create a unique cache key. * @param str The input string to be hashed. * @returns The generated hash value. */ function hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const charCode = str.charCodeAt(i); hash = ((hash << 5) - hash + charCode) | 0; } return (hash + 2147483647 + 1).toString(); } function hasNoCacheHeader(req) { return req.get("Cache-Control") === "no-cache"; } function respondWithCachedResponse(cachedResponse, res, resultHeaderValue = "HIT") { if (res.headersSent) { // The connection has already had a response stopped console.error("Could not resolve response in pooled request because headers were already sent. Did we take too long and express closed the request already?"); return; } const cachedBody = cachedResponse.body; const cachedHeaders = cachedResponse.headers; const cachedStatusCode = cachedResponse.statusCode; // Set headers that we cached if (cachedHeaders) { res.set(JSON.parse(cachedHeaders)); } // Reset the encoding because we don't cache gzipped data res.set("content-encoding", "identity"); res.set("x-cache-result", resultHeaderValue); res.status(cachedStatusCode).send(cachedBody); } function getPoolSize(cacheKey) { const eventListeners = emitter._eventListeners; if (!eventListeners) return 0; const listenersForKey = eventListeners[cacheKey]; if (!listenersForKey) return 0; const listenersForKeyGlobals = listenersForKey["*"]; if (!listenersForKeyGlobals) return 0; return listenersForKeyGlobals.length; } function requestHasPool(cacheKey, options) { return options.pooling && inFlight[cacheKey]; } /** * Middleware function for Express.js to enable caching. Use it * like any express middleware. Calling this function returns * the middleware handler that accepts the (req, res, next) arguments * that Express passes to its route handlers. * * @param opts Options for caching. * @returns Middleware function. */ function expressCache(opts) { const defaults = { timeOutMins: () => 60, shouldGetCache: (req) => { const isDisableCacheHeaderPresent = hasNoCacheHeader(req); if (isDisableCacheHeaderPresent) { // When we return a string it is the same as returning `false` but also // provides a reason that goes in the log return "CACHE_CONTROL_HEADER"; } return true; }, shouldSetCache: (_, res) => { if (res.statusCode >= 200 && res.statusCode < 300) { return true; } return "STATUS_CODE_NOT_2XX"; }, onCacheEvent: () => { }, provideCacheKey: (cacheUrl) => { return "c_" + hashString(cacheUrl); }, requestTimeoutMs: 20000, compression: false, pooling: true, containerData: {}, metaData: {} }; const options = { ...defaults, ...opts }; const { timeOutMins, onCacheEvent, shouldGetCache, shouldSetCache, provideCacheKey, requestTimeoutMs, cache, containerData, metaData, } = options; return async function (req, res, next) { const cacheUrl = req.originalUrl || req.url; const cacheKey = req.cacheHash || provideCacheKey(cacheUrl, req); const shouldGetCacheResult = shouldGetCache(req, res); const missReasons = []; let cachedItemContainer; // Check if the no-pool header is present const noPoolHeader = req.get("x-cache-do-not-pool") === "true"; // If we have a no-pool header, check if there is a pool for this request if (noPoolHeader && requestHasPool(cacheKey, options)) { // A pool exists and the no-pool header is present, see if we // can respond with a cached result instead const tmpCachedItemContainer = await cache.get(cacheKey); if (tmpCachedItemContainer) { // A cached result exists, respond with it instead of pooling respondWithCachedResponse(tmpCachedItemContainer.value, res); return; } } if (shouldGetCacheResult === true) { cachedItemContainer = await cache.get(cacheKey); } else { if (typeof shouldGetCacheResult === "string") { missReasons.push(shouldGetCacheResult); } else { missReasons.push("SHOULD_GET_CACHE_FALSE"); } } if (cachedItemContainer) { onCacheEvent(req, "HIT", { url: cacheUrl, cachedItemContainer, }); respondWithCachedResponse(cachedItemContainer.value, res); onCacheEvent(req, "FINISHED_CACHE_HIT", { url: cacheUrl, cachedItemContainer, }); return; } if (!cachedItemContainer) { if (requestHasPool(cacheKey, options)) { missReasons.push(`RESPONSE_POOLED: ${getPoolSize(cacheKey) + 1}`); } else { missReasons.push("RESPONSE_NOT_IN_CACHE"); } } onCacheEvent(req, "MISS", { url: cacheUrl, reason: missReasons.join("; "), }); const originalSend = res.send; const originalJson = res.json; // Check if there is a pool for this cacheKey if (requestHasPool(cacheKey, options)) { // We already have a request pool for this resource, hook the event handler const respondHandler = (cachedResponse) => { // The event was fired indicating we have a response to the request clearTimeout(requestTimeout); respondWithCachedResponse(cachedResponse, res, "POOLED"); }; // Set a timeout on the pool to ensure we don't hang it forever const requestTimeout = setTimeout(() => { emitter.off(cacheKey, respondHandler); res.status(504).send({ isErr: true, status: 504, err: { key: "REQUEST_TIMEOUT", msg: "Request timed out waiting for the route handler to respond" } }); }, requestTimeoutMs); // Emit the event to resolve the pool emitter.once(cacheKey, respondHandler); return; } // If polling is enabled, store that we have an in-flight request for this resource now if (options.pooling) { inFlight[cacheKey] = true; } const resolvePool = (bodyContent, isJson = false) => { const finalResponse = { body: isJson ? JSON.stringify(bodyContent) : bodyContent, headers: JSON.stringify(res.getHeaders()), statusCode: res.statusCode, requestUrl: req.originalUrl || req.url, }; if (options.pooling) { delete inFlight[cacheKey]; onCacheEvent(req, "POOL_SEND", { url: cacheUrl, reason: `POOL_SIZE: ${getPoolSize(cacheKey)}`, }); } emitter.emit(cacheKey, finalResponse); }; const storeCache = async (bodyContent, isJson = false) => { // Check the status code before storing const shouldCacheResult = shouldSetCache(req, res); if (shouldCacheResult !== true) { resolvePool(bodyContent, isJson); if (typeof shouldCacheResult === "string") { onCacheEvent(req, "NOT_STORED", { url: cacheUrl, reason: `STATUS_CODE (${res.statusCode}); ${shouldCacheResult}`, }); return { didStore: false }; } onCacheEvent(req, "NOT_STORED", { url: cacheUrl, reason: `STATUS_CODE (${res.statusCode})` }); return { didStore: false }; } const cachedResponse = { body: isJson ? JSON.stringify(bodyContent) : bodyContent, headers: JSON.stringify(res.getHeaders()), statusCode: res.statusCode, requestUrl: req.originalUrl || req.url, }; const timeoutMins = timeOutMins(req); const cachedSuccessfully = await cache.set(cacheKey, cachedResponse, timeoutMins, { containerData, metaData }); if (cachedSuccessfully) { onCacheEvent(req, "STORED", { url: cacheUrl, }); } else { onCacheEvent(req, "NOT_STORED", { url: cacheUrl, reason: "CACHE_UNAVAILABLE", }); } resolvePool(bodyContent, isJson); return { didStore: true, }; }; res.send = function (body) { storeCache(body, typeof body === "object").then(({ didStore }) => { if (didStore) { onCacheEvent(req, "FINISHED_CACHE_MISS_AND_STORED", { url: cacheUrl, }); } else { onCacheEvent(req, "FINISHED_CACHE_MISS_AND_NOT_STORED", { url: cacheUrl, }); } }); res.set("x-cache-result", "MISS"); originalSend.call(this, body); return res; }; res.json = function (body) { storeCache(body, true).then(({ didStore }) => { if (didStore) { onCacheEvent(req, "FINISHED_CACHE_MISS_AND_STORED", { url: cacheUrl, }); } else { onCacheEvent(req, "FINISHED_CACHE_MISS_AND_NOT_STORED", { url: cacheUrl }); } }); res.set("x-cache-result", "MISS"); originalJson.call(this, body); return res; }; next(); }; } function expiryFromMins(timeoutMins) { const timeoutMs = timeoutMins * 60000; const expireTime = Date.now() + timeoutMs; const expireAt = new Date(expireTime).toISOString(); return { expiryEnabled: Boolean(timeoutMins), timeoutMins, timeoutMs, expiresTime: expireTime, expiresAt: expireAt }; } var version = "5.0.0"; /** * MemoryCache class for caching data in memory. */ class MemoryCache { cache; dependencies; timers; constructor() { this.cache = {}; this.dependencies = {}; this.timers = {}; } /** * Retrieves a value from the cache. * @param key The cache key. * @returns The cached value if found and not expired, otherwise null. */ async get(key) { const item = this.cache[key]; if (!item || (item.metaData.expiry.expiresTime > 0 && item.metaData.expiry.expiresTime <= Date.now())) { void this.remove(key); return null; } return item; } /** * Sets a value in the cache with an optional timeout and callback. * @param key The cache key. * @param value The value to cache. * @param timeoutMins Timeout in minutes. * @param [options] Options object. */ async set(key, value, timeoutMins = 0, options) { const expiry = expiryFromMins(timeoutMins); const { timeoutMs, } = expiry; // Check if the timeout is greater than the max 32-bit signed integer value // that setTimeout accepts if (timeoutMs > 0x7FFFFFFF) { throw new Error("Timeout cannot be greater than 2147483647ms"); } this.cache[key] = { ...(options?.containerData || {}), value, metaData: { ...(options?.metaData || {}), expiry, modelVersion: version, } }; if (!timeoutMins) { return this.cache[key]; } this.timers[key] = setTimeout(() => { if (this.cache[key]) { this.remove(key); } }, timeoutMs); return this.cache[key]; } /** * Checks if a key exists in the cache. * @param key The cache key to check. * @returns True if the key exists in the cache, otherwise false. */ async has(key) { return key in this.cache; } /** * Removes a value from the cache. * @param key The cache key to remove. */ async remove(key) { if (this.timers[key]) { clearTimeout(this.timers[key]); delete this.timers[key]; } delete this.cache[key]; delete this.dependencies[key]; return true; } /** * Checks if the dependencies have changed. * @param key The cache key. * @param depArrayValues Dependency values to compare. * @returns True if the dependencies have changed, otherwise false. */ dependenciesChanged(key, depArrayValues) { const dependencies = this.dependencies[key]; if (!dependencies) { return false; } const check = JSON.stringify(dependencies) === JSON.stringify(depArrayValues); if (check) { return false; } this.dependencies[key] = depArrayValues; return true; } } /** * RedisCache class for caching data to a redis server. */ class RedisCache { client; dependencies; timers; constructor(options = {}) { if (!options.client) { throw new Error("Must pass redis client as `client` to the constructor object"); } this.client = options.client; this.dependencies = {}; this.timers = {}; } /** * Retrieves a value from the cache. * @param key The cache key. * @returns The cached value if found and not expired, otherwise null. */ async get(key) { if (!this.client.isOpen || !this.client.isReady) { // The redis connection is not open or ready, return null // which will essentially signal no cache and regenerate the request return null; } const data = await this.client.get(key); if (!data) { return null; } let item; // Attempt to parse the stored data to JSON try { item = JSON.parse(data); } catch (err) { // Parsing the data returned an error, invalid JSON void this.remove(key); return null; } // Check if the data was parsed to something useful if (!item) { void this.remove(key); return null; } // We have a useful value, return it return item; } /** * Sets a value in the cache with an optional timeout and callback. * @param key The cache key. * @param value The value to cache. * @param timeoutMins Timeout in minutes. * @param [options] Options object. */ async set(key, value, timeoutMins = 0, options) { if (!this.client.isOpen || !this.client.isReady) { // The redis connection is not open or ready, don't store anything return false; } const expiry = expiryFromMins(timeoutMins); const { expiresTime } = expiry; const cachedItemContainer = { ...(options?.containerData || {}), value, metaData: { ...(options?.metaData || {}), expiry, modelVersion: version, } }; const expiryOption = timeoutMins ? { "PXAT": expiresTime } : undefined; await this.client.set(key, JSON.stringify(cachedItemContainer), expiryOption); return cachedItemContainer; } /** * Removes a value from the cache. * @param key The cache key to remove. */ async remove(key) { if (this.timers[key]) { clearTimeout(this.timers[key]); delete this.timers[key]; } delete this.dependencies[key]; if (!this.client.isOpen || !this.client.isReady) { // The redis connection is not open or ready, don't remove anything return false; } await this.client.del(key); return true; } /** * Checks if a key exists in the cache. * @param key The cache key to check. * @returns True if the key exists in the cache, otherwise false. */ async has(key) { if (!this.client.isOpen) return false; const result = await this.client.exists(key); return result === 1; } } export { MemoryCache, RedisCache, expressCache, hashString, inFlight }; //# sourceMappingURL=index.esm.js.map