- the Nitro middleware answers redirect (301/302, Location from the answer only, same-site targets as paths as in v0.1.1) and gone (410) before the render and the route cache, sets X-Robots-Tag, and answers 404 for a pagination.invalid answer; other not_found answers stay with Nuxt - resolve answers cached in an LRU (seo.resolveCacheTtl, default 60 s, 1000 entries) with in-flight de-duplication; a clear detaches loads in flight so no answer fetched before a purge is stored after it - the playground route-caches /blog/** (swr 300)
105 lines
3.3 KiB
TypeScript
105 lines
3.3 KiB
TypeScript
// A small in-memory LRU with a TTL, a size cap and in-flight
|
|
// de-duplication, for the resolve and site answers. Pure TypeScript (no
|
|
// Nitro imports), so `node --test` drives it directly.
|
|
//
|
|
// clear() (the site cache purge) bumps a generation: a load that started
|
|
// before it still answers its caller, but its value is never stored, and a
|
|
// load after it never joins the older one. So no request after a purge sees
|
|
// an answer fetched before it.
|
|
|
|
export interface LruOptions {
|
|
/** Most entries kept; the least recently used goes first. */
|
|
max: number
|
|
/** How long an entry is fresh, in milliseconds; 0 or less stores nothing. */
|
|
ttlMs: number
|
|
/** The clock (Date.now); tests pass their own. */
|
|
now?: () => number
|
|
}
|
|
|
|
export interface Lru<V> {
|
|
get(key: string): V | undefined
|
|
set(key: string, value: V): void
|
|
delete(key: string): void
|
|
/** Empties the cache and detaches the loads in flight. */
|
|
clear(): void
|
|
readonly size: number
|
|
/**
|
|
* The cached value for `key`, else the loader's, shared by concurrent
|
|
* callers of the same key. The value is stored when `cacheable` accepts
|
|
* it (default: always); a rejected loader stores nothing.
|
|
*/
|
|
load(key: string, loader: () => Promise<V>, cacheable?: (value: V) => boolean): Promise<V>
|
|
}
|
|
|
|
export function createLru<V>({ max, ttlMs, now = Date.now }: LruOptions): Lru<V> {
|
|
const entries = new Map<string, { value: V; expires: number }>()
|
|
const inflight = new Map<string, Promise<V>>()
|
|
let generation = 0
|
|
const limit = Math.max(1, Math.floor(max))
|
|
|
|
const get = (key: string): V | undefined => {
|
|
const hit = entries.get(key)
|
|
if (!hit) return undefined
|
|
if (hit.expires <= now()) {
|
|
entries.delete(key)
|
|
return undefined
|
|
}
|
|
// Map keeps insertion order: re-inserting marks the key most recent.
|
|
entries.delete(key)
|
|
entries.set(key, hit)
|
|
return hit.value
|
|
}
|
|
|
|
const set = (key: string, value: V) => {
|
|
if (ttlMs <= 0) return
|
|
entries.delete(key)
|
|
entries.set(key, { value, expires: now() + ttlMs })
|
|
while (entries.size > limit) {
|
|
const oldest = entries.keys().next().value
|
|
if (oldest === undefined) break
|
|
entries.delete(oldest)
|
|
}
|
|
}
|
|
|
|
return {
|
|
get,
|
|
set,
|
|
delete: (key) => void entries.delete(key),
|
|
clear() {
|
|
entries.clear()
|
|
inflight.clear()
|
|
generation++
|
|
},
|
|
get size() {
|
|
for (const [key, e] of entries) if (e.expires <= now()) entries.delete(key)
|
|
return entries.size
|
|
},
|
|
load(key, loader, cacheable = () => true) {
|
|
const hit = get(key)
|
|
if (hit !== undefined) return Promise.resolve(hit)
|
|
const pending = inflight.get(key)
|
|
if (pending) return pending
|
|
const started = generation
|
|
// The loader starts at once; one that throws at once becomes a
|
|
// rejection, which settles after `p` is assigned and leaves no entry
|
|
// in flight.
|
|
let first: Promise<V>
|
|
try {
|
|
first = Promise.resolve(loader())
|
|
} catch (err) {
|
|
first = Promise.reject(err)
|
|
}
|
|
const p: Promise<V> = first
|
|
.then((value) => {
|
|
if (generation === started && cacheable(value)) set(key, value)
|
|
return value
|
|
})
|
|
.finally(() => {
|
|
if (inflight.get(key) === p) inflight.delete(key)
|
|
})
|
|
inflight.set(key, p)
|
|
return p
|
|
},
|
|
}
|
|
}
|