Files
vue-seo-layer/testing/purge-check.mjs
Jakub Zych 691fa09b0f 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
2026-11-04 09:23:15 +01:00

217 lines
10 KiB
JavaScript

#!/usr/bin/env node
// Site cache purge check: proves on a built Nuxt server with swr route rules
// that a page cached before a CMS change shows the change on the first
// request after a purge (the purge empties Nitro's route cache and the SEO
// layer's resolve cache), and that the purge route refuses unauthenticated,
// wrong-token and proxied requests.
//
// node purge-check.mjs --output <.output dir> --fixtures <dir> --path <page path>
//
// The CMS change is a new title in the fixture resolve answer for the path
// (<fixtures>/seo/resolve.json in a temporary copy). It also checks that a
// redirect the CMS adds for a route-cached page applies at once when the
// resolve cache is off (NUXT_SEO_RESOLVE_CACHE_TTL=0): the redirect decision
// runs before the route cache.
//
// It also checks that a cached page revalidates with its ETag (304), that
// a second purge after re-caching runs (the route is never cached itself)
// and that a server without a token answers 404. vue-journal-layer's copy
// also checks browser max-age=0 (its journal-revalidate plugin) and the
// page's _payload.json (which carries Journal data); neither applies here:
// the SEO answer lives in the page's state, not in useAsyncData.
// The fixtures are copied to a temporary directory, so the source fixtures
// never change. The last line is "purge-check: ok (<n> checks)" on success;
// every failure prints "purge-check: FAIL ..." and exits 1.
import { randomBytes } from 'node:crypto'
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { startFixtureApi } from './fixture-api.mjs'
import { startNuxtServer } from './nuxt-server.mjs'
const PURGE_PATH = '/api/__site__/cache/purge'
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {Record<string, string>} */
const out = {}
const names = new Set(['--output', '--fixtures', '--path'])
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!names.has(arg)) throw new Error(`unknown argument ${arg}`)
const v = argv[++i]
if (v === undefined) throw new Error(`${arg} needs a value`)
out[arg.slice(2)] = v
}
for (const name of names) {
if (!out[name.slice(2)]) throw new Error('--output, --fixtures and --path are required')
}
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
return /** @type {{ output: string, fixtures: string, path: string }} */ (out)
}
/**
* Changes the fixture resolve answer for `path`: appends `marker` to its
* title, or (with `redirectTo`) turns it into a 301 redirect.
* @param {string} dir @param {string} path @param {string} marker @param {string} [redirectTo]
*/
function changeAnswer(dir, path, marker, redirectTo) {
const file = join(dir, 'seo', 'resolve.json')
const answers = JSON.parse(readFileSync(file, 'utf8'))
const answer = answers[path]
if (!answer || answer.action !== 'ok') throw new Error(`no ok answer for ${path} in ${file}`)
if (redirectTo) {
answers[path] = { action: 'redirect', status: 301, location: redirectTo, url: answer.url, locale: '', staging: false, seo: null }
} else {
answer.seo.title = `${answer.seo.title} ${marker}`
}
writeFileSync(file, JSON.stringify(answers, null, 2))
}
/** @param {string} base @param {string} path @param {Record<string, string>} [headers] */
async function get(base, path, headers = {}) {
const res = await fetch(base + path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) })
return { status: res.status, headers: res.headers, body: await res.text() }
}
/** @param {string} base @param {{ kind: string, slug: string }} event @param {Record<string, string>} headers */
async function purge(base, event, headers) {
const res = await fetch(base + PURGE_PATH, {
method: 'POST',
headers: { 'content-type': 'application/json', ...headers },
body: JSON.stringify(event),
signal: AbortSignal.timeout(20000),
})
const text = await res.text()
/** @type {any} */
let json = null
try {
json = JSON.parse(text)
} catch {
json = null
}
return { status: res.status, json, text }
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const event = { kind: 'seo', slug: args.path }
const dir = mkdtempSync(join(tmpdir(), 'purge-check-'))
cpSync(resolve(args.fixtures), dir, { recursive: true })
const token = randomBytes(24).toString('hex')
const auth = { authorization: `Bearer ${token}` }
const marker = `purge-check-marker-${randomBytes(6).toString('hex')}`
const second = `purge-check-second-${randomBytes(6).toString('hex')}`
/** @type {string[]} */
const failures = []
let checks = 0
/** @param {boolean} ok @param {string} what */
const check = (ok, what) => {
checks++
if (!ok) failures.push(what)
return ok
}
const api = await startFixtureApi({ dir })
/** @type {{ url: string, close: () => Promise<void> } | null} */
let nuxt = null
// The steps stop at the first failure that makes the later ones
// meaningless; the failures are reported after the servers stop.
const steps = async () => {
nuxt = await startNuxtServer({
outputDir: args.output,
apiBase: `${api.url}/_seo/api/v1`,
env: { NUXT_CACHE_PURGE_TOKEN: token },
})
const base = nuxt.url
const page = args.path
// 1. The page renders and is cached.
const first = await get(base, page)
if (!check(first.status === 200, `GET ${page} before the change: status ${first.status}, want 200`)) return
// 2. Change the answer behind the API; the cached page must not show it.
changeAnswer(dir, page, marker)
api.reload()
const cached = await get(base, page)
check(cached.status === 200, `GET ${page} after the change: status ${cached.status}, want 200`)
if (!check(!cached.body.includes(marker), `GET ${page} shows the change before any purge: the page is not route-cached (missing swr route rules?)`)) return
// 3. An authorized purge removes the cached entries.
const purged = await purge(base, event, auth)
check(purged.status === 200, `authorized purge: status ${purged.status}, want 200 (${purged.text.slice(0, 200)})`)
check(typeof purged.json?.purged === 'number' && purged.json.purged >= 1, `authorized purge: answer ${purged.text.slice(0, 200)}, want purged >= 1`)
// 4. The next request shows the change (route cache and resolve cache
// both emptied) and revalidates with its ETag.
const fresh = await get(base, page)
check(fresh.status === 200 && fresh.body.includes(marker), `GET ${page} after the purge: status ${fresh.status}, change shown: ${fresh.body.includes(marker)}`)
const etag = fresh.headers.get('etag')
if (check(!!etag, `GET ${page} after the purge: no ETag`)) {
const revalidated = await get(base, page, { 'if-none-match': /** @type {string} */ (etag) })
check(revalidated.status === 304, `conditional GET ${page}: status ${revalidated.status}, want 304`)
}
// 5. A second purge after the page was cached again runs too: the purge
// route itself is never route-cached.
changeAnswer(dir, page, second)
api.reload()
const recached = await get(base, page)
check(!recached.body.includes(second), `GET ${page} after the second change: shown before the second purge (page not re-cached)`)
const again = await purge(base, event, auth)
check(again.status === 200 && typeof again.json?.purged === 'number' && again.json.purged >= 1, `second purge: status ${again.status}, answer ${again.text.slice(0, 200)}, want 200 with purged >= 1`)
const fresh2 = await get(base, page)
check(fresh2.body.includes(second), `GET ${page} after the second purge: the second change is not shown (the purge answer came from a cache)`)
// 6. Refused purges.
const none = await purge(base, event, {})
check(none.status === 401, `purge without a token: status ${none.status}, want 401`)
const wrong = await purge(base, event, { authorization: `Bearer ${randomBytes(24).toString('hex')}` })
check(wrong.status === 401, `purge with a wrong token: status ${wrong.status}, want 401`)
const proxied = await purge(base, event, { ...auth, 'x-forwarded-for': '203.0.113.7' })
check(proxied.status === 404, `proxied purge with the right token: status ${proxied.status}, want 404`)
const realIP = await purge(base, event, { ...auth, 'x-real-ip': '203.0.113.7' })
check(realIP.status === 404, `purge with X-Real-IP and the right token: status ${realIP.status}, want 404`)
// 7. A server without a token has no purge route.
await nuxt.close()
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_seo/api/v1`, env: { NUXT_CACHE_PURGE_TOKEN: '' } })
const disabled = await purge(nuxt.url, event, auth)
check(disabled.status === 404, `purge on a server without a token: status ${disabled.status}, want 404`)
// 8. Without the resolve cache, a redirect the CMS adds for a
// route-cached page applies on the next request, without a purge.
await nuxt.close()
nuxt = await startNuxtServer({ outputDir: args.output, apiBase: `${api.url}/_seo/api/v1`, env: { NUXT_SEO_RESOLVE_CACHE_TTL: '0' } })
const before = await get(nuxt.url, page)
check(before.status === 200, `GET ${page} before the redirect: status ${before.status}, want 200`)
changeAnswer(dir, page, '', 'https://moved.test/elsewhere')
api.reload()
const moved = await get(nuxt.url, page)
check(
moved.status === 301 && moved.headers.get('location') === 'https://moved.test/elsewhere',
`GET ${page} after the CMS added a redirect: status ${moved.status}, Location ${moved.headers.get('location')}, want 301 to https://moved.test/elsewhere`,
)
}
try {
await steps()
} finally {
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
await api.close()
rmSync(dir, { recursive: true, force: true })
}
if (failures.length > 0) {
for (const f of failures) console.error(`purge-check: FAIL ${f}`)
process.exit(1)
}
console.log(`purge-check: ok (${checks} checks)`)
}
main().catch((err) => {
console.error(`purge-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})