Skip to content

Commit a40925f

Browse files
committed
feat: source and transform data routes from prerenderManifest
1 parent 021f825 commit a40925f

File tree

4 files changed

+204
-69
lines changed

4 files changed

+204
-69
lines changed
Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,41 @@
1-
import { getPathsForRoute, localizeRoute } from './handlerUtils'
1+
import { removeTrailingSlash, ensureLocalePrefix } from './handlerUtils'
22

3-
describe('getPathsForRoute', () => {
4-
it('transforms / (root level) data routes to /index', () => {
5-
expect(getPathsForRoute('/', 'buildId')).toContainEqual(expect.stringMatching(/index.json/))
3+
describe('removeTrailingSlash', () => {
4+
it('removes a trailing slash from a string', () => {
5+
expect(removeTrailingSlash('/foo/')).toEqual('/foo')
66
})
7-
it('removes the trailing slash from data routes', () => {
8-
expect(getPathsForRoute('/foo/', 'buildId')).toContainEqual(expect.stringMatching(/foo.json$/))
7+
it('ignores a string without a trailing slash', () => {
8+
expect(removeTrailingSlash('/foo')).toEqual('/foo')
99
})
10-
it('respects the trailing slash for rsc routes', () => {
11-
expect(getPathsForRoute('/foo', 'buildId')).toContainEqual(expect.stringMatching(/foo.rsc$/))
12-
expect(getPathsForRoute('/foo/', 'buildId')).toContainEqual(expect.stringMatching(/foo.rsc\/$/))
10+
it('does not remove a slash on its own', () => {
11+
expect(removeTrailingSlash('/')).toEqual('/')
1312
})
1413
})
1514

16-
describe('localizeRoute', () => {
17-
it('returns a non-localized path for the default locale', () => {
15+
describe('ensureLocalePrefix', () => {
16+
it('adds default locale prefix if missing', () => {
1817
expect(
19-
localizeRoute('/foo', {
18+
ensureLocalePrefix('/foo', {
2019
defaultLocale: 'en',
2120
locales: ['en', 'fr', 'de'],
2221
}),
23-
).toContain('/foo')
22+
).toEqual('/en/foo')
2423
})
25-
it('returns a localized path for each non-default locale', () => {
24+
it('skips prefixing if locale is present', () => {
2625
expect(
27-
localizeRoute('/foo', {
26+
ensureLocalePrefix('/fr/foo', {
2827
defaultLocale: 'en',
2928
locales: ['en', 'fr', 'de'],
3029
}),
31-
).toEqual(expect.arrayContaining(['/fr/foo', '/de/foo']))
32-
})
33-
it('returns every locale for data routes', () => {
30+
).toEqual('/fr/foo')
3431
expect(
35-
localizeRoute(
36-
'/foo',
37-
{
38-
defaultLocale: 'en',
39-
locales: ['en', 'fr', 'de'],
40-
},
41-
true,
42-
),
43-
).toEqual([
44-
expect.stringMatching(/\/en\/foo/),
45-
expect.stringMatching(/\/fr\/foo/),
46-
expect.stringMatching(/\/de\/foo/),
47-
])
32+
ensureLocalePrefix('/en/foo', {
33+
defaultLocale: 'en',
34+
locales: ['en', 'fr', 'de'],
35+
}),
36+
).toEqual('/en/foo')
4837
})
4938
it('skips localization if i18n not configured', () => {
50-
expect(localizeRoute('/foo')).toEqual(['/foo'])
39+
expect(ensureLocalePrefix('/foo')).toEqual('/foo')
5140
})
5241
})

packages/runtime/src/templates/handlerUtils.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -271,42 +271,56 @@ export const netlifyApiFetch = <T>({
271271
})
272272

273273
/**
274-
* Get all paths related to a route including data, i18n and rsc routes
274+
* Remove trailing slash from a route (but not the root route)
275275
*/
276-
export const getPathsForRoute = (
276+
export const removeTrailingSlash = (route: string): string => (route.endsWith('/') ? route.slice(0, -1) || '/' : route)
277+
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 = (
277289
route: string,
278290
buildId: string,
279291
i18n?: {
280292
defaultLocale: string
281293
locales: string[]
282294
},
283-
): string[] => {
284-
const routes = []
285-
// static files
286-
routes.push(...localizeRoute(route, i18n))
287-
// data routes
288-
routes.push(
289-
...localizeRoute(route.endsWith('/') ? route.slice(0, -1) || '/index' : route, i18n, true).map(
290-
(localizeRoute) => `/_next/data/${buildId}${localizeRoute}.json`,
291-
),
292-
)
293-
// rsc routes
294-
routes.push(route.endsWith('/') ? `${route.slice(0, -1) || '/index'}.rsc/` : `${route}.rsc`)
295-
return routes
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
296301
}
297302

298303
/**
299-
* Localize a route based on i18n config
300-
* (don't localize if i18n is not configured or if the route is the default locale and not a data route)
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'
301313
*/
302-
export const localizeRoute = (
314+
export const ensureLocalePrefix = (
303315
route: string,
304316
i18n?: {
305317
defaultLocale: string
306318
locales: string[]
307319
},
308-
data = false,
309-
): string[] =>
320+
): string =>
310321
i18n
311-
? i18n.locales.map((locale) => (locale === i18n.defaultLocale && data === false ? route : `/${locale}${route}`))
312-
: [route]
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

packages/runtime/src/templates/server.test.ts

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,39 @@ jest.mock('./handlerUtils', () => {
1414
})
1515
const mockedApiFetch = netlifyApiFetch as jest.MockedFunction<typeof netlifyApiFetch>
1616

17+
jest.mock(
18+
'prerender-manifest.json',
19+
() => ({
20+
routes: {
21+
'/en/getStaticProps/with-revalidate': {
22+
dataRoute: '/_next/data/en/getStaticProps/with-revalidate.json',
23+
},
24+
},
25+
dynamicRoutes: {
26+
'/blog/[author]/[slug]': {
27+
routeRegex: '^/blog/([^/]+?)/([^/]+?)(?:/)?$',
28+
dataRoute: '/blog/[author]/[slug].rsc',
29+
},
30+
},
31+
}),
32+
{ virtual: true },
33+
)
34+
1735
beforeAll(() => {
1836
const NextServer: NextServerType = getNextServer()
1937
jest.spyOn(NextServer.prototype, 'getRequestHandler').mockImplementation(() => () => Promise.resolve())
20-
Object.setPrototypeOf(NetlifyNextServer, jest.fn())
38+
39+
const MockNetlifyNextServerConstructor = function () {
40+
this.distDir = '.'
41+
this.buildId = 'build-id'
42+
this.nextConfig = {
43+
i18n: {
44+
defaultLocale: 'en',
45+
locales: ['en', 'fr', 'de'],
46+
},
47+
}
48+
}
49+
Object.setPrototypeOf(NetlifyNextServer, MockNetlifyNextServerConstructor)
2150
})
2251

2352
describe('the netlify next server', () => {
@@ -36,21 +65,78 @@ describe('the netlify next server', () => {
3665
expect(response).toBe(undefined)
3766
})
3867

39-
it('throws an error when invalid paths are revalidated', async () => {
68+
it('matches a normalized static route to find the data route', async () => {
69+
const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {})
70+
const requestHandler = netlifyNextServer.getRequestHandler()
71+
72+
const { req: mockReq, res: mockRes } = mockRequest(
73+
'/getStaticProps/with-revalidate/',
74+
{ 'x-prerender-revalidate': 'test' },
75+
'GET',
76+
)
77+
await requestHandler(mockReq, mockRes)
78+
79+
expect(mockedApiFetch).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
payload: expect.objectContaining({
82+
paths: ['/getStaticProps/with-revalidate/', '/_next/data/build-id/en/getStaticProps/with-revalidate.json'],
83+
}),
84+
}),
85+
)
86+
})
87+
88+
it('matches a normalized dynamic route to find the data', async () => {
89+
const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {})
90+
const requestHandler = netlifyNextServer.getRequestHandler()
91+
92+
const { req: mockReq, res: mockRes } = mockRequest('/blog/rob/hello', { 'x-prerender-revalidate': 'test' }, 'GET')
93+
await requestHandler(mockReq, mockRes)
94+
95+
expect(mockedApiFetch).toHaveBeenCalledWith(
96+
expect.objectContaining({
97+
payload: expect.objectContaining({
98+
paths: ['/blog/rob/hello', '/blog/rob/hello.rsc'],
99+
}),
100+
}),
101+
)
102+
})
103+
104+
it('throws an error when route is not found in the manifest', async () => {
40105
const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {})
41106
const requestHandler = netlifyNextServer.getRequestHandler()
42107

43-
const { req: mockReq, res: mockRes } = mockRequest('/not-a-path/', { 'x-prerender-revalidate': 'test' }, 'GET')
108+
const { req: mockReq, res: mockRes } = mockRequest(
109+
'/not-a-valid-path/',
110+
{ 'x-prerender-revalidate': 'test' },
111+
'GET',
112+
)
44113

45-
mockedApiFetch.mockResolvedValueOnce({ code: 404, message: 'Invalid paths' })
46-
await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Invalid paths')
114+
await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('could not find a route')
115+
})
116+
117+
it('throws an error when paths are not found by the API', async () => {
118+
const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {})
119+
const requestHandler = netlifyNextServer.getRequestHandler()
120+
121+
const { req: mockReq, res: mockRes } = mockRequest(
122+
'/getStaticProps/with-revalidate/',
123+
{ 'x-prerender-revalidate': 'test' },
124+
'GET',
125+
)
126+
127+
mockedApiFetch.mockResolvedValueOnce({ code: 500, message: 'Failed to revalidate' })
128+
await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Failed to revalidate')
47129
})
48130

49131
it('throws an error when the revalidate API is unreachable', async () => {
50132
const netlifyNextServer = new NetlifyNextServer({ conf: {} }, {})
51133
const requestHandler = netlifyNextServer.getRequestHandler()
52134

53-
const { req: mockReq, res: mockRes } = mockRequest('', { 'x-prerender-revalidate': 'test' }, 'GET')
135+
const { req: mockReq, res: mockRes } = mockRequest(
136+
'/getStaticProps/with-revalidate/',
137+
{ 'x-prerender-revalidate': 'test' },
138+
'GET',
139+
)
54140

55141
mockedApiFetch.mockRejectedValueOnce(new Error('Unable to connect'))
56142
await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unable to connect')

packages/runtime/src/templates/server.ts

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { NodeRequestHandler, Options } from 'next/dist/server/next-server'
22

3-
import { netlifyApiFetch, getNextServer, NextServerType, getPathsForRoute } from './handlerUtils'
3+
import {
4+
netlifyApiFetch,
5+
getNextServer,
6+
NextServerType,
7+
removeTrailingSlash,
8+
ensureLocalePrefix,
9+
normalizeDataRoute,
10+
} from './handlerUtils'
411

512
const NextServer: NextServerType = getNextServer()
613

@@ -19,12 +26,16 @@ class NetlifyNextServer extends NextServer {
1926
public getRequestHandler(): NodeRequestHandler {
2027
const handler = super.getRequestHandler()
2128
return async (req, res, parsedUrl) => {
22-
if (req.headers['x-prerender-revalidate']) {
23-
// handle on-demand revalidation by purging the ODB cache
24-
await this.netlifyRevalidate(req.url)
25-
}
29+
// preserve the URL before Next.js mutates it for i18n
30+
const originalUrl = req.url
31+
2632
// handle the original res.revalidate() request
27-
return handler(req, res, parsedUrl)
33+
await handler(req, res, parsedUrl)
34+
35+
// handle on-demand revalidation by purging the ODB cache
36+
if (res.statusCode === 200 && req.headers['x-prerender-revalidate']) {
37+
await this.netlifyRevalidate(originalUrl)
38+
}
2839
}
2940
}
3041

@@ -34,20 +45,55 @@ class NetlifyNextServer extends NextServer {
3445
const result = await netlifyApiFetch<{ ok: boolean; code: number; message: string }>({
3546
endpoint: `sites/${process.env.SITE_ID}/refresh_on_demand_builders`,
3647
payload: {
37-
paths: getPathsForRoute(route, this.buildId, this.nextConfig?.i18n),
48+
paths: this.getNetlifyPathsForRoute(route),
3849
domain: this.hostname,
3950
},
4051
token: this.netlifyOptions.revalidateToken,
4152
method: 'POST',
4253
})
43-
if (result.ok !== true) {
54+
if (!result.ok) {
4455
throw new Error(result.message)
4556
}
4657
} catch (error) {
47-
console.log('Error revalidating', error.message)
58+
console.log(`Error revalidating ${route}:`, error.message)
4859
throw error
4960
}
5061
}
62+
63+
private getNetlifyPathsForRoute(route: string): string[] {
64+
const { routes, dynamicRoutes } = this.getPrerenderManifest()
65+
66+
// matches static appDir and non-i18n routes
67+
const normalizedRoute = removeTrailingSlash(route)
68+
if (normalizedRoute in routes) {
69+
const dataRoute = normalizeDataRoute(routes[normalizedRoute].dataRoute, this.buildId)
70+
return [route, dataRoute]
71+
}
72+
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]
78+
}
79+
80+
// matches dynamic routes
81+
for (const dynamicRoute in dynamicRoutes) {
82+
const matches = normalizedRoute.match(dynamicRoutes[dynamicRoute].routeRegex)
83+
if (matches && matches.length !== 0) {
84+
// remove the first match, which is the full route
85+
matches.shift()
86+
// replace the dynamic segments with the actual values
87+
const dataRoute = normalizeDataRoute(
88+
dynamicRoutes[dynamicRoute].dataRoute.replace(/\[(.*?)]/g, () => matches.shift()),
89+
this.buildId,
90+
)
91+
return [route, dataRoute]
92+
}
93+
}
94+
95+
throw new Error(`could not find a route to revalidate`)
96+
}
5197
}
5298

5399
export { NetlifyNextServer }

0 commit comments

Comments
 (0)