- POST /api/__journal__/cache/purge removes every Nitro route-cache entry - 404 without a 32-character token or behind a proxy, 401 on a wrong bearer token (constant-time over sha256 digests) - routeRules keep /api/__journal__/** out of the route cache - runtimeConfig.journal.cachePurgeToken (NUXT_JOURNAL_CACHE_PURGE_TOKEN) - fixture API reload(); testing/purge-check.mjs proves a cached page shows a change after a purge; the playground caches with swr 300
168 lines
7.0 KiB
JavaScript
168 lines
7.0 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 content change shows the change on the first
|
|
// request after a purge, and that the purge route refuses unauthenticated,
|
|
// wrong-token and proxied requests.
|
|
//
|
|
// node purge-check.mjs --output <.output dir> --fixtures <dir> --kind post|category --slug <slug> --path <page path>
|
|
//
|
|
// 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/__journal__/cache/purge'
|
|
|
|
/** @param {string[]} argv */
|
|
function parseArgs(argv) {
|
|
/** @type {Record<string, string>} */
|
|
const out = {}
|
|
const names = new Set(['--output', '--fixtures', '--kind', '--slug', '--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, --kind, --slug and --path are required')
|
|
}
|
|
if (out.kind !== 'post' && out.kind !== 'category') throw new Error(`--kind must be post or category, got ${out.kind}`)
|
|
if (!out.path.startsWith('/')) throw new Error(`--path must start with "/", got ${out.path}`)
|
|
return /** @type {{ output: string, fixtures: string, kind: 'post' | 'category', slug: string, path: string }} */ (out)
|
|
}
|
|
|
|
/** @param {any[]} nodes @returns {any[]} */
|
|
function flatten(nodes) {
|
|
return (nodes ?? []).flatMap((n) => [n, ...flatten(n.children)])
|
|
}
|
|
|
|
/**
|
|
* Appends a marker paragraph to the post's content and content_html, or to
|
|
* the category's content, in the fixture directory.
|
|
* @param {string} dir @param {'post' | 'category'} kind @param {string} slug @param {string} marker
|
|
*/
|
|
function appendMarker(dir, kind, slug, marker) {
|
|
const file = join(dir, kind === 'post' ? 'posts.json' : 'categories.json')
|
|
const rows = JSON.parse(readFileSync(file, 'utf8'))
|
|
const row = (kind === 'post' ? rows : flatten(rows)).find((/** @type {any} */ r) => r.slug === slug)
|
|
if (!row) throw new Error(`no ${kind} with slug ${slug} in ${file}`)
|
|
const para = `\n<p>${marker}</p>\n`
|
|
if (kind === 'post') {
|
|
row.content = (row.content ?? '') + `\n\n${marker}\n`
|
|
row.content_html = (row.content_html ?? '') + para
|
|
} else {
|
|
row.content = (row.content ?? '') + para
|
|
}
|
|
writeFileSync(file, JSON.stringify(rows, null, 2))
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
const dir = mkdtempSync(join(tmpdir(), 'purge-check-'))
|
|
cpSync(resolve(args.fixtures), dir, { recursive: true })
|
|
const token = randomBytes(24).toString('hex')
|
|
const marker = `purge-check-marker-${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
|
|
try {
|
|
nuxt = await startNuxtServer({
|
|
outputDir: args.output,
|
|
apiBase: `${api.url}/_journal/api/v1`,
|
|
env: { NUXT_JOURNAL_CACHE_PURGE_TOKEN: token },
|
|
})
|
|
const base = nuxt.url
|
|
/** @param {string} path @param {Record<string, string>} [headers] */
|
|
const get = async (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 {Record<string, string>} headers */
|
|
const purge = async (headers) => {
|
|
const res = await fetch(base + PURGE_PATH, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json', ...headers },
|
|
body: JSON.stringify({ kind: args.kind, slug: args.slug }),
|
|
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 }
|
|
}
|
|
const auth = { authorization: `Bearer ${token}` }
|
|
|
|
// The steps stop at the first failure that makes the later ones
|
|
// meaningless; the failures are reported after the servers stop.
|
|
const steps = async () => {
|
|
// 1. The page renders and is cached.
|
|
const first = await get(args.path)
|
|
if (!check(first.status === 200, `GET ${args.path} before the change: status ${first.status}, want 200`)) return
|
|
|
|
// 2. Change the content behind the API; the cached page must not show it.
|
|
appendMarker(dir, args.kind, args.slug, marker)
|
|
api.reload()
|
|
const cached = await get(args.path)
|
|
check(cached.status === 200, `GET ${args.path} after the change: status ${cached.status}, want 200`)
|
|
if (!check(!cached.body.includes(marker), `GET ${args.path} shows the change before any purge: the page is not route-cached (missing swr route rules?)`)) return
|
|
|
|
// 3. An authorized purge removes the cached page.
|
|
const purged = await purge(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.
|
|
const fresh = await get(args.path)
|
|
check(fresh.status === 200 && fresh.body.includes(marker), `GET ${args.path} after the purge: status ${fresh.status}, change shown: ${fresh.body.includes(marker)}`)
|
|
|
|
// 5. Refused purges.
|
|
const none = await purge({})
|
|
check(none.status === 401, `purge without a token: status ${none.status}, want 401`)
|
|
const wrong = await purge({ authorization: `Bearer ${randomBytes(24).toString('hex')}` })
|
|
check(wrong.status === 401, `purge with a wrong token: status ${wrong.status}, want 401`)
|
|
const proxied = await purge({ ...auth, 'x-forwarded-for': '203.0.113.7' })
|
|
check(proxied.status === 404, `proxied purge with the right token: status ${proxied.status}, want 404`)
|
|
}
|
|
await steps()
|
|
} finally {
|
|
if (nuxt) await 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)
|
|
})
|