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)
This commit is contained in:
Jakub Zych
2026-11-04 09:20:20 +01:00
parent 160b53b2f5
commit e3baa7028c
7 changed files with 401 additions and 9 deletions

View File

@@ -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 },
},
})

104
server/lib/lru.ts Normal file
View File

@@ -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<V> {
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<V>, cacheable?: (value: V) => boolean): Promise<V>
}
export function createLru<V>({ max, ttlMs, now = Date.now }: LruOptions): Lru<V> {
const entries = new Map<string, { value: V; expires: number }>()
const inflight = new Map<string, Promise<V>>()
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<V>
try {
first = Promise.resolve(loader())
} catch (err) {
first = Promise.reject(err)
}
const p: Promise<V> = 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
},
}
}

View File

@@ -1,9 +1,12 @@
// The server side of the resolve call: GET <apiBase>/resolve?url=<path and
// query>, 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<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> {
@@ -34,5 +57,6 @@ async function fetchResolve(cfg: SeoServerConfig, url: string, headers: Record<s
export async function resolveCached(event: H3Event, url: string): Promise<SeoResolveAnswer | null> {
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)
}

View File

@@ -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' })
}
})

View File

@@ -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=</script><script>alert
# Redirect decision before the page (and its route cache): the Location
# comes from the answer only; a target on the site's origin becomes a path,
# another host stays absolute; a crafted Host or X-Forwarded-Host changes
# nothing. No X-Robots-Tag on a redirect.
301 /stary header=Location: /nowy noheader=X-Robots-Tag
301 /stary reqheader=Host: evil.test reqheader=X-Forwarded-Host: evil.test reqheader=X-Forwarded-Proto: http header=Location: /nowy
302 /stary-zewnetrzny header=Location: https://partner.test/oferta?a=1
# 410 with the app's error page, also on staging with the noindex header.
410 /usuniety
410 /usuniety-staging header=X-Robots-Tag: noindex, nofollow
# A page's x_robots_tag becomes the header.
200 /blog/ukryty header=X-Robots-Tag: noindex, nofollow contains=<meta name="robots" content="noindex, nofollow">
# not_found: a pagination.invalid answer is a 404 (also on a page that would
# render); any other not_found is left to Nuxt's routing.
404 /plain?page=abc
200 /plain?page=2 contains=<title>Plain page | Playground</title>
404 /nieznana-strona
# The resolve cache: every request above to the tracer page made one call.
resolves /blog/lwia-grzywa 1
resolves /stary 1
# The Nuxt-server resolve route answers through the same cache.
200 /api/__seo__/resolve?url=/blog/lwia-grzywa contains="title":"Lwia grzywa | Example" header=X-Robots-Tag: noindex
resolves /blog/lwia-grzywa 1
422 /api/__seo__/resolve?url=https://evil.test/

View File

@@ -81,5 +81,103 @@
"path": "/blog/lwia-grzywa"
}
]
},
"/stary": {
"action": "redirect",
"status": 301,
"location": "https://example.test/nowy",
"url": "https://example.test/stary",
"locale": "",
"staging": false,
"seo": null
},
"/stary-zewnetrzny": {
"action": "redirect",
"status": 302,
"location": "https://partner.test/oferta?a=1",
"url": "https://example.test/stary-zewnetrzny",
"locale": "",
"staging": false,
"seo": null
},
"/usuniety": {
"action": "gone",
"status": 410,
"url": "https://example.test/usuniety",
"locale": "",
"staging": false,
"seo": null
},
"/usuniety-staging": {
"action": "gone",
"status": 410,
"url": "https://example.test/usuniety-staging",
"locale": "",
"staging": true,
"seo": null
},
"/plain?page=abc": {
"action": "not_found",
"status": 404,
"url": "https://example.test/plain?page=abc",
"locale": "pl",
"staging": false,
"seo": null,
"pagination": {
"form": "query",
"param": "page",
"segment": "",
"page": 0,
"invalid": true
}
},
"/blog/ukryty": {
"action": "ok",
"status": 200,
"url": "https://example.test/blog/ukryty",
"locale": "pl",
"staging": false,
"seo": {
"title": "Ukryty | Example",
"description": "Lwia grzywa | Example opis",
"canonical": "https://example.test/blog/ukryty",
"robots": "noindex, nofollow",
"x_robots_tag": "noindex, nofollow",
"og": {
"title": "Lwia grzywa | Example",
"description": "Lwia grzywa | Example opis",
"image": "https://example.test/img/og.png",
"url": "https://example.test/blog/ukryty",
"locale": "pl_PL",
"locale_alternates": [
"en_US"
]
},
"twitter": {
"card": "summary_large_image",
"site": "@example"
},
"verification": []
},
"html_lang": "pl",
"alternates": [],
"jsonld": null,
"breadcrumbs": [
{
"name": "Start",
"url": "https://example.test/",
"path": "/"
},
{
"name": "Blog",
"url": "https://example.test/blog",
"path": "/blog"
},
{
"name": "Lwia grzywa",
"url": "https://example.test/blog/lwia-grzywa",
"path": "/blog/lwia-grzywa"
}
]
}
}

105
tests/lru.test.ts Normal file
View File

@@ -0,0 +1,105 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createLru } from '../server/lib/lru.ts'
function clock(start = 1000) {
let t = start
return { now: () => t, advance: (ms: number) => (t += ms) }
}
test('lru: get answers a value within its TTL and nothing after it', () => {
const c = clock()
const lru = createLru<string>({ max: 10, ttlMs: 60_000, now: c.now })
lru.set('/a', 'A')
assert.equal(lru.get('/a'), 'A')
c.advance(59_999)
assert.equal(lru.get('/a'), 'A')
c.advance(1)
assert.equal(lru.get('/a'), undefined)
assert.equal(lru.size, 0)
})
test('lru: the least recently used entry goes first when full', () => {
const lru = createLru<number>({ max: 2, ttlMs: 60_000 })
lru.set('a', 1)
lru.set('b', 2)
assert.equal(lru.get('a'), 1) // a is now the most recent
lru.set('c', 3)
assert.equal(lru.get('b'), undefined)
assert.equal(lru.get('a'), 1)
assert.equal(lru.get('c'), 3)
assert.equal(lru.size, 2)
})
test('lru: load calls the loader once for concurrent requests of one key', async () => {
const lru = createLru<string>({ max: 10, ttlMs: 60_000 })
let calls = 0
let release: (v: string) => void = () => {}
const loader = () => {
calls++
return new Promise<string>((r) => (release = r))
}
const one = lru.load('/x', loader)
const two = lru.load('/x', loader)
release('X')
assert.deepEqual(await Promise.all([one, two]), ['X', 'X'])
assert.equal(calls, 1)
assert.equal(await lru.load('/x', loader), 'X')
assert.equal(calls, 1, 'a cached value needs no call')
})
test('lru: a value the cacheable test refuses is not stored, and a failed load is not cached', async () => {
const lru = createLru<string | null>({ max: 10, ttlMs: 60_000 })
let calls = 0
await lru.load('/n', async () => (calls++, null), (v) => v !== null)
await lru.load('/n', async () => (calls++, null), (v) => v !== null)
assert.equal(calls, 2)
await assert.rejects(lru.load('/e', async () => Promise.reject(new Error('down'))))
assert.equal(await lru.load('/e', async () => 'ok'), 'ok')
})
test('lru: clear empties the cache and an answer started before it is never stored', async () => {
const lru = createLru<string>({ max: 10, ttlMs: 60_000 })
let release: (v: string) => void = () => {}
const stale = lru.load('/p', () => new Promise<string>((r) => (release = r)))
lru.clear()
release('stale')
assert.equal(await stale, 'stale', 'the request in flight still gets its answer')
assert.equal(lru.get('/p'), undefined, 'but it is not cached after the purge')
let calls = 0
assert.equal(await lru.load('/p', async () => (calls++, 'fresh')), 'fresh')
assert.equal(calls, 1)
})
test('lru: a load after clear does not join a load started before it', async () => {
const lru = createLru<string>({ max: 10, ttlMs: 60_000 })
let releaseOld: (v: string) => void = () => {}
const old = lru.load('/q', () => new Promise<string>((r) => (releaseOld = r)))
lru.clear()
const fresh = lru.load('/q', async () => 'new')
releaseOld('old')
assert.equal(await old, 'old')
assert.equal(await fresh, 'new')
assert.equal(lru.get('/q'), 'new')
})
test('lru: ttl 0 caches nothing but still shares a load in flight', async () => {
const lru = createLru<string>({ max: 10, ttlMs: 0 })
let calls = 0
const loader = async () => (calls++, 'v')
await Promise.all([lru.load('/z', loader), lru.load('/z', loader)])
assert.equal(calls, 1)
await lru.load('/z', loader)
assert.equal(calls, 2)
lru.set('/z', 'w')
assert.equal(lru.get('/z'), undefined)
})
test('lru: a loader that throws at once rejects and leaves nothing in flight', async () => {
const lru = createLru<string>({ max: 10, ttlMs: 60_000 })
const boom = (() => {
throw new Error('sync')
}) as unknown as () => Promise<string>
await assert.rejects(lru.load('/s', boom), /sync/)
assert.equal(await lru.load('/s', async () => 'after'), 'after')
})