Skip to content

Commit 2576f81

Browse files
authoredNov 15, 2023
feat: symlink for speed and to avoid clobbering user files (#56)
* chore: swap globby for fast-glob * chore: consistent object params * feat: don't move output, but use cache * feat: add getPrerenderManifest function * chore: tidy up constants * feat: symlink files instead of copy * feat: move blob store and file handling out * fix: add tests and fixes * fix: fix tests * chore: remove logs and onlys
1 parent d21b4a0 commit 2576f81

19 files changed

+443
-488
lines changed
 

‎package-lock.json

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

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"@netlify/functions": "^2.0.1",
4343
"@vercel/nft": "^0.24.3",
4444
"fs-monkey": "^1.0.5",
45-
"globby": "^13.2.2",
45+
"fast-glob": "^3.3.2",
4646
"os": "^0.1.2",
4747
"outdent": "^0.8.0",
4848
"p-limit": "^4.0.0"

‎src/build/blob.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { getDeployStore } from '@netlify/blobs'
2+
import type { NetlifyPluginConstants } from '@netlify/build'
3+
4+
export const getBlobStore = ({
5+
NETLIFY_API_TOKEN,
6+
NETLIFY_API_HOST,
7+
SITE_ID,
8+
}: Pick<NetlifyPluginConstants, 'NETLIFY_API_TOKEN' | 'NETLIFY_API_HOST' | 'SITE_ID'>): ReturnType<
9+
typeof getDeployStore
10+
> => {
11+
return getDeployStore({
12+
deployID: process.env.DEPLOY_ID,
13+
siteID: SITE_ID,
14+
token: NETLIFY_API_TOKEN,
15+
apiURL: `https://${NETLIFY_API_HOST}`,
16+
})
17+
}

‎src/build/cache.ts

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { NetlifyPluginOptions } from '@netlify/build'
2+
import { resolve } from 'node:path'
3+
4+
export const saveBuildCache = async ({
5+
constants: { PUBLISH_DIR },
6+
utils: { cache },
7+
}: Pick<NetlifyPluginOptions, 'constants' | 'utils'>) => {
8+
if (await cache.save(resolve(PUBLISH_DIR, 'cache'))) {
9+
console.log('Next.js cache saved.')
10+
} else {
11+
console.log('No Next.js cache to save.')
12+
}
13+
}
14+
15+
export const restoreBuildCache = async ({
16+
constants: { PUBLISH_DIR },
17+
utils: { cache },
18+
}: Pick<NetlifyPluginOptions, 'constants' | 'utils'>) => {
19+
if (await cache.restore(resolve(PUBLISH_DIR, 'cache'))) {
20+
console.log('Next.js cache restored.')
21+
} else {
22+
console.log('No Next.js cache to restore.')
23+
}
24+
}

‎src/build/config.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1-
import { NetlifyConfig } from '@netlify/build'
1+
import type { NetlifyPluginConstants, NetlifyPluginOptions } from '@netlify/build'
2+
import type { PrerenderManifest } from 'next/dist/build/index.js'
3+
import { readFile } from 'node:fs/promises'
4+
import { resolve } from 'node:path'
5+
import { STATIC_DIR } from './constants.js'
6+
7+
export const getPrerenderManifest = async ({
8+
PUBLISH_DIR,
9+
}: Pick<NetlifyPluginConstants, 'PUBLISH_DIR'>): Promise<PrerenderManifest> => {
10+
return JSON.parse(await readFile(resolve(PUBLISH_DIR, 'prerender-manifest.json'), 'utf-8'))
11+
}
212

313
/**
414
* Enable Next.js standalone mode at build time
515
*/
6-
export const setBuildConfig = (netlifyConfig: NetlifyConfig) => {
16+
export const setBuildConfig = () => {
717
process.env.NEXT_PRIVATE_STANDALONE = 'true'
18+
}
19+
20+
export const setDeployConfig = ({ netlifyConfig }: Pick<NetlifyPluginOptions, 'netlifyConfig'>) => {
21+
netlifyConfig.build.publish = STATIC_DIR
822
netlifyConfig.redirects ||= []
923
netlifyConfig.redirects.push({
1024
from: '/*',

‎src/build/constants.ts

+11-6
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
import { readFileSync } from 'node:fs'
12
import { join } from 'node:path'
23
import { fileURLToPath } from 'node:url'
34

45
export const MODULE_DIR = fileURLToPath(new URL('.', import.meta.url))
56
export const PLUGIN_DIR = join(MODULE_DIR, '../..')
67

7-
/** A relative path where we store the build output */
8-
export const BUILD_DIR = '.netlify'
8+
const pkg = JSON.parse(readFileSync(join(PLUGIN_DIR, 'package.json'), 'utf-8'))
99

10-
export const SERVER_FUNCTIONS_DIR = join(BUILD_DIR, 'functions-internal')
11-
export const SERVER_HANDLER_NAME = '___netlify-server-handler'
10+
export const PLUGIN_NAME = pkg.name
11+
export const PLUGIN_VERSION = pkg.version
12+
13+
export const STATIC_DIR = '.netlify/static'
14+
15+
export const SERVER_FUNCTIONS_DIR = '.netlify/functions-internal'
16+
export const SERVER_HANDLER_NAME = '_netlify-server-handler'
1217
export const SERVER_HANDLER_DIR = join(SERVER_FUNCTIONS_DIR, SERVER_HANDLER_NAME)
1318

14-
export const EDGE_FUNCTIONS_DIR = join(BUILD_DIR, 'edge-functions')
15-
export const EDGE_HANDLER_NAME = '___netlify-edge-handler'
19+
export const EDGE_FUNCTIONS_DIR = '.netlify/edge-functions'
20+
export const EDGE_HANDLER_NAME = '_netlify-edge-handler'
1621
export const EDGE_HANDLER_DIR = join(EDGE_FUNCTIONS_DIR, EDGE_HANDLER_NAME)

‎src/build/content/prerendered.ts

+82-87
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { getDeployStore } from '@netlify/blobs'
2-
import { NetlifyPluginConstants } from '@netlify/build'
3-
import { globby } from 'globby'
1+
import { NetlifyPluginOptions } from '@netlify/build'
2+
import glob from 'fast-glob'
3+
import type { PrerenderManifest } from 'next/dist/build/index.js'
44
import { readFile } from 'node:fs/promises'
5-
import { join } from 'node:path'
5+
import { basename, dirname, extname, resolve } from 'node:path'
6+
import { join as joinPosix } from 'node:path/posix'
67
import { cpus } from 'os'
78
import pLimit from 'p-limit'
8-
import { parse, ParsedPath } from 'path'
9-
import { BUILD_DIR } from '../constants.js'
9+
import { getBlobStore } from '../blob.js'
10+
import { getPrerenderManifest } from '../config.js'
1011

1112
export type CacheEntry = {
1213
key: string
@@ -45,107 +46,101 @@ type FetchCacheValue = {
4546
}
4647

4748
// static prerendered pages content with JSON data
48-
const isPage = ({ dir, name, ext }: ParsedPath, paths: string[]) => {
49-
return dir.startsWith('server/pages') && ext === '.html' && paths.includes(`${dir}/${name}.json`)
49+
const isPage = (key: string, routes: string[]) => {
50+
return key.startsWith('server/pages') && routes.includes(key.replace(/^server\/pages/, ''))
5051
}
5152
// static prerendered app content with RSC data
52-
const isApp = ({ dir, ext }: ParsedPath) => {
53-
return dir.startsWith('server/app') && ext === '.html'
53+
const isApp = (path: string) => {
54+
return path.startsWith('server/app') && extname(path) === '.html'
5455
}
5556
// static prerendered app route handler
56-
const isRoute = ({ dir, ext }: ParsedPath) => {
57-
return dir.startsWith('server/app') && ext === '.body'
57+
const isRoute = (path: string) => {
58+
return path.startsWith('server/app') && extname(path) === '.body'
5859
}
59-
// fetch cache data
60-
const isFetch = ({ dir }: ParsedPath) => {
61-
return dir.startsWith('cache/fetch-cache')
60+
// fetch cache data (excluding tags manifest)
61+
const isFetch = (path: string) => {
62+
return path.startsWith('cache/fetch-cache') && extname(path) === ''
6263
}
6364

6465
/**
6566
* Transform content file paths into cache entries for the blob store
6667
*/
67-
const buildPrerenderedContentEntries = async (cwd: string): Promise<Promise<CacheEntry>[]> => {
68-
const paths = await globby(
69-
[`cache/fetch-cache/*`, `server/+(app|pages)/**/*.+(html|body|json)`],
70-
{
71-
cwd,
72-
extglob: true,
73-
},
74-
)
68+
const buildPrerenderedContentEntries = async (
69+
src: string,
70+
routes: string[],
71+
): Promise<Promise<CacheEntry>[]> => {
72+
const paths = await glob([`cache/fetch-cache/*`, `server/+(app|pages)/**/*.+(html|body)`], {
73+
cwd: resolve(src),
74+
extglob: true,
75+
})
76+
77+
return paths.map(async (path: string): Promise<CacheEntry> => {
78+
const key = joinPosix(dirname(path), basename(path, extname(path)))
79+
let value
80+
81+
if (isPage(key, routes)) {
82+
value = {
83+
kind: 'PAGE',
84+
html: await readFile(resolve(src, `${key}.html`), 'utf-8'),
85+
pageData: JSON.parse(await readFile(resolve(src, `${key}.json`), 'utf-8')),
86+
} satisfies PageCacheValue
87+
}
88+
89+
if (isApp(path)) {
90+
value = {
91+
kind: 'PAGE',
92+
html: await readFile(resolve(src, `${key}.html`), 'utf-8'),
93+
pageData: await readFile(resolve(src, `${key}.rsc`), 'utf-8'),
94+
...JSON.parse(await readFile(resolve(src, `${key}.meta`), 'utf-8')),
95+
} satisfies PageCacheValue
96+
}
7597

76-
return paths
77-
.map(parse)
78-
.filter((path: ParsedPath) => {
79-
return isPage(path, paths) || isApp(path) || isRoute(path) || isFetch(path)
80-
})
81-
.map(async (path: ParsedPath): Promise<CacheEntry> => {
82-
const { dir, name, ext } = path
83-
const key = join(dir, name)
84-
let value
85-
86-
if (isPage(path, paths)) {
87-
value = {
88-
kind: 'PAGE',
89-
html: await readFile(`${cwd}/${key}.html`, 'utf-8'),
90-
pageData: JSON.parse(await readFile(`${cwd}/${key}.json`, 'utf-8')),
91-
} satisfies PageCacheValue
92-
}
93-
94-
if (isApp(path)) {
95-
value = {
96-
kind: 'PAGE',
97-
html: await readFile(`${cwd}/${key}.html`, 'utf-8'),
98-
pageData: await readFile(`${cwd}/${key}.rsc`, 'utf-8'),
99-
...JSON.parse(await readFile(`${cwd}/${key}.meta`, 'utf-8')),
100-
} satisfies PageCacheValue
101-
}
102-
103-
if (isRoute(path)) {
104-
value = {
105-
kind: 'ROUTE',
106-
body: await readFile(`${cwd}/${key}.body`, 'utf-8'),
107-
...JSON.parse(await readFile(`${cwd}/${key}.meta`, 'utf-8')),
108-
} satisfies RouteCacheValue
109-
}
110-
111-
if (isFetch(path)) {
112-
value = {
113-
kind: 'FETCH',
114-
...JSON.parse(await readFile(`${cwd}/${key}`, 'utf-8')),
115-
} satisfies FetchCacheValue
116-
}
117-
118-
return {
119-
key,
120-
value: {
121-
lastModified: Date.now(),
122-
value,
123-
},
124-
}
125-
})
98+
if (isRoute(path)) {
99+
value = {
100+
kind: 'ROUTE',
101+
body: await readFile(resolve(src, `${key}.body`), 'utf-8'),
102+
...JSON.parse(await readFile(resolve(src, `${key}.meta`), 'utf-8')),
103+
} satisfies RouteCacheValue
104+
}
105+
106+
if (isFetch(path)) {
107+
value = {
108+
kind: 'FETCH',
109+
...JSON.parse(await readFile(resolve(src, key), 'utf-8')),
110+
} satisfies FetchCacheValue
111+
}
112+
113+
return {
114+
key,
115+
value: {
116+
lastModified: Date.now(),
117+
value,
118+
},
119+
}
120+
})
126121
}
127122

128123
/**
129124
* Upload prerendered content to the blob store and remove it from the bundle
130125
*/
131126
export const uploadPrerenderedContent = async ({
132-
NETLIFY_API_TOKEN,
133-
NETLIFY_API_HOST,
134-
SITE_ID,
135-
}: NetlifyPluginConstants) => {
136-
// initialize the blob store
137-
const blob = getDeployStore({
138-
deployID: process.env.DEPLOY_ID,
139-
siteID: SITE_ID,
140-
token: NETLIFY_API_TOKEN,
141-
apiURL: `https://${NETLIFY_API_HOST}`,
142-
})
127+
constants: { PUBLISH_DIR, NETLIFY_API_TOKEN, NETLIFY_API_HOST, SITE_ID },
128+
}: Pick<NetlifyPluginOptions, 'constants'>) => {
143129
// limit concurrent uploads to 2x the number of CPUs
144130
const limit = pLimit(Math.max(2, cpus().length))
145131

146132
// read prerendered content and build JSON key/values for the blob store
133+
let manifest: PrerenderManifest
134+
let blob: ReturnType<typeof getBlobStore>
135+
try {
136+
manifest = await getPrerenderManifest({ PUBLISH_DIR })
137+
blob = getBlobStore({ NETLIFY_API_TOKEN, NETLIFY_API_HOST, SITE_ID })
138+
} catch (error: any) {
139+
console.error(`Unable to upload prerendered content: ${error.message}`)
140+
return
141+
}
147142
const entries = await Promise.allSettled(
148-
await buildPrerenderedContentEntries(join(process.cwd(), BUILD_DIR, '.next')),
143+
await buildPrerenderedContentEntries(PUBLISH_DIR, Object.keys(manifest.routes)),
149144
)
150145
entries.forEach((result) => {
151146
if (result.status === 'rejected') {
@@ -156,7 +151,7 @@ export const uploadPrerenderedContent = async ({
156151
// upload JSON content data to the blob store
157152
const uploads = await Promise.allSettled(
158153
entries
159-
.filter((entry) => entry.status === 'fulfilled')
154+
.filter((entry) => entry.status === 'fulfilled' && entry.value.value.value !== undefined)
160155
.map((entry: PromiseSettledResult<CacheEntry>) => {
161156
const result = entry as PromiseFulfilledResult<CacheEntry>
162157
const { key, value } = result.value
@@ -166,7 +161,7 @@ export const uploadPrerenderedContent = async ({
166161
uploads.forEach((upload, index) => {
167162
if (upload.status === 'rejected') {
168163
const result = entries[index] as PromiseFulfilledResult<CacheEntry>
169-
console.error(`Unable to store ${result.value.key}: ${upload.reason.message}`)
164+
console.error(`Unable to store ${result.value?.key}: ${upload.reason.message}`)
170165
}
171166
})
172167
}

‎src/build/content/server.ts

+18-14
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,32 @@
1-
import { globby } from 'globby'
2-
import { existsSync } from 'node:fs'
3-
import { copyFile, mkdir } from 'node:fs/promises'
1+
import glob from 'fast-glob'
2+
import { mkdir, symlink } from 'node:fs/promises'
43
import { dirname, join } from 'node:path'
54

65
/**
76
* Copy App/Pages Router Javascript needed by the server handler
87
*/
9-
export const copyServerContent = async (src: string, dest: string): Promise<void> => {
10-
const paths = await globby([`*`, `server/*`, `server/chunks/*`, `server/+(app|pages)/**/*.js`], {
8+
export const linkServerContent = async (src: string, dest: string): Promise<void> => {
9+
const paths = await glob([`*`, `server/*`, `server/chunks/*`, `server/+(app|pages)/**/*.js`], {
1110
cwd: src,
1211
extglob: true,
1312
})
14-
1513
await Promise.all(
1614
paths.map(async (path: string) => {
17-
const srcPath = join(src, path)
18-
const destPath = join(dest, path)
19-
20-
if (!existsSync(srcPath)) {
21-
throw new Error(`Source file does not exist: ${srcPath}`)
22-
}
15+
await mkdir(join(dest, dirname(path)), { recursive: true })
16+
await symlink(join(src, path), join(dest, path))
17+
}),
18+
)
19+
}
2320

24-
await mkdir(dirname(destPath), { recursive: true })
25-
await copyFile(srcPath, destPath)
21+
export const linkServerDependencies = async (src: string, dest: string): Promise<void> => {
22+
const paths = await glob([`**`], {
23+
cwd: src,
24+
extglob: true,
25+
})
26+
await Promise.all(
27+
paths.map(async (path: string) => {
28+
await mkdir(join(dest, dirname(path)), { recursive: true })
29+
await symlink(join(src, path), join(dest, path))
2630
}),
2731
)
2832
}

‎src/build/content/static-pages.test.ts

-63
This file was deleted.

‎src/build/content/static.test.ts

+113-63
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,142 @@
1-
import { type getDeployStore } from '@netlify/blobs'
2-
import { join } from 'node:path'
3-
import { beforeEach, expect, test, vi } from 'vitest'
1+
import type { NetlifyPluginOptions } from '@netlify/build'
2+
import glob from 'fast-glob'
3+
import { Mock, afterEach, beforeEach, expect, test, vi } from 'vitest'
44
import { mockFileSystem } from '../../../tests/index.js'
5-
import { BUILD_DIR } from '../constants.js'
6-
import { copyStaticContent } from './static.js'
7-
8-
vi.mock('node:fs', async () => {
9-
const unionFs: any = (await import('unionfs')).default
10-
const fs = await vi.importActual<typeof import('fs')>('node:fs')
11-
unionFs.reset = () => {
12-
unionFs.fss = [fs]
13-
}
14-
const united = unionFs.use(fs)
15-
return { default: united, ...united }
16-
})
5+
import { FixtureTestContext, createFsFixture } from '../../../tests/utils/fixture.js'
6+
import { getBlobStore } from '../blob.js'
7+
import { STATIC_DIR } from '../constants.js'
8+
import { linkStaticAssets, uploadStaticContent } from './static.js'
179

18-
vi.mock('node:fs/promises', async () => {
19-
const fs = await import('node:fs')
20-
const { fsCpHelper, rmHelper } = await import('../../../tests/utils/fs-helper.js')
21-
return {
22-
...fs.promises,
23-
rm: rmHelper,
24-
cp: fsCpHelper,
25-
}
10+
afterEach(() => {
11+
vi.restoreAllMocks()
2612
})
2713

28-
let fakeBlob: ReturnType<typeof getDeployStore>
14+
vi.mock('../blob.js', () => ({
15+
getBlobStore: vi.fn(),
16+
}))
2917

18+
let mockBlobSet = vi.fn()
3019
beforeEach(() => {
31-
fakeBlob = {
32-
set: vi.fn(),
33-
} as unknown as ReturnType<typeof getDeployStore>
20+
;(getBlobStore as Mock).mockReturnValue({
21+
set: mockBlobSet,
22+
})
3423
})
3524

36-
test('should copy the static assets from the build to the publish directory', async () => {
37-
const { cwd, vol } = mockFileSystem({
38-
[`${BUILD_DIR}/.next/static/test.js`]: '',
39-
[`${BUILD_DIR}/.next/static/sub-dir/test2.js`]: '',
25+
test('should clear the static directory contents', async () => {
26+
const PUBLISH_DIR = '.next'
27+
28+
const { vol } = mockFileSystem({
29+
[`${STATIC_DIR}/remove-me.js`]: '',
4030
})
4131

42-
const PUBLISH_DIR = join(cwd, 'publish')
43-
await copyStaticContent({ PUBLISH_DIR }, fakeBlob)
32+
await linkStaticAssets({
33+
constants: { PUBLISH_DIR },
34+
} as Pick<NetlifyPluginOptions, 'constants'>)
4435

45-
expect(fakeBlob.set).toHaveBeenCalledTimes(0)
4636
expect(Object.keys(vol.toJSON())).toEqual(
37+
expect.not.arrayContaining([`${STATIC_DIR}/remove-me.js`]),
38+
)
39+
})
40+
41+
test<FixtureTestContext>('should link static content from the publish directory to the static directory', async (ctx) => {
42+
const PUBLISH_DIR = '.next'
43+
44+
const { cwd } = await createFsFixture(
45+
{
46+
[`${PUBLISH_DIR}/static/test.js`]: '',
47+
[`${PUBLISH_DIR}/static/sub-dir/test2.js`]: '',
48+
},
49+
ctx,
50+
)
51+
52+
await linkStaticAssets({
53+
constants: { PUBLISH_DIR },
54+
} as Pick<NetlifyPluginOptions, 'constants'>)
55+
56+
const files = await glob('**/*', { cwd, dot: true })
57+
58+
expect(files).toEqual(
4759
expect.arrayContaining([
48-
`${PUBLISH_DIR}/_next/static/test.js`,
49-
`${PUBLISH_DIR}/_next/static/sub-dir/test2.js`,
60+
`${PUBLISH_DIR}/static/test.js`,
61+
`${PUBLISH_DIR}/static/sub-dir/test2.js`,
62+
`${STATIC_DIR}/_next/static/test.js`,
63+
`${STATIC_DIR}/_next/static/sub-dir/test2.js`,
5064
]),
5165
)
5266
})
5367

54-
test('should throw expected error if no static assets directory exists', async () => {
55-
const { cwd } = mockFileSystem({})
68+
test<FixtureTestContext>('should link static content from the public directory to the static directory', async (ctx) => {
69+
const PUBLISH_DIR = '.next'
5670

57-
const PUBLISH_DIR = join(cwd, 'publish')
58-
const staticDirectory = join(cwd, '.netlify/.next/static')
71+
const { cwd } = await createFsFixture(
72+
{
73+
'public/fake-image.svg': '',
74+
'public/another-asset.json': '',
75+
},
76+
ctx,
77+
)
5978

60-
await expect(copyStaticContent({ PUBLISH_DIR }, fakeBlob)).rejects.toThrowError(
61-
`Failed to copy static assets: Error: ENOENT: no such file or directory, readdir '${staticDirectory}'`,
79+
await linkStaticAssets({
80+
constants: { PUBLISH_DIR },
81+
} as Pick<NetlifyPluginOptions, 'constants'>)
82+
83+
const files = await glob('**/*', { cwd, dot: true })
84+
85+
expect(files).toEqual(
86+
expect.arrayContaining([
87+
'public/another-asset.json',
88+
'public/fake-image.svg',
89+
`${STATIC_DIR}/another-asset.json`,
90+
`${STATIC_DIR}/fake-image.svg`,
91+
]),
6292
)
6393
})
6494

65-
test('should copy files from the public directory to the publish directory', async () => {
66-
const { cwd, vol } = mockFileSystem({
67-
[`${BUILD_DIR}/.next/static/test.js`]: '',
68-
'public/fake-image.svg': '',
69-
'public/another-asset.json': '',
70-
})
95+
test<FixtureTestContext>('should copy the static pages to the publish directory if the routes do not exist in the prerender-manifest', async (ctx) => {
96+
const PUBLISH_DIR = '.next'
97+
98+
const { cwd } = await createFsFixture(
99+
{
100+
[`${PUBLISH_DIR}/prerender-manifest.json`]: JSON.stringify({
101+
routes: {},
102+
}),
103+
[`${PUBLISH_DIR}/static/test.js`]: '',
104+
[`${PUBLISH_DIR}/server/pages/test.html`]: 'test-1',
105+
[`${PUBLISH_DIR}/server/pages/test2.html`]: 'test-2',
106+
},
107+
ctx,
108+
)
71109

72-
const PUBLISH_DIR = join(cwd, 'publish')
73-
await copyStaticContent({ PUBLISH_DIR }, fakeBlob)
110+
await uploadStaticContent({
111+
constants: { PUBLISH_DIR },
112+
} as Pick<NetlifyPluginOptions, 'constants'>)
74113

75-
expect(Object.keys(vol.toJSON())).toEqual(
76-
expect.arrayContaining([`${PUBLISH_DIR}/fake-image.svg`, `${PUBLISH_DIR}/another-asset.json`]),
77-
)
114+
expect(mockBlobSet).toHaveBeenCalledTimes(2)
115+
expect(mockBlobSet).toHaveBeenCalledWith('server/pages/test.html', 'test-1')
116+
expect(mockBlobSet).toHaveBeenCalledWith('server/pages/test2.html', 'test-2')
78117
})
79118

80-
test('should not copy files if the public directory does not exist', async () => {
81-
const { cwd, vol } = mockFileSystem({
82-
[`${BUILD_DIR}/.next/static/test.js`]: '',
83-
})
119+
test<FixtureTestContext>('should not copy the static pages to the publish directory if the routes exist in the prerender-manifest', async (ctx) => {
120+
const PUBLISH_DIR = '.next'
121+
122+
const { cwd } = await createFsFixture(
123+
{
124+
[`${PUBLISH_DIR}/prerender-manifest.json`]: JSON.stringify({
125+
routes: {
126+
'/test': {},
127+
'/test2': {},
128+
},
129+
}),
130+
[`${PUBLISH_DIR}/static/test.js`]: '',
131+
[`${PUBLISH_DIR}/server/pages/test.html`]: '',
132+
[`${PUBLISH_DIR}/server/pages/test2.html`]: '',
133+
},
134+
ctx,
135+
)
84136

85-
const PUBLISH_DIR = join(cwd, 'publish')
86-
await expect(copyStaticContent({ PUBLISH_DIR }, fakeBlob)).resolves.toBeUndefined()
137+
await uploadStaticContent({
138+
constants: { PUBLISH_DIR },
139+
} as Pick<NetlifyPluginOptions, 'constants'>)
87140

88-
expect(vol.toJSON()).toEqual({
89-
[join(cwd, `${BUILD_DIR}/.next/static/test.js`)]: '',
90-
[`${PUBLISH_DIR}/_next/static/test.js`]: '',
91-
})
141+
expect(mockBlobSet).not.toHaveBeenCalled()
92142
})

‎src/build/content/static.ts

+47-65
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,60 @@
1-
import { getDeployStore } from '@netlify/blobs'
2-
import { NetlifyPluginConstants } from '@netlify/build'
3-
import { globby } from 'globby'
4-
import { existsSync } from 'node:fs'
5-
import { cp, mkdir, readFile } from 'node:fs/promises'
6-
import { ParsedPath, join, parse } from 'node:path'
7-
import { BUILD_DIR } from '../constants.js'
1+
import type { NetlifyPluginOptions } from '@netlify/build'
2+
import glob from 'fast-glob'
3+
import type { PrerenderManifest } from 'next/dist/build/index.js'
4+
import { readFile, rm } from 'node:fs/promises'
5+
import { basename, dirname, resolve } from 'node:path'
6+
import { join as joinPosix } from 'node:path/posix'
7+
import { getBlobStore } from '../blob.js'
8+
import { getPrerenderManifest } from '../config.js'
9+
import { STATIC_DIR } from '../constants.js'
10+
import { linkdir } from '../files.js'
811

9-
/**
10-
* Copy static pages (HTML without associated JSON data)
11-
*/
12-
const copyStaticPages = async (
13-
blob: ReturnType<typeof getDeployStore>,
14-
src: string,
15-
): Promise<void> => {
16-
const paths = await globby([`server/pages/**/*.+(html|json)`], {
17-
cwd: src,
18-
extglob: true,
12+
export const uploadStaticContent = async ({
13+
constants: { PUBLISH_DIR, NETLIFY_API_TOKEN, NETLIFY_API_HOST, SITE_ID },
14+
}: Pick<NetlifyPluginOptions, 'constants'>): Promise<void> => {
15+
const dir = 'server/pages'
16+
const paths = await glob(['**/*.html'], {
17+
cwd: resolve(PUBLISH_DIR, dir),
1918
})
2019

21-
await Promise.all(
22-
paths
23-
.map(parse)
24-
// keep only static files that do not have JSON data
25-
.filter(({ dir, name }: ParsedPath) => !paths.includes(`${dir}/${name}.json`))
26-
.map(async ({ dir, base }: ParsedPath) => {
27-
const relPath = join(dir, base)
28-
const srcPath = join(src, relPath)
29-
await blob.set(relPath, await readFile(srcPath, 'utf-8'))
30-
}),
31-
)
32-
}
33-
34-
/**
35-
* Copies static assets
36-
*/
37-
const copyStaticAssets = async ({
38-
PUBLISH_DIR,
39-
}: Pick<NetlifyPluginConstants, 'PUBLISH_DIR'>): Promise<void> => {
20+
let manifest: PrerenderManifest
21+
let blob: ReturnType<typeof getBlobStore>
4022
try {
41-
const src = join(process.cwd(), BUILD_DIR, '.next/static')
42-
const dist = join(PUBLISH_DIR, '_next/static')
43-
await mkdir(dist, { recursive: true })
44-
await cp(src, dist, { recursive: true, force: true })
45-
} catch (error) {
46-
throw new Error(`Failed to copy static assets: ${error}`)
47-
}
48-
}
49-
50-
/**
51-
* Copies the public folder over
52-
*/
53-
const copyPublicAssets = async ({
54-
PUBLISH_DIR,
55-
}: Pick<NetlifyPluginConstants, 'PUBLISH_DIR'>): Promise<void> => {
56-
const src = join(process.cwd(), 'public')
57-
const dist = PUBLISH_DIR
58-
if (!existsSync(src)) {
23+
manifest = await getPrerenderManifest({ PUBLISH_DIR })
24+
blob = getBlobStore({ NETLIFY_API_TOKEN, NETLIFY_API_HOST, SITE_ID })
25+
} catch (error: any) {
26+
console.error(`Unable to upload static content: ${error.message}`)
5927
return
6028
}
6129

62-
await mkdir(dist, { recursive: true })
63-
await cp(src, dist, { recursive: true, force: true })
30+
const uploads = await Promise.allSettled(
31+
paths
32+
.filter((path) => {
33+
const route = '/' + joinPosix(dirname(path), basename(path, '.html'))
34+
return !Object.keys(manifest.routes).includes(route)
35+
})
36+
.map(async (path) => {
37+
console.log(`Uploading static content: ${path}`)
38+
await blob.set(
39+
joinPosix(dir, path),
40+
await readFile(resolve(PUBLISH_DIR, dir, path), 'utf-8'),
41+
)
42+
}),
43+
)
44+
uploads.forEach((upload, index) => {
45+
if (upload.status === 'rejected') {
46+
console.error(`Unable to store static content: ${upload.reason.message}`)
47+
}
48+
})
6449
}
6550

6651
/**
6752
* Move static content to the publish dir so it is uploaded to the CDN
6853
*/
69-
export const copyStaticContent = async (
70-
{ PUBLISH_DIR }: Pick<NetlifyPluginConstants, 'PUBLISH_DIR'>,
71-
blob: ReturnType<typeof getDeployStore>,
72-
): Promise<void> => {
73-
await Promise.all([
74-
copyStaticPages(blob, join(process.cwd(), BUILD_DIR, '.next')),
75-
copyStaticAssets({ PUBLISH_DIR }),
76-
copyPublicAssets({ PUBLISH_DIR }),
77-
])
54+
export const linkStaticAssets = async ({
55+
constants: { PUBLISH_DIR },
56+
}: Pick<NetlifyPluginOptions, 'constants'>): Promise<void> => {
57+
await rm(resolve(STATIC_DIR), { recursive: true, force: true })
58+
await linkdir(resolve(PUBLISH_DIR, 'static'), resolve(STATIC_DIR, '_next/static'))
59+
await linkdir(resolve('public'), resolve(STATIC_DIR))
7860
}

‎src/build/files.test.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import glob from 'fast-glob'
2+
import { expect, test, vi } from 'vitest'
3+
import { FixtureTestContext, createFsFixture } from '../../tests/utils/fixture.js'
4+
import { linkdir } from './files.js'
5+
6+
vi.mock('node:fs', async () => {
7+
const unionFs: any = (await import('unionfs')).default
8+
const fs = await vi.importActual<typeof import('fs')>('node:fs')
9+
unionFs.reset = () => {
10+
unionFs.fss = [fs]
11+
}
12+
const united = unionFs.use(fs)
13+
return { default: united, ...united }
14+
})
15+
16+
vi.mock('node:fs/promises', async () => {
17+
const fs = await import('node:fs')
18+
const { fsCpHelper, rmHelper } = await import('../../tests/utils/fs-helper.js')
19+
return {
20+
...fs.promises,
21+
rm: rmHelper,
22+
cp: fsCpHelper,
23+
}
24+
})
25+
26+
test<FixtureTestContext>('should link files in the src directory to the dest directory', async (ctx) => {
27+
const { cwd } = await createFsFixture(
28+
{
29+
'/src/test.js': '',
30+
'/src/sub-dir/test2.js': '',
31+
},
32+
ctx,
33+
)
34+
35+
await linkdir(`${cwd}/src`, `${cwd}/dest`)
36+
37+
const files = await glob('**/*', { cwd })
38+
39+
expect(files).toEqual(expect.arrayContaining(['dest/test.js', 'dest/sub-dir/test2.js']))
40+
})
41+
42+
test('should fail gracefully if no files found', async () => {
43+
expect(async () => await linkdir('/not-a-path', '/not-a-path')).not.toThrow()
44+
})

‎src/build/files.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import glob from 'fast-glob'
2+
import { mkdir, symlink } from 'node:fs/promises'
3+
import { dirname, join } from 'node:path'
4+
5+
export const linkdir = async (src: string, dest: string): Promise<void> => {
6+
const paths = await glob(['**'], { cwd: src })
7+
await Promise.all(
8+
paths.map(async (path) => {
9+
await mkdir(join(dest, dirname(path)), { recursive: true })
10+
await symlink(join(src, path), join(dest, path))
11+
}),
12+
)
13+
}

‎src/build/functions/server.ts

+31-33
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1+
import { NetlifyPluginOptions } from '@netlify/build'
12
import { nodeFileTrace } from '@vercel/nft'
2-
import { readFileSync } from 'fs'
3-
import { cp, mkdir, rm, writeFile } from 'fs/promises'
4-
import { join } from 'node:path'
5-
import { BUILD_DIR, PLUGIN_DIR, SERVER_HANDLER_DIR, SERVER_HANDLER_NAME } from '../constants.js'
6-
import { copyServerContent } from '../content/server.js'
7-
8-
const pkg = JSON.parse(readFileSync(join(PLUGIN_DIR, 'package.json'), 'utf-8'))
3+
import { mkdir, rm, symlink, writeFile } from 'fs/promises'
4+
import { dirname, join, resolve } from 'node:path'
5+
import {
6+
PLUGIN_DIR,
7+
PLUGIN_NAME,
8+
PLUGIN_VERSION,
9+
SERVER_HANDLER_DIR,
10+
SERVER_HANDLER_NAME,
11+
} from '../constants.js'
12+
import { linkServerContent, linkServerDependencies } from '../content/server.js'
913

1014
/**
1115
* Create a Netlify function to run the Next.js server
1216
*/
13-
export const createServerHandler = async () => {
17+
export const createServerHandler = async ({
18+
constants: { PUBLISH_DIR },
19+
}: Pick<NetlifyPluginOptions, 'constants'>) => {
1420
// reset the handler directory
15-
await rm(join(process.cwd(), SERVER_HANDLER_DIR), { force: true, recursive: true })
16-
await mkdir(join(process.cwd(), SERVER_HANDLER_DIR), { recursive: true })
21+
await rm(resolve(SERVER_HANDLER_DIR), { recursive: true, force: true })
1722

1823
// trace the handler dependencies
1924
const { fileList } = await nodeFileTrace(
@@ -22,38 +27,34 @@ export const createServerHandler = async () => {
2227
join(PLUGIN_DIR, 'dist/run/handlers/cache.cjs'),
2328
join(PLUGIN_DIR, 'dist/run/handlers/next.cjs'),
2429
],
25-
{ ignore: ['package.json', 'node_modules/next/**'] },
30+
{ base: PLUGIN_DIR, ignore: ['package.json', 'node_modules/next/**'] },
2631
)
2732

2833
// copy the handler dependencies
2934
await Promise.all(
30-
[...fileList].map((path) =>
31-
cp(
32-
path,
33-
join(process.cwd(), SERVER_HANDLER_DIR, path.replace(`node_modules/${pkg.name}/`, '')),
34-
{ recursive: true },
35-
),
36-
),
35+
[...fileList].map(async (path) => {
36+
await mkdir(resolve(SERVER_HANDLER_DIR, dirname(path)), { recursive: true })
37+
await symlink(resolve(PLUGIN_DIR, path), resolve(SERVER_HANDLER_DIR, path))
38+
}),
3739
)
3840

3941
// copy the next.js standalone build output to the handler directory
40-
await copyServerContent(
41-
join(process.cwd(), BUILD_DIR, '.next/standalone/.next'),
42-
join(process.cwd(), SERVER_HANDLER_DIR, '.next'),
42+
await linkServerContent(
43+
resolve(PUBLISH_DIR, 'standalone/.next'),
44+
resolve(SERVER_HANDLER_DIR, '.next'),
4345
)
44-
await cp(
45-
join(process.cwd(), BUILD_DIR, '.next/standalone/node_modules'),
46-
join(process.cwd(), SERVER_HANDLER_DIR, 'node_modules'),
47-
{ recursive: true },
46+
await linkServerDependencies(
47+
resolve(PUBLISH_DIR, 'standalone/node_modules'),
48+
resolve(SERVER_HANDLER_DIR, 'node_modules'),
4849
)
4950

5051
// create the handler metadata file
5152
await writeFile(
52-
join(process.cwd(), SERVER_HANDLER_DIR, `${SERVER_HANDLER_NAME}.json`),
53+
resolve(SERVER_HANDLER_DIR, `${SERVER_HANDLER_NAME}.json`),
5354
JSON.stringify({
5455
config: {
5556
name: 'Next.js Server Handler',
56-
generator: `${pkg.name}@${pkg.version}`,
57+
generator: `${PLUGIN_NAME}@${PLUGIN_VERSION}`,
5758
nodeBundler: 'none',
5859
includedFiles: [
5960
`${SERVER_HANDLER_NAME}*`,
@@ -62,22 +63,19 @@ export const createServerHandler = async () => {
6263
'.next/**',
6364
'node_modules/**',
6465
],
65-
includedFilesBasePath: join(process.cwd(), SERVER_HANDLER_DIR),
66+
includedFilesBasePath: resolve(SERVER_HANDLER_DIR),
6667
},
6768
version: 1,
6869
}),
6970
'utf-8',
7071
)
7172

7273
// configure ESM
73-
await writeFile(
74-
join(process.cwd(), SERVER_HANDLER_DIR, 'package.json'),
75-
JSON.stringify({ type: 'module' }),
76-
)
74+
await writeFile(resolve(SERVER_HANDLER_DIR, 'package.json'), JSON.stringify({ type: 'module' }))
7775

7876
// write the root handler file
7977
await writeFile(
80-
join(process.cwd(), SERVER_HANDLER_DIR, `${SERVER_HANDLER_NAME}.js`),
78+
resolve(SERVER_HANDLER_DIR, `${SERVER_HANDLER_NAME}.js`),
8179
`import handler from './dist/run/handlers/server.js';export default handler`,
8280
)
8381
}

‎src/build/move-build-output.test.ts

-82
This file was deleted.

‎src/build/move-build-output.ts

-42
This file was deleted.

‎src/index.ts

+17-19
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,27 @@
1-
import { NetlifyPluginOptions } from '@netlify/build'
2-
import { setBuildConfig } from './build/config.js'
1+
import type { NetlifyPluginOptions } from '@netlify/build'
2+
import { restoreBuildCache, saveBuildCache } from './build/cache.js'
3+
import { setBuildConfig, setDeployConfig } from './build/config.js'
34
import { uploadPrerenderedContent } from './build/content/prerendered.js'
4-
import { copyStaticContent } from './build/content/static.js'
5-
import { createEdgeHandler } from './build/functions/edge.js'
5+
import { linkStaticAssets, uploadStaticContent } from './build/content/static.js'
66
import { createServerHandler } from './build/functions/server.js'
7-
import { moveBuildOutput } from './build/move-build-output.js'
8-
import { getDeployStore } from '@netlify/blobs'
97

10-
export const onPreBuild = ({ netlifyConfig }: NetlifyPluginOptions) => {
11-
setBuildConfig(netlifyConfig)
8+
export const onPreBuild = async ({ constants, utils }: NetlifyPluginOptions) => {
9+
await restoreBuildCache({ constants, utils })
10+
setBuildConfig()
1211
}
1312

1413
export const onBuild = async ({ constants, utils }: NetlifyPluginOptions) => {
15-
const blob = getDeployStore({
16-
deployID: process.env.DEPLOY_ID,
17-
siteID: constants.SITE_ID,
18-
token: constants.NETLIFY_API_TOKEN,
19-
apiURL: `https://${constants.NETLIFY_API_HOST}`,
20-
})
21-
await moveBuildOutput(constants, utils)
14+
await saveBuildCache({ constants, utils })
2215

2316
return Promise.all([
24-
copyStaticContent(constants, blob),
25-
uploadPrerenderedContent(constants),
26-
createServerHandler(),
27-
createEdgeHandler(),
17+
linkStaticAssets({ constants }),
18+
uploadStaticContent({ constants }),
19+
uploadPrerenderedContent({ constants }),
20+
createServerHandler({ constants }),
21+
// createEdgeHandler(),
2822
])
2923
}
24+
25+
export const onPostBuild = ({ netlifyConfig }: NetlifyPluginOptions) => {
26+
setDeployConfig({ netlifyConfig })
27+
}

‎tests/integration/cache-handler.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe('page router', () => {
5151
)
5252

5353
// wait to have a stale page
54-
await new Promise<void>((resolve) => setTimeout(resolve, 2_000))
54+
await new Promise<void>((resolve) => setTimeout(resolve, 3_000))
5555

5656
// now it should be a cache miss
5757
const call2 = await invokeFunction(ctx, { url: 'static/revalidate' })

‎tests/utils/fixture.ts

+7-8
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import { BlobsServer, type getStore } from '@netlify/blobs'
22
import { TestContext, assert, vi } from 'vitest'
33

4-
import type {
5-
NetlifyPluginConstants,
6-
NetlifyPluginOptions,
7-
NetlifyPluginUtils,
8-
} from '@netlify/build'
4+
import type { NetlifyPluginConstants, NetlifyPluginOptions } from '@netlify/build'
95
import type { LambdaResponse } from '@netlify/serverless-functions-api/dist/lambda/response.js'
106
import { zipFunctions } from '@netlify/zip-it-and-ship-it'
117
import { execaCommand } from 'execa'
128
import { execute } from 'lambda-local'
13-
import { cp, mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
9+
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
1410
import { tmpdir } from 'node:os'
15-
import { join, dirname } from 'node:path'
11+
import { dirname, join } from 'node:path'
1612
import { fileURLToPath } from 'node:url'
1713
import { SERVER_FUNCTIONS_DIR, SERVER_HANDLER_NAME } from '../../src/build/constants.js'
1814
import { streamToString } from './stream-to-string.js'
@@ -129,7 +125,10 @@ export async function runPlugin(
129125
assert.fail(`${message}: ${options?.error || ''}`)
130126
},
131127
},
132-
} as NetlifyPluginUtils,
128+
cache: {
129+
save: vi.fn(),
130+
},
131+
},
133132
} as unknown as NetlifyPluginOptions)
134133

135134
const internalSrcFolder = join(ctx.cwd, SERVER_FUNCTIONS_DIR)

0 commit comments

Comments
 (0)
Please sign in to comment.