Skip to content

Commit ee5f379

Browse files
authored
Merge pull request #21 from netlify/rs/cache-control
feat: add cache-control and vary handling to server handler
2 parents 6c28ebc + ce34a23 commit ee5f379

File tree

4 files changed

+90
-79
lines changed

4 files changed

+90
-79
lines changed

.eslintrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ module.exports = {
2121
{
2222
files: ['src/handlers/**'],
2323
rules: {
24+
'max-statements': ['error', 30],
2425
'import/no-anonymous-default-export': 'off',
2526
},
2627
},

src/handlers/server.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { toComputeResponse, toReqRes } from '@fastly/http-compute-js'
2+
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
23
import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js'
34

45
import { TASK_DIR } from '../helpers/constants.js'
6+
import { setCacheControlHeaders, setVaryHeaders } from '../helpers/headers.js'
57

6-
let nextHandler: WorkerRequestHandler
8+
let nextHandler: WorkerRequestHandler, nextConfig: NextConfigComplete
79

810
export default async (request: Request) => {
911
if (!nextHandler) {
1012
// set the server config
11-
const { setRequestConfig } = await import('../helpers/config.js')
12-
await setRequestConfig()
13+
const { getRunConfig, setRunConfig } = await import('../helpers/config.js')
14+
nextConfig = await getRunConfig()
15+
setRunConfig(nextConfig)
1316

1417
// let Next.js initialize and create the request handler
1518
const { getRequestHandlers } = await import('next/dist/server/lib/start-server.js')
@@ -36,5 +39,8 @@ export default async (request: Request) => {
3639
const response = { headers: res.getHeaders(), statusCode: res.statusCode }
3740
console.log('Next server response:', JSON.stringify(response, null, 2))
3841

42+
setCacheControlHeaders(res)
43+
setVaryHeaders(res, req, nextConfig)
44+
3945
return toComputeResponse(res)
4046
}

src/helpers/config.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,34 @@
11
import { readFile } from 'node:fs/promises'
22

3+
import { NextConfigComplete } from 'next/dist/server/config-shared.js'
4+
35
import { TASK_DIR } from './constants.js'
46

57
/**
6-
* Enable standalone mode at build-time
8+
* Enable Next.js standalone mode at build time
79
*/
810
export const setBuildConfig = () => {
911
process.env.NEXT_PRIVATE_STANDALONE = 'true'
1012
}
1113

1214
/**
13-
* Configure the request-time custom cache handler
15+
* Get Next.js config from the build output
1416
*/
15-
export const setRequestConfig = async () => {
17+
export const getRunConfig = async () => {
1618
// get config from the build output
17-
const runtimeConfig = JSON.parse(await readFile(`${TASK_DIR}/.next/required-server-files.json`, 'utf-8'))
19+
return JSON.parse(await readFile(`${TASK_DIR}/.next/required-server-files.json`, 'utf-8')).config
20+
}
1821

22+
/**
23+
* Configure the custom cache handler at request time
24+
*/
25+
export const setRunConfig = (config: NextConfigComplete) => {
1926
// set the path to the cache handler
20-
runtimeConfig.config.experimental = {
21-
...runtimeConfig.config.experimental,
27+
config.experimental = {
28+
...config.experimental,
2229
incrementalCacheHandlerPath: `${TASK_DIR}/dist/handlers/cache.cjs`,
2330
}
2431

2532
// set config
26-
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(runtimeConfig.config)
33+
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(config)
2734
}

src/helpers/headers.ts

Lines changed: 66 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,94 @@
1-
import type { HandlerEvent } from '@netlify/functions'
1+
import type { IncomingMessage, ServerResponse } from 'http'
2+
23
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
34

4-
export interface NetlifyVaryHeaderBuilder {
5+
type HeaderValue = number | string | string[]
6+
7+
interface NetlifyVaryDirectives {
58
headers: string[]
69
languages: string[]
710
cookies: string[]
811
}
912

10-
const generateNetlifyVaryHeaderValue = ({ headers, languages, cookies }: NetlifyVaryHeaderBuilder): string => {
11-
let NetlifyVaryHeader = ``
12-
if (headers && headers.length !== 0) {
13-
NetlifyVaryHeader += `header=${headers.join(`|`)}`
13+
const generateNetlifyVaryDirectives = ({
14+
headers,
15+
languages,
16+
cookies,
17+
}: NetlifyVaryDirectives): string[] => {
18+
const directives = []
19+
if (headers.length !== 0) {
20+
directives.push(`header=${headers.join(`|`)}`)
1421
}
15-
if (languages && languages.length !== 0) {
16-
if (NetlifyVaryHeader.length !== 0) {
17-
NetlifyVaryHeader += `,`
18-
}
19-
NetlifyVaryHeader += `language=${languages.join(`|`)}`
22+
if (languages.length !== 0) {
23+
directives.push(`language=${languages.join(`|`)}`)
2024
}
21-
if (cookies && cookies.length !== 0) {
22-
if (NetlifyVaryHeader.length !== 0) {
23-
NetlifyVaryHeader += `,`
24-
}
25-
NetlifyVaryHeader += `cookie=${cookies.join(`|`)}`
25+
if (cookies.length !== 0) {
26+
directives.push(`cookie=${cookies.join(`|`)}`)
2627
}
27-
28-
return NetlifyVaryHeader
28+
return directives
2929
}
3030

31-
const getDirectives = (headerValue: string): string[] => headerValue.split(',').map((directive) => directive.trim())
32-
33-
const removeSMaxAgeAndStaleWhileRevalidate = (headerValue: string): string =>
34-
getDirectives(headerValue)
35-
.filter((directive) => {
36-
if (directive.startsWith('s-maxage')) {
37-
return false
38-
}
39-
if (directive.startsWith('stale-while-revalidate')) {
40-
return false
41-
}
42-
return true
43-
})
44-
.join(`,`)
45-
46-
export const handleVary = (headers: Record<string, string>, event: HandlerEvent, nextConfig: NextConfigComplete) => {
47-
const netlifyVaryBuilder: NetlifyVaryHeaderBuilder = {
31+
/**
32+
* Parse a header value into an array of directives
33+
*/
34+
const getDirectives = (headerValue: HeaderValue): string[] => {
35+
const directives = Array.isArray(headerValue) ? headerValue : String(headerValue).split(',')
36+
return directives.map((directive) => directive.trim())
37+
}
38+
/**
39+
* Ensure the Netlify CDN varies on things that Next.js varies on,
40+
* e.g. i18n, preview mode, etc.
41+
*/
42+
export const setVaryHeaders = (
43+
res: ServerResponse,
44+
req: IncomingMessage,
45+
{ basePath, i18n }: NextConfigComplete,
46+
) => {
47+
const netlifyVaryDirectives: NetlifyVaryDirectives = {
4848
headers: [],
4949
languages: [],
5050
cookies: ['__prerender_bypass', '__next_preview_data'],
5151
}
5252

53-
if (headers.vary.length !== 0) {
54-
netlifyVaryBuilder.headers.push(...getDirectives(headers.vary))
53+
const vary = res.getHeader('vary')
54+
if (vary !== undefined) {
55+
netlifyVaryDirectives.headers.push(...getDirectives(vary))
5556
}
5657

57-
const autoDetectedLocales = getAutoDetectedLocales(nextConfig)
58-
59-
if (autoDetectedLocales.length > 1) {
60-
const logicalPath =
61-
nextConfig.basePath && event.path.startsWith(nextConfig.basePath)
62-
? event.path.slice(nextConfig.basePath.length)
63-
: event.path
58+
const path = new URL(req.url ?? '/', `http://${req.headers.host}`).pathname
59+
const locales = i18n && i18n.localeDetection !== false ? i18n.locales : []
6460

61+
if (locales.length > 1) {
62+
const logicalPath = basePath && path.startsWith(basePath) ? path.slice(basePath.length) : path
6563
if (logicalPath === `/`) {
66-
netlifyVaryBuilder.languages.push(...autoDetectedLocales)
67-
netlifyVaryBuilder.cookies.push(`NEXT_LOCALE`)
64+
netlifyVaryDirectives.languages.push(...locales)
65+
netlifyVaryDirectives.cookies.push(`NEXT_LOCALE`)
6866
}
6967
}
7068

71-
const NetlifyVaryHeader = generateNetlifyVaryHeaderValue(netlifyVaryBuilder)
72-
if (NetlifyVaryHeader.length !== 0) {
73-
headers[`netlify-vary`] = NetlifyVaryHeader
74-
}
75-
}
76-
77-
export const handleCacheControl = (headers: Record<string, string>) => {
78-
if (headers['cache-control'] && !headers['cdn-cache-control'] && !headers['netlify-cdn-cache-control']) {
79-
headers['netlify-cdn-cache-control'] = headers['cache-control']
80-
81-
const filteredCacheControlDirectives = removeSMaxAgeAndStaleWhileRevalidate(headers['cache-control'])
82-
83-
// use default cache-control if no directives are left
84-
headers['cache-control'] =
85-
filteredCacheControlDirectives.length === 0
86-
? 'public, max-age=0, must-revalidate'
87-
: filteredCacheControlDirectives
88-
}
69+
res.setHeader(`netlify-vary`, generateNetlifyVaryDirectives(netlifyVaryDirectives))
8970
}
9071

91-
export const getAutoDetectedLocales = (config: NextConfigComplete): Array<string> => {
92-
if (config.i18n && config.i18n.localeDetection !== false && config.i18n.locales.length > 1) {
93-
return config.i18n.locales
72+
/**
73+
* Ensure stale-while-revalidate and s-maxage don't leak to the client, but
74+
* assume the user knows what they are doing if CDN cache controls are set
75+
*/
76+
export const setCacheControlHeaders = (res: ServerResponse) => {
77+
const cacheControl = res.getHeader('cache-control')
78+
if (
79+
cacheControl !== undefined &&
80+
!res.hasHeader('cdn-cache-control') &&
81+
!res.hasHeader('netlify-cdn-cache-control')
82+
) {
83+
const directives = getDirectives(cacheControl).filter(
84+
(directive) =>
85+
!directive.startsWith('s-maxage') && !directive.startsWith('stale-while-revalidate'),
86+
)
87+
88+
res.setHeader('netlify-cdn-cache-control', cacheControl)
89+
res.setHeader(
90+
'cache-control',
91+
directives.length === 0 ? 'public, max-age=0, must-revalidate' : directives,
92+
)
9493
}
95-
96-
return []
9794
}

0 commit comments

Comments
 (0)