Skip to content

Commit 0a7c9e8

Browse files
authored
Merge pull request #2 from netlify/rs/nf-cdn-cache-control
feat: implement stale while revalidate handling via the new CDN platform primitive
2 parents e241218 + 8b9e27f commit 0a7c9e8

File tree

10 files changed

+363
-201
lines changed

10 files changed

+363
-201
lines changed

package-lock.json

Lines changed: 160 additions & 164 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,14 @@
3939
"@netlify/functions": "^2.0.1",
4040
"@vercel/nft": "^0.24.2",
4141
"@vercel/node-bridge": "^3.1.14",
42-
"fs-extra": "^11.1.1",
43-
"next": "^13.4.16"
42+
"fs-extra": "^11.1.1"
4443
},
4544
"devDependencies": {
4645
"@netlify/eslint-config-node": "^7.0.1",
4746
"@types/fs-extra": "^11.0.1",
4847
"@types/mock-fs": "^4.13.1",
4948
"mock-fs": "^5.2.0",
49+
"next": "^13.5.4",
5050
"typescript": "^5.1.6",
5151
"vitest": "^0.34.2"
5252
}

src/helpers/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,19 @@ import { copySync, moveSync } from 'fs-extra/esm'
66
import { __dirname, NETLIFY_PUBLISH_DIR, NETLIFY_TEMP_DIR } from './constants.js'
77

88
/**
9-
* Modify the user's next.config.js to use standalone mode
9+
* Modify the user's next.config.js to use standalone mode and cache handler
1010
*/
1111
export const modifyNextConfig = () => {
1212
// revert any previous changes
1313
revertNextConfig()
1414

15+
// backup config and replace with our own
1516
moveSync('next.config.js', `${NETLIFY_TEMP_DIR}/next.config.js`)
1617
copySync(`${__dirname}/../templates/next.config.cjs`, 'next.config.js')
1718
}
1819

1920
export const revertNextConfig = () => {
21+
// check if modified, then revert
2022
if (readFileSync('next.config.js').includes('Netlify generated code')) {
2123
moveSync(`${NETLIFY_TEMP_DIR}/next.config.js`, 'next.config.js', { overwrite: true })
2224
}

src/helpers/constants.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ export const NETLIFY_TEMP_DIR = '.netlify/temp'
88
export const FUNCTIONS_INTERNAL_DIR = '.netlify/functions-internal'
99
export const FUNCTIONS_URL = '/.netlify/functions'
1010

11-
export const HANDLER_NAME = '___netlify-handler'
12-
export const HANDLER_DIR = `${FUNCTIONS_INTERNAL_DIR}/${HANDLER_NAME}`
13-
export const HANDLER_URL = `${FUNCTIONS_URL}/${HANDLER_NAME}`
11+
export const SERVER_HANDLER_NAME = '___netlify-server-handler'
12+
export const SERVER_HANDLER_DIR = `${FUNCTIONS_INTERNAL_DIR}/${SERVER_HANDLER_NAME}`
13+
export const SERVER_HANDLER_URL = `${FUNCTIONS_URL}/${SERVER_HANDLER_NAME}`

src/helpers/functions.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,74 @@
1+
import { writeFileSync } from 'fs'
2+
13
import { NetlifyConfig } from '@netlify/build'
24
import { nodeFileTrace } from '@vercel/nft'
35
import { copySync, emptyDirSync, readJsonSync, writeJSONSync } from 'fs-extra/esm'
46

5-
import { HANDLER_DIR, HANDLER_NAME, HANDLER_URL, __dirname } from './constants.js'
7+
import {
8+
NETLIFY_TEMP_DIR,
9+
SERVER_HANDLER_DIR,
10+
SERVER_HANDLER_NAME,
11+
SERVER_HANDLER_URL,
12+
__dirname,
13+
} from './constants.js'
614

715
/**
816
* Create a Netlify function to run the Next.js server
917
* @param publishDir The publish directory
1018
* @param config Netlify config
1119
*/
12-
export const createHandlerFunction = async (publishDir: string, config: NetlifyConfig) => {
13-
emptyDirSync(HANDLER_DIR)
14-
20+
export const createServerHandler = async (publishDir: string, config: NetlifyConfig) => {
1521
const pluginDir = `${__dirname}../..`
1622
const pluginPkg = readJsonSync(`${pluginDir}/package.json`)
1723

24+
emptyDirSync(SERVER_HANDLER_DIR)
25+
await copyServerHandlerDependencies(pluginDir)
26+
copyNextJsDependencies(publishDir)
27+
writeServerHandlerFiles(pluginPkg)
28+
29+
config.redirects ||= []
30+
config.redirects.push({ from: `/*`, to: SERVER_HANDLER_URL, status: 200 })
31+
}
32+
33+
const copyServerHandlerDependencies = async (pluginDir: string) => {
34+
const { fileList } = await nodeFileTrace(
35+
[`${pluginDir}/dist/templates/server-handler.js`, `${pluginDir}/dist/templates/cache-handler.cjs`],
36+
{
37+
base: pluginDir,
38+
ignore: ['package.json'],
39+
},
40+
)
41+
42+
fileList.forEach((path) => {
43+
copySync(`${pluginDir}/${path}`, `${SERVER_HANDLER_DIR}/${path}`)
44+
})
45+
}
46+
47+
const copyNextJsDependencies = (publishDir: string) => {
48+
copySync(`${publishDir}/standalone/.next`, `${SERVER_HANDLER_DIR}/.next`)
49+
copySync(`${publishDir}/standalone/node_modules`, `${SERVER_HANDLER_DIR}/node_modules`)
50+
}
51+
52+
const writeServerHandlerFiles = (pluginPkg: { name: string; version: string }) => {
1853
const metadata = {
1954
config: {
2055
name: 'Next.js Server Handler',
2156
generator: `${pluginPkg.name}@${pluginPkg.version}`,
2257
nodeBundler: 'none',
23-
includedFiles: [`${HANDLER_DIR}/${HANDLER_NAME}.*`, `${HANDLER_DIR}/.next/**`, `${HANDLER_DIR}/node_modules/**`],
58+
includedFiles: [`${SERVER_HANDLER_NAME}.*`, `dist/**`, `.next/**`, `node_modules/**`, `package.json`],
59+
includedFilesBasePath: SERVER_HANDLER_DIR,
2460
},
2561
version: 1,
2662
}
2763

28-
const { fileList } = await nodeFileTrace([`${pluginDir}/dist/templates/handler.js`], {
29-
base: pluginDir,
30-
})
31-
const [handler, ...dependencies] = [...fileList]
32-
33-
dependencies.forEach((path) => {
34-
copySync(`${pluginDir}/${path}`, `${HANDLER_DIR}/${path}`)
35-
})
36-
37-
copySync(`${publishDir}/standalone/.next`, `${HANDLER_DIR}/.next`)
38-
copySync(`${publishDir}/standalone/node_modules`, `${HANDLER_DIR}/node_modules`)
39-
40-
copySync(`${pluginDir}/${handler}`, `${HANDLER_DIR}/${HANDLER_NAME}.mjs`)
41-
writeJSONSync(`${HANDLER_DIR}/${HANDLER_NAME}.json`, metadata, { spaces: 2 })
64+
writeJSONSync(`${SERVER_HANDLER_DIR}/${SERVER_HANDLER_NAME}.json`, metadata)
65+
writeJSONSync(`${SERVER_HANDLER_DIR}/package.json`, { type: 'module' })
66+
writeFileSync(
67+
`${SERVER_HANDLER_DIR}/${SERVER_HANDLER_NAME}.js`,
68+
`export { handler } from './dist/templates/server-handler.js'`,
69+
)
70+
}
4271

43-
config.redirects ||= []
44-
config.redirects.push({ from: `/*`, to: HANDLER_URL, status: 200 })
72+
export const createCacheHandler = () => {
73+
copySync(`${__dirname}/../templates/cache-handler.cjs`, `${NETLIFY_TEMP_DIR}/cache-handler.js`)
4574
}

src/helpers/headers.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { HandlerEvent } from '@netlify/functions'
2+
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
3+
4+
export interface NetlifyVaryHeaderBuilder {
5+
headers: string[]
6+
languages: string[]
7+
cookies: string[]
8+
}
9+
10+
const generateNetlifyVaryHeaderValue = ({ headers, languages, cookies }: NetlifyVaryHeaderBuilder): string => {
11+
let NetlifyVaryHeader = ``
12+
if (headers && headers.length !== 0) {
13+
NetlifyVaryHeader += `header=${headers.join(`|`)}`
14+
}
15+
if (languages && languages.length !== 0) {
16+
if (NetlifyVaryHeader.length !== 0) {
17+
NetlifyVaryHeader += `,`
18+
}
19+
NetlifyVaryHeader += `language=${languages.join(`|`)}`
20+
}
21+
if (cookies && cookies.length !== 0) {
22+
if (NetlifyVaryHeader.length !== 0) {
23+
NetlifyVaryHeader += `,`
24+
}
25+
NetlifyVaryHeader += `cookie=${cookies.join(`|`)}`
26+
}
27+
28+
return NetlifyVaryHeader
29+
}
30+
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 = {
48+
headers: [],
49+
languages: [],
50+
cookies: ['__prerender_bypass', '__next_preview_data'],
51+
}
52+
53+
if (headers.vary.length !== 0) {
54+
netlifyVaryBuilder.headers.push(...getDirectives(headers.vary))
55+
}
56+
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
64+
65+
if (logicalPath === `/`) {
66+
netlifyVaryBuilder.languages.push(...autoDetectedLocales)
67+
netlifyVaryBuilder.cookies.push(`NEXT_LOCALE`)
68+
}
69+
}
70+
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+
}
89+
}
90+
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
94+
}
95+
96+
return []
97+
}

src/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@ import type { NetlifyPluginOptions } from '@netlify/build'
22

33
import { modifyNetlifyConfig, modifyNextConfig, revertNextConfig } from './helpers/config.js'
44
import { publishStaticAssets } from './helpers/files.js'
5-
import { createHandlerFunction } from './helpers/functions.js'
5+
import { createServerHandler, createCacheHandler } from './helpers/functions.js'
66

77
type NetlifyPluginOptionsWithFlags = NetlifyPluginOptions & { featureFlags?: Record<string, unknown> }
88

99
export const onPreBuild = () => {
10+
createCacheHandler()
1011
modifyNextConfig()
1112
}
1213

1314
export const onBuild = async ({ constants, netlifyConfig }: NetlifyPluginOptionsWithFlags) => {
1415
publishStaticAssets(constants.PUBLISH_DIR)
15-
await createHandlerFunction(constants.PUBLISH_DIR, netlifyConfig)
16+
await createServerHandler(constants.PUBLISH_DIR, netlifyConfig)
1617
modifyNetlifyConfig(netlifyConfig)
1718
}
1819

src/templates/cache-handler.cts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
module.exports = class CacheHandler {
2+
options: any
3+
cache: Map<string, any>
4+
5+
constructor(options: any) {
6+
this.options = options
7+
this.cache = new Map()
8+
}
9+
10+
// eslint-disable-next-line require-await
11+
async get(key: any) {
12+
return this.cache.get(key)
13+
}
14+
15+
// eslint-disable-next-line require-await
16+
async set(key: any, data: any) {
17+
this.cache.set(key, {
18+
value: data,
19+
lastModified: Date.now(),
20+
})
21+
}
22+
}

src/templates/next.config.cts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ module.exports = {
88
output: 'standalone',
99
experimental: {
1010
...original.experimental,
11-
// incrementalCacheHandlerPath: require.resolve('./cache-handler.js'),
11+
incrementalCacheHandlerPath: require.resolve('./.netlify/temp/cache-handler.js'),
1212
},
1313
}

src/templates/handler.ts renamed to src/templates/server-handler.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,38 @@ import { createRequire } from 'node:module'
33
import { fileURLToPath } from 'node:url'
44

55
import type { Handler, HandlerContext, HandlerEvent } from '@netlify/functions'
6-
import '@vercel/node-bridge/bridge.js'
6+
import { Bridge } from '@vercel/node-bridge/bridge.js'
7+
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
78

9+
import { handleCacheControl, handleVary } from '../helpers/headers.js'
10+
11+
// use require to stop NFT from trying to trace these dependencies
812
const require = createRequire(import.meta.url)
913

10-
const { Bridge } = require('@vercel/node-bridge/bridge.js')
14+
/* these dependencies are generated during the build */
15+
// eslint-disable-next-line import/order
1116
const { getRequestHandlers } = require('next/dist/server/lib/start-server.js')
17+
const requiredServerFiles = require('../../.next/required-server-files.json')
1218

13-
const requiredServerFiles = require('./.next/required-server-files.json')
19+
requiredServerFiles.config.experimental = {
20+
...requiredServerFiles.config.experimental,
21+
incrementalCacheHandlerPath: '../dist/templates/cache-handler.cjs',
22+
}
1423

24+
// read Next config from the build output
1525
process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(requiredServerFiles.config)
26+
const nextConfig = requiredServerFiles.config as NextConfigComplete
1627

17-
const __dirname = fileURLToPath(new URL('.', import.meta.url))
18-
28+
// run the server in the root directory
29+
const __dirname = fileURLToPath(new URL('../..', import.meta.url))
1930
process.chdir(__dirname)
2031

21-
let bridge: typeof Bridge
32+
// memoize the node bridge instance
33+
let bridge: Bridge
2234

2335
export const handler: Handler = async function (event: HandlerEvent, context: HandlerContext) {
2436
if (!bridge) {
25-
// initialize Next.js and create the request handler
37+
// let Next.js initialize and create the request handler
2638
const [nextHandler] = await getRequestHandlers({
2739
port: 3000,
2840
hostname: 'localhost',
@@ -54,6 +66,9 @@ export const handler: Handler = async function (event: HandlerEvent, context: Ha
5466
const response = { headers, statusCode: result.statusCode }
5567
console.log('Next server response:', JSON.stringify(response, null, 2))
5668

69+
handleCacheControl(headers)
70+
handleVary(headers, event, nextConfig)
71+
5772
return {
5873
...result,
5974
headers,

0 commit comments

Comments
 (0)