import express from 'express' ; import { OutgoingHttpHeaders } from 'http' ; import { isTestInstance , parseDurationToMs } from '@server/helpers/core-utils' ; import { logger } from '@server/helpers/logger' ; import { Redis } from '@server/lib/redis' ; import { HttpStatusCode } from '@shared/models' ; import { asyncMiddleware } from '@server/middlewares' ; export interface APICacheOptions { headerBlacklist ? : string [ ] ; excludeStatus ? : HttpStatusCode [ ] ; } interface CacheObject { status : number ; headers : OutgoingHttpHeaders ; data : any ; encoding : BufferEncoding ; timestamp : number ; } export class ApiCache { private readonly options : APICacheOptions ; private readonly timers : { [ id ] : NodeJS . Timeout ; } = { } ; private index : { all : string [ ] ; } = { all : [ ] } ; constructor ( options ) { this . options = { headerBlacklist : [ ] , excludeStatus : [ ] , ... options } ; } buildMiddleware ( strDuration ) { const duration = parseDurationToMs ( strDuration ) ; return asyncMiddleware ( async ( req , res , next ) => { const key = Redis . Instance . getPrefix ( ) + 'api-cache-' + req . originalUrl ; const redis = Redis . Instance . getClient ( ) ; if ( ! Redis . Instance . isConnected ( ) ) return this . makeResponseCacheable ( res , next , key , duration ) ; try { const obj = await redis . hGetAll ( key ) ; if ( obj ?. response ) { return this . sendCachedResponse ( req , res , JSON . parse ( obj . response ) , duration ) ; } return this . makeResponseCacheable ( res , next , key , duration ) ; } catch ( err ) { return this . makeResponseCacheable ( res , next , key , duration ) ; } } ) ; } private shouldCacheResponse ( response ) { if ( ! response ) return false ; if ( this . options . excludeStatus . includes ( response . statusCode ) ) return false ; return true ; } private addIndexEntries ( key ) { this . index . all . unshift ( key ) ; } private filterBlacklistedHeaders ( headers ) { return Object . keys ( headers ) . filter ( key => ! this . options . headerBlacklist . includes ( key ) ) . reduce ( ( acc , header ) => { acc [ header ] = headers [ header ] ; return acc ; } , { } ) ; } private createCacheObject ( status , headers , data , encoding ) { return { status , headers : this . filterBlacklistedHeaders ( headers ) , data , encoding , timestamp : new Date ( ) . getTime ( ) / 1000 } as CacheObject ; } private async cacheResponse ( key , value , duration ) { const redis = Redis . Instance . getClient ( ) ; if ( Redis . Instance . isConnected ( ) ) { await Promise . all ( [ redis . hSet ( key , 'response' , JSON . stringify ( value ) ) , redis . hSet ( key , 'duration' , duration + '' ) , redis . expire ( key , duration / 1000 ) ] ) ; } this . timers [ key ] = setTimeout ( ( ) => { this . clear ( key ) . catch ( err => logger . error ( 'Cannot clear Redis key %s.' , key , { err } ) ) ; } , Math . min ( duration , 2147483647 ) ) ; } private accumulateContent ( res , content ) { if ( ! content ) return ; if ( typeof content === 'string' ) { res . locals . apicache . content = ( res . locals . apicache . content || '' ) + content ; return ; } if ( Buffer . isBuffer ( content ) ) { let oldContent = res . locals . apicache . content ; if ( typeof oldContent === 'string' ) { oldContent = Buffer . from ( oldContent ) ; } if ( ! oldContent ) { oldContent = Buffer . alloc ( 0 ) ; } res . locals . apicache . content = Buffer . concat ( [ oldContent , content ] , oldContent . length + content . length ) ; return ; } res . locals . apicache . content = content ; } private makeResponseCacheable ( res , next , key , duration ) { const self = this ; res . locals . apicache = { write : res . write , writeHead : res . writeHead , end : res . end , cacheable : true , content : undefined , headers : { } } ; res . writeHead = function ( ) { if ( self . shouldCacheResponse ( res ) ) { res . setHeader ( 'cache-control' , 'max-age=' + ( duration / 1000 ) . toFixed ( 0 ) ) ; } else { res . setHeader ( 'cache-control' , 'no-cache, no-store, must-revalidate' ) ; } res . locals . apicache . headers = Object . assign ( { } , res . getHeaders ( ) ) ; return res . locals . apicache . writeHead . apply ( this , arguments as any ) ; } ; res . write = function ( chunk ) { self . accumulateContent ( res , chunk ) ; return res . locals . apicache . write . apply ( this , arguments as any ) ; } ; res . end = function ( content , encoding ) { if ( self . shouldCacheResponse ( res ) ) { self . accumulateContent ( res , content ) ; if ( res . locals . apicache . cacheable && res . locals . apicache . content ) { self . addIndexEntries ( key ) ; const headers = res . locals . apicache . headers || res . getHeaders ( ) ; const cacheObject = self . createCacheObject ( res . statusCode , headers , res . locals . apicache . content , encoding ) ; self . cacheResponse ( key , cacheObject , duration ) . catch ( err => logger . error ( 'Cannot cache response' , { err } ) ) ; } } res . locals . apicache . end . apply ( this , arguments as any ) ; } as any ; next ( ) ; } private sendCachedResponse ( request , response , cacheObject , duration ) { const headers = response . getHeaders ( ) ; if ( isTestInstance ( ) ) { Object . assign ( headers , { 'x-api-cache-cached' : 'true' } ) ; } Object . assign ( headers , this . filterBlacklistedHeaders ( cacheObject . headers || { } ) , { 'cache-control' : 'max-age=' + Math . max ( 0 , ( duration / 1000 - ( new Date ( ) . getTime ( ) / 1000 - cacheObject . timestamp ) ) ) . toFixed ( 0 ) } ) ; let data = cacheObject . data ; if ( data && data . type === 'Buffer' ) { data = typeof data . data === 'number' ? Buffer . alloc ( data . data ) : Buffer . from ( data . data ) ; } const cachedEtag = cacheObject . headers . etag ; const 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 ) ; } private async clear ( target ) { const redis = Redis . Instance . getClient ( ) ; if ( target ) { clearTimeout ( this . timers [ target ] ) ; delete this . timers [ target ] ; try { await redis . del ( target ) ; } catch ( err ) { logger . error ( 'Cannot delete %s in redis cache.' , target , { err } ) ; } this . index . all = this . index . all . filter ( key => key !== target ) ; } else { for ( const key of this . index . all ) { clearTimeout ( this . timers [ key ] ) ; delete this . timers [ key ] ; try { await redis . del ( key ) ; } catch ( err ) { logger . error ( 'Cannot delete %s in redis cache.' , key , { err } ) ; } } this . index . all = [ ] ; } return this . index ; } }