From e3baa7028cf915bd6177d6c300fdf106eb498f15 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 09:20:20 +0100 Subject: [PATCH] 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) --- .playground/nuxt.config.ts | 11 +++- server/lib/lru.ts | 104 +++++++++++++++++++++++++++++ server/lib/resolve.ts | 30 ++++++++- server/middleware/seo.ts | 36 ++++++++-- testing/expect-seo.txt | 26 ++++++++ testing/fixtures/seo/resolve.json | 98 ++++++++++++++++++++++++++++ tests/lru.test.ts | 105 ++++++++++++++++++++++++++++++ 7 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 server/lib/lru.ts create mode 100644 tests/lru.test.ts diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts index d42f86f..297f9ef 100644 --- a/.playground/nuxt.config.ts +++ b/.playground/nuxt.config.ts @@ -1,9 +1,18 @@ // Local playground: an app that extends the layer and sets its own head on // each page (pages/), so the smoke can see the layer's tags win where the // resolve answer has a value and the page's stay where it has none. -// testing/smoke-ssr.mjs builds it against the fixture API. +// testing/smoke-ssr.mjs builds it against the fixture API. The /blog pages +// are route-cached (swr) as on a production site, so the smoke and +// testing/purge-check.mjs see redirects, 410 and purges apply to cached +// pages. export default defineNuxtConfig({ extends: ['..'], compatibilityDate: '2025-07-15', app: { head: { titleTemplate: '%s | Playground' } }, + routeRules: { + // Not '/**': a route-cached render has no Host header, so the v0.1.1 + // check that a path canonical becomes absolute on the request origin + // (testing/expect.txt, /relative) needs uncached pages. + '/blog/**': { swr: 300 }, + }, }) diff --git a/server/lib/lru.ts b/server/lib/lru.ts new file mode 100644 index 0000000..e81a998 --- /dev/null +++ b/server/lib/lru.ts @@ -0,0 +1,104 @@ +// A small in-memory LRU with a TTL, a size cap and in-flight +// de-duplication, for the resolve and site answers. Pure TypeScript (no +// Nitro imports), so `node --test` drives it directly. +// +// clear() (the site cache purge) bumps a generation: a load that started +// before it still answers its caller, but its value is never stored, and a +// load after it never joins the older one. So no request after a purge sees +// an answer fetched before it. + +export interface LruOptions { + /** Most entries kept; the least recently used goes first. */ + max: number + /** How long an entry is fresh, in milliseconds; 0 or less stores nothing. */ + ttlMs: number + /** The clock (Date.now); tests pass their own. */ + now?: () => number +} + +export interface Lru { + get(key: string): V | undefined + set(key: string, value: V): void + delete(key: string): void + /** Empties the cache and detaches the loads in flight. */ + clear(): void + readonly size: number + /** + * The cached value for `key`, else the loader's, shared by concurrent + * callers of the same key. The value is stored when `cacheable` accepts + * it (default: always); a rejected loader stores nothing. + */ + load(key: string, loader: () => Promise, cacheable?: (value: V) => boolean): Promise +} + +export function createLru({ max, ttlMs, now = Date.now }: LruOptions): Lru { + const entries = new Map() + const inflight = new Map>() + let generation = 0 + const limit = Math.max(1, Math.floor(max)) + + const get = (key: string): V | undefined => { + const hit = entries.get(key) + if (!hit) return undefined + if (hit.expires <= now()) { + entries.delete(key) + return undefined + } + // Map keeps insertion order: re-inserting marks the key most recent. + entries.delete(key) + entries.set(key, hit) + return hit.value + } + + const set = (key: string, value: V) => { + if (ttlMs <= 0) return + entries.delete(key) + entries.set(key, { value, expires: now() + ttlMs }) + while (entries.size > limit) { + const oldest = entries.keys().next().value + if (oldest === undefined) break + entries.delete(oldest) + } + } + + return { + get, + set, + delete: (key) => void entries.delete(key), + clear() { + entries.clear() + inflight.clear() + generation++ + }, + get size() { + for (const [key, e] of entries) if (e.expires <= now()) entries.delete(key) + return entries.size + }, + load(key, loader, cacheable = () => true) { + const hit = get(key) + if (hit !== undefined) return Promise.resolve(hit) + const pending = inflight.get(key) + if (pending) return pending + const started = generation + // The loader starts at once; one that throws at once becomes a + // rejection, which settles after `p` is assigned and leaves no entry + // in flight. + let first: Promise + try { + first = Promise.resolve(loader()) + } catch (err) { + first = Promise.reject(err) + } + const p: Promise = first + .then((value) => { + if (generation === started && cacheable(value)) set(key, value) + return value + }) + .finally(() => { + if (inflight.get(key) === p) inflight.delete(key) + }) + inflight.set(key, p) + return p + }, + } +} diff --git a/server/lib/resolve.ts b/server/lib/resolve.ts index 5687f75..1a19afc 100644 --- a/server/lib/resolve.ts +++ b/server/lib/resolve.ts @@ -1,9 +1,12 @@ // The server side of the resolve call: GET /resolve?url=, forwarding the visitor's address so the backend's per-IP throttle +// 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); with the layer switched off nothing is called. +// 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. @@ -12,6 +15,26 @@ 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 | 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 (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 { @@ -34,5 +57,6 @@ async function fetchResolve(cfg: SeoServerConfig, url: string, headers: Record { const cfg = seoServerConfig(useRuntimeConfig(event)) if (!cfg.enabled) return null - return fetchResolve(cfg, url, seoForwardedFor(event)) + const headers = seoForwardedFor(event) + return resolveCache(cfg.resolveCacheTtl).load(url, () => fetchResolve(cfg, url, headers), (answer) => answer !== null) } diff --git a/server/middleware/seo.ts b/server/middleware/seo.ts index 7a950b2..33e9c86 100644 --- a/server/middleware/seo.ts +++ b/server/middleware/seo.ts @@ -1,10 +1,25 @@ -// Resolves every page request through the CMS before Nuxt renders it, so the -// render (and the head) uses one answer and no second call. Skipped: Nuxt's -// and the layers' internal paths (/_nuxt, /__nuxt_error, /_seo …), /api and -// asset files (isPagePath). The answer is left in event.context.seo for the -// app (useSeoResolve reads event.context.seoResolved). +// Resolves every page request through the CMS (resolveCached: LRU and the +// visitor's X-Forwarded-For) and enforces the decision before Nuxt renders +// the page and before Nitro's route cache, so swr-cached pages honour a new +// redirect or 410: +// +// redirect 301 or 302 with Location from the answer (seoRedirectTarget: +// a target on the site's own origin becomes a path, another +// host stays absolute); nothing from the request (Host, path, +// query) ever goes into the Location +// gone 410 with the app's error page +// not_found left to Nuxt's routing, except a pagination.invalid answer +// (?page=abc), which is a 404 +// +// X-Robots-Tag follows the answer (staging: noindex, nofollow everywhere; +// else the page's x_robots_tag), never on a redirect. Skipped: Nuxt's and +// the layers' internal paths (/_nuxt, /__nuxt_error, /_seo …), /api and +// asset files (isPagePath). The answer stays in event.context.seo for the +// render, which then makes no second call (useSeoResolve). With the CMS +// unreachable the page renders normally (fail open). import { isPagePath } from '../../app/utils/pagePath.ts' +import { seoRedirectTarget, seoXRobotsTag } from '../../app/utils/seo.ts' import { seoServerConfig } from '../lib/config.ts' import { resolveCached } from '../lib/resolve.ts' @@ -18,4 +33,15 @@ export default defineEventHandler(async (event) => { const answer = await resolveCached(event, url) event.context.seo = answer event.context.seoResolved = { url, answer } + if (!answer) return + + const target = seoRedirectTarget(answer) + if (target) return sendRedirect(event, target.to, target.code) + + const robots = seoXRobotsTag(answer) + if (robots) setResponseHeader(event, 'x-robots-tag', robots) + if (answer.action === 'gone') throw createError({ statusCode: 410, statusMessage: 'Gone' }) + if (answer.action === 'not_found' && answer.pagination?.invalid === true) { + throw createError({ statusCode: 404, statusMessage: 'Page Not Found' }) + } }) diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt index bd3c9f9..92a0ad6 100644 --- a/testing/expect-seo.txt +++ b/testing/expect-seo.txt @@ -13,3 +13,29 @@ # a new layer node appended; "<" inside the JSON cannot end the script. 200 /blog/lwia-grzywa count=data-hid="seo-jsonld":1 count="@type":"WebPage":1 contains="name":"Lwia grzywa" notcontains=Layer name loses contains="inLanguage":"pl" contains=#itemlist 200 /blog/lwia-grzywa notcontains=