UNPKG

steplix-cache

Version:

Steplix Cache is a Node.js cache helper.

407 lines (329 loc) 13.3 kB
'use strict'; 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();