- 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
70 lines
3.0 KiB
TypeScript
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)
|
|
}
|