Skip to content

Commit b4f04e3

Browse files
authored
fix: improved cdn cache hooks for dynamic 404 pages (#2786)
* test: fixture for dynamic cms results * test: case for invalidating dynamic 404 pages * fix: wip - fix for caching issues on catch all routes * test: not all versions of next support next.config.ts * test: older versions of next have different cache headers * test: edge cache response depends on the node we hit * fix: set cache tags on 404 pages * chore: clean up variable names * chore: remove previous attempt to set cache tags on 404s * fix: also target 404 pages when purging cache * chore: remove wip check for cached api calls * chore: run format:fix
1 parent 114945f commit b4f04e3

File tree

12 files changed

+205
-19
lines changed

12 files changed

+205
-19
lines changed

src/run/handlers/cache.cts

+29-12
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
121121
}
122122

123123
private captureCacheTags(cacheValue: NetlifyIncrementalCacheValue | null, key: string) {
124-
if (!cacheValue) {
125-
return
126-
}
127-
128124
const requestContext = getRequestContext()
125+
129126
// Bail if we can't get request context
130127
if (!requestContext) {
131128
return
@@ -141,6 +138,13 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
141138
return
142139
}
143140

141+
// Set cache tags for 404 pages as well so that the content can later be purged
142+
if (!cacheValue) {
143+
const cacheTags = [`_N_T_${key === '/index' ? '/' : encodeURI(key)}`]
144+
requestContext.responseCacheTags = cacheTags
145+
return
146+
}
147+
144148
if (
145149
cacheValue.kind === 'PAGE' ||
146150
cacheValue.kind === 'PAGES' ||
@@ -226,7 +230,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
226230
...args: Parameters<CacheHandlerForMultipleVersions['get']>
227231
): ReturnType<CacheHandlerForMultipleVersions['get']> {
228232
return this.tracer.withActiveSpan('get cache key', async (span) => {
229-
const [key, ctx = {}] = args
233+
const [key, context = {}] = args
230234
getLogger().debug(`[NetlifyCacheHandler.get]: ${key}`)
231235

232236
span.setAttributes({ key })
@@ -259,21 +263,30 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
259263
return null
260264
}
261265

262-
const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.tags, ctx.softTags)
266+
const staleByTags = await this.checkCacheEntryStaleByTags(
267+
blob,
268+
context.tags,
269+
context.softTags,
270+
)
263271

264272
if (staleByTags) {
265273
span.addEvent('Stale', { staleByTags, key, ttl })
266274
return null
267275
}
268276

269277
this.captureResponseCacheLastModified(blob, key, span)
270-
this.captureCacheTags(blob.value, key)
278+
279+
// Next sets a kind/kindHint and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions
280+
const isDataRequest = Boolean(context.fetchUrl)
281+
if (!isDataRequest) {
282+
this.captureCacheTags(blob.value, key)
283+
}
271284

272285
switch (blob.value?.kind) {
273286
case 'FETCH':
274287
span.addEvent('FETCH', {
275288
lastModified: blob.lastModified,
276-
revalidate: ctx.revalidate,
289+
revalidate: context.revalidate,
277290
ttl,
278291
})
279292
return {
@@ -387,13 +400,17 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
387400

388401
const value = this.transformToStorableObject(data, context)
389402

390-
// if previous CacheHandler.get call returned null (page was either never rendered or was on-demand revalidated)
391-
// and we didn't yet capture cache tags, we try to get cache tags from freshly produced cache value
392-
this.captureCacheTags(value, key)
403+
// Next sets a fetchCache and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions
404+
const isDataReq = Boolean(context.fetchUrl)
405+
if (!isDataReq) {
406+
// if previous CacheHandler.get call returned null (page was either never rendered or was on-demand revalidated)
407+
// and we didn't yet capture cache tags, we try to get cache tags from freshly produced cache value
408+
this.captureCacheTags(value, key)
409+
}
393410

394411
await this.cacheStore.set(key, { lastModified, value }, 'blobStore.set')
395412

396-
if (data?.kind === 'PAGE' || data?.kind === 'PAGES') {
413+
if ((!data && !isDataReq) || data?.kind === 'PAGE' || data?.kind === 'PAGES') {
397414
const requestContext = getRequestContext()
398415
if (requestContext?.didPagesRouterOnDemandRevalidate) {
399416
// encode here to deal with non ASCII characters in the key

src/run/headers.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,6 @@ export const setCacheControlHeaders = (
212212
return
213213
}
214214

215-
const cacheControl = headers.get('cache-control')
216215
if (status === 404) {
217216
if (request.url.endsWith('.php')) {
218217
// temporary CDN Cache Control handling for bot probes on PHP files
@@ -233,6 +232,8 @@ export const setCacheControlHeaders = (
233232
}
234233
}
235234

235+
const cacheControl = headers.get('cache-control')
236+
236237
if (
237238
cacheControl !== null &&
238239
['GET', 'HEAD'].includes(request.method) &&
@@ -274,10 +275,11 @@ export const setCacheControlHeaders = (
274275
}
275276

276277
export const setCacheTagsHeaders = (headers: Headers, requestContext: RequestContext) => {
277-
if (
278-
requestContext.responseCacheTags &&
279-
(headers.has('cache-control') || headers.has('netlify-cdn-cache-control'))
280-
) {
278+
if (!headers.has('cache-control') && !headers.has('netlify-cdn-cache-control')) {
279+
return
280+
}
281+
282+
if (requestContext.responseCacheTags) {
281283
headers.set('netlify-cache-tag', requestContext.responseCacheTags.join(','))
282284
}
283285
}

src/run/revalidate.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ function isRevalidateMethod(
1515
}
1616

1717
// Needing to proxy the response object to intercept the revalidate call for on-demand revalidation on page routes
18-
export const nextResponseProxy = (res: ServerResponse, requestContext: RequestContext) => {
19-
return new Proxy(res, {
18+
export const nextResponseProxy = (response: ServerResponse, requestContext: RequestContext) => {
19+
return new Proxy(response, {
2020
get(target: ServerResponse, key: string) {
2121
const originalValue = Reflect.get(target, key)
2222
if (isRevalidateMethod(key, originalValue)) {

tests/e2e/dynamic-cms.test.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { expect } from '@playwright/test'
2+
import { test } from '../utils/playwright-helpers.js'
3+
4+
test.describe('Dynamic CMS', () => {
5+
test('Invalidates 404 pages from durable cache', async ({ page, dynamicCms }) => {
6+
// 1. Verify the status and headers of the dynamic page
7+
const response1 = await page.goto(new URL('/content/blog', dynamicCms.url).href)
8+
const headers1 = response1?.headers() || {}
9+
10+
expect(response1?.status()).toEqual(404)
11+
expect(headers1['cache-control']).toEqual('public,max-age=0,must-revalidate')
12+
expect(headers1['cache-status']).toEqual(
13+
'"Next.js"; fwd=miss, "Netlify Durable"; fwd=uri-miss; stored, "Netlify Edge"; fwd=miss',
14+
)
15+
expect(headers1['netlify-cache-tag']).toEqual('_n_t_/content/blog')
16+
expect(headers1['netlify-cdn-cache-control']).toMatch(
17+
/s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/,
18+
)
19+
20+
// 2. Publish the blob, revalidate the dynamic page, and wait to regenerate
21+
await page.goto(new URL('/cms/publish', dynamicCms.url).href)
22+
await page.goto(new URL('/api/revalidate?path=/content/blog', dynamicCms.url).href)
23+
await page.waitForTimeout(1000)
24+
25+
// 3. Verify the status and headers of the dynamic page
26+
const response2 = await page.goto(new URL('/content/blog', dynamicCms.url).href)
27+
const headers2 = response2?.headers() || {}
28+
29+
expect(response2?.status()).toEqual(200)
30+
expect(headers2['cache-control']).toEqual('public,max-age=0,must-revalidate')
31+
expect(headers2['cache-status']).toMatch(
32+
/"Next.js"; hit, "Netlify Durable"; fwd=stale; ttl=[0-9]+; stored, "Netlify Edge"; fwd=(stale|miss)/,
33+
)
34+
expect(headers2['netlify-cache-tag']).toEqual('_n_t_/content/blog')
35+
expect(headers2['netlify-cdn-cache-control']).toMatch(
36+
/s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/,
37+
)
38+
39+
// 4. Unpublish the blob, revalidate the dynamic page, and wait to regenerate
40+
await page.goto(new URL('/cms/unpublish', dynamicCms.url).href)
41+
await page.goto(new URL('/api/revalidate?path=/content/blog', dynamicCms.url).href)
42+
await page.waitForTimeout(1000)
43+
44+
// 5. Verify the status and headers of the dynamic page
45+
const response3 = await page.goto(new URL('/content/blog', dynamicCms.url).href)
46+
const headers3 = response3?.headers() || {}
47+
48+
expect(response3?.status()).toEqual(404)
49+
expect(headers3['cache-control']).toEqual('public,max-age=0,must-revalidate')
50+
expect(headers3['cache-status']).toMatch(
51+
/"Next.js"; fwd=miss, "Netlify Durable"; fwd=stale; ttl=[0-9]+; stored, "Netlify Edge"; fwd=(stale|miss)/,
52+
)
53+
expect(headers3['netlify-cache-tag']).toEqual('_n_t_/content/blog')
54+
expect(headers3['netlify-cdn-cache-control']).toMatch(
55+
/s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/,
56+
)
57+
})
58+
})

tests/fixtures/dynamic-cms/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This fixture is meant to emulate dynamic content responses of a CMS-backed next site
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { getDeployStore } from '@netlify/blobs'
2+
import { Context } from '@netlify/functions'
3+
4+
// publish or unpublish "cms content" depending on the sent operation
5+
export default async function handler(_request: Request, context: Context) {
6+
const store = getDeployStore({ name: 'cms-content', consistency: 'strong' })
7+
const BLOB_KEY = 'key'
8+
9+
const operation = context.params['operation']
10+
11+
if (operation === 'publish') {
12+
await store.setJSON(BLOB_KEY, { content: true })
13+
}
14+
15+
if (operation === 'unpublish') {
16+
await store.delete(BLOB_KEY)
17+
}
18+
19+
return Response.json({ ok: true })
20+
}
21+
22+
export const config = {
23+
path: '/cms/:operation',
24+
}
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
output: 'standalone',
4+
eslint: {
5+
ignoreDuringBuilds: true,
6+
},
7+
generateBuildId: () => 'build-id',
8+
}
9+
10+
module.exports = nextConfig
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "dynamic-cms",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"postinstall": "next build",
7+
"dev": "next dev",
8+
"build": "next build"
9+
},
10+
"dependencies": {
11+
"@netlify/blobs": "^8.1.0",
12+
"@netlify/functions": "^2.7.0",
13+
"@netlify/plugin-nextjs": "^5.10.1",
14+
"netlify-cli": "^19.0.3",
15+
"next": "latest",
16+
"react": "18.2.0",
17+
"react-dom": "18.2.0"
18+
},
19+
"devDependencies": {
20+
"@types/node": "22.13.13",
21+
"@types/react": "19.0.12",
22+
"typescript": "5.8.2"
23+
}
24+
}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function NotFound() {
2+
return <p>Custom 404 page</p>
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default async function handler(req, res) {
2+
try {
3+
const pathToPurge = req.query.path ?? '/static/revalidate-manual'
4+
await res.revalidate(pathToPurge)
5+
return res.json({ code: 200, message: 'success' })
6+
} catch (err) {
7+
return res.status(500).send({ code: 500, message: err.message })
8+
}
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { getDeployStore } from '@netlify/blobs'
2+
3+
const Content = ({ value }) => (
4+
<div>
5+
<p>
6+
<span>{JSON.stringify(value)}</span>
7+
</p>
8+
</div>
9+
)
10+
11+
export async function getStaticProps() {
12+
const store = getDeployStore({ name: 'cms-content', consistency: 'strong' })
13+
const BLOB_KEY = 'key'
14+
15+
const value = await store.get(BLOB_KEY, { type: 'json' })
16+
17+
if (!value) {
18+
return {
19+
notFound: true,
20+
}
21+
}
22+
23+
return {
24+
props: {
25+
value: value,
26+
},
27+
}
28+
}
29+
30+
export const getStaticPaths = () => {
31+
return {
32+
paths: [],
33+
fallback: 'blocking', // false or "blocking"
34+
}
35+
}
36+
37+
export default Content

tests/utils/create-e2e-fixture.ts

+1
Original file line numberDiff line numberDiff line change
@@ -440,5 +440,6 @@ export const fixtureFactories = {
440440
publishDirectory: 'apps/site/.next',
441441
smoke: true,
442442
}),
443+
dynamicCms: () => createE2EFixture('dynamic-cms'),
443444
after: () => createE2EFixture('after'),
444445
}

0 commit comments

Comments
 (0)