Skip to content

Commit

Permalink
feat: specify durable cache-control directive
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
serhalp committed Jun 27, 2024
1 parent 55a6919 commit d75a484
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 37 deletions.
5 changes: 4 additions & 1 deletion src/run/handlers/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
140 changes: 115 additions & 25 deletions src/run/headers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
})
Expand All @@ -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)
})
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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)
})
Expand All @@ -326,20 +416,20 @@ 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')

const requestContext = createRequestContext()
requestContext.usedFsRead = true

setCacheControlHeaders(headers, request, requestContext)
setCacheControlHeaders(headers, request, requestContext, true)

expect(headers.set).toHaveBeenNthCalledWith(
1,
Expand All @@ -349,7 +439,7 @@ describe('headers', () => {
expect(headers.set).toHaveBeenNthCalledWith(
2,
'netlify-cdn-cache-control',
'max-age=31536000',
'max-age=31536000, durable',
)
})

Expand All @@ -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)
})
Expand All @@ -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)
})
Expand All @@ -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,
Expand All @@ -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',
)
})

Expand All @@ -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,
Expand All @@ -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',
)
})

Expand All @@ -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)
})
Expand All @@ -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',
)
})

Expand All @@ -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',
)
})

Expand All @@ -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,
Expand All @@ -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',
)
})
})
Expand Down
21 changes: 10 additions & 11 deletions src/run/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) &&
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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}`)
}
}

Expand Down
Loading

0 comments on commit d75a484

Please sign in to comment.