Files
vue-seo-layer/server/lib/resolve.ts
Jakub Zych d1629439c7 feat: root files from the CMS and the 404 report
- server/middleware/seo-root.ts proxies /robots.txt, /sitemap.xml,
  /sitemap-<name>.xml, /llms.txt and the verification and IndexNow key
  files the site answer lists in root_files, with the backend's status,
  content type and X-Robots-Tag; a backend failure is 503 Retry-After 60
- server/lib/site.ts caches GET /_seo/api/v1/site (300 s, cleared by the
  purge); a backend without it (404) keeps the v0.1.1 behaviour: no root
  proxies, the verification lookup by name
- the 404 report and the verification lookup honour seo.cms and rootFiles
- smoke runs for the layer off (seo.cms and the v0.1.1 enabled switch), the
  CMS down (fail open) and no 404 token; npm run purge:check
2026-11-04 09:26:00 +01:00

70 lines
3.0 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'
import { clearSiteCache, siteCacheSize } from './site.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 (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<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)
}