UNPKG

@yonderbox/cache

Version:
321 lines (285 loc) 10.1 kB
'use strict' 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