#!/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 --path // // The CMS change is a new title in the fixture resolve answer for the path // (/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 ( 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} */ 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} [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} 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 } | 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 }} */ (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) })