diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index 294700901c..4e9b61ce4a 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -121,11 +121,8 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { } private captureCacheTags(cacheValue: NetlifyIncrementalCacheValue | null, key: string) { - if (!cacheValue) { - return - } - const requestContext = getRequestContext() + // Bail if we can't get request context if (!requestContext) { return @@ -141,6 +138,13 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { return } + // Set cache tags for 404 pages as well so that the content can later be purged + if (!cacheValue) { + const cacheTags = [`_N_T_${key === '/index' ? '/' : encodeURI(key)}`] + requestContext.responseCacheTags = cacheTags + return + } + if ( cacheValue.kind === 'PAGE' || cacheValue.kind === 'PAGES' || @@ -226,7 +230,7 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { ...args: Parameters ): ReturnType { return this.tracer.withActiveSpan('get cache key', async (span) => { - const [key, ctx = {}] = args + const [key, context = {}] = args getLogger().debug(`[NetlifyCacheHandler.get]: ${key}`) span.setAttributes({ key }) @@ -259,7 +263,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { return null } - const staleByTags = await this.checkCacheEntryStaleByTags(blob, ctx.tags, ctx.softTags) + const staleByTags = await this.checkCacheEntryStaleByTags( + blob, + context.tags, + context.softTags, + ) if (staleByTags) { span.addEvent('Stale', { staleByTags, key, ttl }) @@ -267,13 +275,18 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { } this.captureResponseCacheLastModified(blob, key, span) - this.captureCacheTags(blob.value, key) + + // Next sets a kind/kindHint and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions + const isDataRequest = Boolean(context.fetchUrl) + if (!isDataRequest) { + this.captureCacheTags(blob.value, key) + } switch (blob.value?.kind) { case 'FETCH': span.addEvent('FETCH', { lastModified: blob.lastModified, - revalidate: ctx.revalidate, + revalidate: context.revalidate, ttl, }) return { @@ -387,13 +400,17 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { const value = this.transformToStorableObject(data, context) - // if previous CacheHandler.get call returned null (page was either never rendered or was on-demand revalidated) - // and we didn't yet capture cache tags, we try to get cache tags from freshly produced cache value - this.captureCacheTags(value, key) + // Next sets a fetchCache and fetchUrl for data requests, however fetchUrl was found to be most reliable across versions + const isDataReq = Boolean(context.fetchUrl) + if (!isDataReq) { + // if previous CacheHandler.get call returned null (page was either never rendered or was on-demand revalidated) + // and we didn't yet capture cache tags, we try to get cache tags from freshly produced cache value + this.captureCacheTags(value, key) + } await this.cacheStore.set(key, { lastModified, value }, 'blobStore.set') - if (data?.kind === 'PAGE' || data?.kind === 'PAGES') { + if ((!data && !isDataReq) || data?.kind === 'PAGE' || data?.kind === 'PAGES') { const requestContext = getRequestContext() if (requestContext?.didPagesRouterOnDemandRevalidate) { // encode here to deal with non ASCII characters in the key diff --git a/src/run/headers.ts b/src/run/headers.ts index 766e41aff7..1931f61330 100644 --- a/src/run/headers.ts +++ b/src/run/headers.ts @@ -212,7 +212,6 @@ export const setCacheControlHeaders = ( return } - 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 @@ -233,6 +232,8 @@ export const setCacheControlHeaders = ( } } + const cacheControl = headers.get('cache-control') + if ( cacheControl !== null && ['GET', 'HEAD'].includes(request.method) && @@ -274,10 +275,11 @@ export const setCacheControlHeaders = ( } export const setCacheTagsHeaders = (headers: Headers, requestContext: RequestContext) => { - if ( - requestContext.responseCacheTags && - (headers.has('cache-control') || headers.has('netlify-cdn-cache-control')) - ) { + if (!headers.has('cache-control') && !headers.has('netlify-cdn-cache-control')) { + return + } + + if (requestContext.responseCacheTags) { headers.set('netlify-cache-tag', requestContext.responseCacheTags.join(',')) } } diff --git a/src/run/revalidate.ts b/src/run/revalidate.ts index 25a5b86660..6789072209 100644 --- a/src/run/revalidate.ts +++ b/src/run/revalidate.ts @@ -15,8 +15,8 @@ function isRevalidateMethod( } // Needing to proxy the response object to intercept the revalidate call for on-demand revalidation on page routes -export const nextResponseProxy = (res: ServerResponse, requestContext: RequestContext) => { - return new Proxy(res, { +export const nextResponseProxy = (response: ServerResponse, requestContext: RequestContext) => { + return new Proxy(response, { get(target: ServerResponse, key: string) { const originalValue = Reflect.get(target, key) if (isRevalidateMethod(key, originalValue)) { diff --git a/tests/e2e/dynamic-cms.test.ts b/tests/e2e/dynamic-cms.test.ts new file mode 100644 index 0000000000..a157b47727 --- /dev/null +++ b/tests/e2e/dynamic-cms.test.ts @@ -0,0 +1,58 @@ +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']).toMatch( + /s-maxage=31536000,( stale-while-revalidate=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|miss)/, + ) + expect(headers2['netlify-cache-tag']).toEqual('_n_t_/content/blog') + expect(headers2['netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000,( stale-while-revalidate=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|miss)/, + ) + expect(headers3['netlify-cache-tag']).toEqual('_n_t_/content/blog') + expect(headers3['netlify-cdn-cache-control']).toMatch( + /s-maxage=31536000,( stale-while-revalidate=31536000,)? durable/, + ) + }) +}) diff --git a/tests/fixtures/dynamic-cms/README.md b/tests/fixtures/dynamic-cms/README.md new file mode 100644 index 0000000000..e9f8e0c8ce --- /dev/null +++ b/tests/fixtures/dynamic-cms/README.md @@ -0,0 +1 @@ +This fixture is meant to emulate dynamic content responses of a CMS-backed next site diff --git a/tests/fixtures/dynamic-cms/netlify/functions/cms.ts b/tests/fixtures/dynamic-cms/netlify/functions/cms.ts new file mode 100644 index 0000000000..243421e0be --- /dev/null +++ b/tests/fixtures/dynamic-cms/netlify/functions/cms.ts @@ -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', +} diff --git a/tests/fixtures/dynamic-cms/next.config.js b/tests/fixtures/dynamic-cms/next.config.js new file mode 100644 index 0000000000..6346ab0742 --- /dev/null +++ b/tests/fixtures/dynamic-cms/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + eslint: { + ignoreDuringBuilds: true, + }, + generateBuildId: () => 'build-id', +} + +module.exports = nextConfig diff --git a/tests/fixtures/dynamic-cms/package.json b/tests/fixtures/dynamic-cms/package.json new file mode 100644 index 0000000000..33ebc7f177 --- /dev/null +++ b/tests/fixtures/dynamic-cms/package.json @@ -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" + } +} diff --git a/tests/fixtures/dynamic-cms/pages/404.js b/tests/fixtures/dynamic-cms/pages/404.js new file mode 100644 index 0000000000..3c251e6665 --- /dev/null +++ b/tests/fixtures/dynamic-cms/pages/404.js @@ -0,0 +1,3 @@ +export default function NotFound() { + return

Custom 404 page

+} diff --git a/tests/fixtures/dynamic-cms/pages/api/revalidate.js b/tests/fixtures/dynamic-cms/pages/api/revalidate.js new file mode 100644 index 0000000000..e134a56577 --- /dev/null +++ b/tests/fixtures/dynamic-cms/pages/api/revalidate.js @@ -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 }) + } +} diff --git a/tests/fixtures/dynamic-cms/pages/content/[...slug].js b/tests/fixtures/dynamic-cms/pages/content/[...slug].js new file mode 100644 index 0000000000..54abf74b16 --- /dev/null +++ b/tests/fixtures/dynamic-cms/pages/content/[...slug].js @@ -0,0 +1,37 @@ +import { getDeployStore } from '@netlify/blobs' + +const Content = ({ value }) => ( +
+

+ {JSON.stringify(value)} +

+
+) + +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 diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index b133e6bc25..a2b5c48f00 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -440,5 +440,6 @@ export const fixtureFactories = { publishDirectory: 'apps/site/.next', smoke: true, }), + dynamicCms: () => createE2EFixture('dynamic-cms'), after: () => createE2EFixture('after'), }