Files
vue-seo-layer/server/lib/resolve.ts
Jakub Zych 691fa09b0f feat: shared site cache purge route clears the SEO caches
- POST /api/__site__/cache/purge, its library and the site config plugin
  moved from vue-journal-layer with the same contract (token rules, 404 for
  proxied requests unless allowProxied, deprecated siteCache names); the
  route also clears the resolve cache
- server/plugins/seo-cache-purge.ts clears the SEO caches after any 200
  purge, so an app whose journal, menu or FAQ layer copy of the route wins
  keeps fresh SEO answers until those copies are removed
- cachePurge and siteCache runtime keys; /api/__site__/** never cached
- testing/purge-check.mjs (CMS answer change, plus a redirect added for a
  route-cached page applying at once) and purge-token-check.mjs
2026-11-04 09:23:15 +01:00

68 lines
2.8 KiB
TypeScript

// The server side of the resolve call: GET <apiBase>/resolve?url=<path and
// query> through an LRU (path and query as key, seo.resolveCacheTtl seconds,
// default 60, at most 1000 entries, concurrent requests for one key share
// one call), forwarding the visitor's address so the backend's per-IP throttle
// (seo-resolve) keeps one bucket per visitor, also on route-cached renders
// (server/plugins/seo-client-address.ts records it). Any failure (timeout,
// 4xx, 5xx, an unexpected body) answers null and the page renders without
// CMS head tags (fail open) and is not cached; with the layer switched off
// nothing is called. The site cache purge clears the cache (clearSeoCaches).
//
// Nitro auto-imports (useRuntimeConfig, $fetch) are used inside the
// functions only, so `node --test` can import this file.
import type { H3Event } from 'h3'
import { seoAnswer } from '../../app/utils/seo.ts'
import type { SeoResolveAnswer } from '../../app/utils/types.ts'
import { seoServerConfig, type SeoServerConfig } from './config.ts'
import { createLru, type Lru } from './lru.ts'
/** Most resolve answers kept. */
export const RESOLVE_CACHE_MAX = 1000
let cache: Lru<SeoResolveAnswer | null> | null = null
let cacheTtl = -1
function resolveCache(ttlSeconds: number): Lru<SeoResolveAnswer | null> {
if (!cache || cacheTtl !== ttlSeconds) {
cache = createLru<SeoResolveAnswer | null>({ max: RESOLVE_CACHE_MAX, ttlMs: ttlSeconds * 1000 })
cacheTtl = ttlSeconds
}
return cache
}
/** Empties the layer's SEO caches (the resolve answers); the site cache purge calls it. */
export function clearSeoCaches(): void {
cache?.clear()
}
/** How many answers the layer's caches hold (tests and diagnostics). */
export function seoCacheSizes(): { resolve: number } {
return { resolve: cache?.size ?? 0 }
}
/** The x-forwarded-for value the SEO calls carry for this request. */
export function seoForwardedFor(event: H3Event): Record<string, string> {
const recorded = event.context.seoForwardedFor as string | undefined
return recorded ? { 'x-forwarded-for': recorded } : {}
}
async function fetchResolve(cfg: SeoServerConfig, url: string, headers: Record<string, string>): Promise<SeoResolveAnswer | null> {
try {
return seoAnswer(await $fetch('/resolve', { baseURL: cfg.apiBase, query: { url }, headers, timeout: cfg.timeoutMs }))
} catch {
return null
}
}
/**
* The resolve answer for `url` (path and query), or null on any failure or
* when the layer is off (public.seo.enabled or seo.cms false).
*/
export async function resolveCached(event: H3Event, url: string): Promise<SeoResolveAnswer | null> {
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) return null
const headers = seoForwardedFor(event)
return resolveCache(cfg.resolveCacheTtl).load(url, () => fetchResolve(cfg, url, headers), (answer) => answer !== null)
}