// The server side of the resolve call: GET /resolve?url= 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' import { clearSiteCache, siteCacheSize } from './site.ts' /** Most resolve answers kept. */ export const RESOLVE_CACHE_MAX = 1000 let cache: Lru | null = null let cacheTtl = -1 function resolveCache(ttlSeconds: number): Lru { if (!cache || cacheTtl !== ttlSeconds) { cache = createLru({ max: RESOLVE_CACHE_MAX, ttlMs: ttlSeconds * 1000 }) cacheTtl = ttlSeconds } return cache } /** Empties the layer's SEO caches (resolve answers and the site answer); the site cache purge calls it. */ export function clearSeoCaches(): void { cache?.clear() clearSiteCache() } /** How many answers the layer's caches hold (tests and diagnostics). */ export function seoCacheSizes(): { resolve: number; site: number } { return { resolve: cache?.size ?? 0, site: siteCacheSize() } } /** The x-forwarded-for value the SEO calls carry for this request. */ export function seoForwardedFor(event: H3Event): Record { const recorded = event.context.seoForwardedFor as string | undefined return recorded ? { 'x-forwarded-for': recorded } : {} } async function fetchResolve(cfg: SeoServerConfig, url: string, headers: Record): Promise { 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 { 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) }