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:
@@ -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
104
server/lib/lru.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
105
tests/lru.test.ts
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user