import LRU from 'lru-cache' ; import { formatDuration , intervalToDuration } from 'date-fns' ; import type { Timings } from './metrics.server' ; import { time } from './metrics.server' ; import { getUser } from './session.server' ; function niceFormatDuration ( milliseconds ) { const duration = intervalToDuration ( { start : 0 , end : milliseconds } ) ; const formatted = formatDuration ( duration , { delimiter : ', ' } ) ; const ms = milliseconds % 1000 ; return [ formatted , ms ? `${ ms . toFixed ( 3 ) }ms` : null ] . filter ( Boolean ) . join ( ', ' ) ; } declare global { var lruCache ; } const lruCache = ( global . lruCache = global . lruCache ? global . lruCache : createLruCache ( ) ) ; function createLruCache ( ) { const newCache = new LRU < string , { metadata : CacheMetadata ; value : any ; } > ( { max : 1000 , maxAge : 1000 * 60 * 60 , } ) ; Object . assign ( newCache , { name : 'LRU' } ) ; return newCache as typeof newCache & { name : 'LRU' ; } ; } type CacheMetadata = { createdTime : number ; maxAge : number | null ; } ; function shouldRefresh ( metadata ) { if ( metadata . maxAge ) { return Date . now ( ) > metadata . createdTime + metadata . maxAge ; } return false ; } type VNUP < Value > = Value | null | undefined | Promise < Value | null | undefined > ; const keysRefreshing = new Set ( ) ; async function cachified < Value , Cache extends { name : string ; get : ( key : string ) => VNUP < { metadata : CacheMetadata ; value : Value ; } > ; set : ( key : string , value : { metadata : CacheMetadata ; value : Value ; } ) => unknown | Promise < unknown > ; del : ( key : string ) => unknown | Promise < unknown > ; } > ( options ) { const { key , cache , getFreshValue , request , checkValue = value => Boolean ( value ) , fallbackToCache = true , timings , timingType = 'getting fresh value' , maxAge , } = options ; const forceFresh = typeof options . forceFresh === 'string' ? options . forceFresh . split ( ',' ) . includes ( key ) : options . forceFresh ?? ( request ? await shouldForceFresh ( request , key ) : false ) ; function assertCacheEntry ( entry ) { if ( typeof entry !== 'object' || entry === null ) { throw new Error ( `Cache entry for ${ key } is not a cache entry object, it's a ${ typeof entry }` ) ; } if ( ! ( 'metadata' in entry ) ) { throw new Error ( `Cache entry for ${ key } does not have a metadata property` ) ; } if ( ! ( 'value' in entry ) ) { throw new Error ( `Cache entry for ${ key } does not have a value property` ) ; } } if ( ! forceFresh ) { try { const cached = await time ( { name : `cache.get(${ key })` , type : 'cache read' , fn : ( ) => cache . get ( key ) , timings , } ) ; if ( cached ) { assertCacheEntry ( cached ) ; if ( shouldRefresh ( cached . metadata ) ) { if ( ! keysRefreshing . has ( key ) ) { keysRefreshing . add ( key ) ; setTimeout ( ( ) => { void cachified ( Object . assign ( { } , options , { forceFresh : true } ) ) . catch ( ( ) => { } ) . finally ( ( ) => { keysRefreshing . delete ( key ) ; } ) ; } , 200 ) ; } } const valueCheck = checkValue ( cached . value ) ; if ( valueCheck === true ) { return cached . value ; } else { const reason = typeof valueCheck === 'string' ? valueCheck : 'unknown' ; console . warn ( `check failed for cached value of ${ key }\nReason: ${ reason }.\nDeleting the cache key and trying to get a fresh value.` , cached ) ; await cache . del ( key ) ; } } } catch ( error ) { console . error ( `error with cache at ${ key }. Deleting the cache key and trying to get a fresh value.` , error ) ; await cache . del ( key ) ; } } const start = performance . now ( ) ; const value = await time ( { name : `getFreshValue for ${ key }` , type : timingType , fn : getFreshValue , timings , } ) . catch ( ( error ) => { console . error ( `getting a fresh value for ${ key } failed` , { fallbackToCache , forceFresh } , error ) ; if ( fallbackToCache && forceFresh ) { return cachified ( { ... options , forceFresh : false } ) ; } else { throw error ; } } ) ; const totalTime = performance . now ( ) - start ; const valueCheck = checkValue ( value ) ; if ( valueCheck === true ) { const metadata = { maxAge : maxAge ?? null , createdTime : Date . now ( ) , } ; try { console . log ( `Updating the cache value for ${ key }.` , `Getting a fresh value for this took ${ niceFormatDuration ( totalTime ) }.` , `Caching for a minimum of ${ typeof maxAge === 'number' ? `${ niceFormatDuration ( maxAge ) }` : 'forever' } in ${ cache . name }.` ) ; await cache . set ( key , { metadata , value } ) ; } catch ( error ) { console . error ( `error setting cache: ${ key }` , error ) ; } } else { const reason = typeof valueCheck === 'string' ? valueCheck : 'unknown' ; console . error ( `check failed for cached value of ${ key }\nReason: ${ reason }.\nDeleting the cache key and trying to get a fresh value.` , value ) ; throw new Error ( `check failed for fresh value of ${ key }` ) ; } return value ; } async function shouldForceFresh ( request , key ) { const fresh = new URL ( request . url ) . searchParams . get ( 'fresh' ) ; if ( typeof fresh !== 'string' ) return false ; if ( ( await getUser ( request ) ) ?. role !== 'ADMIN' ) return false ; if ( fresh === '' ) return true ; return fresh . split ( ',' ) . includes ( key ) ; } export { cachified , lruCache } ;