Skip to content

Instantly share code, notes, and snippets.

@kentcdodds
Last active April 19, 2023 04:54
Show Gist options
  • Save kentcdodds/0b68f47e8a4d29a8952e4701865f73d6 to your computer and use it in GitHub Desktop.
Save kentcdodds/0b68f47e8a4d29a8952e4701865f73d6 to your computer and use it in GitHub Desktop.
Turn any function into a cachified one. With forceFresh support and value checking (for when the data type changes). This uses redis, but you could change it to use whatever you want.
type CacheMetadata = {
createdTime: number
maxAge: number | null
expires: number | null
}
function shouldRefresh(metadata: CacheMetadata) {
if (metadata.maxAge) {
return Date.now() > metadata.createdTime + metadata.maxAge
}
if (metadata.expires) {
return Date.now() > metadata.expires
}
return false
}
async function cachified<ReturnValue>(options: {
key: string
getFreshValue: () => Promise<ReturnValue>
checkValue?: (value: ReturnValue) => boolean
forceFresh?: boolean
request?: Request
fallbackToCache?: boolean
timings?: Timings
timingType?: string
maxAge?: number
expires?: Date
}): Promise<ReturnValue> {
const {
key,
getFreshValue,
request,
forceFresh = request ? await shouldForceFresh(request) : false,
checkValue = value => Boolean(value),
fallbackToCache = true,
timings,
timingType = 'getting fresh value',
maxAge,
expires,
} = options
if (!forceFresh) {
try {
const cached = await time({
name: `redis.get(${key})`,
type: 'redis cache read',
fn: () => get(key),
timings,
})
if (cached) {
const cachedParsed = JSON.parse(cached) as {
metadata?: CacheMetadata
value?: ReturnValue
}
if (cachedParsed.metadata && shouldRefresh(cachedParsed.metadata)) {
// time to refresh the value. Fire and forget so we don't slow down
// this request
// originally I thought it may be good to make sure we don't have
// multiple requests for the same key triggering multiple refreshes
// like this, but as I thought about it I realized the liklihood of
// this causing real issues is pretty small (unless there's a failure)
// to update the value, in which case we should probably be notified
// anyway...
void cachified({...options, forceFresh: true})
}
if (cachedParsed.value && checkValue(cachedParsed.value)) {
return cachedParsed.value
} else {
console.warn(
`check failed for cached value of ${key}. Deleting the cache key and trying to get a fresh value.`,
cachedParsed,
)
await del(key)
}
}
} catch (error: unknown) {
console.error(`error with cache at ${key}`, getErrorMessage(error))
}
}
const value = await time({
name: `getFreshValue for ${key}`,
type: timingType,
fn: getFreshValue,
timings,
}).catch((error: unknown) => {
// If we got this far without forceFresh then we know there's nothing
// in the cache so no need to bother. So we need both the option to fallback
// and the ability.
if (fallbackToCache && forceFresh) {
return cachified({...options, forceFresh: false})
} else {
throw error
}
})
if (checkValue(value)) {
const metadata: CacheMetadata = {
maxAge: maxAge ?? null,
expires: expires?.getTime() ?? null,
createdTime: Date.now(),
}
void set(key, JSON.stringify({metadata, value})).catch(error => {
console.error(`error setting redis.${key}`, getErrorMessage(error))
})
} else {
console.error(`check failed for fresh value of ${key}:`, value)
throw new Error(`check failed for fresh value of ${key}`)
}
return value
}
async function shouldForceFresh(request: Request) {
return (
new URL(request.url).searchParams.has('fresh') &&
(await getUser(request))?.role === 'ADMIN'
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment