apicache-plus
Version:
Effortless api response caching for Express/Node using plain-english durations
1,434 lines (1,263 loc) • 45.7 kB
JavaScript
var zlib = require('zlib')
var accepts = require('accepts')
var stream = require('stream')
var querystring = require('querystring')
var jsonSortify = require('json.sortify')
var generateUuidV4 = require('uuid').v4
var MemoryCache = require('./memory-cache')
var RedisCache = require('./redis-cache')
var Compressor = require('./compressor')
var pkg = require('../package.json')
var helpers = require('./helpers')
var setLongTimeout = helpers.setLongTimeout
var clearLongTimeout = helpers.clearLongTimeout
var delegateLazily = helpers.delegateLazily
var isKoa = helpers.isKoa
var FIVE_MINUTES = 5 * 60 * 1000
var SAFE_HTTP_METHODS = ['GET', 'HEAD', 'OPTIONS']
var CACHEABLE_STATUS_CODES = {
200: null,
201: null,
202: null,
203: null,
204: null,
205: null,
300: null,
301: null,
302: null,
305: null,
307: null,
}
var t = {
ms: 1,
second: 1000,
minute: 60000,
hour: 3600000,
day: 3600000 * 24,
week: 3600000 * 24 * 7,
month: 3600000 * 24 * 30,
year: 3600000 * 24 * 365,
}
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) {
// getHeaders added in node v7.7.0
return Object.assign({}, res.getHeaders ? res.getHeaders() : res._headers)
}
function ApiCache() {
var memCache = new MemoryCache()
var redisCache
var globalOptions = {
debug: false,
defaultDuration: 3600000,
enabled: true,
isBypassable: false,
append: null,
interceptKeyParts: null,
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: {
include: [],
exclude: [],
},
events: {
expire: undefined,
},
headers: {
// 'cache-control': 'no-cache' // example of header overwrite
},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: 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 shouldDebug() {
var debugEnv = process.env.DEBUG && process.env.DEBUG.split(',').indexOf('apicache') !== -1
return !!(globalOptions.debug || debugEnv)
}
function debug(a, b, c, d) {
if (!shouldDebug()) return
var arr = ['\x1b[36m[apicache]\x1b[0m', a, b, c, d].filter(function(arg) {
return arg !== undefined
})
console.log.apply(null, arr)
}
function shouldCacheResponse(request, response, toggle, options) {
if (!response) return false
var codes = (options || globalOptions).statusCodes
if (toggle && !toggle(request, response)) {
return false
}
if ((!codes.exclude || !codes.exclude.length) && (!codes.include || !codes.include.length)) {
return response.statusCode in CACHEABLE_STATUS_CODES
}
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, options) {
return Object.keys(headers)
.filter(function(key) {
return (options || globalOptions).headerBlacklist.indexOf(key) === -1
})
.reduce(function(acc, header) {
acc[header] = headers[header]
return acc
}, {})
}
function createCacheObject(status, headers, data, encoding, options) {
return {
status: status,
headers: filterBlacklistedHeaders(headers, options),
data: data,
encoding: encoding,
timestamp: Date.now(), // This is used to properly decrement max-age headers in cached responses
}
}
function cacheResponse(key, value, duration) {
var expireCallback = globalOptions.events.expire
memCache.add(key, value, duration, expireCallback)
// add automatic cache clearing from duration
timers[key] = setLongTimeout(function() {
instance.clear(key, true)
}, duration)
}
function debugCacheAddition(cache, key, strDuration, req, res, options) {
if (!shouldDebug()) return Promise.resolve()
var elapsed = new Date() - req.apicacheTimer
return Promise.resolve(cache.get(key))
.then(function(cached) {
var cacheObject
if (cached && cached.value) {
cached = cached.value
cacheObject = createCacheObject(
cached.status,
cached.headers,
cached.data && cached.data.toString(cached.encoding),
cached.encoding,
options
)
cacheObject.timestamp = cached.timestamp
} else cacheObject = {}
debug('adding cache entry for "' + key + '" @ ' + strDuration, logDuration(elapsed))
debug('cacheObject: ', cacheObject)
})
.catch(function(err) {
debug('error debugging cache addition', err)
})
}
var isNodeLte7 = (function(ret) {
return function() {
if (ret !== undefined) return ret
return (ret = parseInt(process.versions.node.split('.')[0], 10) <= 7)
}
})()
function getHeadersFromParams(res, statusMsgOrHeaders, maybeHeaders) {
if (statusMsgOrHeaders && typeof statusMsgOrHeaders !== 'string') {
maybeHeaders = statusMsgOrHeaders
}
if (!maybeHeaders) maybeHeaders = {}
return Object.keys(maybeHeaders).reduce(function(memo, item) {
memo[item.toLowerCase()] = maybeHeaders[item]
return memo
// some framework writeHead patches remove headers from params, so merge with res.getHeaders()
}, getSafeHeaders(res))
}
function preWriteHead(res, statusCode, statusMsgOrHeaders, maybeHeaders) {
if (statusCode) res.statusCode = statusCode
if (statusMsgOrHeaders && typeof statusMsgOrHeaders !== 'string') {
maybeHeaders = statusMsgOrHeaders
} else if (statusMsgOrHeaders) res.statusMessage = statusMsgOrHeaders
if (!maybeHeaders) maybeHeaders = {}
Object.keys(maybeHeaders).forEach(function(name) {
res.setHeader(name, maybeHeaders[name])
})
}
function fixEncodingHeaders(chunk, headers) {
if (!chunk || !headers) return
if (Compressor.isReallyCompressed(chunk, headers['content-encoding'])) {
delete headers['content-length']
} else {
delete headers['content-encoding']
}
}
function getCacheControlMaxAge(syncedMaxAge, group, options) {
var maxAge
if (group) maxAge = Math.min(3, syncedMaxAge)
else if (!options.shouldSyncExpiration) maxAge = Math.min(30, syncedMaxAge)
else maxAge = syncedMaxAge
var directive = options.append || options.appendKey ? 'private, ' : '' // public is default
return directive + 'max-age=' + maxAge.toFixed(0) + ', must-revalidate'
}
function makeResponseCacheable(
req,
res,
next,
key,
duration,
strDuration,
toggle,
options,
afterFn
) {
if (!afterFn) afterFn = Promise.resolve.bind(Promise)
var afterTryingToCache = (function(isCalled) {
return function() {
if (isCalled !== undefined) return isCalled
return (isCalled = afterFn())
}
})()
var shouldCacheRes = (function(shouldIt) {
return function(req, res, toggle, options) {
if (shouldIt !== undefined) return shouldIt
return (shouldIt = shouldCacheResponse(req, res, toggle, options))
}
})()
// monkeypatch res to create cache object
var apicacheResPatches = {
write: res.write,
writeHead: res.writeHead,
end: res.end,
}
if (!req.apicacheResPatches) {
req.apicacheResPatches = apicacheResPatches
req.customWriteHeads = []
req.runReversedCustomWriteHeadsOnce = (function(isCalled) {
return function() {
if (isCalled) return
isCalled = true
var fn
while ((fn = req.customWriteHeads.pop())) {
fn.apply(null, arguments)
}
}
})()
}
function customWriteHead(statusCode, statusMsgOrHeaders, maybeHeaders) {
if (res._shouldCacheResWriteHeadVersionAlreadyRun) return
// to use with shouldCacheRes()
res.statusCode = statusCode
if (!res._currentResponseHeaders) {
// these most likely (if default node writeHead implementation) won't be available at res.getHeaders() yet
res._currentResponseHeaders = getHeadersFromParams(res, statusMsgOrHeaders, maybeHeaders)
}
// add cache control headers
if (!options.headers['cache-control'] && !res._currentResponseHeaders['cache-control']) {
if (shouldCacheRes(req, res, toggle, options)) {
var cacheControl
if (SAFE_HTTP_METHODS.indexOf(req.method) !== -1) {
var syncedMaxAge = Math.ceil(duration / 1000)
cacheControl = getCacheControlMaxAge(syncedMaxAge, req.apicacheGroup, options)
} else {
cacheControl = 'no-store'
}
res.setHeader('cache-control', cacheControl)
res._currentResponseHeaders['cache-control'] = cacheControl
} else {
res.setHeader('cache-control', 'no-store')
}
}
if (shouldCacheRes(req, res, toggle, options)) {
res._shouldCacheResWriteHeadVersionAlreadyRun = true
// append header overwrites if applicable
Object.keys(options.headers).forEach(function(name) {
res.setHeader(name, options.headers[name])
res._currentResponseHeaders[name.toLowerCase()] = options.headers[name]
})
}
}
req.customWriteHeads.unshift(customWriteHead)
res.writeHead = function(statusCode, statusMsgOrHeaders, maybeHeaders) {
req.runReversedCustomWriteHeadsOnce(statusCode, statusMsgOrHeaders, maybeHeaders)
return apicacheResPatches.writeHead.apply(this, arguments)
}
var getWstream = (function(wstream) {
return function(method, chunk, encoding) {
if (wstream) return wstream
if (
res._shouldCacheResWriteOrEndVersionAlreadyRun ||
!shouldCacheRes(req, res, toggle, options)
) {
var emptyWstream = new stream.Writable({
write(_c, _e, cb) {
cb()
},
}).on('finish', afterTryingToCache)
return (wstream = Promise.resolve(emptyWstream))
}
res._shouldCacheResWriteOrEndVersionAlreadyRun = true
var getCacheObject = function() {
return createCacheObject(res.statusCode, res._currentResponseHeaders, null, null, options)
}
var getGroup = function() {
return req.apicacheGroup
}
var expireCallback = globalOptions.events.expire
var cache = redisCache || memCache
var chunkSize = Buffer.byteLength(chunk || '', encoding)
// if res.end was called first (without calling write), it will have only one chunk
// res.socket.writableHighWaterMark is node gte 9
var highWaterMark =
method === 'end'
? chunkSize
: res.socket.writableHighWaterMark || res.socket._writableState.highWaterMark
var cacheWstream = cache
.createWriteStream(
key,
getCacheObject,
duration,
expireCallback,
getGroup,
highWaterMark,
// this is needed while memCache index/groups are still handled externally
!redisCache &&
function(statusCode, headers, data, encoding) {
addIndexEntries(key, req)
var cacheObject = createCacheObject(statusCode, headers, data, encoding, options)
cacheResponse(key, cacheObject, duration)
}
)
.then(function(wstream) {
return wstream
.on('error', function(err) {
debug('error in makeResponseCacheable function', err)
})
.on('finish', function() {
afterTryingToCache()
if (!wstream.isLocked) {
debugCacheAddition(cache, key, strDuration, req, res, options)
}
})
.on('unpipe', afterTryingToCache)
})
return (wstream = cacheWstream.then(function(wstream) {
if (wstream.isLocked) return wstream
// also indicates it may be already compressed
var otherMiddlewareMayWantToChangeCompression =
Compressor.getFirstContentEncoding(res._currentResponseHeaders['content-encoding']) !==
'identity'
// some middlewares will preset content-encoding to e.g. gzip too early
// even if apicache is about to receive uncompressed response stream (middleware attached before apicache's one)
fixEncodingHeaders(chunk, res._currentResponseHeaders)
// don't compress before caching
if (otherMiddlewareMayWantToChangeCompression) return wstream
var tstream = Compressor.run(
{
chunkSize: chunkSize,
requestMehod: req.method,
responseStatusCode: res.statusCode,
responseMethod: method,
responseHeaders: res._currentResponseHeaders,
},
debug
).on('error', function(err) {
debug('error in makeResponseCacheable function', err)
wstream.emit('error')
})
tstream.pipe(wstream)
return tstream
}))
}
})()
;['write', 'end'].forEach(function(method) {
var ret
res[method] = function(chunk, encoding) {
if (!this.headersSent) {
req.runReversedCustomWriteHeadsOnce(
res.statusCode,
res.statusMsgOrHeaders,
getSafeHeaders(res)
)
}
ret = apicacheResPatches[method].apply(this, arguments)
getWstream(method, chunk, encoding).then(function(wstream) {
wstream[method](chunk, encoding)
})
return ret
}
})
// res.end(data) writes data twice
if (isNodeLte7()) {
var _end = res.end
res.end = function(chunk, encoding) {
if (Buffer.byteLength(chunk || '') > 0) {
this.write(chunk, encoding)
arguments[0] = undefined
}
return _end.apply(this, arguments)
}
}
return next()
}
var CACHE_CONTROL_NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/
var CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/
function sendCachedResponse(request, response, cacheObject, toggle, next, duration, options) {
if (toggle && !toggle(request, response)) {
return next()
}
if (isKoa(request)) request.respond = false
var isMarkedToCache = !!request.apicacheResPatches
if (isMarkedToCache) {
// undo patches
Object.keys(request.apicacheResPatches).forEach(function(key) {
response[key] = request.apicacheResPatches[key]
})
}
if (options.afterHit) {
response.on('finish', function() {
isKoa(request) ? options.afterHit(request) : options.afterHit(request, response)
})
}
var elapsed = new Date() - request.apicacheTimer
debug(
'sending cached',
redisCache ? '(redis)' : '(memory-cache)',
'version of',
cacheObject.key,
logDuration(elapsed)
)
var headers = getSafeHeaders(response)
var cacheObjectHeaders = cacheObject.headers || {}
var cacheControl
// sync max-age with the cache expiration.
var elapsedMs = Date.now() - cacheObject.timestamp
var updatedMaxAge = Math.ceil((duration - elapsedMs) / 1000)
// when caching with options.interceptKeyParts,
// same key/cache could be reused for responding
// with different middleware options.headers
// and to different request methods, so recheck
if (options.headers['cache-control']) cacheControl = options.headers['cache-control']
else if (SAFE_HTTP_METHODS.indexOf(request.method) !== -1) {
if (cacheObjectHeaders['cache-control']) cacheControl = cacheObjectHeaders['cache-control']
else {
cacheControl = getCacheControlMaxAge(updatedMaxAge, request.apicacheGroup, options)
}
} else cacheControl = 'no-store'
Object.assign(headers, filterBlacklistedHeaders(cacheObjectHeaders, options), {
'cache-control': cacheControl.replace(/max-age=\s*([+-]?\d+)/, function(_match, maxAge) {
return 'max-age=' + Math.max(0, Math.min(parseInt(maxAge, 10), updatedMaxAge))
}),
})
// 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': pkg.version,
})
}
function hasNoBodyRequestingPreconditionCheck() {
return (
!request.headers['if-match'] &&
!request.headers['if-unmodified-since'] &&
!request.headers['if-range']
)
}
function clientDoesntWantToReloadItsCache() {
return (
!request.headers['cache-control'] ||
!CACHE_CONTROL_NO_CACHE_REGEXP.test(request.headers['cache-control'])
)
}
// test Etag against If-None-Match for 304
function isEtagFresh() {
var cachedEtag = cacheObjectHeaders.etag
var requestEtags = (request.headers['if-none-match'] || '').replace('*', '').split(/\s*,\s*/)
return Boolean(
cachedEtag &&
requestEtags.length > 0 &&
(requestEtags.indexOf(cachedEtag) !== -1 ||
requestEtags.indexOf('W/' + cachedEtag) !== -1 ||
requestEtags
.map(function(rEtag) {
return 'W/' + rEtag
})
.indexOf(cachedEtag) !== -1)
)
}
function isResourceFresh() {
var cachedLastModified = cacheObjectHeaders['last-modified']
var requestModifiedSince = request.headers['if-modified-since']
if (!cachedLastModified || !requestModifiedSince) return false
try {
return Date.parse(cachedLastModified) <= Date.parse(requestModifiedSince)
} catch (err) {
return false
}
}
if (
hasNoBodyRequestingPreconditionCheck() &&
clientDoesntWantToReloadItsCache() &&
(isEtagFresh() || isResourceFresh())
) {
if ('content-length' in headers) delete headers['content-length']
response.writeHead(304, headers)
return response.end()
}
if (request.method === 'HEAD') {
response.writeHead(cacheObject.status || 200, headers)
// skip body for HEAD
return response.end()
}
function getRstream() {
return (redisCache || memCache)
.createReadStream(
cacheObject.key,
cacheObject['data-token'] || cacheObject.data,
cacheObject.encoding,
// res.socket.writableHighWaterMark is node gte 9
response.socket.writableHighWaterMark || response.socket._writableState.highWaterMark
)
.on('error', function() {
debug('error in sendCachedResponse function')
response.end()
})
}
// dont use headers['content-encoding'] as it may be already changed to e.g. gzip by some middleware
var cachedEncoding = (cacheObjectHeaders['content-encoding'] || 'identity').split(',')[0]
var currentResCacheEncoding = (headers['content-encoding'] || 'identity').split(',')[0]
var noOtherMiddlewareMayWantToChangeCompression = cachedEncoding === currentResCacheEncoding
var requestAccepts = accepts(request)
if (
(cachedEncoding === 'identity' ||
(noOtherMiddlewareMayWantToChangeCompression &&
!CACHE_CONTROL_NO_TRANSFORM_REGEX.test(headers['cache-control'] || ''))) &&
requestAccepts.encodings(cachedEncoding)
) {
// Doing response.writeHead(cacheObject.status || 200, headers)
// can make writeHead patch from some compression middlewares fail
// (although using res.writeHead with headers that don't mess with content-length / content-encoding
// would mostly be ok)
preWriteHead(response, cacheObject.status || 200, headers)
return getRstream().pipe(response)
// try to decompress
} else if (cachedEncoding !== 'identity' && requestAccepts.encodings('identity')) {
var tstream
if (cachedEncoding === 'br' && zlib.createBrotliDecompress) {
tstream = zlib.createBrotliDecompress()
} else if (['gzip', 'deflate'].indexOf(cachedEncoding) !== -1) {
tstream = zlib.createUnzip()
} else {
var errorMessage = "can't decompress" + cachedEncoding + 'encoding'
throw new Error(errorMessage)
}
delete headers['content-encoding']
preWriteHead(response, cacheObject.status || 200, headers)
tstream.on('error', function() {
debug('error in decompression stream')
this.unpipe()
// if erroed cause didn't need to decompress
// try sending it without transforming
getRstream()
.on('error', function() {
debug('error in decompression stream')
this.unpipe()
response.end()
// if node < 8
if (!this.destroy) return this.pause()
this.destroy()
})
.pipe(response)
// if node < 8
if (!this.destroy) return this.pause()
this.destroy()
})
return getRstream()
.pipe(tstream)
.pipe(response)
} else {
var contentEncodings = Array.from(new Set([cachedEncoding, 'identity']))
var statusMessage =
'Please accept' +
contentEncodings.slice(0, -1).join(', ') +
contentEncodings.slice(-1).join(', or ')
response.writeHead(406, statusMessage)
return response.end()
}
}
var syncOptions = (function() {
function syncOptionsByIndex(i) {
Object.assign(middlewareOptions[i].options, globalOptions, middlewareOptions[i].localOptions)
var options = middlewareOptions[i].options
if (options.headerBlacklist) {
options.headerBlacklist = options.headerBlacklist.map(function(v) {
return v.toLowerCase()
})
}
}
return function(i) {
if (i) return syncOptionsByIndex(i)
else {
for (i in middlewareOptions) {
syncOptionsByIndex(i)
}
}
}
})()
function getAppendice(append, req, res) {
if (!append) return ''
var appendice
if (typeof append === 'function') {
appendice = isKoa(req) ? append(req) : append(req, res)
// ['x', 'y'] => req?.x?.y
} else if (append.length > 0) {
appendice = req
for (var i = 0; i < append.length; i++) {
if (!appendice) break
appendice = appendice[append[i]]
}
}
return appendice || ''
}
function getKeyParts(req, res, options) {
var query
if (req.query !== null && typeof req.query === 'object') query = Object.assign({}, req.query)
// In Express,the url is ambiguous based on where a router is mounted. originalUrl will give the full Url
var url = (req.originalUrl || req.url).replace(/\/?(?:\?([^#]*)(?:#.*)?)?$/, function(
_match,
qs
) {
if (!query) {
query = querystring.parse(qs)
delete query['']
}
return ''
})
// couldn't use (?:(?<!^)\/)? negative look-behind instead of \/? at regexp above to keep / if at beginning (node gte 9)
// also, it will consider a maybe possible req.originalUrl || req.url '' as '/'
if (url === '') url = '/'
if (options.jsonp) {
if ([true, false].indexOf(options.jsonp) === -1) {
delete query.jsonp
delete query.callback
} else delete query[options.jsonp]
}
var parts = {
method: req.method,
url: url,
params: Object.assign(query, typeof req.body === 'object' ? Object.assign({}, req.body) : {}),
appendice: getAppendice(options.append || options.appendKey, req, res),
}
if (options.interceptKeyParts) {
parts =
(isKoa(req)
? options.interceptKeyParts(req, parts)
: options.interceptKeyParts(req, res, parts)) || parts
}
return parts
}
function getSimilarKeyFromOtherMethod(key, method, otherMethod) {
return key.replace(method.toLowerCase(), otherMethod.toLowerCase())
}
this.getKey = function(keyParts) {
if (!keyParts || typeof keyParts !== 'object') return '{}'
var url
if (typeof keyParts.url === 'string') {
url = keyParts.url.trim()
if (url[0] !== '/') url = '/' + url
if (url.length > 1) url = url.replace(/\/$/, '')
} else url = ''
var appendice
if (typeof keyParts.appendice === 'string') appendice = keyParts.appendice
else if (Number.isNaN(keyParts.appendice)) appendice = 'NaN'
else if ([null, undefined].indexOf(keyParts.appendice) === -1) {
try {
appendice = jsonSortify(keyParts.appendice)
} catch (err) {
appendice = ''
}
} else appendice = ''
return (
(typeof keyParts.method === 'string' ? keyParts.method.toLowerCase() : '') +
url +
(keyParts.params !== null && typeof keyParts.params === 'object'
? jsonSortify(keyParts.params)
: '{}') +
appendice
)
}
this.has = function(key) {
if ([null, undefined].indexOf(key) !== -1) return Promise.resolve(false)
return (redisCache || memCache).has(key)
}
this.get = function(key) {
if ([null, undefined].indexOf(key) !== -1) return Promise.resolve(false)
return Promise.resolve((redisCache || memCache).get(key)).then(function(cached) {
if (!cached) return null
cached = cached.value
// this.set can store any value, so return raw cached if not what apicache regularly stores
if (
cached === null ||
typeof cached !== 'object' ||
!['status', 'headers', 'data', 'encoding', 'timestamp'].every(function(k) {
return k in cached
})
) {
return cached
}
// try formatting and decompressing
try {
var data = cached.data
if ([null, undefined].indexOf(data) === -1) {
if (Buffer.byteLength(data) === 0) data = ''
else {
var cachedEncoding = cached.headers['content-encoding']
if (cachedEncoding && cachedEncoding !== 'identity') {
var decompress = {
br: zlib.brotliDecompressSync,
gzip: zlib.unzipSync,
deflate: zlib.unzipSync,
}[cachedEncoding]
if (decompress) data = decompress(data)
}
data = data.toString(cached.encoding)
try {
data = JSON.parse(data)
} catch (err) {}
}
}
// don't send with all properties if it's really what apicache regularly stores
// (e.g. encoding as it was already used above for parsing and redis data-extra-pttl prop)
return {
status: cached.status,
headers: cached.headers,
data: data,
timestamp: cached.timestamp,
}
} catch (err) {
return cached
}
})
}
this.set = function(key, value, duration, group, expirationCallback) {
if ([null, undefined].indexOf(key) !== -1) return Promise.resolve(false)
duration = this.getDuration(duration)
return (
// for now, this is the way we make sure .set can safely modify an already existing item
// e.g. it will remove from old group
Promise.resolve(this.clear(key))
.then(function() {
return Promise.resolve(
(redisCache || memCache).add(key, value, duration, expirationCallback, group)
)
})
// this is needed while memCache index/groups are still handled externally
.then(function() {
if (!redisCache) {
addIndexEntries(key, { apicacheGroup: group })
}
return value
})
.catch(function() {
return false
})
)
}
this.clear = function(target, isAutomatic) {
if (redisCache) {
return redisCache
.clear(target)
.then(function(deleteCount) {
debug(deleteCount, 'keys cleared')
return deleteCount
})
.catch(function() {
debug('error in clear function')
})
}
var group = index.groups[target]
if (group) {
debug('clearing group "' + target + '"')
group.forEach(function(key) {
debug('clearing cached entry for "' + key + '"')
clearLongTimeout(timers[key])
delete timers[key]
memCache.delete(key)
index.all = index.all.filter(doesntMatch(key))
})
delete index.groups[target]
} else if (target || target === '') {
debug('clearing ' + (isAutomatic ? 'expired' : 'cached') + ' entry for "' + target + '"')
clearLongTimeout(timers[target])
delete timers[target]
// clear actual cached entry
memCache.delete(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))
var isGroupEmpty = !index.groups[groupName].length
// delete group if now empty
if (isGroupEmpty) {
delete index.groups[groupName]
}
})
} else {
debug('clearing entire index')
memCache.clear()
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 (redisCache) return redisCache.getIndex(group)
if (group) {
return index.groups[group]
} else {
return index
}
}
function isLocalOptions(value) {
return value !== null && typeof value === 'object'
}
function isMiddlewareToggle(value) {
return typeof value === 'function'
}
this.middleware = function cache(strDuration, middlewareToggle, localOptions) {
// Apicache#middleware(localOptions)
if (isLocalOptions(strDuration)) {
localOptions = strDuration
middlewareToggle = null
strDuration = null
// Apicache#middleware(middlewareToggle[, localOptions])
} else if (isMiddlewareToggle(strDuration)) {
localOptions = middlewareToggle
middlewareToggle = strDuration
strDuration = null
// Apicache#middleware([strDuration[, middlewareToggle[, localOptions]]])
} else if (isLocalOptions(middlewareToggle)) {
localOptions = middlewareToggle
middlewareToggle = null
}
if (!localOptions) localOptions = {}
var duration = parseDuration(strDuration)
var opt = {}
var middlewareOptionsIndex = middlewareOptions.length
middlewareOptions.push({
options: opt,
})
var options = function(localOptions) {
if (localOptions) {
if (localOptions.defaultDuration) {
localOptions.defaultDuration = parseDuration(localOptions.defaultDuration)
if ([null, undefined].indexOf(strDuration) !== -1) duration = localOptions.defaultDuration
}
middlewareOptions[middlewareOptionsIndex].localOptions = localOptions
syncOptions(middlewareOptionsIndex)
}
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 (var 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) {
if (isKoa(req)) {
var ctx = req
next = res
res = ctx.res
if (req.apicacheIsFrameworkAdapted) {
req = ctx.req
} else {
req.apicacheIsFrameworkAdapted = true
;[ctx, ctx.req, ctx.request, ctx.res, ctx.response].forEach(function(obj) {
if ('apicacheGroup' in obj && !('apicacheGroup' in ctx.state)) {
ctx.state.apicacheGroup = obj.apicacheGroup
}
Object.defineProperty(obj, 'apicacheGroup', {
get() {
return ctx.state.apicacheGroup
},
set(v) {
ctx.state.apicacheGroup = v
},
})
})
// It will e.g. delegate req.query to ctx.query which delegates to ctx.request.query
// And make it work as ctx for getting it's future props at options.append, for instance
// at a downstream apicache middleware
req = delegateLazily(ctx.req, ctx)
}
}
function bypass() {
debug('bypass detected, skipping cache.')
return next()
}
// initial bypass chances
if (!opt.enabled) return bypass()
if (
opt.isBypassable &&
(req.headers['cache-control'] === 'no-store' ||
['1', 'true'].indexOf(req.headers['x-apicache-bypass']) !== -1 ||
['1', 'true'].indexOf(req.headers['x-apicache-force-fetch']) !== -1)
) {
return bypass()
}
// this can change at runtime
duration = instance.getDuration(duration)
if (opt.optimizeDuration && opt.append && duration > FIVE_MINUTES) {
duration = FIVE_MINUTES
}
// embed timer
req.apicacheTimer = new Date()
var keyParts = getKeyParts(req, res, opt)
var key = instance.getKey(keyParts)
var cache = redisCache || memCache
// can have different keys e.g. one middleware has appendice while the other one doesn't
var makeResponseCacheableCount = `makeResponseCacheableCount${key}`
function _makeResponseCacheable() {
try {
perf.miss(key)
req[makeResponseCacheableCount] = (req[makeResponseCacheableCount] || 0) + 1
return makeResponseCacheable(
req,
res,
next,
key,
duration,
strDuration,
middlewareToggle,
opt,
function() {
if (--req[makeResponseCacheableCount] > 0) return Promise.resolve()
return cache.releaseLockWithId(
'make-cacheable:' + key,
typeof req.id === 'function' ? req.id() : req.id
)
}
)
} catch (err) {
debug(err)
return next()
}
}
function isSameRequestStackAllowedToMakeResponseCacheable() {
return cache.acquireLockWithId(
'make-cacheable:' + key,
typeof req.id === 'function' ? req.id() : req.id
)
}
function maybeMakeResponseCacheable() {
if (!req.id) {
req.id = generateUuidV4()
}
return isSameRequestStackAllowedToMakeResponseCacheable().then(function(isAllowed) {
if (isAllowed) return Promise.resolve(_makeResponseCacheable())
else {
// don't wait for first concurrent request finish its response
// (e.g. don't wait for a full download of a large file),
// but also don't let this one add to cache
if (SAFE_HTTP_METHODS.indexOf(req.method) !== -1) return next()
// give time to prior request finish caching
return new Promise(function(resolve) {
setLongTimeout(function() {
resolve(attemptCacheHit())
}, 10)
})
}
})
}
function getCached(key, withFallback) {
if (withFallback === undefined) withFallback = true
return Promise.resolve(cache.getValue(key)).then(function(cached) {
if (cached) {
cached.key = key
} else if (req.method === 'HEAD' && withFallback) {
var getMethodKey = getSimilarKeyFromOtherMethod(key, 'head', 'get')
return getCached(getMethodKey, false)
}
return cached
})
}
function attemptCacheHit() {
return getCached(key)
.then(function(cached) {
if (cached) {
perf.hit(key)
try {
return sendCachedResponse(req, res, cached, middlewareToggle, next, duration, opt)
} catch (err) {
debug(err)
if (res.headersSent) {
perf.miss(key)
if (isKoa(req)) req.respond = false
return res.end()
}
return maybeMakeResponseCacheable()
}
} else {
return maybeMakeResponseCacheable()
}
})
.catch(function(err) {
debug(err)
perf.miss(key)
if (res.headersSent) {
if (isKoa(req)) req.respond = false
res.end()
} else return next()
})
}
return attemptCacheHit()
}
cache.options = options
return cache
}
this.options = function(options) {
if (options) {
var redisConnectionChanged =
(options.redisClient !== undefined && globalOptions.redisClient !== options.redisClient) ||
(options.redisPrefix !== undefined && globalOptions.redisPrefix !== options.redisPrefix)
Object.assign(globalOptions, options)
if ('defaultDuration' in options) {
// Convert the default duration to a number in milliseconds (if needed)
globalOptions.defaultDuration = parseDuration(globalOptions.defaultDuration, 3600000)
}
syncOptions()
if (globalOptions.trackPerformance) {
debug('WARNING: using trackPerformance flag can cause high memory usage!')
}
if (redisConnectionChanged) this.initRedis()
return this
} else {
return globalOptions
}
}
this.resetIndex = function() {
index = {
all: [],
groups: {},
}
}
this.initRedis = function() {
if (!globalOptions.redisClient) redisCache = null
else redisCache = new RedisCache(globalOptions, debug)
}
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()
this.initRedis()
function _this() {
return this.middleware.apply(this, arguments)
}
return Object.assign(_this.bind(this), this)
}
module.exports = new ApiCache()