Skip to content

Commit 85a5284

Browse files
committed
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 1bdf599 commit 85a5284

File tree

7 files changed

+216
-38
lines changed

7 files changed

+216
-38
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

0 commit comments

Comments
 (0)