@yonderbox/cache
Version:
Expressjs RFC 7234 compliant cache middleware
321 lines (285 loc) • 10.1 kB
JavaScript
const cacheTypes = [ 'nodeCache', 'ttlCache', 'lruCache' ]
const cacheType = ( cacheTypes.indexOf( process.env.CACHE_TYPE ) >= 0 ) ? process.env.CACHE_TYPE : 'lruCache'
import path from 'path'
import { fileURLToPath, parse as urlParse } from 'url'
const __dirname = path.dirname( fileURLToPath( import.meta.url ) )
import jsonfile from 'jsonfile'
const pkg = jsonfile.readFileSync( path.resolve( __dirname, './package.json' ) )
import _ from 'lodash'
import yves from 'yves'
import cors from 'cors'
import got from 'got'
const exposeCacheEndpoints = true
const debug = yves.debugger( pkg.name.replace( /-/g, ':' ) )
const debugIntercept = yves.debugger( pkg.name.replace( /-/g, ':' ) + ':intercept' )
const debugCacheInvalidation = yves.debugger( `${pkg.name.replace( /^@/, '' ).replace( /[/-]+/g, ':' )}:invalidation` )
import cacheControlParser from 'cache-control-parser'
const { parse: parseCacheControl } = cacheControlParser
import NodeCache from 'node-cache'
let nodeCache
import TTLCache from '@isaacs/ttlcache'
let ttlCache
import { LRUCache } from 'lru-cache'
let lruCache
const proxyType = process.env.PROXY_TYPE || null
const ttl = parseInt( process.env.TTL ) || 0
export function expressMiddleware() {
if ( proxyType == 'internal' && ttl ) {
yonderboxCache.create()
return ( req, res, next ) => {
const reqDirectives = parseCacheControl( req.headers[ 'cache-control' ] || '' )
debug( 'cache-control request %y', reqDirectives )
if ( req.method !== 'GET' ) {
return next()
}
const key = req.originalUrl
const cachedResponse = !reqDirectives[ 'no-cache' ] ? yonderboxCache.get( key ) : null
debug( 'get %y = %y', key, cachedResponse )
if ( cachedResponse ) {
const cacheControl = _.get( cachedResponse, 'headers.cache-control' )
const resDirectives = cacheControl ? parseCacheControl( cacheControl ) : {}
debug( 'cache-control response %y %y', cacheControl, resDirectives )
if ( cachedResponse.headers ) res.set( cachedResponse.headers )
res.set( 'x-cache-status', 'HIT' )
debug( 'x-cache-status %y %y', 'HIT', res.getHeaders() )
res.statusCode = cachedResponse.status
return res.send( cachedResponse.body )
}
res.originalSend = res.send
res.send = body => {
const headers = res.getHeaders()
let cacheControl = _.get( headers, 'cache-control' )
if ( !cacheControl ) {
cacheControl = 'public, max-age=20'
res.set( 'Cache-Control', cacheControl )
}
const resDirectives = cacheControl ? parseCacheControl( cacheControl ) : {}
debug( 'cache-control response %y %y', cacheControl, resDirectives )
const oriHeaders = Object.assign( {}, res.getHeaders() )
res.set( 'x-cache-status', 'MISS' )
debug( 'x-cache-status %y %y', 'MISS', res.getHeaders() )
res.originalSend( body )
if ( ( !_.get( headers, 'authorization' ) || resDirectives[ 'public' ] ) && !resDirectives[ 'private' ] && !resDirectives[ 'no-store' ] && res.statusCode == 200 ) {
const obj = { body, headers: oriHeaders, status: res.statusCode }
debug( 'set %y = %y', key, obj )
yonderboxCache.set( key, obj, ttl )
}
}
return next()
}
} else {
return ( req, res, next ) => {
return next()
}
}
}
export function create( key ) {
if ( cacheType == 'nodeCache' && !nodeCache ) {
nodeCache = new NodeCache( { stdTTL: ttl / 1000, useClones: false, maxKeys: -1, checkperiod: 600 } )
return nodeCache
}
if ( cacheType == 'ttlCache' && !ttlCache ) {
ttlCache = new TTLCache( { ttl: ttl, max: Infinity, updateAgeOnGet: false, noUpdateTTL: false, noDisposeOnSet: false } )
return ttlCache
}
if ( cacheType == 'lruCache' && !lruCache ) {
lruCache = new LRUCache( { ttl: ttl, ttlAutopurge: true/*, maxSize: 5000*/ } )
return lruCache
}
}
export function get( key ) {
if ( cacheType == 'nodeCache' && nodeCache ) {
return nodeCache.get( key )
}
if ( cacheType == 'ttlCache' && ttlCache ) {
return ttlCache.get( key )
}
if ( cacheType == 'lruCache' && lruCache ) {
return lruCache.get( key )
}
}
export function set( key, value, duration ) {
if ( cacheType == 'nodeCache' && nodeCache ) {
nodeCache.set( key, value, duration )
}
if ( cacheType == 'ttlCache' && ttlCache ) {
return ttlCache.set( key, value, { ttl: duration } )
}
if ( cacheType == 'lruCache' && lruCache ) {
return lruCache.set( key, value, { ttl: duration } )
}
}
export function flushAll() {
if ( cacheType == 'nodeCache' && nodeCache ) {
nodeCache.flushAll()
}
if ( cacheType == 'ttlCache' && ttlCache ) {
return ttlCache.clear()
}
if ( cacheType == 'lruCache' && lruCache ) {
return lruCache.clear()
}
}
export function purgeKey( key ) {
if ( cacheType == 'nodeCache' && nodeCache ) {
return nodeCache.del( key )
}
if ( cacheType == 'ttlCache' && ttlCache ) {
return ttlCache.delete( key )
}
if ( cacheType == 'lruCache' && lruCache ) {
return lruCache.delete( key )
}
}
export function getStats() {
const result = { type: cacheType }
if ( cacheType == 'nodeCache' && nodeCache ) {
result.stats = nodeCache.getStats()
}
if ( cacheType == 'ttlCache' && ttlCache ) {
const x = ttlCache.entries()
let cnt = 0
while ( x && x.next && x.next().value ) cnt++
result.stats = { count: cnt }
}
if ( cacheType == 'lruCache' && lruCache && lruCache.indexes ) {
const x = lruCache.indexes()
let cnt = 0
while ( x && x.next && x.next().value ) cnt++
result.stats = { count: cnt, free: lruCache.free.length }
}
return result
}
function endPoints( app, Adapter ) {
if ( exposeCacheEndpoints || ( process.env.NODE_ENV == 'development' || process.env.NODE_LOCAL ) ) {
app.get(
'/cache',
cors( { /*exposedHeaders: [ 'x-powered-by' ]*/ } ),
async function ( req, res ) {
res.json( { apq: await Adapter.mongodbCacheCount(), proxyType, proxy: yonderboxCache.getStats() } )
}
)
app.delete(
'/cache',
cors( { /*exposedHeaders: [ 'x-powered-by' ]*/ } ),
async function ( req, res ) {
Adapter.mongodbCacheClear()
if ( proxyType == 'internal' ) {
yonderboxCache.flushAll()
}
res.json( { apq: { entries: 0, sets: 0, gets: 0 }, proxyType, proxy: yonderboxCache.getStats() } )
}
)
}
}
function instrumentResponse( res, poweredBy ) {
const original_json = res.json.bind( res )
res.json = ( txt ) => {
debugIntercept( 'json %y', txt )
original_json( txt )
}
const original_status = res.status.bind( res )
res.status = ( code ) => {
debugIntercept( 'status %y', code )
original_status( code )
}
const original_send = res.send.bind( res )
res.send = ( body ) => {
debugIntercept( 'send %y', body )
if ( proxyType == 'nuster' ) {
// Check cache-control header for private, no-cache, must-revalidate, and if so dirty hack to bypass nuster cache
const cacheControl = parseCacheControl( _.get( res.getHeaders(), 'cache-control', '' ) )
if ( /*!cacheControl['no-store'] &&*/ ( cacheControl[ 'private' ] || cacheControl[ 'no-cache' ] || cacheControl[ 'must-revalidate' ] ) ) {
res.status( 201 )
}
}
original_send( body )
}
const original_set = res.set.bind( res )
res.set = ( field, value ) => {
debugIntercept( 'set %y = %y', field, value )
if ( !value && typeof field == 'object' ) {
for ( let key in field ) {
original_set( key, field[ key ] )
}
} else {
original_set( field, value )
}
}
const original_header = res.header.bind( res )
res.header = ( field, value ) => {
debugIntercept( 'header %y = %y', field, value )
if ( !value && typeof field == 'object' ) {
for ( let key in field ) {
original_header( key, field[ key ] )
}
} else {
original_header( field, value )
}
}
const original_setHeader = res.setHeader.bind( res )
res.setHeader = ( field, value ) => {
debugIntercept( 'setHeader %y = %y', field, value )
if ( !value && typeof field == 'object' ) {
for ( let key in field ) {
original_setHeader( key, field[ key ] )
}
} else {
original_setHeader( field, value )
}
}
res.set( 'x-powered-by', poweredBy )
}
function invalidateURL( url ) {
if ( proxyType == 'internal' ) {
const parsed = urlParse( url )
if ( parsed ) {
const deletedValues = yonderboxCache.purgeKey( parsed.path )
debugCacheInvalidation( 'proxy purge (PROXY_TYPE %y) deleted %y', proxyType, deletedValues )
}
} else if ( proxyType == 'nuster' ) {
const gotURL = url
const gotOptions = {
method: 'PURGE',
headers: {
// host: 'localhost',
'user-agent': `${pkg.name}/${pkg.version}`
}
}
debugCacheInvalidation( 'proxy purge (PROXY_TYPE %y) %y %y', proxyType, gotURL, gotOptions )
return got( gotURL, gotOptions )
} else if ( proxyType == 'cloudflare' ) {
// https://api.cloudflare.com/#zone-purge-files-by-url
const gotURL = `https://api.cloudflare.com/client/v4/zones/${process.env.CLOUDFLARE_ZONE_ID}/purge_cache`
const gotOptions = {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CLOUDFLARE_PURGE_TOKEN}`,
'Content-Type': 'application/json',
},
json: {
files: [
url,
],
},
}
debugCacheInvalidation( 'proxy purge (PROXY_TYPE %y) %y', proxyType, url )
return got.post( gotURL, gotOptions )
} else {
debugCacheInvalidation( 'NO proxy purge (as PROXY_TYPE %y is unknown) %y', proxyType, url )
}
}
const yonderboxCache = {
create,
get,
set,
expressMiddleware,
flushAll,
purgeKey,
getStats,
endPoints,
instrumentResponse,
invalidateURL,
}
export default yonderboxCache