steplix-cache
Version:
Steplix Cache is a Node.js cache helper.
407 lines (329 loc) • 13.3 kB
JavaScript
;
const pkg = require('../package.json');
const timeUnits = {
ms: 1,
second: 1000,
minute: 60000,
hour: 3600000,
day: 3600000 * 24,
week: 3600000 * 24 * 7,
month: 3600000 * 24 * 30
};
const getSafeHeaders = res => res.getHeaders ? res.getHeaders() : res._headers;
const _defineCacheClient = instance => {
Object.defineProperty(instance, 'cacheClient', {
configurable: false,
enumerable: true,
get: function () {
if (!cacheClient) {
cacheClient = require('./steplix');
}
return cacheClient;
}
});
};
// eslint-disable-next-line
const regexpDuration = /^([\d\.,]+)\s?(\w+)$/;
const regexpCaseS = /s$/i;
let cacheClient;
function ApiCache () {
// Middlewares options
const middlewareOptions = [];
// Global Cache options
const globalOptions = {
// if false or undefined, turns off caching globally (useful on dev)
enabled: process.env.CACHE_ENABLED,
// should be either a number (in ms) or a string, defaults to '1 hour'
defaultDuration: process.env.CACHE_MIDDLEWARE_DURATION || '1 hour',
// should be either a string, defaults to 'cache-control'
headerCacheControl: process.env.CACHE_HEADER_CONTROL || 'cache-control',
// should be either a boolean (true | 1), defaults to false
respectCacheControl: process.env.CACHE_RESPECT_HEADER_CONTROL,
// should be either a strong or a function (in function case, return a string custom key), defaults to req.originalUrl || req.url
cacheKey: null,
// list of status codes that should never be cached
statusCodeBlacklist: {
include: [],
exclude: []
},
// list of headers that should never be cached
headerBlacklist: [],
// overwrite headers
headers: {
// 'cache-control': 'no-cache'
}
};
const instance = this;
_defineCacheClient(instance);
//
// Private functions
//
function shouldCacheResponse (request, response, toggle) {
const { exclude = [], include = [] } = globalOptions.statusCodeBlacklist;
if (!response) return false;
if (toggle && !toggle(request, response)) return false;
if (exclude.length && exclude.indexOf(response.statusCode) !== -1) return false;
if (include.length && include.indexOf(response.statusCode) === -1) return false;
return true;
}
function filterBlacklistedHeaders (headers) {
return Object
.keys(headers)
.filter(key => globalOptions.headerBlacklist.indexOf(key) === -1)
.reduce((acc, header) => {
acc[header] = headers[header];
return acc;
}, {});
}
function createCacheObject (status, headers, data, encoding) {
return {
data,
status,
encoding,
headers: filterBlacklistedHeaders(headers),
// seconds since epoch. This is used to properly decrement max-age headers in cached responses.
timestamp: new Date().getTime() / 1000
};
}
function accumulateContent (res, content) {
if (content) {
if (typeof content === 'string') {
res._apicache.content = (res._apicache.content || '') + content;
}
else if (Buffer.isBuffer(content)) {
let oldContent = res._apicache.content;
if (typeof oldContent === 'string') {
oldContent = Buffer.from(oldContent);
}
if (!oldContent) {
oldContent = Buffer.alloc(0);
}
res._apicache.content = Buffer.concat(
[oldContent, content],
oldContent.length + content.length
);
}
else {
res._apicache.content = content;
}
}
}
function makeResponseCacheable (req, res, next, key, duration, strDuration, toggle) {
// monkeypatch res.end to create cache object
res._apicache = {
write: res.write,
writeHead: res.writeHead,
end: res.end,
cacheable: true,
content: undefined
};
// append header overwrites if applicable
Object.keys(globalOptions.headers || {}).forEach(name => res.setHeader(name, globalOptions.headers[name]));
res.writeHead = function () {
// add cache control headers
if (!globalOptions.headers['cache-control']) {
if (shouldCacheResponse(req, res, toggle)) {
res.setHeader('cache-control', 'max-age=' + (duration / 1000).toFixed(0));
}
else {
res.setHeader('cache-control', 'no-cache, no-store, must-revalidate');
}
}
res._apicache.headers = Object.assign({}, getSafeHeaders(res));
return res._apicache.writeHead.apply(this, arguments);
};
// patch res.write
res.write = function (content) {
accumulateContent(res, content);
return res._apicache.write.apply(this, arguments);
};
// patch res.end
res.end = function (content, encoding) {
if (shouldCacheResponse(req, res, toggle)) {
accumulateContent(res, content);
if (res._apicache.cacheable && res._apicache.content) {
var cacheObject = createCacheObject(
res.statusCode,
res._apicache.headers || getSafeHeaders(res),
res._apicache.content,
encoding
);
instance.cacheClient.put(key, cacheObject, duration);
}
}
return res._apicache.end.apply(this, arguments);
};
next();
}
function sendCachedResponse (request, response, cacheObject, precondition, next, duration) {
if (precondition && !precondition(request, response)) {
return next();
}
var headers = getSafeHeaders(response);
Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}), {
// set properly-decremented max-age header. This ensures that max-age is in sync with the cache expiration.
'cache-control': `max-age=${Math.max(0, (duration / 1000 - (new Date().getTime() / 1000 - cacheObject.timestamp)).toFixed(0))}`
});
// only embed apicache headers when not in production environment
if (process.env.NODE_ENV !== 'production') {
Object.assign(headers, {
'x-apicache-store': globalOptions.redisClient ? 'redis' : 'memory',
'x-apicache-version': pkg.version
});
}
// unstringify buffers
var data = cacheObject.data;
if (data && data.type === 'Buffer') {
data = typeof data.data === 'number' ? Buffer.alloc(data.data) : Buffer.from(data.data);
}
// test Etag against If-None-Match for 304
var cachedEtag = cacheObject.headers.etag;
var requestEtag = request.headers['if-none-match'];
if (requestEtag && cachedEtag === requestEtag) {
response.writeHead(304, headers);
return response.end();
}
response.writeHead(cacheObject.status || 200, headers);
return response.end(data, cacheObject.encoding);
}
function syncOptions () {
for (const i in middlewareOptions) {
Object.assign(middlewareOptions[i].options, { ...globalOptions, ...(middlewareOptions[i].localOptions || {}), ...(middlewareOptions[i].options || {}) });
}
}
function parseDuration (duration, defaultDuration) {
if (typeof duration === 'number') return duration;
if (typeof duration === 'string') {
const split = duration.match(regexpDuration);
if (split.length === 3) {
const len = parseFloat(split[1]);
let unit = split[2].replace(regexpCaseS, '').toLowerCase();
if (unit === 'm') {
unit = 'ms';
}
return (len || 1) * (timeUnits[unit] || 0);
}
}
return defaultDuration;
}
//
// Public functions
//
/**
* Remove key from cache.
*
* @param key {string} Cache key wildcard. (the key finder works in conjunction with the minimatch module)
*/
this.clear = function (key) {
instance.cacheClient.removeMatch(key);
};
/**
* Function for parse duration string to milliseconds.
*
* @param strDuration {string|number} Duration.
*
* @return duration in milliseconds
*/
this.getDuration = function (strDuration) {
return parseDuration(strDuration, globalOptions.defaultDuration);
};
/**
* Express middleware.
*
* @param strDuration {string|number} Duration.
* @param options {string|function|object} If is string, is cache key. But if is object, is custom options. These options override the global apicache options only for this middleware function.
* @param precondition {function} Function in charge of performing the Precondition to decide whether or not to cache.
*
* @return express middleware cache function
*/
this.middleware = function cache (strDuration, options, precondition) {
if (typeof options === 'string' || typeof options === 'function') {
options = {
cacheKey: options
};
}
const duration = instance.getDuration(strDuration);
options = options || {};
precondition = precondition || options.precondition;
middlewareOptions.push({ options });
const overrideOptions = localOptions => {
if (localOptions) {
middlewareOptions.find(middleware => middleware.options === options).localOptions = localOptions;
}
syncOptions();
return options;
};
overrideOptions(options);
const cache = (req, res, next) => {
const bypass = () => next();
// initial bypass chances
if (
!options.enabled ||
req.headers['x-apicache-bypass'] ||
req.headers['x-apicache-force-fetch'] ||
(options.respectCacheControl && req.headers['cache-control'] === 'no-cache')
) {
return bypass();
}
// embed timer
req.apicacheTimer = new Date();
// In Express 4.x the url is ambigious based on where a router is mounted. originalUrl will give the full Url
let key = req.originalUrl || req.url;
// cacheKey (either custom function or response path)
if (options.cacheKey) {
if (typeof options.cacheKey === 'function') {
key = options.cacheKey(req, res);
}
else if (typeof options.cacheKey === 'string') {
key = options.cacheKey;
}
}
// send if cache hit from cachec client
(async () => {
try {
const obj = await instance.cacheClient.get(key);
if (obj && obj.data) {
return sendCachedResponse(req, res, obj, precondition, next, duration);
}
}
catch (e) {
// Ignore
}
return makeResponseCacheable(req, res, next, key, duration, strDuration, precondition);
})();
};
cache.options = overrideOptions;
return cache;
};
/**
* Override middleware options
*
* @param options {object} Custom options. These options override the global apicache options.
*
* @return global apicache options
*/
this.options = function (options) {
if (options) {
Object.assign(globalOptions, options);
syncOptions();
if ('defaultDuration' in options) {
// Convert the default duration to a number in milliseconds (if needed)
globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000);
}
return this;
}
return globalOptions;
};
/**
* Middleware only for success responses
*
* @param strDuration {string|number} Duration.
* @param options {string|function|object} If is string, is cache key. But if is object, is custom options. These options override the global apicache options only for this middleware function.
*
* @return express middleware cache function
*/
this.ok = function (strDuration, options) {
return this.middleware(strDuration, options, (req, res) => res.statusCode >= 200 && res.statusCode < 300);
};
}
module.exports = new ApiCache();