diff --git a/src/build/content/prerendered.ts b/src/build/content/prerendered.ts index 3510aefe12..6511b16c13 100644 --- a/src/build/content/prerendered.ts +++ b/src/build/content/prerendered.ts @@ -57,6 +57,7 @@ const routeToFilePath = (path: string) => { const buildPagesCacheValue = async ( path: string, + initialRevalidateSeconds: number | false | undefined, shouldUseEnumKind: boolean, shouldSkipJson = false, ): Promise => ({ @@ -65,6 +66,7 @@ const buildPagesCacheValue = async ( pageData: shouldSkipJson ? {} : JSON.parse(await readFile(`${path}.json`, 'utf-8')), headers: undefined, status: undefined, + revalidate: initialRevalidateSeconds, }) const buildAppCacheValue = async ( @@ -178,6 +180,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise } value = await buildPagesCacheValue( join(ctx.publishDir, 'server/pages', key), + meta.initialRevalidateSeconds, shouldUseEnumKind, ) break @@ -210,6 +213,7 @@ export const copyPrerenderedContent = async (ctx: PluginContext): Promise const key = routeToFilePath(route) const value = await buildPagesCacheValue( join(ctx.publishDir, 'server/pages', key), + undefined, shouldUseEnumKind, true, // there is no corresponding json file for fallback, so we are skipping it for this entry ) diff --git a/src/run/handlers/cache.cts b/src/run/handlers/cache.cts index c00a4d5e14..8c2a06956d 100644 --- a/src/run/handlers/cache.cts +++ b/src/run/handlers/cache.cts @@ -260,6 +260,11 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions { const { revalidate, ...restOfPageValue } = blob.value + const requestContext = getRequestContext() + if (requestContext) { + requestContext.pageHandlerRevalidate = revalidate + } + await this.injectEntryToPrerenderManifest(key, revalidate) return { diff --git a/src/run/handlers/request-context.cts b/src/run/handlers/request-context.cts index cc67739242..af4083e1ca 100644 --- a/src/run/handlers/request-context.cts +++ b/src/run/handlers/request-context.cts @@ -21,6 +21,7 @@ export type RequestContext = { didPagesRouterOnDemandRevalidate?: boolean serverTiming?: string routeHandlerRevalidate?: NetlifyCachedRouteValue['revalidate'] + pageHandlerRevalidate?: NetlifyCachedRouteValue['revalidate'] /** * Track promise running in the background and need to be waited for. * Uses `context.waitUntil` if available, otherwise stores promises to diff --git a/src/run/headers.ts b/src/run/headers.ts index c93c0a605b..4a1f4682ab 100644 --- a/src/run/headers.ts +++ b/src/run/headers.ts @@ -2,6 +2,7 @@ import type { Span } from '@opentelemetry/api' import type { NextConfigComplete } from 'next/dist/server/config-shared.js' import { encodeBlobKey } from '../shared/blobkey.js' +import type { NetlifyCachedRouteValue } from '../shared/cache-types.cjs' import { getLogger, RequestContext } from './handlers/request-context.cjs' import type { RuntimeTracer } from './handlers/tracer.cjs' @@ -208,6 +209,19 @@ export const adjustDateHeader = async ({ headers.set('date', lastModifiedDate.toUTCString()) } +function setCacheControlFromRequestContext( + headers: Headers, + revalidate: NetlifyCachedRouteValue['revalidate'], +) { + const cdnCacheControl = + // if we are serving already stale response, instruct edge to not attempt to cache that response + headers.get('x-nextjs-cache') === 'STALE' + ? 'public, max-age=0, must-revalidate, durable' + : `s-maxage=${revalidate === false ? 31536000 : revalidate}, stale-while-revalidate=31536000, durable` + + headers.set('netlify-cdn-cache-control', cdnCacheControl) +} + /** * Ensure stale-while-revalidate and s-maxage don't leak to the client, but * assume the user knows what they are doing if CDN cache controls are set @@ -225,13 +239,7 @@ export const setCacheControlHeaders = ( !headers.has('netlify-cdn-cache-control') ) { // handle CDN Cache Control on Route Handler responses - const cdnCacheControl = - // if we are serving already stale response, instruct edge to not attempt to cache that response - headers.get('x-nextjs-cache') === 'STALE' - ? 'public, max-age=0, must-revalidate, durable' - : `s-maxage=${requestContext.routeHandlerRevalidate === false ? 31536000 : requestContext.routeHandlerRevalidate}, stale-while-revalidate=31536000, durable` - - headers.set('netlify-cdn-cache-control', cdnCacheControl) + setCacheControlFromRequestContext(headers, requestContext.routeHandlerRevalidate) return } @@ -242,14 +250,29 @@ export const setCacheControlHeaders = ( .log('NetlifyHeadersHandler.trailingSlashRedirect') } - if (status === 404 && request.url.endsWith('.php')) { - // temporary CDN Cache Control handling for bot probes on PHP files - // https://linear.app/netlify/issue/FRB-1344/prevent-excessive-ssr-invocations-due-to-404-routes - headers.set('cache-control', 'public, max-age=0, must-revalidate') - headers.set('netlify-cdn-cache-control', `max-age=31536000, durable`) + 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 + // https://linear.app/netlify/issue/FRB-1344/prevent-excessive-ssr-invocations-due-to-404-routes + headers.set('cache-control', 'public, max-age=0, must-revalidate') + headers.set('netlify-cdn-cache-control', `max-age=31536000, durable`) + return + } + + if ( + (!cacheControl || cacheControl.includes('no-store')) && + typeof requestContext.pageHandlerRevalidate !== 'undefined' && + ['GET', 'HEAD'].includes(request.method) && + !headers.has('cdn-cache-control') && + !headers.has('netlify-cdn-cache-control') + ) { + // handle CDN Cache Control on 404 Page responses + setCacheControlFromRequestContext(headers, requestContext.pageHandlerRevalidate) + return + } } - const cacheControl = headers.get('cache-control') if ( cacheControl !== null && ['GET', 'HEAD'].includes(request.method) && diff --git a/tests/fixtures/page-router-404-get-static-props-with-revalidate/next.config.js b/tests/fixtures/page-router-404-get-static-props-with-revalidate/next.config.js new file mode 100644 index 0000000000..6346ab0742 --- /dev/null +++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/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/page-router-404-get-static-props-with-revalidate/package.json b/tests/fixtures/page-router-404-get-static-props-with-revalidate/package.json new file mode 100644 index 0000000000..4506b1ddb2 --- /dev/null +++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/package.json @@ -0,0 +1,15 @@ +{ + "name": "page-router-404-get-static-props-with-revalidate", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/404.js b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/404.js new file mode 100644 index 0000000000..cb047699af --- /dev/null +++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/404.js @@ -0,0 +1,17 @@ +export default function NotFound({ timestamp }) { + return ( +

+ Custom 404 page with revalidate:

{timestamp}
+

+ ) +} + +/** @type {import('next').GetStaticProps} */ +export const getStaticProps = ({ locale }) => { + return { + props: { + timestamp: Date.now(), + }, + revalidate: 300, + } +} diff --git a/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/products/[slug].js b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/products/[slug].js new file mode 100644 index 0000000000..714c8ac143 --- /dev/null +++ b/tests/fixtures/page-router-404-get-static-props-with-revalidate/pages/products/[slug].js @@ -0,0 +1,40 @@ +const Product = ({ time, slug }) => ( +
+

Product {slug}

+

+ This page uses getStaticProps() and getStaticPaths() to pre-fetch a Product + {time} +

+
+) + +/** @type {import('next').GetStaticProps} */ +export async function getStaticProps({ params }) { + if (params.slug === 'not-found-no-revalidate') { + return { + notFound: true, + } + } else if (params.slug === 'not-found-with-revalidate') { + return { + notFound: true, + revalidate: 600, + } + } + + return { + props: { + time: new Date().toISOString(), + slug: params.slug, + }, + } +} + +/** @type {import('next').GetStaticPaths} */ +export const getStaticPaths = () => { + return { + paths: [], + fallback: 'blocking', // false or "blocking" + } +} + +export default Product diff --git a/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js b/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js index f41d142c67..306314ab1f 100644 --- a/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js +++ b/tests/fixtures/page-router-base-path-i18n/pages/products/[slug].js @@ -8,7 +8,19 @@ const Product = ({ time, slug }) => ( ) +/** @type {import('next').GetStaticProps} */ export async function getStaticProps({ params }) { + if (params.slug === 'not-found-no-revalidate') { + return { + notFound: true, + } + } else if (params.slug === 'not-found-with-revalidate') { + return { + notFound: true, + revalidate: 600, + } + } + return { props: { time: new Date().toISOString(), diff --git a/tests/integration/page-router.test.ts b/tests/integration/page-router.test.ts index eb039eb79d..3c6b624862 100644 --- a/tests/integration/page-router.test.ts +++ b/tests/integration/page-router.test.ts @@ -4,7 +4,7 @@ import { HttpResponse, http, passthrough } from 'msw' import { setupServer } from 'msw/node' import { platform } from 'node:process' import { v4 } from 'uuid' -import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' import { type FixtureTestContext } from '../utils/contexts.js' import { createFixture, invokeFunction, runPlugin } from '../utils/fixture.js' import { encodeBlobKey, generateRandomObjectID, startMockBlobStore } from '../utils/helpers.js' @@ -153,3 +153,136 @@ test('Should serve correct locale-aware custom 404 pages', a 'Served 404 page content should use non-default locale if non-default locale is explicitly used in pathname (after basePath)', ).toBe('fr') }) + +describe('404 caching', () => { + describe('default nextjs 404 (no custom 404)', () => { + test.todo('not matching dynamic paths') + test.todo('matching dynamic path without revalidate') + test.todo('matching dynamic path with revalidate') + }) + + describe('404 without getStaticProps', () => { + test.todo('not matching dynamic paths') + test.todo('matching dynamic path without revalidate') + test.todo('matching dynamic path with revalidate') + }) + + describe('404 with getStaticProps without revalidate', () => { + test('not matching dynamic paths should be cached permanently', async (ctx) => { + console.log('[test] not matching dynamic paths') + + await createFixture('page-router-base-path-i18n', ctx) + await runPlugin(ctx) + + const notExistingPage = await invokeFunction(ctx, { + url: '/base/path/not-existing-page', + }) + + expect( + notExistingPage.headers['netlify-cdn-cache-control'], + 'should be cached permanently', + ).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable') + }) + test('matching dynamic path without revalidate should be cached permanently', async (ctx) => { + console.log('[test] matching dynamic path without revalidate') + + await createFixture('page-router-base-path-i18n', ctx) + await runPlugin(ctx) + + const notExistingPage = await invokeFunction(ctx, { + url: '/base/path/products/not-found-no-revalidate', + }) + + expect( + notExistingPage.headers['netlify-cdn-cache-control'], + 'should be cached permanently', + ).toBe('s-maxage=31536000, stale-while-revalidate=31536000, durable') + }) + test('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => { + console.log('[test] matching dynamic path with revalidate') + + await createFixture('page-router-base-path-i18n', ctx) + await runPlugin(ctx) + + const notExistingPage = await invokeFunction(ctx, { + url: '/base/path/products/not-found-with-revalidate', + }) + + expect(notExistingPage.statusCode).toBe(404) + // this page was not prerendered, so no STALE case here + expect( + notExistingPage.headers['netlify-cdn-cache-control'], + 'should be cached for 600 seconds', + ).toBe('s-maxage=600, stale-while-revalidate=31536000, durable') + }) + }) + + describe('404 with getStaticProps with revalidate', () => { + test('not matching dynamic paths should be cached for 404 page revalidate', async (ctx) => { + console.log('[test] not matching dynamic paths') + + await createFixture('page-router-404-get-static-props-with-revalidate', ctx) + await runPlugin(ctx) + + // ignoring initial stale case + await invokeFunction(ctx, { + url: 'not-existing-page', + }) + await new Promise((res) => setTimeout(res, 100)) + + const notExistingPage = await invokeFunction(ctx, { + url: 'not-existing-page', + }) + + expect( + notExistingPage.headers['netlify-cdn-cache-control'], + 'should be cached permanently', + ).toBe('s-maxage=300, stale-while-revalidate=31536000, durable') + }) + test.only('matching dynamic path without revalidate should be cached permanently', async (ctx) => { + console.log('[test] matching dynamic path without revalidate') + + await createFixture('page-router-404-get-static-props-with-revalidate', ctx) + await runPlugin(ctx) + + // // ignoring initial stale case + // await invokeFunction(ctx, { + // url: 'products/not-found-no-revalidate', + // }) + // await new Promise((res) => setTimeout(res, 100)) + + const notExistingPage = await invokeFunction(ctx, { + url: 'products/not-found-no-revalidate', + }) + + expect( + notExistingPage.headers['netlify-cdn-cache-control'], + 'should be cached permanently', + ).toBe('s-maxage=31536000, durable') + }) + + test.only('matching dynamic path with revalidate should be cached for revalidate time', async (ctx) => { + console.log('[test] matching dynamic path with revalidate') + + await createFixture('page-router-404-get-static-props-with-revalidate', ctx) + await runPlugin(ctx) + + // // ignoring initial stale case + // await invokeFunction(ctx, { + // url: 'products/not-found-with-revalidate', + // }) + // await new Promise((res) => setTimeout(res, 100)) + + const notExistingPage = await invokeFunction(ctx, { + url: 'products/not-found-with-revalidate', + }) + + expect(notExistingPage.statusCode).toBe(404) + // this page was not prerendered, so no STALE case here + expect( + notExistingPage.headers['netlify-cdn-cache-control'], + 'should be cached for 600 seconds', + ).toBe('s-maxage=600, stale-while-revalidate=31535400, durable') + }) + }) +})