Skip to content

Commit d66e30b

Browse files
authored
fix: more robust handling of export output (#418)
* chore: rename fixtures * chore: add custom dist export fixture * fix: more robust handling of export output * chore: ignore new dist * chore: fix error snapshot
1 parent 1a0542c commit d66e30b

File tree

16 files changed

+214
-39
lines changed

16 files changed

+214
-39
lines changed

.prettierignore

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package.json
88
edge-runtime/vendor/
99
deno.lock
1010
tests/fixtures/dist-dir/cool/output
11+
tests/fixtures/output-export-custom-dist/custom-dist
1112
.nx
1213
custom-dist-dir
1314
pnpm.lock

src/build/content/static.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,12 @@ export const copyStaticAssets = async (ctx: PluginContext): Promise<void> => {
6868

6969
export const copyStaticExport = async (ctx: PluginContext): Promise<void> => {
7070
await tracer.withActiveSpan('copyStaticExport', async () => {
71+
if (!ctx.exportDetail?.outDirectory) {
72+
ctx.failBuild('Export directory not found')
73+
}
7174
try {
7275
await rm(ctx.staticDir, { recursive: true, force: true })
73-
await cp(ctx.resolveFromSiteDir('out'), ctx.staticDir, { recursive: true })
76+
await cp(ctx.exportDetail.outDirectory, ctx.staticDir, { recursive: true })
7477
} catch (error) {
7578
ctx.failBuild('Failed copying static export', error)
7679
}

src/build/plugin-context.ts

+61-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFileSync } from 'node:fs'
1+
import { existsSync, readFileSync } from 'node:fs'
22
import { readFile } from 'node:fs/promises'
33
// Here we need to actually import `resolve` from node:path as we want to resolve the paths
44
// eslint-disable-next-line no-restricted-imports
@@ -32,6 +32,11 @@ export interface RequiredServerFilesManifest {
3232
ignore: string[]
3333
}
3434

35+
export interface ExportDetail {
36+
success: boolean
37+
outDirectory: string
38+
}
39+
3540
export class PluginContext {
3641
utils: NetlifyPluginUtils
3742
netlifyConfig: NetlifyPluginOptions['netlifyConfig']
@@ -200,6 +205,28 @@ export class PluginContext {
200205
return JSON.parse(await readFile(join(this.publishDir, 'prerender-manifest.json'), 'utf-8'))
201206
}
202207

208+
/**
209+
* Uses various heuristics to try to find the .next dir.
210+
* Works by looking for BUILD_ID, so requires the site to have been built
211+
*/
212+
findDotNext(): string | false {
213+
for (const dir of [
214+
// The publish directory
215+
this.publishDir,
216+
// In the root
217+
resolve(DEFAULT_PUBLISH_DIR),
218+
// The sibling of the publish directory
219+
resolve(this.publishDir, '..', DEFAULT_PUBLISH_DIR),
220+
// In the package dir
221+
resolve(this.constants.PACKAGE_PATH || '', DEFAULT_PUBLISH_DIR),
222+
]) {
223+
if (existsSync(join(dir, 'BUILD_ID'))) {
224+
return dir
225+
}
226+
}
227+
return false
228+
}
229+
203230
/**
204231
* Get Next.js middleware config from the build output
205232
*/
@@ -215,13 +242,45 @@ export class PluginContext {
215242
/** Get RequiredServerFiles manifest from build output **/
216243
get requiredServerFiles(): RequiredServerFilesManifest {
217244
if (!this._requiredServerFiles) {
245+
let requiredServerFilesJson = join(this.publishDir, 'required-server-files.json')
246+
247+
if (!existsSync(requiredServerFilesJson)) {
248+
const dotNext = this.findDotNext()
249+
if (dotNext) {
250+
requiredServerFilesJson = join(dotNext, 'required-server-files.json')
251+
}
252+
}
253+
218254
this._requiredServerFiles = JSON.parse(
219-
readFileSync(join(this.publishDir, 'required-server-files.json'), 'utf-8'),
255+
readFileSync(requiredServerFilesJson, 'utf-8'),
220256
) as RequiredServerFilesManifest
221257
}
222258
return this._requiredServerFiles
223259
}
224260

261+
#exportDetail: ExportDetail | null = null
262+
263+
/** Get metadata when output = export */
264+
get exportDetail(): ExportDetail | null {
265+
if (this.buildConfig.output !== 'export') {
266+
return null
267+
}
268+
if (!this.#exportDetail) {
269+
const detailFile = join(
270+
this.requiredServerFiles.appDir,
271+
this.buildConfig.distDir,
272+
'export-detail.json',
273+
)
274+
if (!existsSync(detailFile)) {
275+
return null
276+
}
277+
try {
278+
this.#exportDetail = JSON.parse(readFileSync(detailFile, 'utf-8'))
279+
} catch {}
280+
}
281+
return this.#exportDetail
282+
}
283+
225284
/** Get Next Config from build output **/
226285
get buildConfig(): NextConfigComplete {
227286
return this.requiredServerFiles.config

src/build/verification.ts

+25-17
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync } from 'node:fs'
2+
import { join } from 'node:path'
23

34
import { satisfies } from 'semver'
45

@@ -10,7 +11,7 @@ const SUPPORTED_NEXT_VERSIONS = '>=13.5.0'
1011
export function verifyPublishDir(ctx: PluginContext) {
1112
if (!existsSync(ctx.publishDir)) {
1213
ctx.failBuild(
13-
`Your publish directory was not found at: ${ctx.publishDir}, please check your build settings`,
14+
`Your publish directory was not found at: ${ctx.publishDir}. Please check your build settings`,
1415
)
1516
}
1617
// for next.js sites the publish directory should never equal the package path which should be
@@ -22,33 +23,40 @@ export function verifyPublishDir(ctx: PluginContext) {
2223
// that directory will be above packagePath
2324
if (ctx.publishDir === ctx.resolveFromPackagePath('')) {
2425
ctx.failBuild(
25-
`Your publish directory cannot be the same as the base directory of your site, please check your build settings`,
26+
`Your publish directory cannot be the same as the base directory of your site. Please check your build settings`,
2627
)
2728
}
2829
try {
29-
// `PluginContext.buildConfig` is getter and we only test wether it throws
30+
// `PluginContext.buildConfig` is getter and we only test whether it throws
3031
// and don't actually need to use its value
3132
// eslint-disable-next-line no-unused-expressions
3233
ctx.buildConfig
3334
} catch {
3435
ctx.failBuild(
35-
'Your publish directory does not contain expected Next.js build output, please check your build settings',
36+
'Your publish directory does not contain expected Next.js build output. Please check your build settings',
3637
)
3738
}
38-
if (
39-
(ctx.buildConfig.output === 'standalone' || ctx.buildConfig.output === undefined) &&
40-
!existsSync(ctx.standaloneRootDir)
41-
) {
42-
ctx.failBuild(
43-
`Your publish directory does not contain expected Next.js build output, please make sure you are using Next.js version (${SUPPORTED_NEXT_VERSIONS})`,
44-
)
39+
if (ctx.buildConfig.output === 'standalone' || ctx.buildConfig.output === undefined) {
40+
if (!existsSync(join(ctx.publishDir, 'BUILD_ID'))) {
41+
ctx.failBuild(
42+
'Your publish directory does not contain expected Next.js build output. Please check your build settings',
43+
)
44+
}
45+
if (!existsSync(ctx.standaloneRootDir)) {
46+
ctx.failBuild(
47+
`Your publish directory does not contain expected Next.js build output. Please make sure you are using Next.js version (${SUPPORTED_NEXT_VERSIONS})`,
48+
)
49+
}
4550
}
46-
if (ctx.buildConfig.output === 'export' && !existsSync(ctx.resolveFromSiteDir('out'))) {
47-
ctx.failBuild(
48-
`Your export directory was not found at: ${ctx.resolveFromSiteDir(
49-
'out',
50-
)}, please check your build settings`,
51-
)
51+
if (ctx.buildConfig.output === 'export') {
52+
if (!ctx.exportDetail?.success) {
53+
ctx.failBuild(`Your export failed to build. Please check your build settings`)
54+
}
55+
if (!existsSync(ctx.exportDetail?.outDirectory)) {
56+
ctx.failBuild(
57+
`Your export directory was not found at: ${ctx.exportDetail?.outDirectory}. Please check your build settings`,
58+
)
59+
}
5260
}
5361
}
5462

tests/e2e/export.test.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, type Locator } from '@playwright/test'
2+
import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
3+
import { test } from '../utils/playwright-helpers.js'
4+
5+
const expectImageWasLoaded = async (locator: Locator) => {
6+
expect(await locator.evaluate((img: HTMLImageElement) => img.naturalHeight)).toBeGreaterThan(0)
7+
}
8+
test('Renders the Home page correctly with output export', async ({ page, outputExport }) => {
9+
const response = await page.goto(outputExport.url)
10+
const headers = response?.headers() || {}
11+
12+
await expect(page).toHaveTitle('Simple Next App')
13+
14+
expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss')
15+
16+
const h1 = page.locator('h1')
17+
await expect(h1).toHaveText('Home')
18+
19+
await expectImageWasLoaded(page.locator('img'))
20+
})
21+
22+
test('Renders the Home page correctly with output export and publish set to out', async ({
23+
page,
24+
ouputExportPublishOut,
25+
}) => {
26+
const response = await page.goto(ouputExportPublishOut.url)
27+
const headers = response?.headers() || {}
28+
29+
await expect(page).toHaveTitle('Simple Next App')
30+
31+
expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss')
32+
33+
const h1 = page.locator('h1')
34+
await expect(h1).toHaveText('Home')
35+
36+
await expectImageWasLoaded(page.locator('img'))
37+
})
38+
39+
test('Renders the Home page correctly with output export and custom dist dir', async ({
40+
page,
41+
outputExportCustomDist,
42+
}) => {
43+
const response = await page.goto(outputExportCustomDist.url)
44+
const headers = response?.headers() || {}
45+
46+
await expect(page).toHaveTitle('Simple Next App')
47+
48+
expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss')
49+
50+
const h1 = page.locator('h1')
51+
await expect(h1).toHaveText('Home')
52+
53+
await expectImageWasLoaded(page.locator('img'))
54+
})

tests/e2e/simple-app.test.ts

-14
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,6 @@ test('Renders the Home page correctly', async ({ page, simple }) => {
2525
expect(body).toBe('{"words":"hello world"}')
2626
})
2727

28-
test('Renders the Home page correctly with output export', async ({ page, outputExport }) => {
29-
const response = await page.goto(outputExport.url)
30-
const headers = response?.headers() || {}
31-
32-
await expect(page).toHaveTitle('Simple Next App')
33-
34-
expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss')
35-
36-
const h1 = page.locator('h1')
37-
await expect(h1).toHaveText('Home')
38-
39-
await expectImageWasLoaded(page.locator('img'))
40-
})
41-
4228
test('Renders the Home page correctly with distDir', async ({ page, distDir }) => {
4329
await page.goto(distDir.url)
4430

Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
custom-dist
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const metadata = {
2+
title: 'Simple Next App',
3+
description: 'Description for Simple Next App',
4+
}
5+
6+
export default function RootLayout({ children }) {
7+
return (
8+
<html lang="en">
9+
<body>{children}</body>
10+
</html>
11+
)
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default function Home() {
2+
return (
3+
<main>
4+
<h1>Home</h1>
5+
<img src="/squirrel.jpg" alt="a cute squirrel" width="300px" />
6+
</main>
7+
)
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
output: 'export',
4+
distDir: 'custom-dist',
5+
eslint: {
6+
ignoreDuringBuilds: true,
7+
},
8+
}
9+
10+
module.exports = nextConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "output-export",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"postinstall": "next build",
7+
"dev": "next dev",
8+
"build": "next build"
9+
},
10+
"dependencies": {
11+
"next": "latest",
12+
"react": "18.2.0",
13+
"react-dom": "18.2.0"
14+
}
15+
}
Loading
Loading

tests/integration/simple-app.test.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,14 @@ describe('verification', () => {
9595
test<FixtureTestContext>("Should warn if publish dir doesn't exist", async (ctx) => {
9696
await createFixture('simple', ctx)
9797
expect(() => runPlugin(ctx, { PUBLISH_DIR: 'no-such-directory' })).rejects.toThrowError(
98-
/Your publish directory was not found at: \S+no-such-directory, please check your build settings/,
98+
/Your publish directory was not found at: \S+no-such-directory. Please check your build settings/,
9999
)
100100
})
101101

102102
test<FixtureTestContext>('Should warn if publish dir is root', async (ctx) => {
103103
await createFixture('simple', ctx)
104104
expect(() => runPlugin(ctx, { PUBLISH_DIR: '.' })).rejects.toThrowError(
105-
'Your publish directory cannot be the same as the base directory of your site, please check your build settings',
105+
'Your publish directory cannot be the same as the base directory of your site. Please check your build settings',
106106
)
107107
})
108108

@@ -111,16 +111,25 @@ describe('verification', () => {
111111
expect(() =>
112112
runPlugin(ctx, { PUBLISH_DIR: 'app/.', PACKAGE_PATH: 'app' }),
113113
).rejects.toThrowError(
114-
'Your publish directory cannot be the same as the base directory of your site, please check your build settings',
114+
'Your publish directory cannot be the same as the base directory of your site. Please check your build settings',
115115
)
116116
})
117117

118118
test<FixtureTestContext>('Should warn if publish dir is not set to Next.js output directory', async (ctx) => {
119119
await createFixture('simple', ctx)
120120
expect(() => runPlugin(ctx, { PUBLISH_DIR: 'public' })).rejects.toThrowError(
121-
'Your publish directory does not contain expected Next.js build output, please check your build settings',
121+
'Your publish directory does not contain expected Next.js build output. Please check your build settings',
122122
)
123123
})
124+
test<FixtureTestContext>('Should not warn if using "out" as publish dir when output is "export"', async (ctx) => {
125+
await createFixture('output-export', ctx)
126+
await expect(runPlugin(ctx, { PUBLISH_DIR: 'out' })).resolves.not.toThrow()
127+
})
128+
129+
test<FixtureTestContext>('Should not throw when using custom distDir and output is "export', async (ctx) => {
130+
await createFixture('output-export-custom-dist', ctx)
131+
await expect(runPlugin(ctx, { PUBLISH_DIR: 'custom-dist' })).resolves.not.toThrow()
132+
})
124133
})
125134

126135
test<FixtureTestContext>('Should add cache-tags to prerendered app pages', async (ctx) => {

tests/smoke/deploy.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe('version check', () => {
5656
async () => {
5757
// we are not able to get far enough to extract concrete next version, so this error message lack used Next.js version
5858
await expect(selfCleaningFixtureFactories.next12_0_3()).rejects.toThrow(
59-
/Your publish directory does not contain expected Next.js build output, please make sure you are using Next.js version \(>=13.5.0\)/,
59+
/Your publish directory does not contain expected Next.js build output. Please make sure you are using Next.js version \(>=13.5.0\)/,
6060
)
6161
},
6262
)

tests/utils/create-e2e-fixture.ts

+8
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,14 @@ async function cleanup(dest: string, deployId?: string): Promise<void> {
273273
export const fixtureFactories = {
274274
simple: () => createE2EFixture('simple'),
275275
outputExport: () => createE2EFixture('output-export'),
276+
ouputExportPublishOut: () =>
277+
createE2EFixture('output-export', {
278+
publishDirectory: 'out',
279+
}),
280+
outputExportCustomDist: () =>
281+
createE2EFixture('output-export-custom-dist', {
282+
publishDirectory: 'custom-dist',
283+
}),
276284
distDir: () =>
277285
createE2EFixture('dist-dir', {
278286
publishDirectory: 'cool/output',

0 commit comments

Comments
 (0)