cache-express
Version:
Express Cache Middleware: Effortlessly cache responses with custom timeouts, dependencies, and enhanced performance
295 lines (251 loc) • 8.19 kB
JavaScript
const EventEmitter = require("events");
/**
* Hashes a string to create a unique cache key.
* @param {string} str - The input string to be hashed.
* @returns {number} - 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;
}
/**
* Normalizes a URL so that query params in different orders produce the same cache key.
* e.g. /api?foo=1&bar=2 and /api?bar=2&foo=1 will produce the same key.
* @param {string} url - The request URL.
* @returns {string} - Normalized URL string.
*/
function normalizeUrl(url) {
const qIndex = url.indexOf("?");
if (qIndex === -1) return url;
const path = url.slice(0, qIndex);
const queryString = url.slice(qIndex + 1);
const params = queryString.split("&").filter(Boolean);
params.sort();
return path + "?" + params.join("&");
}
/**
* MemoryCache class for caching data in memory.
*/
class MemoryCache {
constructor() {
this.cache = {};
this.dependencies = {};
}
/**
* Retrieves a value from the cache.
* @param {string} key - The cache key.
* @param {Array} depArrayValues - Dependency values for cache checking.
* @returns {*} - The cached value if found and not expired, otherwise null.
*/
get(key, depArrayValues) {
const item = this.cache[key];
const checkDepsChanged = this.dependenciesChanged(key, depArrayValues);
if (checkDepsChanged) {
if (item && item.timer) {
clearInterval(item.timer);
}
delete this.cache[key];
return null;
}
if (item && (!item.expireTime || item.expireTime > Date.now())) {
return item.value;
} else {
delete this.cache[key];
return null;
}
}
/**
* Sets a value in the cache with an optional timeout and callback.
* @param {string} key - The cache key.
* @param {*} value - The value to cache.
* @param {number} timeoutMs - Timeout in milliseconds.
* @param {function} callback - Callback function when the cache expires.
* @param {Array} dependencies - Dependency values for cache checking.
*/
set(key, value, timeoutMs, callback, dependencies) {
if (timeoutMs && timeoutMs > 0) {
const expireTime = Date.now() + timeoutMs;
this.cache[key] = { value, expireTime, dependencies, timeoutMs };
if (callback) {
this.cache[key].timer = setTimeout(() => {
if (this.cache[key]) {
callback(key, this.cache[key].value);
delete this.cache[key];
}
}, timeoutMs);
}
} else {
this.cache[key] = { value, dependencies };
}
this.dependencies[key] = dependencies;
}
/**
* Removes a value from the cache.
* @param {string} key - The cache key to remove.
*/
remove(key) {
delete this.cache[key];
delete this.dependencies[key];
}
/**
* Checks if a key exists in the cache.
* @param {string} key - The cache key to check.
* @returns {boolean} - True if the key exists in the cache, otherwise false.
*/
has(key) {
return key in this.cache;
}
/**
* Checks if the dependencies have changed.
* @param {string} key - The cache key.
* @param {Array} depArrayValues - Dependency values to compare.
* @returns {boolean} - 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;
} else {
this.dependencies[key] = depArrayValues;
return true;
}
}
}
const cache = new MemoryCache();
// Global event emitter for request queuing (Issue #3)
const pendingEmitter = new EventEmitter();
pendingEmitter.setMaxListeners(0); // allow unlimited listeners
const pendingRequests = new Set();
/**
* Middleware function for Express.js to enable caching.
*
* @param {Object} [opts] - Options for caching.
* @param {Function} [opts.dependsOn=() => []] - A function that returns an array of dependency values for cache checking.
* @param {number} [opts.timeOut=3600000] - Timeout in milliseconds for cache expiration. Default is 1 hour (3600000 ms).
* @param {Function} [opts.onTimeout=() => { console.log("Cache removed"); }] - A callback function to execute when a cached item expires.
* @param {Function} [opts.onCacheMiss=(url: string) => {}] - A callback function to execute when a request is not found in cache.
* @param {Function} [opts.onCacheServed=(url: string) => {}] - A callback function to execute when a cached item is served.
* @param {Function} [opts.onCacheStored=(url: string) => {}] - A callback function to execute when a cached item is stored / updated.
* @returns {function} - Middleware function.
*/
function expressCache(opts = {}) {
const defaults = {
dependsOn: () => [],
timeOut: 60 * 60 * 1000,
onTimeout: () => {
console.log("Cache removed");
},
onCacheMiss: () => {},
onCacheServed: () => {},
onCacheStored: () => {}
};
const options = {
...defaults,
...opts,
};
const { dependsOn, timeOut, onTimeout, onCacheMiss, onCacheServed, onCacheStored } = options;
return function (req, res, next) {
const rawUrl = req.originalUrl || req.url;
const cacheUrl = normalizeUrl(rawUrl);
const cacheKey = "c_" + hashString(cacheUrl);
const depArrayValues = dependsOn();
const cachedResponse = cache.get(cacheKey, depArrayValues);
if (cachedResponse) {
// Serve from cache
const cachedBody = cachedResponse.body;
const cachedHeaders = cachedResponse.headers;
const cachedStatusCode = cachedResponse.statusCode;
if (cachedHeaders) {
res.set(JSON.parse(cachedHeaders));
}
if (typeof cachedBody === "string") {
try {
const jsonData = JSON.parse(cachedBody);
onCacheServed(cacheUrl);
res.status(cachedStatusCode).json(jsonData);
} catch (error) {
onCacheServed(cacheUrl);
res.status(cachedStatusCode).send(cachedBody);
}
} else {
onCacheServed(cacheUrl);
res.status(cachedStatusCode).send(cachedBody);
}
return;
}
// Issue #3: If a request for this key is already in-flight, queue this one
if (pendingRequests.has(cacheKey)) {
pendingEmitter.once(cacheKey, (cachedData) => {
if (!cachedData) {
// upstream failed, fall through to next()
return next();
}
const { body, headers, statusCode } = cachedData;
if (headers) {
res.set(JSON.parse(headers));
}
if (typeof body === "string") {
try {
const jsonData = JSON.parse(body);
onCacheServed(cacheUrl);
res.status(statusCode).json(jsonData);
} catch (e) {
onCacheServed(cacheUrl);
res.status(statusCode).send(body);
}
} else {
onCacheServed(cacheUrl);
res.status(statusCode).send(body);
}
});
return;
}
// Mark this key as pending
pendingRequests.add(cacheKey);
onCacheMiss(cacheUrl);
const originalSend = res.send;
const originalJson = res.json;
const storAndNotify = (body, statusCode) => {
const isSuccess = statusCode >= 200 && statusCode < 400;
if (isSuccess) {
const entry = {
body: typeof body === "object" ? JSON.stringify(body) : body,
headers: JSON.stringify(res.getHeaders()),
statusCode
};
cache.set(cacheKey, entry, timeOut, onTimeout, depArrayValues);
onCacheStored(cacheUrl);
// Notify all waiting requests
pendingEmitter.emit(cacheKey, entry);
} else {
// Notify waiting requests that upstream failed
pendingEmitter.emit(cacheKey, null);
}
pendingRequests.delete(cacheKey);
};
res.send = function (body) {
// statusCode is already set on res by Express before send() is called
storAndNotify(body, res.statusCode);
originalSend.call(this, body);
};
res.json = function (body) {
// statusCode is already set on res by Express before json() is called
storAndNotify(body, res.statusCode);
originalJson.call(this, body);
};
next();
};
}
module.exports = expressCache;
module.exports.hash = hashString;
module.exports.normalizeUrl = normalizeUrl;
module.exports.MemoryCache = MemoryCache;