Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/run/handlers/cache.cts
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,12 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
}

private captureCacheTags(cacheValue: NetlifyIncrementalCacheValue | null, key: string) {
const requestContext = getRequestContext()

if (!cacheValue) {
return
}

const requestContext = getRequestContext()
// Bail if we can't get request context
if (!requestContext) {
return
Expand Down Expand Up @@ -393,7 +394,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {

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

if (data?.kind === 'PAGE' || data?.kind === 'PAGES') {
if (!data || data?.kind === 'PAGE' || data?.kind === 'PAGES') {
const requestContext = getRequestContext()
if (requestContext?.didPagesRouterOnDemandRevalidate) {
// encode here to deal with non ASCII characters in the key
Expand Down
2 changes: 1 addition & 1 deletion src/run/handlers/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export default async (
}

setCacheControlHeaders(response, request, requestContext, nextConfig)
setCacheTagsHeaders(response.headers, requestContext)
setCacheTagsHeaders(response.headers, request, requestContext)
setVaryHeaders(response.headers, request, nextConfig)
setCacheStatusHeader(response.headers, nextCache)

Expand Down
25 changes: 19 additions & 6 deletions src/run/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ export const setCacheControlHeaders = (
.log('NetlifyHeadersHandler.trailingSlashRedirect')
}

const cacheControl = headers.get('cache-control')
if (status === 404) {
if (request.url.endsWith('.php')) {
// temporary CDN Cache Control handling for bot probes on PHP files
Expand All @@ -241,6 +240,8 @@ export const setCacheControlHeaders = (
}
}

const cacheControl = headers.get('cache-control')

if (
cacheControl !== null &&
['GET', 'HEAD'].includes(request.method) &&
Expand Down Expand Up @@ -273,6 +274,7 @@ export const setCacheControlHeaders = (
['GET', 'HEAD'].includes(request.method) &&
!headers.has('cdn-cache-control') &&
!headers.has('netlify-cdn-cache-control') &&
!new URL(request.url).pathname.startsWith('/api/') &&
requestContext.usedFsReadForNonFallback
) {
// handle CDN Cache Control on static files
Expand All @@ -281,13 +283,24 @@ export const setCacheControlHeaders = (
}
}

export const setCacheTagsHeaders = (headers: Headers, requestContext: RequestContext) => {
if (
requestContext.responseCacheTags &&
(headers.has('cache-control') || headers.has('netlify-cdn-cache-control'))
) {
export const setCacheTagsHeaders = (
headers: Headers,
request: Request,
requestContext: RequestContext,
) => {
if (!headers.has('cache-control') && !headers.has('netlify-cdn-cache-control')) {
return
}

if (requestContext.responseCacheTags) {
headers.set('netlify-cache-tag', requestContext.responseCacheTags.join(','))
return
}

const key = new URL(request.url).pathname
const cacheTag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}`
console.log('setCacheTagsHeaders', 'netlify-cache-tag', key)
headers.set('netlify-cache-tag', cacheTag)
}

/**
Expand Down
52 changes: 52 additions & 0 deletions tests/e2e/dynamic-cms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect } from '@playwright/test'
import { test } from '../utils/playwright-helpers.js'

test.describe('Dynamic CMS', () => {
test('Invalidates 404 pages from durable cache', async ({ page, dynamicCms }) => {
// 1. Verify the status and headers of the dynamic page
const response1 = await page.goto(new URL('/content/blog', dynamicCms.url).href)
const headers1 = response1?.headers() || {}

expect(response1?.status()).toEqual(404)
expect(headers1['cache-control']).toEqual('public,max-age=0,must-revalidate')
expect(headers1['cache-status']).toEqual(
'"Next.js"; fwd=miss, "Netlify Durable"; fwd=uri-miss; stored, "Netlify Edge"; fwd=miss',
)
expect(headers1['netlify-cache-tag']).toEqual('_n_t_/content/blog')
expect(headers1['netlify-cdn-cache-control']).toEqual('s-maxage=31536000, durable')

// 2. Publish the blob, revalidate the dynamic page, and wait to regenerate
await page.goto(new URL('/cms/publish', dynamicCms.url).href)
await page.goto(new URL('/api/revalidate?path=/content/blog', dynamicCms.url).href)
await page.waitForTimeout(1000)

// 3. Verify the status and headers of the dynamic page
const response2 = await page.goto(new URL('/content/blog', dynamicCms.url).href)
const headers2 = response2?.headers() || {}

expect(response2?.status()).toEqual(200)
expect(headers2['cache-control']).toEqual('public,max-age=0,must-revalidate')
expect(headers2['cache-status']).toMatch(
/"Next.js"; hit, "Netlify Durable"; fwd=stale; ttl=[0-9]+; stored, "Netlify Edge"; fwd=stale/,
)
expect(headers2['netlify-cache-tag']).toEqual('_n_t_/content/blog')
expect(headers2['netlify-cdn-cache-control']).toEqual('s-maxage=31536000, durable')

// 4. Unpublish the blob, revalidate the dynamic page, and wait to regenerate
await page.goto(new URL('/cms/unpublish', dynamicCms.url).href)
await page.goto(new URL('/api/revalidate?path=/content/blog', dynamicCms.url).href)
await page.waitForTimeout(1000)

// 5. Verify the status and headers of the dynamic page
const response3 = await page.goto(new URL('/content/blog', dynamicCms.url).href)
const headers3 = response3?.headers() || {}

expect(response3?.status()).toEqual(404)
expect(headers3['cache-control']).toEqual('public,max-age=0,must-revalidate')
expect(headers3['cache-status']).toMatch(
/"Next.js"; fwd=miss, "Netlify Durable"; fwd=stale; ttl=[0-9]+; stored, "Netlify Edge"; fwd=stale/,
)
expect(headers3['netlify-cache-tag']).toEqual('_n_t_/content/blog')
expect(headers3['netlify-cdn-cache-control']).toEqual('s-maxage=31536000, durable')
})
})
1 change: 1 addition & 0 deletions tests/fixtures/dynamic-cms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This fixture is meant to emulate dynamic content responses of a CMS-backed next site
24 changes: 24 additions & 0 deletions tests/fixtures/dynamic-cms/netlify/functions/cms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getDeployStore } from '@netlify/blobs'
import { Context } from '@netlify/functions'

// publish or unpublish "cms content" depending on the sent operation
export default async function handler(_request: Request, context: Context) {
const store = getDeployStore({ name: 'cms-content', consistency: 'strong' })
const BLOB_KEY = 'key'

const operation = context.params['operation']

if (operation === 'publish') {
await store.setJSON(BLOB_KEY, { content: true })
}

if (operation === 'unpublish') {
await store.delete(BLOB_KEY)
}

return Response.json({ ok: true })
}

export const config = {
path: '/cms/:operation',
}
10 changes: 10 additions & 0 deletions tests/fixtures/dynamic-cms/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
eslint: {
ignoreDuringBuilds: true,
},
generateBuildId: () => 'build-id',
}

module.exports = nextConfig
24 changes: 24 additions & 0 deletions tests/fixtures/dynamic-cms/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "dynamic-cms",
"version": "0.1.0",
"private": true,
"scripts": {
"postinstall": "next build",
"dev": "next dev",
"build": "next build"
},
"dependencies": {
"@netlify/blobs": "^8.1.0",
"@netlify/functions": "^2.7.0",
"@netlify/plugin-nextjs": "^5.10.1",
"netlify-cli": "^19.0.3",
"next": "latest",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/node": "22.13.13",
"@types/react": "19.0.12",
"typescript": "5.8.2"
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/dynamic-cms/pages/404.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function NotFound() {
return <p>Custom 404 page</p>
}
9 changes: 9 additions & 0 deletions tests/fixtures/dynamic-cms/pages/api/revalidate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default async function handler(req, res) {
try {
const pathToPurge = req.query.path ?? '/static/revalidate-manual'
await res.revalidate(pathToPurge)
return res.json({ code: 200, message: 'success' })
} catch (err) {
return res.status(500).send({ code: 500, message: err.message })
}
}
37 changes: 37 additions & 0 deletions tests/fixtures/dynamic-cms/pages/content/[...slug].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getDeployStore } from '@netlify/blobs'

const Content = ({ value }) => (
<div>
<p>
<span>{JSON.stringify(value)}</span>
</p>
</div>
)

export async function getStaticProps() {
const store = getDeployStore({ name: 'cms-content', consistency: 'strong' })
const BLOB_KEY = 'key'

const value = await store.get(BLOB_KEY, { type: 'json' })

if (!value) {
return {
notFound: true,
}
}

return {
props: {
value: value,
},
}
}

export const getStaticPaths = () => {
return {
paths: [],
fallback: 'blocking', // false or "blocking"
}
}

export default Content
1 change: 1 addition & 0 deletions tests/utils/create-e2e-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,5 +440,6 @@ export const fixtureFactories = {
publishDirectory: 'apps/site/.next',
smoke: true,
}),
dynamicCms: () => createE2EFixture('dynamic-cms'),
after: () => createE2EFixture('after'),
}
Loading