feat: browsers revalidate route-cached pages; document the site cache purge
- privateRevalidate sets max-age=0 on cache-control values with s-maxage - server/plugins/journal-revalidate.ts wraps res.setHeader per request, so Nitro's 200 and h3's 304 answers both carry it - purge-check: max-age=0 on 200 and 304, purged _payload.json, a second purge after re-caching, X-Real-IP refused, 404 on a server without a token - README: cachePurgeToken row and a Site cache purge section
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
//
|
||||
// node purge-check.mjs --output <.output dir> --fixtures <dir> --kind post|category --slug <slug> --path <page path>
|
||||
//
|
||||
// It also checks that route-cached pages and their 304 revalidations tell
|
||||
// browsers max-age=0, that a page's _payload.json (when the build serves
|
||||
// one) is purged too, that a second purge after re-caching runs (the route
|
||||
// is never cached itself) and that a server without a token answers 404.
|
||||
// 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.
|
||||
@@ -64,12 +68,50 @@ function appendMarker(dir, kind, slug, marker) {
|
||||
writeFileSync(file, JSON.stringify(rows, 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 }
|
||||
}
|
||||
|
||||
/** The payload path Nuxt serves for a page path. @param {string} path */
|
||||
function payloadPath(path) {
|
||||
return `${path.replace(/\/+$/, '')}/_payload.json`
|
||||
}
|
||||
|
||||
/** @param {string | null} value */
|
||||
function revalidates(value) {
|
||||
return /(^|[\s,])max-age=0(\s*,|\s*$)/.test(value ?? '') && /(^|[\s,])s-maxage=/.test(value ?? '')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const event = { kind: args.kind, slug: args.slug }
|
||||
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 = []
|
||||
@@ -84,72 +126,83 @@ async function main() {
|
||||
const api = await startFixtureApi({ dir })
|
||||
/** @type {{ url: string, close: () => Promise<void> } | null} */
|
||||
let nuxt = null
|
||||
try {
|
||||
// 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}/_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}` }
|
||||
const page = args.path
|
||||
const payload = payloadPath(page)
|
||||
|
||||
// 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
|
||||
// 1. The page (and its payload, when the build has one) render and are cached.
|
||||
const first = await get(base, page)
|
||||
if (!check(first.status === 200, `GET ${page} before the change: status ${first.status}, want 200`)) return
|
||||
const hasPayload = (await get(base, payload)).status === 200
|
||||
|
||||
// 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
|
||||
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 page.
|
||||
const purged = await purge(auth)
|
||||
// 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.
|
||||
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`)
|
||||
// 4. The next request shows the change and tells browsers to revalidate.
|
||||
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 cc = fresh.headers.get('cache-control')
|
||||
check(revalidates(cc), `GET ${page} after the purge: cache-control "${cc}", want max-age=0 and s-maxage`)
|
||||
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`)
|
||||
const cc304 = revalidated.headers.get('cache-control')
|
||||
check(revalidates(cc304), `conditional GET ${page}: cache-control "${cc304}", want max-age=0 and s-maxage`)
|
||||
}
|
||||
if (hasPayload) {
|
||||
const p = await get(base, payload)
|
||||
check(p.status === 200 && p.body.includes(marker), `GET ${payload} after the purge: status ${p.status}, change shown: ${p.body.includes(marker)}`)
|
||||
}
|
||||
|
||||
// 5. A second purge after the page was cached again runs too: the purge
|
||||
// route itself is never route-cached.
|
||||
appendMarker(dir, args.kind, args.slug, 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}/_journal/api/v1`, env: { NUXT_JOURNAL_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`)
|
||||
}
|
||||
try {
|
||||
await steps()
|
||||
} finally {
|
||||
if (nuxt) await nuxt.close()
|
||||
if (nuxt) await /** @type {{ close: () => Promise<void> }} */ (nuxt).close()
|
||||
await api.close()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user