diff --git a/src/run/handlers/server.ts b/src/run/handlers/server.ts index d7dd4c5c0a..e574080ade 100644 --- a/src/run/handlers/server.ts +++ b/src/run/handlers/server.ts @@ -112,7 +112,10 @@ export default async (request: Request, context: FutureContext) => { await adjustDateHeader({ headers: response.headers, request, span, tracer, requestContext }) - setCacheControlHeaders(response.headers, request, requestContext) + const useDurableCache = context.flags.get('serverless_functions_nextjs_durable_cache') as + | boolean + | undefined + setCacheControlHeaders(response.headers, request, requestContext, useDurableCache) setCacheTagsHeaders(response.headers, requestContext) setVaryHeaders(response.headers, request, nextConfig) setCacheStatusHeader(response.headers) diff --git a/src/run/headers.test.ts b/src/run/headers.test.ts index ee71cd8891..4419559e3b 100644 --- a/src/run/headers.test.ts +++ b/src/run/headers.test.ts @@ -194,6 +194,96 @@ describe('headers', () => { describe('setCacheControlHeaders', () => { const defaultUrl = 'https://example.com' + describe('Durable Cache feature flag disabled', () => { + test('should set permanent, non-durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => { + const headers = new Headers() + const request = new Request(defaultUrl) + vi.spyOn(headers, 'set') + + const requestContext = createRequestContext() + requestContext.usedFsRead = true + + setCacheControlHeaders(headers, request, requestContext, false) + + expect(headers.set).toHaveBeenNthCalledWith( + 1, + 'cache-control', + 'public, max-age=0, must-revalidate', + ) + expect(headers.set).toHaveBeenNthCalledWith( + 2, + 'netlify-cdn-cache-control', + 'max-age=31536000', + ) + }) + + describe('route handler responses with a specified `revalidate` value', () => { + test('should set non-durable SWC=1yr with 1yr TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is `false` (GET)', () => { + const headers = new Headers() + const request = new Request(defaultUrl) + vi.spyOn(headers, 'set') + + const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } + setCacheControlHeaders(headers, request, ctx, false) + + expect(headers.set).toHaveBeenCalledTimes(1) + expect(headers.set).toHaveBeenNthCalledWith( + 1, + 'netlify-cdn-cache-control', + 's-maxage=31536000, stale-while-revalidate=31536000', + ) + }) + + test('should set non-durable SWC=1yr with 1yr TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is `false` (HEAD)', () => { + const headers = new Headers() + const request = new Request(defaultUrl, { method: 'HEAD' }) + vi.spyOn(headers, 'set') + + const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } + setCacheControlHeaders(headers, request, ctx, false) + + expect(headers.set).toHaveBeenCalledTimes(1) + expect(headers.set).toHaveBeenNthCalledWith( + 1, + 'netlify-cdn-cache-control', + 's-maxage=31536000, stale-while-revalidate=31536000', + ) + }) + + test('should set non-durable SWC=1yr with given TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is a number (GET)', () => { + const headers = new Headers() + const request = new Request(defaultUrl) + vi.spyOn(headers, 'set') + + const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 } + setCacheControlHeaders(headers, request, ctx, false) + + expect(headers.set).toHaveBeenCalledTimes(1) + expect(headers.set).toHaveBeenNthCalledWith( + 1, + 'netlify-cdn-cache-control', + 's-maxage=7200, stale-while-revalidate=31536000', + ) + }) + + test('should set non-durable SWC=1yr with 1yr TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is a number (HEAD)', () => { + const headers = new Headers() + const request = new Request(defaultUrl, { method: 'HEAD' }) + vi.spyOn(headers, 'set') + + const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 } + setCacheControlHeaders(headers, request, ctx, false) + + expect(headers.set).toHaveBeenCalledTimes(1) + expect(headers.set).toHaveBeenNthCalledWith( + 1, + 'netlify-cdn-cache-control', + 's-maxage=7200, stale-while-revalidate=31536000', + ) + }) + }) + }) + describe('route handler responses with a specified `revalidate` value', () => { test('should not set any headers if "cdn-cache-control" is present', () => { const givenHeaders = { @@ -204,7 +294,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(0) }) @@ -218,7 +308,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(0) }) @@ -232,7 +322,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(1) expect(headers.set).toHaveBeenNthCalledWith( @@ -251,7 +341,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(1) expect(headers.set).toHaveBeenNthCalledWith( @@ -267,7 +357,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(1) expect(headers.set).toHaveBeenNthCalledWith( @@ -283,7 +373,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(1) expect(headers.set).toHaveBeenNthCalledWith( @@ -299,7 +389,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(1) expect(headers.set).toHaveBeenNthCalledWith( @@ -315,7 +405,7 @@ describe('headers', () => { vi.spyOn(headers, 'set') const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false } - setCacheControlHeaders(headers, request, ctx) + setCacheControlHeaders(headers, request, ctx, true) expect(headers.set).toHaveBeenCalledTimes(0) }) @@ -326,12 +416,12 @@ describe('headers', () => { const request = new Request(defaultUrl) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenCalledTimes(0) }) - test('should set permanent "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => { + test('should set permanent, durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => { const headers = new Headers() const request = new Request(defaultUrl) vi.spyOn(headers, 'set') @@ -339,7 +429,7 @@ describe('headers', () => { const requestContext = createRequestContext() requestContext.usedFsRead = true - setCacheControlHeaders(headers, request, requestContext) + setCacheControlHeaders(headers, request, requestContext, true) expect(headers.set).toHaveBeenNthCalledWith( 1, @@ -349,7 +439,7 @@ describe('headers', () => { expect(headers.set).toHaveBeenNthCalledWith( 2, 'netlify-cdn-cache-control', - 'max-age=31536000', + 'max-age=31536000, durable', ) }) @@ -362,7 +452,7 @@ describe('headers', () => { const request = new Request(defaultUrl) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenCalledTimes(0) }) @@ -376,7 +466,7 @@ describe('headers', () => { const request = new Request(defaultUrl) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenCalledTimes(0) }) @@ -389,7 +479,7 @@ describe('headers', () => { const request = new Request(defaultUrl) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenNthCalledWith( 1, @@ -399,7 +489,7 @@ describe('headers', () => { expect(headers.set).toHaveBeenNthCalledWith( 2, 'netlify-cdn-cache-control', - 'public, max-age=0, must-revalidate', + 'public, max-age=0, must-revalidate, durable', ) }) @@ -411,7 +501,7 @@ describe('headers', () => { const request = new Request(defaultUrl, { method: 'HEAD' }) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenNthCalledWith( 1, @@ -421,7 +511,7 @@ describe('headers', () => { expect(headers.set).toHaveBeenNthCalledWith( 2, 'netlify-cdn-cache-control', - 'public, max-age=0, must-revalidate', + 'public, max-age=0, must-revalidate, durable', ) }) @@ -433,7 +523,7 @@ describe('headers', () => { const request = new Request(defaultUrl, { method: 'POST' }) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenCalledTimes(0) }) @@ -446,13 +536,13 @@ describe('headers', () => { const request = new Request(defaultUrl) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenNthCalledWith(1, 'cache-control', 'public') expect(headers.set).toHaveBeenNthCalledWith( 2, 'netlify-cdn-cache-control', - 'public, s-maxage=604800', + 'public, s-maxage=604800, durable', ) }) @@ -464,13 +554,13 @@ describe('headers', () => { const request = new Request(defaultUrl) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenNthCalledWith(1, 'cache-control', 'max-age=604800') expect(headers.set).toHaveBeenNthCalledWith( 2, 'netlify-cdn-cache-control', - 'max-age=604800, stale-while-revalidate=86400', + 'max-age=604800, stale-while-revalidate=86400, durable', ) }) @@ -482,7 +572,7 @@ describe('headers', () => { const request = new Request(defaultUrl) vi.spyOn(headers, 'set') - setCacheControlHeaders(headers, request, createRequestContext()) + setCacheControlHeaders(headers, request, createRequestContext(), true) expect(headers.set).toHaveBeenNthCalledWith( 1, @@ -492,7 +582,7 @@ describe('headers', () => { expect(headers.set).toHaveBeenNthCalledWith( 2, 'netlify-cdn-cache-control', - 's-maxage=604800, stale-while-revalidate=86400', + 's-maxage=604800, stale-while-revalidate=86400, durable', ) }) }) diff --git a/src/run/headers.ts b/src/run/headers.ts index a5b80cc13b..894fd04863 100644 --- a/src/run/headers.ts +++ b/src/run/headers.ts @@ -65,12 +65,6 @@ const omitHeaderValues = (header: string, values: string[]): string => { return filteredValues.join(', ') } -const mapHeaderValues = (header: string, callback: (value: string) => string): string => { - const headerValues = getHeaderValueArray(header) - const mappedValues = headerValues.map(callback) - return mappedValues.join(', ') -} - /** * Ensure the Netlify CDN varies on things that Next.js varies on, * e.g. i18n, preview mode, etc. @@ -219,7 +213,9 @@ export const setCacheControlHeaders = ( headers: Headers, request: Request, requestContext: RequestContext, + useDurableCache = false, ) => { + const durableCacheDirective = useDurableCache ? ', durable' : '' if ( typeof requestContext.routeHandlerRevalidate !== 'undefined' && ['GET', 'HEAD'].includes(request.method) && @@ -231,7 +227,7 @@ export const setCacheControlHeaders = ( // 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' - : `s-maxage=${requestContext.routeHandlerRevalidate === false ? 31536000 : requestContext.routeHandlerRevalidate}, stale-while-revalidate=31536000` + : `s-maxage=${requestContext.routeHandlerRevalidate === false ? 31536000 : requestContext.routeHandlerRevalidate}, stale-while-revalidate=31536000${durableCacheDirective}` headers.set('netlify-cdn-cache-control', cdnCacheControl) return @@ -253,9 +249,12 @@ export const setCacheControlHeaders = ( // 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' - : mapHeaderValues(cacheControl, (value) => - value === 'stale-while-revalidate' ? 'stale-while-revalidate=31536000' : value, - ) + : [ + ...getHeaderValueArray(cacheControl).map((value) => + value === 'stale-while-revalidate' ? 'stale-while-revalidate=31536000' : value, + ), + ...(useDurableCache ? ['durable'] : []), + ].join(', ') headers.set('cache-control', browserCacheControl || 'public, max-age=0, must-revalidate') headers.set('netlify-cdn-cache-control', cdnCacheControl) @@ -270,7 +269,7 @@ export const setCacheControlHeaders = ( ) { // handle CDN Cache Control on static files headers.set('cache-control', 'public, max-age=0, must-revalidate') - headers.set('netlify-cdn-cache-control', `max-age=31536000`) + headers.set('netlify-cdn-cache-control', `max-age=31536000${durableCacheDirective}`) } } diff --git a/tests/e2e/durable-cache.test.ts b/tests/e2e/durable-cache.test.ts new file mode 100644 index 0000000000..269203ba89 --- /dev/null +++ b/tests/e2e/durable-cache.test.ts @@ -0,0 +1,19 @@ +import { expect } from '@playwright/test' +import { test } from '../utils/playwright-helpers.js' + +// This fixture is deployed to a separate site with the feature flag enabled +test('sets cache-control `durable` directive when feature flag is enabled', async ({ + page, + durableCache, +}) => { + const response = await page.goto(durableCache.url) + const headers = response?.headers() || {} + + const h1 = page.locator('h1') + await expect(h1).toHaveText('Home') + + expect(headers['netlify-cdn-cache-control']).toBe( + 's-maxage=31536000, stale-while-revalidate=31536000, durable', + ) + expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate') +}) diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 9718e28711..6ae3578d06 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -428,4 +428,10 @@ export const fixtureFactories = { publishDirectory: 'apps/site/.next', smoke: true, }), + durableCache: () => + createE2EFixture('simple', { + // https://app.netlify.com/sites/next-runtime-testing-durable-cache + // This has has the `serverless_functions_nextjs_durable_cache` feature flag enabled + siteId: 'a8ceaa01-86fd-4c9a-8563-3769560d452a', + }), }