Skip to content

Commit 14ca14a

Browse files
authored
feat: add experimental support for TTL (#833)
* feat: wip ttl support * chore: add debug headers * fix: ttl parsing * chore: move utils into helpers file * chore: add jest ts support * chore: change from review
1 parent 38fdd62 commit 14ca14a

File tree

10 files changed

+180
-21
lines changed

10 files changed

+180
-21
lines changed

babel.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
// This is just for jest
22
module.exports = {
3-
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
3+
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
44
}

demos/default/pages/getStaticProps/withRevalidate/[id].js

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const Show = ({ show, time }) => (
88

99
<h1>Show #{show.id}</h1>
1010
<p>{show.name}</p>
11-
<p>Rendered at {time}</p>
11+
<p>Rendered at {time} (slowly)</p>
1212
<hr />
1313

1414
<Link href="/">
@@ -33,7 +33,7 @@ export async function getStaticProps({ params }) {
3333
const res = await fetch(`https://api.tvmaze.com/shows/${id}`)
3434
const data = await res.json()
3535
const time = new Date().toLocaleTimeString()
36-
await new Promise((resolve) => setTimeout(resolve, 1000))
36+
await new Promise((resolve) => setTimeout(resolve, 3000))
3737
return {
3838
props: {
3939
show: data,

package-lock.json

+57
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"devDependencies": {
7373
"@babel/core": "^7.15.8",
7474
"@babel/preset-env": "^7.15.8",
75+
"@babel/preset-typescript": "^7.16.0",
7576
"@netlify/build": "^18.25.2",
7677
"@netlify/eslint-config-node": "^3.3.7",
7778
"@testing-library/cypress": "^8.0.1",

src/helpers/config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ exports.generateRedirects = async ({ netlifyConfig, basePath, i18n }) => {
129129
// ISR redirects are handled by the regular function. Forced to avoid pre-rendered pages
130130
...isrRedirects.map((redirect) => ({
131131
from: `${basePath}${redirect}`,
132-
to: HANDLER_FUNCTION_PATH,
132+
to: process.env.EXPERIMENTAL_ODB_TTL ? ODB_FUNCTION_PATH : HANDLER_FUNCTION_PATH,
133133
status: 200,
134134
force: true,
135135
})),

src/helpers/files.js

+52-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
const { cpus } = require('os')
33

44
const { yellowBright } = require('chalk')
5-
const { existsSync, readJson, move, cpSync, copy, writeJson } = require('fs-extra')
5+
const { existsSync, readJson, move, cpSync, copy, writeJson, readFile, writeFile } = require('fs-extra')
66
const globby = require('globby')
77
const { outdent } = require('outdent')
88
const pLimit = require('p-limit')
@@ -219,6 +219,57 @@ exports.moveStaticPages = async ({ netlifyConfig, target, i18n }) => {
219219
}
220220
}
221221

222+
const patchFile = async ({ file, from, to }) => {
223+
if (!existsSync(file)) {
224+
return
225+
}
226+
const content = await readFile(file, 'utf8')
227+
if (content.includes(to)) {
228+
return
229+
}
230+
const newContent = content.replace(from, to)
231+
await writeFile(`${file}.orig`, content)
232+
await writeFile(file, newContent)
233+
}
234+
235+
const getServerFile = (root) => {
236+
let serverFile
237+
try {
238+
serverFile = require.resolve('next/dist/server/next-server', { paths: [root] })
239+
} catch {
240+
// Ignore
241+
}
242+
if (!serverFile) {
243+
try {
244+
// eslint-disable-next-line node/no-missing-require
245+
serverFile = require.resolve('next/dist/next-server/server/next-server', { paths: [root] })
246+
} catch {
247+
// Ignore
248+
}
249+
}
250+
return serverFile
251+
}
252+
253+
exports.patchNextFiles = async (root) => {
254+
const serverFile = getServerFile(root)
255+
console.log(`Patching ${serverFile}`)
256+
if (serverFile) {
257+
await patchFile({
258+
file: serverFile,
259+
from: `let ssgCacheKey = `,
260+
to: `let ssgCacheKey = process.env._BYPASS_SSG || `,
261+
})
262+
}
263+
}
264+
265+
exports.unpatchNextFiles = async (root) => {
266+
const serverFile = getServerFile(root)
267+
const origFile = `${serverFile}.orig`
268+
if (existsSync(origFile)) {
269+
await move(origFile, serverFile, { overwrite: true })
270+
}
271+
}
272+
222273
exports.movePublicFiles = async ({ appDir, publish }) => {
223274
const publicDir = join(appDir, 'public')
224275
if (existsSync(publicDir)) {

src/index.js

+8-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const { join, relative } = require('path')
33
const { ODB_FUNCTION_NAME } = require('./constants')
44
const { restoreCache, saveCache } = require('./helpers/cache')
55
const { getNextConfig, configureHandlerFunctions, generateRedirects } = require('./helpers/config')
6-
const { moveStaticPages, movePublicFiles } = require('./helpers/files')
6+
const { moveStaticPages, movePublicFiles, patchNextFiles, unpatchNextFiles } = require('./helpers/files')
77
const { generateFunctions, setupImageFunction, generatePagesResolver } = require('./helpers/functions')
88
const {
99
verifyNetlifyBuildVersion,
@@ -56,6 +56,10 @@ module.exports = {
5656

5757
await movePublicFiles({ appDir, publish })
5858

59+
if (process.env.EXPERIMENTAL_ODB_TTL) {
60+
await patchNextFiles(basePath)
61+
}
62+
5963
if (process.env.EXPERIMENTAL_MOVE_STATIC_PAGES) {
6064
console.log(
6165
"The flag 'EXPERIMENTAL_MOVE_STATIC_PAGES' is no longer required, as it is now the default. To disable this behavior, set the env var 'SERVE_STATIC_FILES_FROM_ORIGIN' to 'true'",
@@ -75,10 +79,12 @@ module.exports = {
7579
})
7680
},
7781

78-
async onPostBuild({ netlifyConfig, utils: { cache, functions }, constants: { FUNCTIONS_DIST } }) {
82+
async onPostBuild({ netlifyConfig, utils: { cache, functions, failBuild }, constants: { FUNCTIONS_DIST } }) {
7983
await saveCache({ cache, publish: netlifyConfig.build.publish })
8084
await checkForOldFunctions({ functions })
8185
await checkZipSize(join(FUNCTIONS_DIST, `${ODB_FUNCTION_NAME}.zip`))
86+
const { basePath } = await getNextConfig({ publish: netlifyConfig.build.publish, failBuild })
87+
await unpatchNextFiles(basePath)
8288
},
8389
onEnd() {
8490
logBetaMessage()

src/templates/getHandler.js

+21-13
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const path = require('path')
55

66
const { Bridge } = require('@vercel/node/dist/bridge')
77

8-
const { downloadFile } = require('./handlerUtils')
8+
const { downloadFile, getMaxAge, getMultiValueHeaders } = require('./handlerUtils')
99

1010
const makeHandler =
1111
() =>
@@ -17,6 +17,10 @@ const makeHandler =
1717
// eslint-disable-next-line node/no-missing-require
1818
require.resolve('./pages.js')
1919
} catch {}
20+
// eslint-disable-next-line no-underscore-dangle
21+
process.env._BYPASS_SSG = 'true'
22+
23+
const ONE_YEAR_IN_SECONDS = 31536000
2024

2125
// We don't want to write ISR files to disk in the lambda environment
2226
conf.experimental.isrFlushToDisk = false
@@ -106,6 +110,7 @@ const makeHandler =
106110
bridge.listen()
107111

108112
return async (event, context) => {
113+
let requestMode = mode
109114
// Ensure that paths are encoded - but don't double-encode them
110115
event.path = new URL(event.path, event.rawUrl).pathname
111116
// Next expects to be able to parse the query from the URL
@@ -118,17 +123,12 @@ const makeHandler =
118123
base = `${protocol}://${host}`
119124
}
120125
const { headers, ...result } = await bridge.launcher(event, context)
126+
121127
/** @type import("@netlify/functions").HandlerResponse */
122128

123129
// Convert all headers to multiValueHeaders
124-
const multiValueHeaders = {}
125-
for (const key of Object.keys(headers)) {
126-
if (Array.isArray(headers[key])) {
127-
multiValueHeaders[key] = headers[key]
128-
} else {
129-
multiValueHeaders[key] = [headers[key]]
130-
}
131-
}
130+
131+
const multiValueHeaders = getMultiValueHeaders(headers)
132132

133133
if (multiValueHeaders['set-cookie']?.[0]?.includes('__prerender_bypass')) {
134134
delete multiValueHeaders.etag
@@ -137,12 +137,20 @@ const makeHandler =
137137

138138
// Sending SWR headers causes undefined behaviour with the Netlify CDN
139139
const cacheHeader = multiValueHeaders['cache-control']?.[0]
140+
140141
if (cacheHeader?.includes('stale-while-revalidate')) {
141-
console.log({ cacheHeader })
142+
if (requestMode === 'odb' && process.env.EXPERIMENTAL_ODB_TTL) {
143+
requestMode = 'isr'
144+
const ttl = getMaxAge(cacheHeader)
145+
// Long-expiry TTL is basically no TTL
146+
if (ttl > 0 && ttl < ONE_YEAR_IN_SECONDS) {
147+
result.ttl = ttl
148+
}
149+
multiValueHeaders['x-rendered-at'] = [new Date().toISOString()]
150+
}
142151
multiValueHeaders['cache-control'] = ['public, max-age=0, must-revalidate']
143152
}
144-
multiValueHeaders['x-render-mode'] = [mode]
145-
153+
multiValueHeaders['x-render-mode'] = [requestMode]
146154
return {
147155
...result,
148156
multiValueHeaders,
@@ -157,7 +165,7 @@ const { tmpdir } = require('os')
157165
const { promises, existsSync } = require("fs");
158166
// We copy the file here rather than requiring from the node module
159167
const { Bridge } = require("./bridge");
160-
const { downloadFile } = require('./handlerUtils')
168+
const { downloadFile, getMaxAge, getMultiValueHeaders } = require('./handlerUtils')
161169
162170
const { builder } = require("@netlify/functions");
163171
const { config } = require("${publishDir}/required-server-files.json")

src/templates/handlerUtils.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { promisify } from 'util'
66

77
const streamPipeline = promisify(pipeline)
88

9-
export const downloadFile = async (url, destination) => {
9+
export const downloadFile = async (url: string, destination: string): Promise<void> => {
1010
console.log(`Downloading ${url} to ${destination}`)
1111

1212
const httpx = url.startsWith('https') ? https : http
@@ -31,3 +31,35 @@ export const downloadFile = async (url, destination) => {
3131
})
3232
})
3333
}
34+
35+
export const getMaxAge = (header: string): number => {
36+
const parts = header.split(',')
37+
let maxAge
38+
for (const part of parts) {
39+
const [key, value] = part.split('=')
40+
if (key?.trim() === 's-maxage') {
41+
maxAge = value?.trim()
42+
}
43+
}
44+
if (maxAge) {
45+
const result = Number.parseInt(maxAge)
46+
return Number.isNaN(result) ? 0 : result
47+
}
48+
return 0
49+
}
50+
51+
export const getMultiValueHeaders = (
52+
headers: Record<string, string | Array<string>>,
53+
): Record<string, Array<string>> => {
54+
const multiValueHeaders: Record<string, Array<string>> = {}
55+
for (const key of Object.keys(headers)) {
56+
const header = headers[key]
57+
58+
if (Array.isArray(header)) {
59+
multiValueHeaders[key] = header
60+
} else {
61+
multiValueHeaders[key] = [header]
62+
}
63+
}
64+
return multiValueHeaders
65+
}

test/index.js

+4
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,8 @@ describe('onBuild()', () => {
353353

354354
describe('onPostBuild', () => {
355355
test('saves cache with right paths', async () => {
356+
await moveNextDist()
357+
356358
const save = jest.fn()
357359

358360
await plugin.onPostBuild({
@@ -366,6 +368,8 @@ describe('onPostBuild', () => {
366368
})
367369

368370
test('warns if old functions exist', async () => {
371+
await moveNextDist()
372+
369373
const list = jest.fn().mockResolvedValue([
370374
{
371375
name: 'next_test',

0 commit comments

Comments
 (0)