Files
vue-seo-layer/server/lib/resolve.ts
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

63 lines
2.7 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()
}
/** 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)
}