Files
Jakub Zych e3baa7028c feat: redirect and 410 enforcement, X-Robots-Tag and the resolve cache
- 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)
2026-11-04 09:20:20 +01:00

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
},
}
}