123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832 |
- var url = require('url')
- var MemoryCache = require('./memory-cache')
- var t = {
- ms: 1,
- second: 1000,
- minute: 60000,
- hour: 3600000,
- day: 3600000 * 24,
- week: 3600000 * 24 * 7,
- month: 3600000 * 24 * 30,
- }
- var instances = []
- var matches = function (a) {
- return function (b) {
- return a === b
- }
- }
- var doesntMatch = function (a) {
- return function (b) {
- return !matches(a)(b)
- }
- }
- var logDuration = function (d, prefix) {
- var str = d > 1000 ? (d / 1000).toFixed(2) + 'sec' : d + 'ms'
- return '\x1b[33m- ' + (prefix ? prefix + ' ' : '') + str + '\x1b[0m'
- }
- function getSafeHeaders(res) {
- return res.getHeaders ? res.getHeaders() : res._headers
- }
- function ApiCache() {
- var memCache = new MemoryCache()
- var 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,
- }
- var middlewareOptions = []
- var instance = this
- var index = null
- var timers = {}
- var performanceArray = [] // for tracking cache hit rate
- instances.push(this)
- this.id = instances.length
- function debug(a, b, c, d) {
- var arr = ['\x1b[36m[apicache]\x1b[0m', a, b, c, d].filter(function (arg) {
- return arg !== undefined
- })
- var debugEnv =
- process.env.DEBUG &&
- process.env.DEBUG.split(',').indexOf('apicache') !== -1
- return (globalOptions.debug || debugEnv) && console.log.apply(null, arr)
- }
- function shouldCacheResponse(request, response, toggle) {
- var opt = globalOptions
- var 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
- }
- function addIndexEntries(key, req) {
- var groupName = req.apicacheGroup
- if (groupName) {
- debug('group detected "' + groupName + '"')
- var group = (index.groups[groupName] = index.groups[groupName] || [])
- group.unshift(key)
- }
- index.all.unshift(key)
- }
- 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
- }, {})
- }
- 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.
- }
- }
- function cacheResponse(key, value, duration) {
- var redis = globalOptions.redisClient
- var 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))
- }
- function accumulateContent(res, content) {
- if (content) {
- if (typeof content == 'string') {
- res._apicache.content = (res._apicache.content || '') + content
- } else if (Buffer.isBuffer(content)) {
- var 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
- }
- }
- }
- 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)
- var headers = res._apicache.headers || getSafeHeaders(res)
- var cacheObject = createCacheObject(
- res.statusCode,
- headers,
- res._apicache.content,
- encoding,
- )
- cacheResponse(key, cacheObject, duration)
- // display log entry
- var 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()
- }
- function sendCachedResponse(
- request,
- response,
- cacheObject,
- toggle,
- next,
- duration,
- ) {
- if (toggle && !toggle(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
- // unstringify buffers
- var 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
- 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 (var i in middlewareOptions) {
- Object.assign(
- middlewareOptions[i].options,
- globalOptions,
- middlewareOptions[i].localOptions,
- )
- }
- }
- this.clear = function (target, isAutomatic) {
- var group = index.groups[target]
- var 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()
- }
- function parseDuration(duration, defaultDuration) {
- if (typeof duration === 'number') return duration
- if (typeof duration === 'string') {
- var split = duration.match(/^([\d\.,]+)\s?(\w+)$/)
- if (split.length === 3) {
- var len = parseFloat(split[1])
- var unit = split[2].replace(/s$/i, '').toLowerCase()
- if (unit === 'm') {
- unit = 'ms'
- }
- return (len || 1) * (t[unit] || 0)
- }
- }
- return defaultDuration
- }
- 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>
- */
- this.getPerformance = function () {
- return performanceArray.map(function (p) {
- return p.report()
- })
- }
- this.getIndex = function (group) {
- if (group) {
- return index.groups[group]
- } else {
- return index
- }
- }
- this.middleware = function cache(
- strDuration,
- middlewareToggle,
- localOptions,
- ) {
- var duration = instance.getDuration(strDuration)
- var opt = {}
- middlewareOptions.push({
- options: opt,
- })
- var 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
- */
- 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 a number between 0 and 1, or null if the array has no hits or misses
- */
- this.hitRate = function (array) {
- var hits = 0
- var misses = 0
- for (var i = 0; i < array.length; i++) {
- var n8 = array[i]
- for (j = 0; j < 4; j++) {
- switch (n8 & 3) {
- case 1:
- hits++
- break
- case 2:
- misses++
- break
- }
- n8 >>= 2
- }
- }
- var 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) {
- var arrayIndex = ~~(this.callCount / 4) % array.length
- var bitOffset = (this.callCount % 4) * 2 // 2 bits per record, 4 records per uint8 array element
- var clearMask = ~(3 << bitOffset)
- var 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
- }
- }
- var perf = globalOptions.trackPerformance
- ? new CachePerformance()
- : new NOOPCachePerformance()
- performanceArray.push(perf)
- var 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']
- )
- 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
- var key =
- req.hostname +
- (req.originalUrl || req.url) +
- JSON.stringify(req.cookies)
- // 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) {
- var appendKey = req
- for (var i = 0; i < opt.appendKey.length; i++) {
- appendKey = appendKey[opt.appendKey[i]]
- }
- key += '$$appendKey=' + appendKey
- }
- // attempt cache hit
- var redis = opt.redisClient
- var cached = !redis ? memCache.getValue(key) : null
- // send if cache hit from memory-cache
- if (cached) {
- var 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) {
- var 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
- }
- 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
- }
- }
- this.resetIndex = function () {
- index = {
- all: [],
- groups: {},
- }
- }
- this.newInstance = function (config) {
- var instance = new ApiCache()
- if (config) {
- instance.options(config)
- }
- return instance
- }
- this.clone = function () {
- return this.newInstance(this.options())
- }
- // initialize index
- this.resetIndex()
- }
- module.exports = new ApiCache()
|