From 4bd24ffe7e2152e29ae7887e21e9385e39ac6c69 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Tue, 25 Jun 2024 17:59:15 -0400 Subject: [PATCH] feat: specify `durable` cache-control directive This is gated behind a feature flag for now. I can't link to any public docs yet, but by the time you're reading this you should be able to find a section on "Durable caching" at https://docs.netlify.com. --- src/run/handlers/server.ts | 4 +- src/run/headers.test.ts | 140 +++++++++++++++---- src/run/headers.ts | 21 ++- tests/e2e/durable-cache.test.ts | 19 +++ tests/e2e/simple-app.test.ts | 4 +- tests/integration/run/server-handler.test.ts | 56 ++++++++ tests/utils/create-e2e-fixture.ts | 6 + tests/utils/fixture.ts | 8 +- 8 files changed, 219 insertions(+), 39 deletions(-) create mode 100644 tests/e2e/durable-cache.test.ts create mode 100644 tests/integration/run/server-handler.test.ts diff --git a/src/run/handlers/server.ts b/src/run/handlers/server.ts index d7dd4c5c0a..1037b4777d 100644 --- a/src/run/handlers/server.ts +++ b/src/run/handlers/server.ts @@ -112,7 +112,9 @@ 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_disable') !== true + 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..7d356fd4dc 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: boolean, ) => { + 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/e2e/simple-app.test.ts b/tests/e2e/simple-app.test.ts index 131b32fac1..87a90dcca9 100644 --- a/tests/e2e/simple-app.test.ts +++ b/tests/e2e/simple-app.test.ts @@ -12,7 +12,9 @@ test('Renders the Home page correctly', async ({ page, simple }) => { await expect(page).toHaveTitle('Simple Next App') - expect(headers['cache-status']).toBe('"Next.js"; hit\n"Netlify Edge"; fwd=miss') + expect(headers['cache-status']).toBe( + '"Next.js"; hit\n"Netlify Durable"; fwd=miss\n"Netlify Edge"; fwd=miss', + ) const h1 = page.locator('h1') await expect(h1).toHaveText('Home') diff --git a/tests/integration/run/server-handler.test.ts b/tests/integration/run/server-handler.test.ts new file mode 100644 index 0000000000..f649878850 --- /dev/null +++ b/tests/integration/run/server-handler.test.ts @@ -0,0 +1,56 @@ +import { getLogger } from 'lambda-local' +import { v4 } from 'uuid' +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { type FixtureTestContext } from '../../utils/contexts.js' +import { createFixture, invokeFunction, runPlugin } from '../../utils/fixture.js' +import { generateRandomObjectID, startMockBlobStore } from '../../utils/helpers.js' + +// Disable the verbose logging of the lambda-local runtime +getLogger().level = 'alert' + +beforeEach(async (ctx) => { + // set for each test a new deployID and siteID + ctx.deployID = generateRandomObjectID() + ctx.siteID = v4() + vi.stubEnv('SITE_ID', ctx.siteID) + vi.stubEnv('DEPLOY_ID', ctx.deployID) + // hide debug logs in tests + vi.spyOn(console, 'debug').mockImplementation(() => {}) + + await startMockBlobStore(ctx) +}) + +describe('`serverless_functions_nextjs_durable_cache_disable` feature flag', () => { + test('uses durable cache when flag is nil', async (ctx) => { + await createFixture('simple', ctx) + await runPlugin(ctx) + + const { headers } = await invokeFunction(ctx, { + flags: { serverless_functions_nextjs_durable_cache_disable: undefined }, + }) + + expect(headers['netlify-cdn-cache-control']).toContain('durable') + }) + + test('uses durable cache when flag is `false`', async (ctx) => { + await createFixture('simple', ctx) + await runPlugin(ctx) + + const { headers } = await invokeFunction(ctx, { + flags: { serverless_functions_nextjs_durable_cache_disable: false }, + }) + + expect(headers['netlify-cdn-cache-control']).toContain('durable') + }) + + test('does not use durable cache when flag is `true`', async (ctx) => { + await createFixture('simple', ctx) + await runPlugin(ctx) + + const { headers } = await invokeFunction(ctx, { + flags: { serverless_functions_nextjs_durable_cache_disable: true }, + }) + + expect(headers['netlify-cdn-cache-control']).not.toContain('durable') + }) +}) diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 9718e28711..f1724c8d6a 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 site has all the Durable Cache feature flags enabled. + siteId: 'a8ceaa01-86fd-4c9a-8563-3769560d452a', + }), } diff --git a/tests/utils/fixture.ts b/tests/utils/fixture.ts index 7063af22eb..4f725af6f2 100644 --- a/tests/utils/fixture.ts +++ b/tests/utils/fixture.ts @@ -322,6 +322,9 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) { ) } +const DEFAULT_FLAGS = { + serverless_functions_nextjs_durable_cache_disable: true, +} /** * Execute the function with the provided parameters * @param ctx @@ -346,9 +349,11 @@ export async function invokeFunction( body?: unknown /** Environment variables that should be set during the invocation */ env?: Record + /** Feature flags that should be set during the invocation */ + flags?: Record } = {}, ) { - const { httpMethod, headers, body, url, env } = options + const { httpMethod, headers, flags, url, env } = options // now for the execution set the process working directory to the dist entry point const cwdMock = vi .spyOn(process, 'cwd') @@ -381,6 +386,7 @@ export async function invokeFunction( headers: headers || {}, httpMethod: httpMethod || 'GET', rawUrl: new URL(url || '/', 'https://example.netlify').href, + flags: flags ?? DEFAULT_FLAGS, }, lambdaFunc: { handler }, timeoutMs: 4_000,