feat: shared site cache purge route clears the SEO caches

- POST /api/__site__/cache/purge, its library and the site config plugin
  moved from vue-journal-layer with the same contract (token rules, 404 for
  proxied requests unless allowProxied, deprecated siteCache names); the
  route also clears the resolve cache
- server/plugins/seo-cache-purge.ts clears the SEO caches after any 200
  purge, so an app whose journal, menu or FAQ layer copy of the route wins
  keeps fresh SEO answers until those copies are removed
- cachePurge and siteCache runtime keys; /api/__site__/** never cached
- testing/purge-check.mjs (CMS answer change, plus a redirect added for a
  route-cached page applying at once) and purge-token-check.mjs
This commit is contained in:
Jakub Zych
2026-11-04 09:23:15 +01:00
parent e3baa7028c
commit 691fa09b0f
9 changed files with 1265 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
// Clears the SEO layer's caches after every successful site cache purge,
// whichever layer's copy of POST /api/__site__/cache/purge answered it.
//
// vue-journal-layer, vue-menu-layer and vue-faq-layer ship their own copy of
// the shared purge route until they extend this layer, and Nitro keeps one
// handler per route and method, so in an app that extends several of them
// this layer's route (which clears the caches itself) may not be the one
// that runs. The journal's deprecated POST /api/__journal__/cache/purge
// purges the route cache too, so it clears them as well. Clearing twice is
// harmless.
import { clearSeoCaches } from '../lib/resolve.ts'
const PURGE_PATHS = new Set(['/api/__site__/cache/purge', '/api/__journal__/cache/purge'])
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('afterResponse', (event) => {
if (event.method !== 'POST') return
if (!PURGE_PATHS.has((event.path.split('?')[0] ?? '').replace(/\/+$/, ''))) return
if (event.node.res.statusCode === 200) clearSeoCaches()
})
})

View File

@@ -0,0 +1,48 @@
// Keeps the site cache purge settings out of the public site config.
//
// nuxt-site-config (pulled in by @nuxtjs/seo) copies every NUXT_SITE_* and
// NUXT_PUBLIC_SITE_* environment variable into the site config, camel-cased,
// and Nuxt serializes the site config into every page's payload (and into
// window.__NUXT_SITE_CONFIG__ on client-only pages). The purge token's
// earlier name NUXT_SITE_CACHE_PURGE_TOKEN therefore reached every page as
// cachePurgeToken, and NUXT_SITE_CACHE_ALLOW_PROXIED as cacheAllowProxied.
// The settings now live under NUXT_CACHE_PURGE_TOKEN and
// NUXT_CACHE_PURGE_ALLOW_PROXIED, outside that prefix. The old names keep
// working for one release, so this plugin also removes the two keys from
// every entry of the site config stack on nuxt-site-config's
// `site-config:init` hook, before anything reads it. The stripping stays
// with the new names too: a site that still sets an old variable, or sets
// both, must not publish it.
//
// When a setting comes from an old name, the plugin logs one deprecation
// warning per process (globalThis[SITE_PURGE_WARNED], so a site extending
// several layers that ship this file warns once). The warning names
// variables, never a value.
//
// Without nuxt-site-config the hook is never called and the plugin does
// nothing else. Shared: several layers ship this file byte for byte;
// running it more than once is harmless.
import { SITE_PURGE_WARNED, redactSiteConfigStack, resolveSitePurgeConfig, sitePurgeDeprecationMessage } from '../lib/siteCachePurge.ts'
interface SiteConfigInitContext {
siteConfig?: { stack?: unknown }
}
type LooseHooks = { hook: (name: string, fn: (ctx: SiteConfigInitContext | undefined) => void) => unknown }
export default defineNitroPlugin((nitroApp) => {
const g = globalThis as Record<symbol, unknown>
if (!g[SITE_PURGE_WARNED]) {
const message = sitePurgeDeprecationMessage(resolveSitePurgeConfig(useRuntimeConfig()).deprecated)
if (message) {
g[SITE_PURGE_WARNED] = true
console.warn(message)
}
}
// The hook name is untyped in an app without nuxt-site-config.
;(nitroApp.hooks as unknown as LooseHooks).hook('site-config:init', (ctx) => {
redactSiteConfigStack(ctx?.siteConfig?.stack)
})
})