Skip to content

Commit 60594f1

Browse files
authored
feat: use prerenderManifest data instead of globbing when copying prerendered content (#105)
* feat: use publish dir constant when getting middlewareManifest * feat: better error checking when copying static content * feat: simply static content copying * chore: better variable names for copying server content * feat: rework prerendered content method * chore: simplify static content logic * fix: normalize prerendered index routes * fix: explicity add prerendered not found routes * feat: better guard against wrongly unpublishing static on failed builds * fix: don't attempt to copy app router 404 page on page router site * chore: fix static content tests
1 parent 4886838 commit 60594f1

File tree

8 files changed

+222
-183
lines changed

8 files changed

+222
-183
lines changed

src/build/config.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { NetlifyPluginConstants, NetlifyPluginOptions } from '@netlify/build'
22
import type { PrerenderManifest } from 'next/dist/build/index.js'
33
import { MiddlewareManifest } from 'next/dist/build/webpack/plugins/middleware-plugin.js'
4+
import { existsSync } from 'node:fs'
45
import { readFile } from 'node:fs/promises'
56
import { resolve } from 'node:path'
67
import { SERVER_HANDLER_NAME } from './constants.js'
@@ -14,8 +15,12 @@ export const getPrerenderManifest = async ({
1415
/**
1516
* Get Next.js middleware config from the build output
1617
*/
17-
export const getMiddlewareManifest = async (): Promise<MiddlewareManifest> => {
18-
return JSON.parse(await readFile(resolve('.next/server/middleware-manifest.json'), 'utf-8'))
18+
export const getMiddlewareManifest = async ({
19+
PUBLISH_DIR,
20+
}: Pick<NetlifyPluginConstants, 'PUBLISH_DIR'>): Promise<MiddlewareManifest> => {
21+
return JSON.parse(
22+
await readFile(resolve(PUBLISH_DIR, 'server/middleware-manifest.json'), 'utf-8'),
23+
)
1924
}
2025

2126
/**
@@ -35,3 +40,14 @@ export const setPostBuildConfig = ({
3540
status: 200,
3641
})
3742
}
43+
44+
export const verifyBuildConfig = ({
45+
constants: { PUBLISH_DIR },
46+
utils: {
47+
build: { failBuild },
48+
},
49+
}: Pick<NetlifyPluginOptions, 'constants' | 'utils'>) => {
50+
if (!existsSync(resolve(PUBLISH_DIR))) {
51+
failBuild('Publish directory not found, please check your netlify.toml')
52+
}
53+
}

src/build/constants.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,18 @@ const pkg = JSON.parse(readFileSync(join(PLUGIN_DIR, 'package.json'), 'utf-8'))
1010
export const PLUGIN_NAME = pkg.name
1111
export const PLUGIN_VERSION = pkg.version
1212

13-
/** The directory that is published to Netlify */
13+
/** This directory is swapped with the publish dir and deployed to the Netlify CDN */
1414
export const STATIC_DIR = '.netlify/static'
15-
export const TEMP_DIR = '.netlify/temp'
16-
/** The directory inside the publish directory that will be uploaded by build to the blob store */
15+
16+
/** This directory will be deployed to the blob store */
1717
export const BLOB_DIR = '.netlify/blobs/deploy'
1818

19+
/** This directory contains files for the serverless lambda function */
1920
export const SERVER_FUNCTIONS_DIR = '.netlify/functions-internal'
2021
export const SERVER_HANDLER_NAME = '___netlify-server-handler'
2122
export const SERVER_HANDLER_DIR = join(SERVER_FUNCTIONS_DIR, SERVER_HANDLER_NAME)
2223

24+
/** This directory contains files for deno edge functions */
2325
export const EDGE_FUNCTIONS_DIR = '.netlify/edge-functions'
2426
export const EDGE_HANDLER_NAME = '___netlify-edge-handler'
2527
export const EDGE_HANDLER_DIR = join(EDGE_FUNCTIONS_DIR, EDGE_HANDLER_NAME)

src/build/content/prerendered.ts

+100-92
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import type { NetlifyPluginOptions } from '@netlify/build'
22
import glob from 'fast-glob'
3+
import { existsSync } from 'node:fs'
34
import { mkdir, readFile, writeFile } from 'node:fs/promises'
4-
import { basename, dirname, extname, resolve } from 'node:path'
5-
import { join as joinPosix } from 'node:path/posix'
5+
import { dirname, resolve } from 'node:path'
66
import { getPrerenderManifest } from '../config.js'
77
import { BLOB_DIR } from '../constants.js'
88

99
export type CacheEntry = {
10-
key: string
11-
value: CacheEntryValue
12-
}
13-
14-
export type CacheEntryValue = {
1510
lastModified: number
16-
value: PageCacheValue | RouteCacheValue | FetchCacheValue
11+
value: CacheValue
1712
}
1813

14+
type CacheValue = PageCacheValue | RouteCacheValue | FetchCacheValue
15+
1916
export type PageCacheValue = {
2017
kind: 'PAGE'
2118
html: string
@@ -42,110 +39,121 @@ type FetchCacheValue = {
4239
}
4340
}
4441

45-
// static prerendered pages content with JSON data
46-
const isPage = (key: string, routes: string[]) => {
47-
return key.startsWith('server/pages') && routes.includes(key.replace(/^server\/pages/, ''))
48-
}
49-
// static prerendered app content with RSC data
50-
const isApp = (path: string) => {
51-
return path.startsWith('server/app') && extname(path) === '.html'
52-
}
53-
// static prerendered app route handler
54-
const isRoute = (path: string) => {
55-
return path.startsWith('server/app') && extname(path) === '.body'
56-
}
57-
// fetch cache data (excluding tags manifest)
58-
const isFetch = (path: string) => {
59-
return path.startsWith('cache/fetch-cache') && extname(path) === ''
42+
const writeCacheEntry = async (key: string, value: CacheValue) => {
43+
await mkdir(dirname(resolve(BLOB_DIR, key)), { recursive: true })
44+
await writeFile(
45+
resolve(BLOB_DIR, key),
46+
JSON.stringify({ lastModified: Date.now(), value } satisfies CacheEntry),
47+
'utf-8',
48+
)
6049
}
6150

62-
/**
63-
* Transform content file paths into cache entries for the blob store
64-
*/
65-
const buildPrerenderedContentEntries = async (
66-
src: string,
67-
routes: string[],
68-
): Promise<Promise<CacheEntry>[]> => {
69-
const paths = await glob([`cache/fetch-cache/*`, `server/+(app|pages)/**/*.+(html|body)`], {
70-
cwd: resolve(src),
71-
extglob: true,
72-
})
73-
74-
return paths.map(async (path: string): Promise<CacheEntry> => {
75-
const key = joinPosix(dirname(path), basename(path, extname(path)))
76-
let value
77-
78-
if (isPage(key, routes)) {
79-
value = {
80-
kind: 'PAGE',
81-
html: await readFile(resolve(src, `${key}.html`), 'utf-8'),
82-
pageData: JSON.parse(await readFile(resolve(src, `${key}.json`), 'utf-8')),
83-
} satisfies PageCacheValue
84-
}
51+
const urlPathToFilePath = (path: string) => (path === '/' ? '/index' : path)
8552

86-
if (isApp(path)) {
87-
value = {
88-
kind: 'PAGE',
89-
html: await readFile(resolve(src, `${key}.html`), 'utf-8'),
90-
pageData: await readFile(resolve(src, `${key}.rsc`), 'utf-8'),
91-
...JSON.parse(await readFile(resolve(src, `${key}.meta`), 'utf-8')),
92-
} satisfies PageCacheValue
93-
}
53+
const buildPagesCacheValue = async (path: string): Promise<PageCacheValue> => ({
54+
kind: 'PAGE',
55+
html: await readFile(resolve(`${path}.html`), 'utf-8'),
56+
pageData: JSON.parse(await readFile(resolve(`${path}.json`), 'utf-8')),
57+
})
9458

95-
if (isRoute(path)) {
96-
value = {
97-
kind: 'ROUTE',
98-
body: await readFile(resolve(src, `${key}.body`), 'utf-8'),
99-
...JSON.parse(await readFile(resolve(src, `${key}.meta`), 'utf-8')),
100-
} satisfies RouteCacheValue
101-
}
59+
const buildAppCacheValue = async (path: string): Promise<PageCacheValue> => ({
60+
kind: 'PAGE',
61+
html: await readFile(resolve(`${path}.html`), 'utf-8'),
62+
pageData: await readFile(resolve(`${path}.rsc`), 'utf-8'),
63+
...JSON.parse(await readFile(resolve(`${path}.meta`), 'utf-8')),
64+
})
10265

103-
if (isFetch(path)) {
104-
value = {
105-
kind: 'FETCH',
106-
...JSON.parse(await readFile(resolve(src, key), 'utf-8')),
107-
} satisfies FetchCacheValue
108-
}
66+
const buildRouteCacheValue = async (path: string): Promise<RouteCacheValue> => ({
67+
kind: 'ROUTE',
68+
body: await readFile(resolve(`${path}.body`), 'utf-8'),
69+
...JSON.parse(await readFile(resolve(`${path}.meta`), 'utf-8')),
70+
})
10971

110-
return {
111-
key,
112-
value: {
113-
lastModified: Date.now(),
114-
value,
115-
},
116-
}
117-
})
118-
}
72+
const buildFetchCacheValue = async (path: string): Promise<FetchCacheValue> => ({
73+
kind: 'FETCH',
74+
...JSON.parse(await readFile(resolve(path), 'utf-8')),
75+
})
11976

12077
/**
121-
* Upload prerendered content to the blob store and remove it from the bundle
78+
* Upload prerendered content to the blob store
12279
*/
123-
export const uploadPrerenderedContent = async ({
80+
export const copyPrerenderedContent = async ({
12481
constants: { PUBLISH_DIR },
125-
utils,
82+
utils: {
83+
build: { failBuild },
84+
},
12685
}: Pick<NetlifyPluginOptions, 'constants' | 'utils'>) => {
12786
try {
12887
// read prerendered content and build JSON key/values for the blob store
12988
const manifest = await getPrerenderManifest({ PUBLISH_DIR })
130-
const entries = await Promise.all(
131-
await buildPrerenderedContentEntries(PUBLISH_DIR, Object.keys(manifest.routes)),
132-
)
89+
const routes = Object.entries(manifest.routes)
90+
const notFoundRoute = 'server/app/_not-found'
13391

134-
// movce JSON content to the blob store directory for upload
13592
await Promise.all(
136-
entries
137-
.filter((entry) => entry.value.value !== undefined)
138-
.map(async (entry) => {
139-
const dest = resolve(BLOB_DIR, entry.key)
140-
await mkdir(dirname(dest), { recursive: true })
141-
await writeFile(resolve(BLOB_DIR, entry.key), JSON.stringify(entry.value), 'utf-8')
142-
}),
93+
routes.map(async ([path, route]) => {
94+
let key, value
95+
96+
switch (true) {
97+
case route.dataRoute?.endsWith('.json'):
98+
key = `server/pages/${urlPathToFilePath(path)}`
99+
value = await buildPagesCacheValue(resolve(PUBLISH_DIR, key))
100+
break
101+
102+
case route.dataRoute?.endsWith('.rsc'):
103+
key = `server/app/${urlPathToFilePath(path)}`
104+
value = await buildAppCacheValue(resolve(PUBLISH_DIR, key))
105+
break
106+
107+
case route.dataRoute === null:
108+
key = `server/app/${urlPathToFilePath(path)}`
109+
value = await buildRouteCacheValue(resolve(PUBLISH_DIR, key))
110+
break
111+
112+
default:
113+
throw new Error(`Unrecognized prerendered content: ${path}`)
114+
}
115+
116+
await writeCacheEntry(key, value)
117+
}),
143118
)
119+
120+
// app router 404 pages are not in the prerender manifest
121+
// so we need to check for them manually
122+
if (existsSync(resolve(PUBLISH_DIR, `${notFoundRoute}.html`))) {
123+
await writeCacheEntry(
124+
notFoundRoute,
125+
await buildAppCacheValue(resolve(PUBLISH_DIR, notFoundRoute)),
126+
)
127+
}
144128
} catch (error) {
145-
utils.build.failBuild(
129+
failBuild(
146130
'Failed assembling prerendered content for upload',
147131
error instanceof Error ? { error } : {},
148132
)
149-
throw error
133+
}
134+
}
135+
136+
/**
137+
* Upload fetch content to the blob store
138+
*/
139+
export const copyFetchContent = async ({
140+
constants: { PUBLISH_DIR },
141+
utils: {
142+
build: { failBuild },
143+
},
144+
}: Pick<NetlifyPluginOptions, 'constants' | 'utils'>) => {
145+
try {
146+
const paths = await glob([`cache/fetch-cache/!(*.*)`], {
147+
cwd: resolve(PUBLISH_DIR),
148+
extglob: true,
149+
})
150+
151+
await Promise.all(
152+
paths.map(async (key) => {
153+
await writeCacheEntry(key, await buildFetchCacheValue(resolve(PUBLISH_DIR, key)))
154+
}),
155+
)
156+
} catch (error) {
157+
failBuild('Failed assembling fetch content for upload', error instanceof Error ? { error } : {})
150158
}
151159
}

src/build/content/server.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ import { SERVER_HANDLER_DIR } from '../constants.js'
1212
export const copyNextServerCode = async ({
1313
constants: { PUBLISH_DIR },
1414
}: Pick<NetlifyPluginOptions, 'constants'>): Promise<void> => {
15-
const src = resolve(PUBLISH_DIR, 'standalone/.next')
16-
const dest = resolve(SERVER_HANDLER_DIR, '.next')
15+
const srcDir = resolve(PUBLISH_DIR, 'standalone/.next')
16+
const destDir = resolve(SERVER_HANDLER_DIR, '.next')
1717

1818
const paths = await glob([`*`, `server/*`, `server/chunks/*`, `server/+(app|pages)/**/*.js`], {
19-
cwd: src,
19+
cwd: srcDir,
2020
extglob: true,
2121
})
2222

2323
await Promise.all(
2424
paths.map(async (path: string) => {
25-
await cp(join(src, path), join(dest, path), { recursive: true })
25+
await cp(join(srcDir, path), join(destDir, path), { recursive: true })
2626
}),
2727
)
2828
}
@@ -67,15 +67,15 @@ async function recreateNodeModuleSymlinks(src: string, dest: string, org?: strin
6767
export const copyNextDependencies = async ({
6868
constants: { PUBLISH_DIR },
6969
}: Pick<NetlifyPluginOptions, 'constants'>): Promise<void> => {
70-
const src = resolve(PUBLISH_DIR, 'standalone/node_modules')
71-
const dest = resolve(SERVER_HANDLER_DIR, 'node_modules')
70+
const srcDir = resolve(PUBLISH_DIR, 'standalone/node_modules')
71+
const destDir = resolve(SERVER_HANDLER_DIR, 'node_modules')
7272

73-
await cp(src, dest, { recursive: true })
73+
await cp(srcDir, destDir, { recursive: true })
7474

7575
// use the node_modules tree from the process.cwd() and not the one from the standalone output
7676
// as the standalone node_modules are already wrongly assembled by Next.js.
7777
// see: https://github.com/vercel/next.js/issues/50072
78-
await recreateNodeModuleSymlinks(resolve('node_modules'), dest)
78+
await recreateNodeModuleSymlinks(resolve('node_modules'), destDir)
7979
}
8080

8181
export const writeTagsManifest = async ({

0 commit comments

Comments
 (0)