|
|
|
let url = require("url");
|
|
|
|
let MemoryCache = require("./memory-cache");
|
|
|
|
|
|
|
|
let t = {
|
|
|
|
ms: 1,
|
|
|
|
second: 1000,
|
|
|
|
minute: 60000,
|
|
|
|
hour: 3600000,
|
|
|
|
day: 3600000 * 24,
|
|
|
|
week: 3600000 * 24 * 7,
|
|
|
|
month: 3600000 * 24 * 30,
|
|
|
|
};
|
|
|
|
|
|
|
|
let instances = [];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Does a === b
|
|
|
|
* @param {any} a
|
|
|
|
* @returns {function(any): boolean}
|
|
|
|
*/
|
|
|
|
let matches = function (a) {
|
|
|
|
return function (b) {
|
|
|
|
return a === b;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Does a!==b
|
|
|
|
* @param {any} a
|
|
|
|
* @returns {function(any): boolean}
|
|
|
|
*/
|
|
|
|
let doesntMatch = function (a) {
|
|
|
|
return function (b) {
|
|
|
|
return !matches(a)(b);
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get log duration
|
|
|
|
* @param {number} d Time in ms
|
|
|
|
* @param {string} prefix Prefix for log
|
|
|
|
* @returns {string} Coloured log string
|
|
|
|
*/
|
|
|
|
let logDuration = function (d, prefix) {
|
|
|
|
let str = d > 1000 ? (d / 1000).toFixed(2) + "sec" : d + "ms";
|
|
|
|
return "\x1b[33m- " + (prefix ? prefix + " " : "") + str + "\x1b[0m";
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get safe headers
|
|
|
|
* @param {Object} res Express response object
|
|
|
|
* @returns {Object}
|
|
|
|
*/
|
|
|
|
function getSafeHeaders(res) {
|
|
|
|
return res.getHeaders ? res.getHeaders() : res._headers;
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Constructor for ApiCache instance */
|
|
|
|
function ApiCache() {
|
|
|
|
let memCache = new MemoryCache();
|
|
|
|
|
|
|
|
let globalOptions = {
|
|
|
|
debug: false,
|
|
|
|
defaultDuration: 3600000,
|
|
|
|
enabled: true,
|
|
|
|
appendKey: [],
|
|
|
|
jsonp: false,
|
|
|
|
redisClient: false,
|
|
|
|
headerBlacklist: [],
|
|
|
|
statusCodes: {
|
|
|
|
include: [],
|
|
|
|
exclude: [],
|
|
|
|
},
|
|
|
|
events: {
|
|
|
|
expire: undefined,
|
|
|
|
},
|
|
|
|
headers: {
|
|
|
|
// 'cache-control': 'no-cache' // example of header overwrite
|
|
|
|
},
|
|
|
|
trackPerformance: false,
|
|
|
|
respectCacheControl: false,
|
|
|
|
};
|
|
|
|
|
|
|
|
let middlewareOptions = [];
|
|
|
|
let instance = this;
|
|
|
|
let index = null;
|
|
|
|
let timers = {};
|
|
|
|
let performanceArray = []; // for tracking cache hit rate
|
|
|
|
|
|
|
|
instances.push(this);
|
|
|
|
this.id = instances.length;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Logs a message to the console if the `DEBUG` environment variable is set.
|
|
|
|
* @param {string} a The first argument to log.
|
|
|
|
* @param {string} b The second argument to log.
|
|
|
|
* @param {string} c The third argument to log.
|
|
|
|
* @param {string} d The fourth argument to log, and so on... (optional)
|
|
|
|
*
|
|
|
|
* Generated by Trelent
|
|
|
|
*/
|
|
|
|
function debug(a, b, c, d) {
|
|
|
|
let arr = ["\x1b[36m[apicache]\x1b[0m", a, b, c, d].filter(function (arg) {
|
|
|
|
return arg !== undefined;
|
|
|
|
});
|
|
|
|
let debugEnv = process.env.DEBUG && process.env.DEBUG.split(",").indexOf("apicache") !== -1;
|
|
|
|
|
|
|
|
return (globalOptions.debug || debugEnv) && console.log.apply(null, arr);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns true if the given request and response should be logged.
|
|
|
|
* @param {Object} request The HTTP request object.
|
|
|
|
* @param {Object} response The HTTP response object.
|
|
|
|
* @param {function(Object, Object):boolean} toggle
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
function shouldCacheResponse(request, response, toggle) {
|
|
|
|
let opt = globalOptions;
|
|
|
|
let codes = opt.statusCodes;
|
|
|
|
|
|
|
|
if (!response) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (toggle && !toggle(request, response)) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (codes.exclude && codes.exclude.length && codes.exclude.indexOf(response.statusCode) !== -1) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (codes.include && codes.include.length && codes.include.indexOf(response.statusCode) === -1) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Add key to index array
|
|
|
|
* @param {string} key Key to add
|
|
|
|
* @param {Object} req Express request object
|
|
|
|
*/
|
|
|
|
function addIndexEntries(key, req) {
|
|
|
|
let groupName = req.apicacheGroup;
|
|
|
|
|
|
|
|
if (groupName) {
|
|
|
|
debug("group detected \"" + groupName + "\"");
|
|
|
|
let group = (index.groups[groupName] = index.groups[groupName] || []);
|
|
|
|
group.unshift(key);
|
|
|
|
}
|
|
|
|
|
|
|
|
index.all.unshift(key);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns a new object containing only the whitelisted headers.
|
|
|
|
* @param {Object} headers The original object of header names and
|
|
|
|
* values.
|
|
|
|
* @param {string[]} globalOptions.headerWhitelist An array of
|
|
|
|
* strings representing the whitelisted header names to keep in the
|
|
|
|
* output object.
|
|
|
|
*
|
|
|
|
* Generated by Trelent
|
|
|
|
*/
|
|
|
|
function filterBlacklistedHeaders(headers) {
|
|
|
|
return Object.keys(headers)
|
|
|
|
.filter(function (key) {
|
|
|
|
return globalOptions.headerBlacklist.indexOf(key) === -1;
|
|
|
|
})
|
|
|
|
.reduce(function (acc, header) {
|
|
|
|
acc[header] = headers[header];
|
|
|
|
return acc;
|
|
|
|
}, {});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a cache object
|
|
|
|
* @param {Object} headers The response headers to filter.
|
|
|
|
* @returns {Object} A new object containing only the whitelisted
|
|
|
|
* response headers.
|
|
|
|
*
|
|
|
|
* Generated by Trelent
|
|
|
|
*/
|
|
|
|
function createCacheObject(status, headers, data, encoding) {
|
|
|
|
return {
|
|
|
|
status: status,
|
|
|
|
headers: filterBlacklistedHeaders(headers),
|
|
|
|
data: data,
|
|
|
|
encoding: encoding,
|
|
|
|
timestamp: new Date().getTime() / 1000, // seconds since epoch. This is used to properly decrement max-age headers in cached responses.
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets a cache value for the given key.
|
|
|
|
* @param {string} key The cache key to set.
|
|
|
|
* @param {any} value The cache value to set.
|
|
|
|
* @param {number} duration How long in milliseconds the cached
|
|
|
|
* response should be valid for (defaults to 1 hour).
|
|
|
|
*
|
|
|
|
* Generated by Trelent
|
|
|
|
*/
|
|
|
|
function cacheResponse(key, value, duration) {
|
|
|
|
let redis = globalOptions.redisClient;
|
|
|
|
let expireCallback = globalOptions.events.expire;
|
|
|
|
|
|
|
|
if (redis && redis.connected) {
|
|
|
|
try {
|
|
|
|
redis.hset(key, "response", JSON.stringify(value));
|
|
|
|
redis.hset(key, "duration", duration);
|
|
|
|
redis.expire(key, duration / 1000, expireCallback || function () {});
|
|
|
|
} catch (err) {
|
|
|
|
debug("[apicache] error in redis.hset()");
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
memCache.add(key, value, duration, expireCallback);
|
|
|
|
}
|
|
|
|
|
|
|
|
// add automatic cache clearing from duration, includes max limit on setTimeout
|
|
|
|
timers[key] = setTimeout(function () {
|
|
|
|
instance.clear(key, true);
|
|
|
|
}, Math.min(duration, 2147483647));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Appends content to the response.
|
|
|
|
* @param {Object} res Express response object
|
|
|
|
* @param {(string|Buffer)} content The content to append.
|
|
|
|
*
|
|
|
|
* Generated by Trelent
|
|
|
|
*/
|
|
|
|
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 ? new Buffer(oldContent) : Buffer.from(oldContent);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!oldContent) {
|
|
|
|
oldContent = !Buffer.alloc ? new Buffer(0) : Buffer.alloc(0);
|
|
|
|
}
|
|
|
|
|
|
|
|
res._apicache.content = Buffer.concat(
|
|
|
|
[oldContent, content],
|
|
|
|
oldContent.length + content.length
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
res._apicache.content = content;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Monkeypatches the response object to add cache control headers
|
|
|
|
* and create a cache object.
|
|
|
|
* @param {Object} req Express request object
|
|
|
|
* @param {Object} res Express response object
|
|
|
|
* @param {function} next Function to call next
|
|
|
|
* @param {string} key Key to add response as
|
|
|
|
* @param {number} duration Time to cache response for
|
|
|
|
* @param {string} strDuration Duration in string form
|
|
|
|
* @param {function(Object, Object):boolean} toggle
|
|
|
|
*/
|
|
|
|
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(function (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) {
|
|
|
|
addIndexEntries(key, req);
|
|
|
|
let headers = res._apicache.headers || getSafeHeaders(res);
|
|
|
|
let cacheObject = createCacheObject(
|
|
|
|
res.statusCode,
|
|
|
|
headers,
|
|
|
|
res._apicache.content,
|
|
|
|
encoding
|
|
|
|
);
|
|
|
|
cacheResponse(key, cacheObject, duration);
|
|
|
|
|
|
|
|
// display log entry
|
|
|
|
let elapsed = new Date() - req.apicacheTimer;
|
|
|
|
debug("adding cache entry for \"" + key + "\" @ " + strDuration, logDuration(elapsed));
|
|
|
|
debug("_apicache.headers: ", res._apicache.headers);
|
|
|
|
debug("res.getHeaders(): ", getSafeHeaders(res));
|
|
|
|
debug("cacheObject: ", cacheObject);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return res._apicache.end.apply(this, arguments);
|
|
|
|
};
|
|
|
|
|
|
|
|
next();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Send a cached response to client
|
|
|
|
* @param {Request} request Express request object
|
|
|
|
* @param {Response} response Express response object
|
|
|
|
* @param {object} cacheObject Cache object to send
|
|
|
|
* @param {function(Object, Object):boolean} toggle
|
|
|
|
* @param {function} next Function to call next
|
|
|
|
* @param {number} duration Not used
|
|
|
|
* @returns {boolean|undefined} true if the request should be
|
|
|
|
* cached, false otherwise. If undefined, defaults to true.
|
|
|
|
*/
|
|
|
|
function sendCachedResponse(request, response, cacheObject, toggle, next, duration) {
|
|
|
|
if (toggle && !toggle(request, response)) {
|
|
|
|
return next();
|
|
|
|
}
|
|
|
|
|
|
|
|
let headers = getSafeHeaders(response);
|
|
|
|
|
|
|
|
// Modified by @louislam, removed Cache-control, since I don't need client side cache!
|
|
|
|
// Original Source: https://github.com/kwhitley/apicache/blob/0d5686cc21fad353c6dddee646288c2fca3e4f50/src/apicache.js#L254
|
|
|
|
Object.assign(headers, filterBlacklistedHeaders(cacheObject.headers || {}));
|
|
|
|
|
|
|
|
// only embed apicache headers when not in production environment
|
|
|
|
if (process.env.NODE_ENV !== "production") {
|
|
|
|
Object.assign(headers, {
|
|
|
|
"apicache-store": globalOptions.redisClient ? "redis" : "memory",
|
|
|
|
"apicache-version": "1.6.2-modified",
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// unstringify buffers
|
|
|
|
let data = cacheObject.data;
|
|
|
|
if (data && data.type === "Buffer") {
|
|
|
|
data =
|
|
|
|
typeof data.data === "number" ? new Buffer.alloc(data.data) : new Buffer.from(data.data);
|
|
|
|
}
|
|
|
|
|
|
|
|
// test Etag against If-None-Match for 304
|
|
|
|
let cachedEtag = cacheObject.headers.etag;
|
|
|
|
let 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);
|
|
|
|
}
|
|
|
|
|
|
|
|
/** Sync caching options */
|
|
|
|
function syncOptions() {
|
|
|
|
for (let i in middlewareOptions) {
|
|
|
|
Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Clear key from cache
|
|
|
|
* @param {string} target Key to clear
|
|
|
|
* @param {boolean} isAutomatic Is the key being cleared automatically
|
|
|
|
* @returns {number}
|
|
|
|
*/
|
|
|
|
this.clear = function (target, isAutomatic) {
|
|
|
|
let group = index.groups[target];
|
|
|
|
let redis = globalOptions.redisClient;
|
|
|
|
|
|
|
|
if (group) {
|
|
|
|
debug("clearing group \"" + target + "\"");
|
|
|
|
|
|
|
|
group.forEach(function (key) {
|
|
|
|
debug("clearing cached entry for \"" + key + "\"");
|
|
|
|
clearTimeout(timers[key]);
|
|
|
|
delete timers[key];
|
|
|
|
if (!globalOptions.redisClient) {
|
|
|
|
memCache.delete(key);
|
|
|
|
} else {
|
|
|
|
try {
|
|
|
|
redis.del(key);
|
|
|
|
} catch (err) {
|
|
|
|
console.log("[apicache] error in redis.del(\"" + key + "\")");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
index.all = index.all.filter(doesntMatch(key));
|
|
|
|
});
|
|
|
|
|
|
|
|
delete index.groups[target];
|
|
|
|
} else if (target) {
|
|
|
|
debug("clearing " + (isAutomatic ? "expired" : "cached") + " entry for \"" + target + "\"");
|
|
|
|
clearTimeout(timers[target]);
|
|
|
|
delete timers[target];
|
|
|
|
// clear actual cached entry
|
|
|
|
if (!redis) {
|
|
|
|
memCache.delete(target);
|
|
|
|
} else {
|
|
|
|
try {
|
|
|
|
redis.del(target);
|
|
|
|
} catch (err) {
|
|
|
|
console.log("[apicache] error in redis.del(\"" + target + "\")");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// remove from global index
|
|
|
|
index.all = index.all.filter(doesntMatch(target));
|
|
|
|
|
|
|
|
// remove target from each group that it may exist in
|
|
|
|
Object.keys(index.groups).forEach(function (groupName) {
|
|
|
|
index.groups[groupName] = index.groups[groupName].filter(doesntMatch(target));
|
|
|
|
|
|
|
|
// delete group if now empty
|
|
|
|
if (!index.groups[groupName].length) {
|
|
|
|
delete index.groups[groupName];
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
debug("clearing entire index");
|
|
|
|
|
|
|
|
if (!redis) {
|
|
|
|
memCache.clear();
|
|
|
|
} else {
|
|
|
|
// clear redis keys one by one from internal index to prevent clearing non-apicache entries
|
|
|
|
index.all.forEach(function (key) {
|
|
|
|
clearTimeout(timers[key]);
|
|
|
|
delete timers[key];
|
|
|
|
try {
|
|
|
|
redis.del(key);
|
|
|
|
} catch (err) {
|
|
|
|
console.log("[apicache] error in redis.del(\"" + key + "\")");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
this.resetIndex();
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.getIndex();
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Converts a duration string to an integer number of milliseconds.
|
|
|
|
* @param {(string|number)} duration The string to convert.
|
|
|
|
* @param {number} defaultDuration The default duration to return if
|
|
|
|
* can't parse duration
|
|
|
|
* @returns {number} The converted value in milliseconds, or the
|
|
|
|
* defaultDuration if it can't be parsed.
|
|
|
|
*/
|
|
|
|
function parseDuration(duration, defaultDuration) {
|
|
|
|
if (typeof duration === "number") {
|
|
|
|
return duration;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (typeof duration === "string") {
|
|
|
|
let split = duration.match(/^([\d\.,]+)\s?(\w+)$/);
|
|
|
|
|
|
|
|
if (split.length === 3) {
|
|
|
|
let len = parseFloat(split[1]);
|
|
|
|
let unit = split[2].replace(/s$/i, "").toLowerCase();
|
|
|
|
if (unit === "m") {
|
|
|
|
unit = "ms";
|
|
|
|
}
|
|
|
|
|
|
|
|
return (len || 1) * (t[unit] || 0);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return defaultDuration;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Parse duration
|
|
|
|
* @param {(number|string)} duration
|
|
|
|
* @returns {number} Duration parsed to a number
|
|
|
|
*/
|
|
|
|
this.getDuration = function (duration) {
|
|
|
|
return parseDuration(duration, globalOptions.defaultDuration);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return cache performance statistics (hit rate). Suitable for
|
|
|
|
* putting into a route:
|
|
|
|
* <code>
|
|
|
|
* app.get('/api/cache/performance', (req, res) => {
|
|
|
|
* res.json(apicache.getPerformance())
|
|
|
|
* })
|
|
|
|
* </code>
|
|
|
|
* @returns {any[]}
|
|
|
|
*/
|
|
|
|
this.getPerformance = function () {
|
|
|
|
return performanceArray.map(function (p) {
|
|
|
|
return p.report();
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get index of a group
|
|
|
|
* @param {string} group
|
|
|
|
* @returns {number}
|
|
|
|
*/
|
|
|
|
this.getIndex = function (group) {
|
|
|
|
if (group) {
|
|
|
|
return index.groups[group];
|
|
|
|
} else {
|
|
|
|
return index;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Express middleware
|
|
|
|
* @param {(string|number)} strDuration Duration to cache responses
|
|
|
|
* for.
|
|
|
|
* @param {function(Object, Object):boolean} middlewareToggle
|
|
|
|
* @param {Object} localOptions Options for APICache
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
this.middleware = function cache(strDuration, middlewareToggle, localOptions) {
|
|
|
|
let duration = instance.getDuration(strDuration);
|
|
|
|
let opt = {};
|
|
|
|
|
|
|
|
middlewareOptions.push({
|
|
|
|
options: opt,
|
|
|
|
});
|
|
|
|
|
|
|
|
let options = function (localOptions) {
|
|
|
|
if (localOptions) {
|
|
|
|
middlewareOptions.find(function (middleware) {
|
|
|
|
return middleware.options === opt;
|
|
|
|
}).localOptions = localOptions;
|
|
|
|
}
|
|
|
|
|
|
|
|
syncOptions();
|
|
|
|
|
|
|
|
return opt;
|
|
|
|
};
|
|
|
|
|
|
|
|
options(localOptions);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A Function for non tracking performance
|
|
|
|
*/
|
|
|
|
function NOOPCachePerformance() {
|
|
|
|
this.report = this.hit = this.miss = function () {}; // noop;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A function for tracking and reporting hit rate. These
|
|
|
|
* statistics are returned by the getPerformance() call above.
|
|
|
|
*/
|
|
|
|
function CachePerformance() {
|
|
|
|
/**
|
|
|
|
* Tracks the hit rate for the last 100 requests. If there
|
|
|
|
* have been fewer than 100 requests, the hit rate just
|
|
|
|
* considers the requests that have happened.
|
|
|
|
*/
|
|
|
|
this.hitsLast100 = new Uint8Array(100 / 4); // each hit is 2 bits
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Tracks the hit rate for the last 1000 requests. If there
|
|
|
|
* have been fewer than 1000 requests, the hit rate just
|
|
|
|
* considers the requests that have happened.
|
|
|
|
*/
|
|
|
|
this.hitsLast1000 = new Uint8Array(1000 / 4); // each hit is 2 bits
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Tracks the hit rate for the last 10000 requests. If there
|
|
|
|
* have been fewer than 10000 requests, the hit rate just
|
|
|
|
* considers the requests that have happened.
|
|
|
|
*/
|
|
|
|
this.hitsLast10000 = new Uint8Array(10000 / 4); // each hit is 2 bits
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Tracks the hit rate for the last 100000 requests. If
|
|
|
|
* there have been fewer than 100000 requests, the hit rate
|
|
|
|
* just considers the requests that have happened.
|
|
|
|
*/
|
|
|
|
this.hitsLast100000 = new Uint8Array(100000 / 4); // each hit is 2 bits
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The number of calls that have passed through the
|
|
|
|
* middleware since the server started.
|
|
|
|
*/
|
|
|
|
this.callCount = 0;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The total number of hits since the server started
|
|
|
|
*/
|
|
|
|
this.hitCount = 0;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The key from the last cache hit. This is useful in
|
|
|
|
* identifying which route these statistics apply to.
|
|
|
|
*/
|
|
|
|
this.lastCacheHit = null;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The key from the last cache miss. This is useful in
|
|
|
|
* identifying which route these statistics apply to.
|
|
|
|
*/
|
|
|
|
this.lastCacheMiss = null;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return performance statistics
|
|
|
|
* @returns {Object}
|
|
|
|
*/
|
|
|
|
this.report = function () {
|
|
|
|
return {
|
|
|
|
lastCacheHit: this.lastCacheHit,
|
|
|
|
lastCacheMiss: this.lastCacheMiss,
|
|
|
|
callCount: this.callCount,
|
|
|
|
hitCount: this.hitCount,
|
|
|
|
missCount: this.callCount - this.hitCount,
|
|
|
|
hitRate: this.callCount == 0 ? null : this.hitCount / this.callCount,
|
|
|
|
hitRateLast100: this.hitRate(this.hitsLast100),
|
|
|
|
hitRateLast1000: this.hitRate(this.hitsLast1000),
|
|
|
|
hitRateLast10000: this.hitRate(this.hitsLast10000),
|
|
|
|
hitRateLast100000: this.hitRate(this.hitsLast100000),
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Computes a cache hit rate from an array of hits and
|
|
|
|
* misses.
|
|
|
|
* @param {Uint8Array} array An array representing hits and
|
|
|
|
* misses.
|
|
|
|
* @returns {?number} a number between 0 and 1, or null if
|
|
|
|
* the array has no hits or misses
|
|
|
|
*/
|
|
|
|
this.hitRate = function (array) {
|
|
|
|
let hits = 0;
|
|
|
|
let misses = 0;
|
|
|
|
for (let i = 0; i < array.length; i++) {
|
|
|
|
let n8 = array[i];
|
|
|
|
for (let j = 0; j < 4; j++) {
|
|
|
|
switch (n8 & 3) {
|
|
|
|
case 1:
|
|
|
|
hits++;
|
|
|
|
break;
|
|
|
|
case 2:
|
|
|
|
misses++;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
n8 >>= 2;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let total = hits + misses;
|
|
|
|
if (total == 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
return hits / total;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Record a hit or miss in the given array. It will be
|
|
|
|
* recorded at a position determined by the current value of
|
|
|
|
* the callCount variable.
|
|
|
|
* @param {Uint8Array} array An array representing hits and
|
|
|
|
* misses.
|
|
|
|
* @param {boolean} hit true for a hit, false for a miss
|
|
|
|
* Each element in the array is 8 bits, and encodes 4
|
|
|
|
* hit/miss records. Each hit or miss is encoded as to bits
|
|
|
|
* as follows: 00 means no hit or miss has been recorded in
|
|
|
|
* these bits 01 encodes a hit 10 encodes a miss
|
|
|
|
*/
|
|
|
|
this.recordHitInArray = function (array, hit) {
|
|
|
|
let arrayIndex = ~~(this.callCount / 4) % array.length;
|
|
|
|
let bitOffset = (this.callCount % 4) * 2; // 2 bits per record, 4 records per uint8 array element
|
|
|
|
let clearMask = ~(3 << bitOffset);
|
|
|
|
let record = (hit ? 1 : 2) << bitOffset;
|
|
|
|
array[arrayIndex] = (array[arrayIndex] & clearMask) | record;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Records the hit or miss in the tracking arrays and
|
|
|
|
* increments the call count.
|
|
|
|
* @param {boolean} hit true records a hit, false records a
|
|
|
|
* miss
|
|
|
|
*/
|
|
|
|
this.recordHit = function (hit) {
|
|
|
|
this.recordHitInArray(this.hitsLast100, hit);
|
|
|
|
this.recordHitInArray(this.hitsLast1000, hit);
|
|
|
|
this.recordHitInArray(this.hitsLast10000, hit);
|
|
|
|
this.recordHitInArray(this.hitsLast100000, hit);
|
|
|
|
if (hit) {
|
|
|
|
this.hitCount++;
|
|
|
|
}
|
|
|
|
this.callCount++;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Records a hit event, setting lastCacheMiss to the given key
|
|
|
|
* @param {string} key The key that had the cache hit
|
|
|
|
*/
|
|
|
|
this.hit = function (key) {
|
|
|
|
this.recordHit(true);
|
|
|
|
this.lastCacheHit = key;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Records a miss event, setting lastCacheMiss to the given key
|
|
|
|
* @param {string} key The key that had the cache miss
|
|
|
|
*/
|
|
|
|
this.miss = function (key) {
|
|
|
|
this.recordHit(false);
|
|
|
|
this.lastCacheMiss = key;
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
let perf = globalOptions.trackPerformance ? new CachePerformance() : new NOOPCachePerformance();
|
|
|
|
|
|
|
|
performanceArray.push(perf);
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Cache a request
|
|
|
|
* @param {Object} req Express request object
|
|
|
|
* @param {Object} res Express response object
|
|
|
|
* @param {function} next Function to call next
|
|
|
|
* @returns {any}
|
|
|
|
*/
|
|
|
|
let cache = function (req, res, next) {
|
|
|
|
function bypass() {
|
|
|
|
debug("bypass detected, skipping cache.");
|
|
|
|
return next();
|
|
|
|
}
|
|
|
|
|
|
|
|
// initial bypass chances
|
|
|
|
if (!opt.enabled) {
|
|
|
|
return bypass();
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
req.headers["x-apicache-bypass"] ||
|
|
|
|
req.headers["x-apicache-force-fetch"] ||
|
|
|
|
(opt.respectCacheControl && req.headers["cache-control"] == "no-cache")
|
|
|
|
) {
|
|
|
|
return bypass();
|
|
|
|
}
|
|
|
|
|
|
|
|
// REMOVED IN 0.11.1 TO CORRECT MIDDLEWARE TOGGLE EXECUTE ORDER
|
|
|
|
// if (typeof middlewareToggle === 'function') {
|
|
|
|
// if (!middlewareToggle(req, res)) return bypass()
|
|
|
|
// } else if (middlewareToggle !== undefined && !middlewareToggle) {
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
// Remove querystring from key if jsonp option is enabled
|
|
|
|
if (opt.jsonp) {
|
|
|
|
key = url.parse(key).pathname;
|
|
|
|
}
|
|
|
|
|
|
|
|
// add appendKey (either custom function or response path)
|
|
|
|
if (typeof opt.appendKey === "function") {
|
|
|
|
key += "$$appendKey=" + opt.appendKey(req, res);
|
|
|
|
} else if (opt.appendKey.length > 0) {
|
|
|
|
let appendKey = req;
|
|
|
|
|
|
|
|
for (let i = 0; i < opt.appendKey.length; i++) {
|
|
|
|
appendKey = appendKey[opt.appendKey[i]];
|
|
|
|
}
|
|
|
|
key += "$$appendKey=" + appendKey;
|
|
|
|
}
|
|
|
|
|
|
|
|
// attempt cache hit
|
|
|
|
let redis = opt.redisClient;
|
|
|
|
let cached = !redis ? memCache.getValue(key) : null;
|
|
|
|
|
|
|
|
// send if cache hit from memory-cache
|
|
|
|
if (cached) {
|
|
|
|
let elapsed = new Date() - req.apicacheTimer;
|
|
|
|
debug("sending cached (memory-cache) version of", key, logDuration(elapsed));
|
|
|
|
|
|
|
|
perf.hit(key);
|
|
|
|
return sendCachedResponse(req, res, cached, middlewareToggle, next, duration);
|
|
|
|
}
|
|
|
|
|
|
|
|
// send if cache hit from redis
|
|
|
|
if (redis && redis.connected) {
|
|
|
|
try {
|
|
|
|
redis.hgetall(key, function (err, obj) {
|
|
|
|
if (!err && obj && obj.response) {
|
|
|
|
let elapsed = new Date() - req.apicacheTimer;
|
|
|
|
debug("sending cached (redis) version of", key, logDuration(elapsed));
|
|
|
|
|
|
|
|
perf.hit(key);
|
|
|
|
return sendCachedResponse(
|
|
|
|
req,
|
|
|
|
res,
|
|
|
|
JSON.parse(obj.response),
|
|
|
|
middlewareToggle,
|
|
|
|
next,
|
|
|
|
duration
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
perf.miss(key);
|
|
|
|
return makeResponseCacheable(
|
|
|
|
req,
|
|
|
|
res,
|
|
|
|
next,
|
|
|
|
key,
|
|
|
|
duration,
|
|
|
|
strDuration,
|
|
|
|
middlewareToggle
|
|
|
|
);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} catch (err) {
|
|
|
|
// bypass redis on error
|
|
|
|
perf.miss(key);
|
|
|
|
return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
perf.miss(key);
|
|
|
|
return makeResponseCacheable(req, res, next, key, duration, strDuration, middlewareToggle);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
cache.options = options;
|
|
|
|
|
|
|
|
return cache;
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Process options
|
|
|
|
* @param {Object} options
|
|
|
|
* @returns {Object}
|
|
|
|
*/
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (globalOptions.trackPerformance) {
|
|
|
|
debug("WARNING: using trackPerformance flag can cause high memory usage!");
|
|
|
|
}
|
|
|
|
|
|
|
|
return this;
|
|
|
|
} else {
|
|
|
|
return globalOptions;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
/** Reset the index */
|
|
|
|
this.resetIndex = function () {
|
|
|
|
index = {
|
|
|
|
all: [],
|
|
|
|
groups: {},
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create a new instance of ApiCache
|
|
|
|
* @param {Object} config Config to pass
|
|
|
|
* @returns {ApiCache}
|
|
|
|
*/
|
|
|
|
this.newInstance = function (config) {
|
|
|
|
let instance = new ApiCache();
|
|
|
|
|
|
|
|
if (config) {
|
|
|
|
instance.options(config);
|
|
|
|
}
|
|
|
|
|
|
|
|
return instance;
|
|
|
|
};
|
|
|
|
|
|
|
|
/** Clone this instance */
|
|
|
|
this.clone = function () {
|
|
|
|
return this.newInstance(this.options());
|
|
|
|
};
|
|
|
|
|
|
|
|
// initialize index
|
|
|
|
this.resetIndex();
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = new ApiCache();
|