Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 4bd24ff

Browse files
committedJul 3, 2024··
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.
1 parent a8d8fca commit 4bd24ff

File tree

8 files changed

+219
-39
lines changed

8 files changed

+219
-39
lines changed
 

‎src/run/handlers/server.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ export default async (request: Request, context: FutureContext) => {
112112

113113
await adjustDateHeader({ headers: response.headers, request, span, tracer, requestContext })
114114

115-
setCacheControlHeaders(response.headers, request, requestContext)
115+
const useDurableCache =
116+
context.flags.get('serverless_functions_nextjs_durable_cache_disable') !== true
117+
setCacheControlHeaders(response.headers, request, requestContext, useDurableCache)
116118
setCacheTagsHeaders(response.headers, requestContext)
117119
setVaryHeaders(response.headers, request, nextConfig)
118120
setCacheStatusHeader(response.headers)

‎src/run/headers.test.ts

+115-25
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,96 @@ describe('headers', () => {
194194
describe('setCacheControlHeaders', () => {
195195
const defaultUrl = 'https://example.com'
196196

197+
describe('Durable Cache feature flag disabled', () => {
198+
test('should set permanent, non-durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => {
199+
const headers = new Headers()
200+
const request = new Request(defaultUrl)
201+
vi.spyOn(headers, 'set')
202+
203+
const requestContext = createRequestContext()
204+
requestContext.usedFsRead = true
205+
206+
setCacheControlHeaders(headers, request, requestContext, false)
207+
208+
expect(headers.set).toHaveBeenNthCalledWith(
209+
1,
210+
'cache-control',
211+
'public, max-age=0, must-revalidate',
212+
)
213+
expect(headers.set).toHaveBeenNthCalledWith(
214+
2,
215+
'netlify-cdn-cache-control',
216+
'max-age=31536000',
217+
)
218+
})
219+
220+
describe('route handler responses with a specified `revalidate` value', () => {
221+
test('should set non-durable SWC=1yr with 1yr TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is `false` (GET)', () => {
222+
const headers = new Headers()
223+
const request = new Request(defaultUrl)
224+
vi.spyOn(headers, 'set')
225+
226+
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
227+
setCacheControlHeaders(headers, request, ctx, false)
228+
229+
expect(headers.set).toHaveBeenCalledTimes(1)
230+
expect(headers.set).toHaveBeenNthCalledWith(
231+
1,
232+
'netlify-cdn-cache-control',
233+
's-maxage=31536000, stale-while-revalidate=31536000',
234+
)
235+
})
236+
237+
test('should set non-durable SWC=1yr with 1yr TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is `false` (HEAD)', () => {
238+
const headers = new Headers()
239+
const request = new Request(defaultUrl, { method: 'HEAD' })
240+
vi.spyOn(headers, 'set')
241+
242+
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
243+
setCacheControlHeaders(headers, request, ctx, false)
244+
245+
expect(headers.set).toHaveBeenCalledTimes(1)
246+
expect(headers.set).toHaveBeenNthCalledWith(
247+
1,
248+
'netlify-cdn-cache-control',
249+
's-maxage=31536000, stale-while-revalidate=31536000',
250+
)
251+
})
252+
253+
test('should set non-durable SWC=1yr with given TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is a number (GET)', () => {
254+
const headers = new Headers()
255+
const request = new Request(defaultUrl)
256+
vi.spyOn(headers, 'set')
257+
258+
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 }
259+
setCacheControlHeaders(headers, request, ctx, false)
260+
261+
expect(headers.set).toHaveBeenCalledTimes(1)
262+
expect(headers.set).toHaveBeenNthCalledWith(
263+
1,
264+
'netlify-cdn-cache-control',
265+
's-maxage=7200, stale-while-revalidate=31536000',
266+
)
267+
})
268+
269+
test('should set non-durable SWC=1yr with 1yr TTL if "{netlify-,}cdn-cache-control" is not present and `revalidate` is a number (HEAD)', () => {
270+
const headers = new Headers()
271+
const request = new Request(defaultUrl, { method: 'HEAD' })
272+
vi.spyOn(headers, 'set')
273+
274+
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 }
275+
setCacheControlHeaders(headers, request, ctx, false)
276+
277+
expect(headers.set).toHaveBeenCalledTimes(1)
278+
expect(headers.set).toHaveBeenNthCalledWith(
279+
1,
280+
'netlify-cdn-cache-control',
281+
's-maxage=7200, stale-while-revalidate=31536000',
282+
)
283+
})
284+
})
285+
})
286+
197287
describe('route handler responses with a specified `revalidate` value', () => {
198288
test('should not set any headers if "cdn-cache-control" is present', () => {
199289
const givenHeaders = {
@@ -204,7 +294,7 @@ describe('headers', () => {
204294
vi.spyOn(headers, 'set')
205295

206296
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
207-
setCacheControlHeaders(headers, request, ctx)
297+
setCacheControlHeaders(headers, request, ctx, true)
208298

209299
expect(headers.set).toHaveBeenCalledTimes(0)
210300
})
@@ -218,7 +308,7 @@ describe('headers', () => {
218308
vi.spyOn(headers, 'set')
219309

220310
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
221-
setCacheControlHeaders(headers, request, ctx)
311+
setCacheControlHeaders(headers, request, ctx, true)
222312

223313
expect(headers.set).toHaveBeenCalledTimes(0)
224314
})
@@ -232,7 +322,7 @@ describe('headers', () => {
232322
vi.spyOn(headers, 'set')
233323

234324
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
235-
setCacheControlHeaders(headers, request, ctx)
325+
setCacheControlHeaders(headers, request, ctx, true)
236326

237327
expect(headers.set).toHaveBeenCalledTimes(1)
238328
expect(headers.set).toHaveBeenNthCalledWith(
@@ -251,7 +341,7 @@ describe('headers', () => {
251341
vi.spyOn(headers, 'set')
252342

253343
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
254-
setCacheControlHeaders(headers, request, ctx)
344+
setCacheControlHeaders(headers, request, ctx, true)
255345

256346
expect(headers.set).toHaveBeenCalledTimes(1)
257347
expect(headers.set).toHaveBeenNthCalledWith(
@@ -267,7 +357,7 @@ describe('headers', () => {
267357
vi.spyOn(headers, 'set')
268358

269359
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
270-
setCacheControlHeaders(headers, request, ctx)
360+
setCacheControlHeaders(headers, request, ctx, true)
271361

272362
expect(headers.set).toHaveBeenCalledTimes(1)
273363
expect(headers.set).toHaveBeenNthCalledWith(
@@ -283,7 +373,7 @@ describe('headers', () => {
283373
vi.spyOn(headers, 'set')
284374

285375
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 }
286-
setCacheControlHeaders(headers, request, ctx)
376+
setCacheControlHeaders(headers, request, ctx, true)
287377

288378
expect(headers.set).toHaveBeenCalledTimes(1)
289379
expect(headers.set).toHaveBeenNthCalledWith(
@@ -299,7 +389,7 @@ describe('headers', () => {
299389
vi.spyOn(headers, 'set')
300390

301391
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: 7200 }
302-
setCacheControlHeaders(headers, request, ctx)
392+
setCacheControlHeaders(headers, request, ctx, true)
303393

304394
expect(headers.set).toHaveBeenCalledTimes(1)
305395
expect(headers.set).toHaveBeenNthCalledWith(
@@ -315,7 +405,7 @@ describe('headers', () => {
315405
vi.spyOn(headers, 'set')
316406

317407
const ctx: RequestContext = { ...createRequestContext(), routeHandlerRevalidate: false }
318-
setCacheControlHeaders(headers, request, ctx)
408+
setCacheControlHeaders(headers, request, ctx, true)
319409

320410
expect(headers.set).toHaveBeenCalledTimes(0)
321411
})
@@ -326,20 +416,20 @@ describe('headers', () => {
326416
const request = new Request(defaultUrl)
327417
vi.spyOn(headers, 'set')
328418

329-
setCacheControlHeaders(headers, request, createRequestContext())
419+
setCacheControlHeaders(headers, request, createRequestContext(), true)
330420

331421
expect(headers.set).toHaveBeenCalledTimes(0)
332422
})
333423

334-
test('should set permanent "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => {
424+
test('should set permanent, durable "netlify-cdn-cache-control" if "cache-control" is not set and "requestContext.usedFsRead" is truthy', () => {
335425
const headers = new Headers()
336426
const request = new Request(defaultUrl)
337427
vi.spyOn(headers, 'set')
338428

339429
const requestContext = createRequestContext()
340430
requestContext.usedFsRead = true
341431

342-
setCacheControlHeaders(headers, request, requestContext)
432+
setCacheControlHeaders(headers, request, requestContext, true)
343433

344434
expect(headers.set).toHaveBeenNthCalledWith(
345435
1,
@@ -349,7 +439,7 @@ describe('headers', () => {
349439
expect(headers.set).toHaveBeenNthCalledWith(
350440
2,
351441
'netlify-cdn-cache-control',
352-
'max-age=31536000',
442+
'max-age=31536000, durable',
353443
)
354444
})
355445

@@ -362,7 +452,7 @@ describe('headers', () => {
362452
const request = new Request(defaultUrl)
363453
vi.spyOn(headers, 'set')
364454

365-
setCacheControlHeaders(headers, request, createRequestContext())
455+
setCacheControlHeaders(headers, request, createRequestContext(), true)
366456

367457
expect(headers.set).toHaveBeenCalledTimes(0)
368458
})
@@ -376,7 +466,7 @@ describe('headers', () => {
376466
const request = new Request(defaultUrl)
377467
vi.spyOn(headers, 'set')
378468

379-
setCacheControlHeaders(headers, request, createRequestContext())
469+
setCacheControlHeaders(headers, request, createRequestContext(), true)
380470

381471
expect(headers.set).toHaveBeenCalledTimes(0)
382472
})
@@ -389,7 +479,7 @@ describe('headers', () => {
389479
const request = new Request(defaultUrl)
390480
vi.spyOn(headers, 'set')
391481

392-
setCacheControlHeaders(headers, request, createRequestContext())
482+
setCacheControlHeaders(headers, request, createRequestContext(), true)
393483

394484
expect(headers.set).toHaveBeenNthCalledWith(
395485
1,
@@ -399,7 +489,7 @@ describe('headers', () => {
399489
expect(headers.set).toHaveBeenNthCalledWith(
400490
2,
401491
'netlify-cdn-cache-control',
402-
'public, max-age=0, must-revalidate',
492+
'public, max-age=0, must-revalidate, durable',
403493
)
404494
})
405495

@@ -411,7 +501,7 @@ describe('headers', () => {
411501
const request = new Request(defaultUrl, { method: 'HEAD' })
412502
vi.spyOn(headers, 'set')
413503

414-
setCacheControlHeaders(headers, request, createRequestContext())
504+
setCacheControlHeaders(headers, request, createRequestContext(), true)
415505

416506
expect(headers.set).toHaveBeenNthCalledWith(
417507
1,
@@ -421,7 +511,7 @@ describe('headers', () => {
421511
expect(headers.set).toHaveBeenNthCalledWith(
422512
2,
423513
'netlify-cdn-cache-control',
424-
'public, max-age=0, must-revalidate',
514+
'public, max-age=0, must-revalidate, durable',
425515
)
426516
})
427517

@@ -433,7 +523,7 @@ describe('headers', () => {
433523
const request = new Request(defaultUrl, { method: 'POST' })
434524
vi.spyOn(headers, 'set')
435525

436-
setCacheControlHeaders(headers, request, createRequestContext())
526+
setCacheControlHeaders(headers, request, createRequestContext(), true)
437527

438528
expect(headers.set).toHaveBeenCalledTimes(0)
439529
})
@@ -446,13 +536,13 @@ describe('headers', () => {
446536
const request = new Request(defaultUrl)
447537
vi.spyOn(headers, 'set')
448538

449-
setCacheControlHeaders(headers, request, createRequestContext())
539+
setCacheControlHeaders(headers, request, createRequestContext(), true)
450540

451541
expect(headers.set).toHaveBeenNthCalledWith(1, 'cache-control', 'public')
452542
expect(headers.set).toHaveBeenNthCalledWith(
453543
2,
454544
'netlify-cdn-cache-control',
455-
'public, s-maxage=604800',
545+
'public, s-maxage=604800, durable',
456546
)
457547
})
458548

@@ -464,13 +554,13 @@ describe('headers', () => {
464554
const request = new Request(defaultUrl)
465555
vi.spyOn(headers, 'set')
466556

467-
setCacheControlHeaders(headers, request, createRequestContext())
557+
setCacheControlHeaders(headers, request, createRequestContext(), true)
468558

469559
expect(headers.set).toHaveBeenNthCalledWith(1, 'cache-control', 'max-age=604800')
470560
expect(headers.set).toHaveBeenNthCalledWith(
471561
2,
472562
'netlify-cdn-cache-control',
473-
'max-age=604800, stale-while-revalidate=86400',
563+
'max-age=604800, stale-while-revalidate=86400, durable',
474564
)
475565
})
476566

@@ -482,7 +572,7 @@ describe('headers', () => {
482572
const request = new Request(defaultUrl)
483573
vi.spyOn(headers, 'set')
484574

485-
setCacheControlHeaders(headers, request, createRequestContext())
575+
setCacheControlHeaders(headers, request, createRequestContext(), true)
486576

487577
expect(headers.set).toHaveBeenNthCalledWith(
488578
1,
@@ -492,7 +582,7 @@ describe('headers', () => {
492582
expect(headers.set).toHaveBeenNthCalledWith(
493583
2,
494584
'netlify-cdn-cache-control',
495-
's-maxage=604800, stale-while-revalidate=86400',
585+
's-maxage=604800, stale-while-revalidate=86400, durable',
496586
)
497587
})
498588
})

‎src/run/headers.ts

+10-11
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,6 @@ const omitHeaderValues = (header: string, values: string[]): string => {
6565
return filteredValues.join(', ')
6666
}
6767

68-
const mapHeaderValues = (header: string, callback: (value: string) => string): string => {
69-
const headerValues = getHeaderValueArray(header)
70-
const mappedValues = headerValues.map(callback)
71-
return mappedValues.join(', ')
72-
}
73-
7468
/**
7569
* Ensure the Netlify CDN varies on things that Next.js varies on,
7670
* e.g. i18n, preview mode, etc.
@@ -219,7 +213,9 @@ export const setCacheControlHeaders = (
219213
headers: Headers,
220214
request: Request,
221215
requestContext: RequestContext,
216+
useDurableCache: boolean,
222217
) => {
218+
const durableCacheDirective = useDurableCache ? ', durable' : ''
223219
if (
224220
typeof requestContext.routeHandlerRevalidate !== 'undefined' &&
225221
['GET', 'HEAD'].includes(request.method) &&
@@ -231,7 +227,7 @@ export const setCacheControlHeaders = (
231227
// if we are serving already stale response, instruct edge to not attempt to cache that response
232228
headers.get('x-nextjs-cache') === 'STALE'
233229
? 'public, max-age=0, must-revalidate'
234-
: `s-maxage=${requestContext.routeHandlerRevalidate === false ? 31536000 : requestContext.routeHandlerRevalidate}, stale-while-revalidate=31536000`
230+
: `s-maxage=${requestContext.routeHandlerRevalidate === false ? 31536000 : requestContext.routeHandlerRevalidate}, stale-while-revalidate=31536000${durableCacheDirective}`
235231

236232
headers.set('netlify-cdn-cache-control', cdnCacheControl)
237233
return
@@ -253,9 +249,12 @@ export const setCacheControlHeaders = (
253249
// if we are serving already stale response, instruct edge to not attempt to cache that response
254250
headers.get('x-nextjs-cache') === 'STALE'
255251
? 'public, max-age=0, must-revalidate'
256-
: mapHeaderValues(cacheControl, (value) =>
257-
value === 'stale-while-revalidate' ? 'stale-while-revalidate=31536000' : value,
258-
)
252+
: [
253+
...getHeaderValueArray(cacheControl).map((value) =>
254+
value === 'stale-while-revalidate' ? 'stale-while-revalidate=31536000' : value,
255+
),
256+
...(useDurableCache ? ['durable'] : []),
257+
].join(', ')
259258

260259
headers.set('cache-control', browserCacheControl || 'public, max-age=0, must-revalidate')
261260
headers.set('netlify-cdn-cache-control', cdnCacheControl)
@@ -270,7 +269,7 @@ export const setCacheControlHeaders = (
270269
) {
271270
// handle CDN Cache Control on static files
272271
headers.set('cache-control', 'public, max-age=0, must-revalidate')
273-
headers.set('netlify-cdn-cache-control', `max-age=31536000`)
272+
headers.set('netlify-cdn-cache-control', `max-age=31536000${durableCacheDirective}`)
274273
}
275274
}
276275

‎tests/e2e/durable-cache.test.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { expect } from '@playwright/test'
2+
import { test } from '../utils/playwright-helpers.js'
3+
4+
// This fixture is deployed to a separate site with the feature flag enabled
5+
test('sets cache-control `durable` directive when feature flag is enabled', async ({
6+
page,
7+
durableCache,
8+
}) => {
9+
const response = await page.goto(durableCache.url)
10+
const headers = response?.headers() || {}
11+
12+
const h1 = page.locator('h1')
13+
await expect(h1).toHaveText('Home')
14+
15+
expect(headers['netlify-cdn-cache-control']).toBe(
16+
's-maxage=31536000, stale-while-revalidate=31536000, durable',
17+
)
18+
expect(headers['cache-control']).toBe('public,max-age=0,must-revalidate')
19+
})

‎tests/e2e/simple-app.test.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ test('Renders the Home page correctly', async ({ page, simple }) => {
1212

1313
await expect(page).toHaveTitle('Simple Next App')
1414

15-
expect(headers['cache-status']).toBe('"Next.js"; hit\n"Netlify Edge"; fwd=miss')
15+
expect(headers['cache-status']).toBe(
16+
'"Next.js"; hit\n"Netlify Durable"; fwd=miss\n"Netlify Edge"; fwd=miss',
17+
)
1618

1719
const h1 = page.locator('h1')
1820
await expect(h1).toHaveText('Home')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { getLogger } from 'lambda-local'
2+
import { v4 } from 'uuid'
3+
import { beforeEach, describe, expect, test, vi } from 'vitest'
4+
import { type FixtureTestContext } from '../../utils/contexts.js'
5+
import { createFixture, invokeFunction, runPlugin } from '../../utils/fixture.js'
6+
import { generateRandomObjectID, startMockBlobStore } from '../../utils/helpers.js'
7+
8+
// Disable the verbose logging of the lambda-local runtime
9+
getLogger().level = 'alert'
10+
11+
beforeEach<FixtureTestContext>(async (ctx) => {
12+
// set for each test a new deployID and siteID
13+
ctx.deployID = generateRandomObjectID()
14+
ctx.siteID = v4()
15+
vi.stubEnv('SITE_ID', ctx.siteID)
16+
vi.stubEnv('DEPLOY_ID', ctx.deployID)
17+
// hide debug logs in tests
18+
vi.spyOn(console, 'debug').mockImplementation(() => {})
19+
20+
await startMockBlobStore(ctx)
21+
})
22+
23+
describe('`serverless_functions_nextjs_durable_cache_disable` feature flag', () => {
24+
test<FixtureTestContext>('uses durable cache when flag is nil', async (ctx) => {
25+
await createFixture('simple', ctx)
26+
await runPlugin(ctx)
27+
28+
const { headers } = await invokeFunction(ctx, {
29+
flags: { serverless_functions_nextjs_durable_cache_disable: undefined },
30+
})
31+
32+
expect(headers['netlify-cdn-cache-control']).toContain('durable')
33+
})
34+
35+
test<FixtureTestContext>('uses durable cache when flag is `false`', async (ctx) => {
36+
await createFixture('simple', ctx)
37+
await runPlugin(ctx)
38+
39+
const { headers } = await invokeFunction(ctx, {
40+
flags: { serverless_functions_nextjs_durable_cache_disable: false },
41+
})
42+
43+
expect(headers['netlify-cdn-cache-control']).toContain('durable')
44+
})
45+
46+
test<FixtureTestContext>('does not use durable cache when flag is `true`', async (ctx) => {
47+
await createFixture('simple', ctx)
48+
await runPlugin(ctx)
49+
50+
const { headers } = await invokeFunction(ctx, {
51+
flags: { serverless_functions_nextjs_durable_cache_disable: true },
52+
})
53+
54+
expect(headers['netlify-cdn-cache-control']).not.toContain('durable')
55+
})
56+
})

‎tests/utils/create-e2e-fixture.ts

+6
Original file line numberDiff line numberDiff line change
@@ -428,4 +428,10 @@ export const fixtureFactories = {
428428
publishDirectory: 'apps/site/.next',
429429
smoke: true,
430430
}),
431+
durableCache: () =>
432+
createE2EFixture('simple', {
433+
// https://app.netlify.com/sites/next-runtime-testing-durable-cache
434+
// This site has all the Durable Cache feature flags enabled.
435+
siteId: 'a8ceaa01-86fd-4c9a-8563-3769560d452a',
436+
}),
431437
}

‎tests/utils/fixture.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,9 @@ export async function uploadBlobs(ctx: FixtureTestContext, blobsDir: string) {
322322
)
323323
}
324324

325+
const DEFAULT_FLAGS = {
326+
serverless_functions_nextjs_durable_cache_disable: true,
327+
}
325328
/**
326329
* Execute the function with the provided parameters
327330
* @param ctx
@@ -346,9 +349,11 @@ export async function invokeFunction(
346349
body?: unknown
347350
/** Environment variables that should be set during the invocation */
348351
env?: Record<string, string | number>
352+
/** Feature flags that should be set during the invocation */
353+
flags?: Record<string, unknown>
349354
} = {},
350355
) {
351-
const { httpMethod, headers, body, url, env } = options
356+
const { httpMethod, headers, flags, url, env } = options
352357
// now for the execution set the process working directory to the dist entry point
353358
const cwdMock = vi
354359
.spyOn(process, 'cwd')
@@ -381,6 +386,7 @@ export async function invokeFunction(
381386
headers: headers || {},
382387
httpMethod: httpMethod || 'GET',
383388
rawUrl: new URL(url || '/', 'https://example.netlify').href,
389+
flags: flags ?? DEFAULT_FLAGS,
384390
},
385391
lambdaFunc: { handler },
386392
timeoutMs: 4_000,

0 commit comments

Comments
 (0)
Please sign in to comment.