Skip to content

Commit fb93b54

Browse files
authored
feat: refresh hooks api implementation (#1950)
* feat: subclass NextServer to override request handler * feat: add revalidate API route * feat: subclass NextServer to override request handler * feat: add revalidate API route * feat: add refresh hooks api implementation * chore: increase revalidate ttl for testing * chore: add time to page for testing * feat: add error reporting for caught exceptions * fix: use req.url verbatim * chore: test throw in revalidate handler * chore: fix lint issue * fix: update revalidate error handling * fix: error handling in revalidate api function * chore: refactor revalidate handling for easier testing * test: add tests for refresh hooks * test: add e2e test for refresh hooks revalidate * feat: revalidate data routes * fix: export named NetlifyNextServer for module interop * fix: revalidate error messaging in demo * fix: remove revalidate data routes for milestone 1 * fix: amend test for revalidate success/failure * feat: add additional paths for routes * chore: refactor tests * chore: add tests for handlerUtils * chore: remove eslint directive * test: remove rsc trailing slash * Revert "test: remove rsc trailing slash" This reverts commit 6b4a2ce. * feat: source and transform data routes from prerenderManifest * feat: update revalidate page with more test scenarios * fix: revalidate api endpoint path order * chore: update revalidate test paths * fix: cache and normalize manifest routes * chore: update tests for caching and localizing routes * chore: fix eslint complaint * chore: update cypress tests * fix: consolidated static route matching for revalidate with i18n * chore: improve comments on revalidate api route * chore: update snapshots and cypress tests * chore: reset with-revalidate TTL after testing * chore: add comment about paths to revalidate endpoint * chore: update cypress test to match old ttl for revalidate api * chore: move tests to resolve edge clash * chore: add comments to functions generation
1 parent 4f6cdd9 commit fb93b54

File tree

10 files changed

+584
-17
lines changed

10 files changed

+584
-17
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
describe('On-demand revalidation', () => {
2+
it('revalidates static ISR route with default locale', () => {
3+
cy.request({ url: '/api/revalidate/?select=0' }).then((res) => {
4+
expect(res.status).to.eq(200)
5+
expect(res.body).to.have.property('message', 'success')
6+
})
7+
})
8+
it('revalidates static ISR route with non-default locale', () => {
9+
cy.request({ url: '/api/revalidate/?select=1' }).then((res) => {
10+
expect(res.status).to.eq(200)
11+
expect(res.body).to.have.property('message', 'success')
12+
})
13+
})
14+
it('revalidates root static ISR route with default locale', () => {
15+
cy.request({ url: '/api/revalidate/?select=2' }).then((res) => {
16+
expect(res.status).to.eq(200)
17+
expect(res.body).to.have.property('message', 'success')
18+
})
19+
})
20+
it('revalidates root static ISR route with non-default locale', () => {
21+
cy.request({ url: '/api/revalidate/?select=3' }).then((res) => {
22+
expect(res.status).to.eq(200)
23+
expect(res.body).to.have.property('message', 'success')
24+
})
25+
})
26+
it('revalidates dynamic prerendered ISR route with default locale', () => {
27+
cy.request({ url: '/api/revalidate/?select=4' }).then((res) => {
28+
expect(res.status).to.eq(200)
29+
expect(res.body).to.have.property('message', 'success')
30+
})
31+
})
32+
it('fails to revalidate dynamic non-prerendered ISR route with fallback false', () => {
33+
cy.request({ url: '/api/revalidate/?select=5', failOnStatusCode: false }).then((res) => {
34+
expect(res.status).to.eq(500)
35+
expect(res.body).to.have.property('message')
36+
expect(res.body.message).to.include('Invalid response 404')
37+
})
38+
})
39+
it('revalidates dynamic non-prerendered ISR route with fallback blocking', () => {
40+
cy.request({ url: '/api/revalidate/?select=6' }).then((res) => {
41+
expect(res.status).to.eq(200)
42+
expect(res.body).to.have.property('message', 'success')
43+
})
44+
})
45+
it('revalidates dynamic non-prerendered ISR route with fallback blocking and non-default locale', () => {
46+
cy.request({ url: '/api/revalidate/?select=7' }).then((res) => {
47+
expect(res.status).to.eq(200)
48+
expect(res.body).to.have.property('message', 'success')
49+
})
50+
})
51+
it('revalidates dynamic prerendered appDir route', () => {
52+
cy.request({ url: '/api/revalidate/?select=8' }).then((res) => {
53+
expect(res.status).to.eq(200)
54+
expect(res.body).to.have.property('message', 'success')
55+
})
56+
})
57+
it('fails to revalidate dynamic non-prerendered appDir route', () => {
58+
cy.request({ url: '/api/revalidate/?select=9' }).then((res) => {
59+
expect(res.status).to.eq(200)
60+
expect(res.body).to.have.property('message', 'success')
61+
})
62+
})
63+
it('revalidates dynamic prerendered appDir route with catch-all params', () => {
64+
cy.request({ url: '/api/revalidate/?select=10' }).then((res) => {
65+
expect(res.status).to.eq(200)
66+
expect(res.body).to.have.property('message', 'success')
67+
})
68+
})
69+
})

demos/default/pages/api/revalidate.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export default async function handler(req, res) {
2+
const query = req.query
3+
const select = Number(query.select) || 0
4+
5+
// these paths are used for e2e testing res.revalidate()
6+
const paths = [
7+
'/getStaticProps/with-revalidate/', // valid path
8+
'/fr/getStaticProps/with-revalidate/', // valid path (with locale)
9+
'/', // valid path (index)
10+
'/fr/', // valid path (index with locale)
11+
'/getStaticProps/withRevalidate/2/', // valid path (with dynamic route)
12+
'/getStaticProps/withRevalidate/3/', // invalid path (fallback false with dynamic route)
13+
'/getStaticProps/withRevalidate/withFallbackBlocking/3/', // valid path (fallback blocking with dynamic route)
14+
'/fr/getStaticProps/withRevalidate/withFallbackBlocking/3/', // valid path (fallback blocking with dynamic route and locale)
15+
'/blog/nick/', // valid path (with prerendered appDir dynamic route)
16+
'/blog/greg/', // invalid path (with non-prerendered appDir dynamic route)
17+
'/blog/rob/hello/', // valid path (with appDir dynamic route catch-all)
18+
]
19+
20+
try {
21+
await res.revalidate(paths[select])
22+
return res.json({ code: 200, message: 'success' })
23+
} catch (err) {
24+
return res.status(500).send({ code: 500, message: err.message })
25+
}
26+
}

demos/default/pages/getStaticProps/with-revalidate.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import Link from 'next/link'
22

3-
const Show = ({ show }) => (
3+
const Show = ({ show, time }) => (
44
<div>
5-
<p>This page uses getStaticProps() to pre-fetch a TV show.</p>
5+
<p>This page uses getStaticProps() to pre-fetch a TV show at {time}</p>
66

77
<hr />
88

@@ -22,6 +22,7 @@ export async function getStaticProps(context) {
2222
return {
2323
props: {
2424
show: data,
25+
time: new Date().toISOString(),
2526
},
2627
// ODB handler will use the minimum TTL=60s
2728
revalidate: 1,

packages/runtime/src/helpers/functions.ts

+16
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,16 @@ export const generateFunctions = async (
4545
})
4646
const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND)
4747
await ensureDir(join(functionsDir, functionName))
48+
49+
// write main API handler file
4850
await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource)
51+
52+
// copy handler dependencies (VercelNodeBridge, NetlifyNextServer, etc.)
4953
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
54+
await copyFile(
55+
join(__dirname, '..', '..', 'lib', 'templates', 'server.js'),
56+
join(functionsDir, functionName, 'server.js'),
57+
)
5058
await copyFile(
5159
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
5260
join(functionsDir, functionName, 'handlerUtils.js'),
@@ -65,8 +73,16 @@ export const generateFunctions = async (
6573
const writeHandler = async (functionName: string, isODB: boolean) => {
6674
const handlerSource = await getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) })
6775
await ensureDir(join(functionsDir, functionName))
76+
77+
// write main handler file (standard or ODB)
6878
await writeFile(join(functionsDir, functionName, `${functionName}.js`), handlerSource)
79+
80+
// copy handler dependencies (VercelNodeBridge, NetlifyNextServer, etc.)
6981
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
82+
await copyFile(
83+
join(__dirname, '..', '..', 'lib', 'templates', 'server.js'),
84+
join(functionsDir, functionName, 'server.js'),
85+
)
7086
await copyFile(
7187
join(__dirname, '..', '..', 'lib', 'templates', 'handlerUtils.js'),
7288
join(functionsDir, functionName, 'handlerUtils.js'),

packages/runtime/src/templates/getHandler.ts

+21-15
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import { outdent as javascript } from 'outdent'
55

66
import type { NextConfig } from '../helpers/config'
77

8-
import type { NextServerType } from './handlerUtils'
9-
108
/* eslint-disable @typescript-eslint/no-var-requires */
11-
129
const { promises } = require('fs')
1310
const { Server } = require('http')
1411
const path = require('path')
@@ -22,17 +19,17 @@ const {
2219
getMaxAge,
2320
getMultiValueHeaders,
2421
getPrefetchResponse,
25-
getNextServer,
2622
normalizePath,
2723
} = require('./handlerUtils')
24+
const { NetlifyNextServer } = require('./server')
2825
/* eslint-enable @typescript-eslint/no-var-requires */
2926

3027
type Mutable<T> = {
3128
-readonly [K in keyof T]: T[K]
3229
}
3330

3431
// We return a function and then call `toString()` on it to serialise it as the launcher function
35-
// eslint-disable-next-line max-params
32+
// eslint-disable-next-line max-params, max-lines-per-function
3633
const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[string, string]> = [], mode = 'ssr') => {
3734
// Change working directory into the site root, unless using Nx, which moves the
3835
// dist directory and handles this itself
@@ -68,22 +65,30 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str
6865
// We memoize this because it can be shared between requests, but don't instantiate it until
6966
// the first request because we need the host and port.
7067
let bridge: NodeBridge
71-
const getBridge = (event: HandlerEvent): NodeBridge => {
68+
const getBridge = (event: HandlerEvent, context: HandlerContext): NodeBridge => {
69+
const {
70+
clientContext: { custom: customContext },
71+
} = context
72+
7273
if (bridge) {
7374
return bridge
7475
}
7576
const url = new URL(event.rawUrl)
7677
const port = Number.parseInt(url.port) || 80
7778
base = url.origin
7879

79-
const NextServer: NextServerType = getNextServer()
80-
const nextServer = new NextServer({
81-
conf,
82-
dir,
83-
customServer: false,
84-
hostname: url.hostname,
85-
port,
86-
})
80+
const nextServer = new NetlifyNextServer(
81+
{
82+
conf,
83+
dir,
84+
customServer: false,
85+
hostname: url.hostname,
86+
port,
87+
},
88+
{
89+
revalidateToken: customContext.odb_refresh_hooks,
90+
},
91+
)
8792
const requestHandler = nextServer.getRequestHandler()
8893
const server = new Server(async (req, res) => {
8994
try {
@@ -119,7 +124,7 @@ const makeHandler = (conf: NextConfig, app, pageRoot, staticManifest: Array<[str
119124
process.env._NETLIFY_GRAPH_TOKEN = graphToken
120125
}
121126

122-
const { headers, ...result } = await getBridge(event).launcher(event, context)
127+
const { headers, ...result } = await getBridge(event, context).launcher(event, context)
123128

124129
// Convert all headers to multiValueHeaders
125130

@@ -180,6 +185,7 @@ export const getHandler = ({ isODB = false, publishDir = '../../../.next', appDi
180185
// We copy the file here rather than requiring from the node module
181186
const { Bridge } = require("./bridge");
182187
const { augmentFsModule, getMaxAge, getMultiValueHeaders, getPrefetchResponse, getNextServer, normalizePath } = require('./handlerUtils')
188+
const { NetlifyNextServer } = require('./server')
183189
184190
${isODB ? `const { builder } = require("@netlify/functions")` : ''}
185191
const { config } = require("${publishDir}/required-server-files.json")

packages/runtime/src/templates/handlerUtils.ts

+69
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs, { createWriteStream, existsSync } from 'fs'
2+
import { ServerResponse } from 'http'
23
import { tmpdir } from 'os'
34
import path from 'path'
45
import { pipeline } from 'stream'
@@ -222,3 +223,71 @@ export const normalizePath = (event: HandlerEvent) => {
222223
// Ensure that paths are encoded - but don't double-encode them
223224
return new URL(event.rawUrl).pathname
224225
}
226+
227+
// Simple Netlify API client
228+
export const netlifyApiFetch = <T>({
229+
endpoint,
230+
payload,
231+
token,
232+
method = 'GET',
233+
}: {
234+
endpoint: string
235+
payload: unknown
236+
token: string
237+
method: 'GET' | 'POST'
238+
}): Promise<T> =>
239+
new Promise((resolve, reject) => {
240+
const body = JSON.stringify(payload)
241+
242+
const req = https.request(
243+
{
244+
hostname: 'api.netlify.com',
245+
port: 443,
246+
path: `/api/v1/${endpoint}`,
247+
method,
248+
headers: {
249+
'Content-Type': 'application/json',
250+
'Content-Length': body.length,
251+
Authorization: `Bearer ${token}`,
252+
},
253+
},
254+
(res: ServerResponse) => {
255+
let data = ''
256+
res.on('data', (chunk) => {
257+
data += chunk
258+
})
259+
res.on('end', () => {
260+
resolve(JSON.parse(data))
261+
})
262+
},
263+
)
264+
265+
req.on('error', reject)
266+
267+
req.write(body)
268+
req.end()
269+
})
270+
271+
// Remove trailing slash from a route (except for the root route)
272+
export const normalizeRoute = (route: string): string => (route.endsWith('/') ? route.slice(0, -1) || '/' : route)
273+
274+
// Check if a route has a locale prefix (including the root route)
275+
const isLocalized = (route: string, i18n: { defaultLocale: string; locales: string[] }): boolean =>
276+
i18n.locales.some((locale) => route === `/${locale}` || route.startsWith(`/${locale}/`))
277+
278+
// Remove the locale prefix from a route (if any)
279+
export const unlocalizeRoute = (route: string, i18n: { defaultLocale: string; locales: string[] }): string =>
280+
isLocalized(route, i18n) ? `/${route.split('/').slice(2).join('/')}` : route
281+
282+
// Add the default locale prefix to a route (if necessary)
283+
export const localizeRoute = (route: string, i18n: { defaultLocale: string; locales: string[] }): string =>
284+
isLocalized(route, i18n) ? route : normalizeRoute(`/${i18n.defaultLocale}${route}`)
285+
286+
// Normalize a data route to include the locale prefix and remove the index suffix
287+
export const localizeDataRoute = (dataRoute: string, localizedRoute: string): string => {
288+
if (dataRoute.endsWith('.rsc')) return dataRoute
289+
const locale = localizedRoute.split('/').find(Boolean)
290+
return dataRoute
291+
.replace(new RegExp(`/_next/data/(.+?)/(${locale}/)?`), `/_next/data/$1/${locale}/`)
292+
.replace(/\/index\.json$/, '.json')
293+
}

0 commit comments

Comments
 (0)