Portfolio/server/utils/cache.ts
2026-09-08 19:54:06 +02:00

72 lines
1.8 KiB
TypeScript

/**
* A tiny stale-while-revalidate cache.
*
* The blog lives in a git repo we do not own the request cycle of, so:
* - fresh entries are served straight away
* - stale entries are served immediately and refreshed in the background
* - if the refresh fails we keep serving the stale copy rather than 500ing
* - the webhook calls `bustCache()` so a push shows up without a redeploy
*/
interface Entry<T> {
value: T
storedAt: number
}
const store = new Map<string, Entry<unknown>>()
const inflight = new Map<string, Promise<unknown>>()
export async function cached<T>(key: string, ttlSeconds: number, load: () => Promise<T>): Promise<T> {
const ttl = Math.max(0, ttlSeconds) * 1000
const hit = store.get(key) as Entry<T> | undefined
const isFresh = hit && Date.now() - hit.storedAt < ttl
if (hit && isFresh) return hit.value
const refresh = () => {
const running = inflight.get(key) as Promise<T> | undefined
if (running) return running
const p = load()
.then((value) => {
store.set(key, { value, storedAt: Date.now() })
return value
})
.finally(() => inflight.delete(key))
inflight.set(key, p)
return p
}
// nothing cached yet — we have to wait
if (!hit) return refresh()
// stale: hand back the old copy, warm the new one behind it
refresh().catch((error) => {
console.error(`[cache] background refresh of "${key}" failed:`, error)
})
return hit.value
}
export function bustCache(prefix?: string): number {
if (!prefix) {
const n = store.size
store.clear()
return n
}
let n = 0
for (const key of store.keys()) {
if (key.startsWith(prefix)) {
store.delete(key)
n++
}
}
return n
}
export function cacheKeys(): string[] {
return [...store.keys()]
}