Skip to content

Commit 0e61a26

Browse files
committed
fix: cache and normalize manifest routes
1 parent 9fc4123 commit 0e61a26

File tree

2 files changed

+63
-84
lines changed

2 files changed

+63
-84
lines changed

packages/runtime/src/templates/handlerUtils.ts

Lines changed: 22 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,7 @@ export const normalizePath = (event: HandlerEvent) => {
224224
return new URL(event.rawUrl).pathname
225225
}
226226

227-
/**
228-
* Simple Netlify API client
229-
*/
227+
// Simple Netlify API client
230228
export const netlifyApiFetch = <T>({
231229
endpoint,
232230
payload,
@@ -270,57 +268,26 @@ export const netlifyApiFetch = <T>({
270268
req.end()
271269
})
272270

273-
/**
274-
* Remove trailing slash from a route (but not the root route)
275-
*/
276-
export const removeTrailingSlash = (route: string): string => (route.endsWith('/') ? route.slice(0, -1) || '/' : route)
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)
277273

278-
/**
279-
* Normalize a data route to include the build ID and index suffix
280-
*
281-
* @param route The route to normalize
282-
* @param buildId The Next.js build ID
283-
* @param i18n The i18n config from next.config.js
284-
* @returns The normalized route
285-
* @example
286-
* normalizeDataRoute('/_next/data/en.json', 'dev', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/_next/data/dev/en/index.json'
287-
*/
288-
export const normalizeDataRoute = (
289-
route: string,
290-
buildId: string,
291-
i18n?: {
292-
defaultLocale: string
293-
locales: string[]
294-
},
295-
): string => {
296-
if (route.endsWith('.rsc')) return route
297-
const withBuildId = route.replace(/^\/_next\/data\//, `/_next/data/${buildId}/`)
298-
return i18n && i18n.locales.some((locale) => withBuildId.endsWith(`${buildId}/${locale}.json`))
299-
? withBuildId.replace(/\.json$/, '/index.json')
300-
: withBuildId
301-
}
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}/`))
302277

303-
/**
304-
* Ensure that a route has a locale prefix
305-
*
306-
* @param route The route to ensure has a locale prefix
307-
* @param i18n The i18n config from next.config.js
308-
* @returns The route with a locale prefix
309-
* @example
310-
* ensureLocalePrefix('/', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en'
311-
* ensureLocalePrefix('/foo', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en/foo'
312-
* ensureLocalePrefix('/en/foo', { defaultLocale: 'en', locales: ['en', 'fr'] }) // '/en/foo'
313-
*/
314-
export const ensureLocalePrefix = (
315-
route: string,
316-
i18n?: {
317-
defaultLocale: string
318-
locales: string[]
319-
},
320-
): string =>
321-
i18n
322-
? // eslint-disable-next-line unicorn/no-nested-ternary
323-
i18n.locales.some((locale) => route === `/${locale}` || route.startsWith(`/${locale}/`))
324-
? route
325-
: `/${i18n.defaultLocale}${route === '/' ? '' : route}`
326-
: route
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+
}
Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,48 @@
1+
import { PrerenderManifest } from 'next/dist/build'
12
import { NodeRequestHandler, Options } from 'next/dist/server/next-server'
23

34
import {
45
netlifyApiFetch,
56
getNextServer,
67
NextServerType,
7-
removeTrailingSlash,
8-
ensureLocalePrefix,
9-
normalizeDataRoute,
8+
normalizeRoute,
9+
localizeRoute,
10+
localizeDataRoute,
11+
unlocalizeRoute,
1012
} from './handlerUtils'
1113

1214
const NextServer: NextServerType = getNextServer()
1315

14-
interface NetlifyOptions {
16+
interface NetlifyConfig {
1517
revalidateToken?: string
1618
}
1719

1820
class NetlifyNextServer extends NextServer {
19-
private netlifyOptions: NetlifyOptions
21+
private netlifyConfig: NetlifyConfig
22+
private netlifyPrerenderManifest: PrerenderManifest
2023

21-
public constructor(options: Options, netlifyOptions: NetlifyOptions) {
24+
public constructor(options: Options, netlifyConfig: NetlifyConfig) {
2225
super(options)
23-
this.netlifyOptions = netlifyOptions
26+
this.netlifyConfig = netlifyConfig
27+
// copy the prerender manifest so it doesn't get mutated by Next.js
28+
const manifest = this.getPrerenderManifest()
29+
this.netlifyPrerenderManifest = {
30+
...manifest,
31+
routes: { ...manifest.routes },
32+
dynamicRoutes: { ...manifest.dynamicRoutes },
33+
}
2434
}
2535

2636
public getRequestHandler(): NodeRequestHandler {
2737
const handler = super.getRequestHandler()
2838
return async (req, res, parsedUrl) => {
2939
// preserve the URL before Next.js mutates it for i18n
30-
const originalUrl = req.url
31-
40+
const { url, headers } = req
3241
// handle the original res.revalidate() request
3342
await handler(req, res, parsedUrl)
34-
3543
// handle on-demand revalidation by purging the ODB cache
36-
if (res.statusCode === 200 && req.headers['x-prerender-revalidate']) {
37-
await this.netlifyRevalidate(originalUrl)
44+
if (res.statusCode === 200 && headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) {
45+
await this.netlifyRevalidate(url)
3846
}
3947
}
4048
}
@@ -48,7 +56,7 @@ class NetlifyNextServer extends NextServer {
4856
paths: this.getNetlifyPathsForRoute(route),
4957
domain: this.hostname,
5058
},
51-
token: this.netlifyOptions.revalidateToken,
59+
token: this.netlifyConfig.revalidateToken,
5260
method: 'POST',
5361
})
5462
if (!result.ok) {
@@ -61,39 +69,43 @@ class NetlifyNextServer extends NextServer {
6169
}
6270

6371
private getNetlifyPathsForRoute(route: string): string[] {
64-
const { routes, dynamicRoutes } = this.getPrerenderManifest()
72+
const { i18n } = this.nextConfig
73+
const { routes, dynamicRoutes } = this.netlifyPrerenderManifest
6574

66-
// matches static appDir and non-i18n routes
67-
const normalizedRoute = removeTrailingSlash(route)
75+
// matches static non-i18n routes
76+
const normalizedRoute = normalizeRoute(route)
6877
if (normalizedRoute in routes) {
69-
const dataRoute = normalizeDataRoute(routes[normalizedRoute].dataRoute, this.buildId)
78+
const { dataRoute } = routes[normalizedRoute]
7079
return [route, dataRoute]
7180
}
7281

73-
// matches static pageDir i18n routes
74-
const localizedRoute = ensureLocalePrefix(normalizedRoute, this.nextConfig?.i18n)
75-
if (localizedRoute in routes) {
76-
const dataRoute = normalizeDataRoute(routes[localizedRoute].dataRoute, this.buildId, this.nextConfig?.i18n)
77-
return [route, dataRoute]
82+
// matches static i18n routes
83+
if (i18n) {
84+
const localizedRoute = localizeRoute(normalizedRoute, i18n)
85+
if (localizedRoute in routes) {
86+
const dataRoute = localizeDataRoute(routes[localizedRoute].dataRoute, localizedRoute)
87+
return [route, dataRoute]
88+
}
7889
}
7990

8091
// matches dynamic routes
8192
for (const dynamicRoute in dynamicRoutes) {
82-
const matches = normalizedRoute.match(dynamicRoutes[dynamicRoute].routeRegex)
93+
const unlocalizedRoute = i18n ? unlocalizeRoute(normalizedRoute, i18n) : normalizedRoute
94+
const matches = unlocalizedRoute.match(dynamicRoutes[dynamicRoute].routeRegex)
8395
if (matches && matches.length !== 0) {
8496
// remove the first match, which is the full route
8597
matches.shift()
8698
// replace the dynamic segments with the actual values
87-
const dataRoute = normalizeDataRoute(
88-
dynamicRoutes[dynamicRoute].dataRoute.replace(/\[(.*?)]/g, () => matches.shift()),
89-
this.buildId,
90-
)
99+
const interpolatedDataRoute = dynamicRoutes[dynamicRoute].dataRoute.replace(/\[(.*?)]/g, () => matches.shift())
100+
const dataRoute = i18n
101+
? localizeDataRoute(interpolatedDataRoute, localizeRoute(normalizedRoute, i18n))
102+
: interpolatedDataRoute
91103
return [route, dataRoute]
92104
}
93105
}
94106

95-
throw new Error(`could not find a route to revalidate`)
107+
throw new Error(`not an ISR route`)
96108
}
97109
}
98110

99-
export { NetlifyNextServer }
111+
export { NetlifyNextServer, NetlifyConfig }

0 commit comments

Comments
 (0)