Skip to content

Commit 72101f3

Browse files
authored
feat: add next.js version check and fail the build if version not satisfied (#291)
* chore: clean clean-package package.json field * test: add smoke tests with multiple next versions in monorepo * feat: add next.js version check and bail early if not satisfied * refactor: move version check to assembling handler function so that we don't have to handle figuring out where to resolve next from * test: add integration tests for additional verification checks * chore: revert useless change * test: handle win32 path sep in assertion regex * test: cleanup fixtures after using them in deploy/smoke tests * test: update comment that was wrong after c&p from another test * test: remove weird empty import * test: prepare new fixture for running tests against multiple next versions * use 'latest' in new smoke tests * fix: actually resolve from standalone context * fix: actually check for standalone dir first
1 parent ce97a32 commit 72101f3

File tree

36 files changed

+649
-35
lines changed

36 files changed

+649
-35
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"clean-package": {
9292
"indent": 2,
9393
"remove": [
94+
"clean-package",
9495
"dependencies",
9596
"devDependencies",
9697
"scripts"

src/build/content/server.ts

+46-4
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
import { existsSync } from 'node:fs'
2-
import { cp, mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises'
2+
import {
3+
cp,
4+
mkdir,
5+
readFile,
6+
readdir,
7+
readlink,
8+
symlink,
9+
writeFile,
10+
access,
11+
} from 'node:fs/promises'
312
import { createRequire } from 'node:module'
413
// eslint-disable-next-line no-restricted-imports
514
import { dirname, join, resolve, sep } from 'node:path'
6-
import { sep as posixSep, relative as posixRelative } from 'node:path/posix'
15+
import { sep as posixSep, relative as posixRelative, join as posixJoin } from 'node:path/posix'
716

817
import glob from 'fast-glob'
918

1019
import { RUN_CONFIG } from '../../run/constants.js'
1120
import { PluginContext } from '../plugin-context.js'
21+
import { verifyNextVersion } from '../verification.js'
1222

1323
const toPosixPath = (path: string) => path.split(sep).join(posixSep)
1424

25+
function isError(error: unknown): error is NodeJS.ErrnoException {
26+
return error instanceof Error
27+
}
28+
1529
/**
1630
* Copy App/Pages Router Javascript needed by the server handler
1731
*/
@@ -23,6 +37,18 @@ export const copyNextServerCode = async (ctx: PluginContext): Promise<void> => {
2337
ctx.relPublishDir,
2438
'required-server-files.json',
2539
)
40+
try {
41+
await access(reqServerFilesPath)
42+
} catch (error) {
43+
if (isError(error) && error.code === 'ENOENT') {
44+
// this error at this point is problem in runtime and not user configuration
45+
ctx.failBuild(
46+
`Failed creating server handler. required-server-files.json file not found at expected location "${reqServerFilesPath}". Your repository setup is currently not yet supported.`,
47+
)
48+
} else {
49+
throw error
50+
}
51+
}
2652
const reqServerFiles = JSON.parse(await readFile(reqServerFilesPath, 'utf-8'))
2753

2854
// if the resolved dist folder does not match the distDir of the required-server-files.json
@@ -157,9 +183,25 @@ export const copyNextDependencies = async (ctx: PluginContext): Promise<void> =>
157183
await Promise.all(promises)
158184

159185
// detect if it might lead to a runtime issue and throw an error upfront on build time instead of silently failing during runtime
160-
const require = createRequire(ctx.serverHandlerDir)
186+
const serverHandlerRequire = createRequire(posixJoin(ctx.serverHandlerDir, ':internal:'))
187+
188+
let nextVersion: string | undefined
189+
try {
190+
const { version } = serverHandlerRequire('next/package.json')
191+
if (version) {
192+
nextVersion = version as string
193+
}
194+
} catch {
195+
// failed to resolve package.json - currently this is resolvable in all known next versions, but if next implements
196+
// exports map it still might be a problem in the future, so we are not breaking here
197+
}
198+
199+
if (nextVersion) {
200+
verifyNextVersion(ctx, nextVersion)
201+
}
202+
161203
try {
162-
const nextEntryAbsolutePath = require.resolve('next')
204+
const nextEntryAbsolutePath = serverHandlerRequire.resolve('next')
163205
const nextRequire = createRequire(nextEntryAbsolutePath)
164206
nextRequire.resolve('styled-jsx')
165207
} catch {

src/build/plugin-context.ts

+1-14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, readFileSync } from 'node:fs'
1+
import { 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
@@ -213,17 +213,4 @@ export class PluginContext {
213213
failBuild(message: string, error?: unknown): never {
214214
return this.utils.build.failBuild(message, error instanceof Error ? { error } : undefined)
215215
}
216-
217-
verifyPublishDir() {
218-
if (!existsSync(this.publishDir)) {
219-
this.failBuild(
220-
`Your publish directory was not found at: ${this.publishDir}, please check your build settings`,
221-
)
222-
}
223-
if (this.publishDir === this.resolve(this.packagePath)) {
224-
this.failBuild(
225-
`Your publish directory cannot be the same as the base directory of your site, please check your build settings`,
226-
)
227-
}
228-
}
229216
}

src/build/verification.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { existsSync } from 'node:fs'
2+
3+
import { satisfies } from 'semver'
4+
5+
import type { PluginContext } from './plugin-context.js'
6+
7+
const SUPPORTED_NEXT_VERSIONS = '>=13.5.0'
8+
9+
export function verifyPublishDir(ctx: PluginContext) {
10+
if (!existsSync(ctx.publishDir)) {
11+
ctx.failBuild(
12+
`Your publish directory was not found at: ${ctx.publishDir}, please check your build settings`,
13+
)
14+
}
15+
if (ctx.publishDir === ctx.resolve(ctx.packagePath)) {
16+
ctx.failBuild(
17+
`Your publish directory cannot be the same as the base directory of your site, please check your build settings`,
18+
)
19+
}
20+
try {
21+
// `PluginContext.buildConfig` is getter and we only test wether it throws
22+
// and don't actually need to use its value
23+
// eslint-disable-next-line no-unused-expressions
24+
ctx.buildConfig
25+
} catch {
26+
ctx.failBuild(
27+
'Your publish directory does not contain expected Next.js build output, please check your build settings',
28+
)
29+
}
30+
if (!existsSync(ctx.standaloneRootDir)) {
31+
ctx.failBuild(
32+
`Your publish directory does not contain expected Next.js build output, please make sure you are using Next.js version (${SUPPORTED_NEXT_VERSIONS})`,
33+
)
34+
}
35+
}
36+
37+
export function verifyNextVersion(ctx: PluginContext, nextVersion: string): void | never {
38+
if (!satisfies(nextVersion, SUPPORTED_NEXT_VERSIONS, { includePrerelease: true })) {
39+
ctx.failBuild(
40+
`@netlify/plugin-next@5 requires Next.js version ${SUPPORTED_NEXT_VERSIONS}, but found ${nextVersion}. Please upgrade your project's Next.js version.`,
41+
)
42+
}
43+
}

src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createEdgeHandlers } from './build/functions/edge.js'
1212
import { createServerHandler } from './build/functions/server.js'
1313
import { setImageConfig } from './build/image-cdn.js'
1414
import { PluginContext } from './build/plugin-context.js'
15+
import { verifyPublishDir } from './build/verification.js'
1516

1617
export const onPreBuild = async (options: NetlifyPluginOptions) => {
1718
// Enable Next.js standalone mode at build time
@@ -23,7 +24,7 @@ export const onPreBuild = async (options: NetlifyPluginOptions) => {
2324

2425
export const onBuild = async (options: NetlifyPluginOptions) => {
2526
const ctx = new PluginContext(options)
26-
ctx.verifyPublishDir()
27+
verifyPublishDir(ctx)
2728

2829
// only save the build cache if not run via the CLI
2930
if (!options.constants.IS_LOCAL) {

tests/integration/simple-app.test.ts

+22-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { load } from 'cheerio'
22
import { getLogger } from 'lambda-local'
33
import { v4 } from 'uuid'
4-
import { beforeEach, expect, test, vi } from 'vitest'
4+
import { beforeEach, describe, expect, test, vi } from 'vitest'
55
import {
66
createFixture,
77
invokeFunction,
@@ -68,11 +68,27 @@ test<FixtureTestContext>('Test that the simple next app is working', async (ctx)
6868
expect(load(notExisting.body)('h1').text()).toBe('404')
6969
})
7070

71-
test<FixtureTestContext>('Should warn if publish dir is root', async (ctx) => {
72-
await createFixture('simple-next-app', ctx)
73-
expect(() => runPlugin(ctx, { PUBLISH_DIR: '.' })).rejects.toThrowError(
74-
'check your build settings',
75-
)
71+
describe('verification', () => {
72+
test<FixtureTestContext>("Should warn if publish dir doesn't exist", async (ctx) => {
73+
await createFixture('simple-next-app', ctx)
74+
expect(() => runPlugin(ctx, { PUBLISH_DIR: 'no-such-directory' })).rejects.toThrowError(
75+
/Your publish directory was not found at: \S+no-such-directory, please check your build settings/,
76+
)
77+
})
78+
79+
test<FixtureTestContext>('Should warn if publish dir is root', async (ctx) => {
80+
await createFixture('simple-next-app', ctx)
81+
expect(() => runPlugin(ctx, { PUBLISH_DIR: '.' })).rejects.toThrowError(
82+
'Your publish directory cannot be the same as the base directory of your site, please check your build settings',
83+
)
84+
})
85+
86+
test<FixtureTestContext>('Should warn if publish dir is not set to Next.js output directory', async (ctx) => {
87+
await createFixture('simple-next-app', ctx)
88+
expect(() => runPlugin(ctx, { PUBLISH_DIR: 'public' })).rejects.toThrowError(
89+
'Your publish directory does not contain expected Next.js build output, please check your build settings',
90+
)
91+
})
7692
})
7793

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

tests/smoke/deploy.test.ts

+122-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
1-
import { expect, test } from 'vitest'
1+
import { expect, test, describe, afterEach } from 'vitest'
22
import { Fixture, fixtureFactories } from '../utils/create-e2e-fixture'
33

4+
const usedFixtures = new Set<Fixture>()
5+
/**
6+
* When fixture is used, it is automatically cleanup after test finishes
7+
*/
8+
const selfCleaningFixtureFactories = new Proxy(fixtureFactories, {
9+
get(target, prop) {
10+
return async () => {
11+
const val = target[prop]
12+
if (typeof val === 'function') {
13+
const fixture = await val()
14+
usedFixtures.add(fixture)
15+
return fixture
16+
}
17+
18+
return val
19+
}
20+
},
21+
})
22+
23+
afterEach(async () => {
24+
for (const fixture of usedFixtures) {
25+
await fixture.cleanup()
26+
}
27+
usedFixtures.clear()
28+
})
29+
430
async function smokeTest(createFixture: () => Promise<Fixture>) {
531
const fixture = await createFixture()
632
const response = await fetch(fixture.url)
@@ -12,18 +38,18 @@ async function smokeTest(createFixture: () => Promise<Fixture>) {
1238
}
1339

1440
test('yarn@3 monorepo with pnpm linker', async () => {
15-
await smokeTest(fixtureFactories.yarnMonorepoWithPnpmLinker)
41+
await smokeTest(selfCleaningFixtureFactories.yarnMonorepoWithPnpmLinker)
1642
})
1743

1844
test('npm monorepo deploying from site directory without --filter', async () => {
19-
await smokeTest(fixtureFactories.npmMonorepoEmptyBaseNoPackagePath)
45+
await smokeTest(selfCleaningFixtureFactories.npmMonorepoEmptyBaseNoPackagePath)
2046
})
2147

2248
test(
2349
'npm monorepo creating site workspace as part of build step (no packagePath set) should not deploy',
2450
{ retry: 0 },
2551
async () => {
26-
const deployPromise = fixtureFactories.npmMonorepoSiteCreatedAtBuild()
52+
const deployPromise = selfCleaningFixtureFactories.npmMonorepoSiteCreatedAtBuild()
2753

2854
await expect(deployPromise).rejects.toThrow(
2955
/Failed creating server handler. BUILD_ID file not found at expected location/,
@@ -39,3 +65,95 @@ test(
3965
)
4066
},
4167
)
68+
69+
describe('version check', () => {
70+
test(
71+
'[email protected] (first version building on recent node versions) should not deploy',
72+
{ retry: 0 },
73+
async () => {
74+
// we are not able to get far enough to extract concrete next version, so this error message lack used Next.js version
75+
await expect(selfCleaningFixtureFactories.next12_0_3()).rejects.toThrow(
76+
/Your publish directory does not contain expected Next.js build output, please make sure you are using Next.js version \(>=13.5.0\)/,
77+
)
78+
},
79+
)
80+
test(
81+
'[email protected] (first version with standalone output supported) should not deploy',
82+
{ retry: 0 },
83+
async () => {
84+
await expect(selfCleaningFixtureFactories.next12_1_0()).rejects.toThrow(
85+
new RegExp(
86+
`@netlify/plugin-next@5 requires Next.js version >=13.5.0, but found 12.1.0. Please upgrade your project's Next.js version.`,
87+
),
88+
)
89+
},
90+
)
91+
test('yarn monorepo multiple next versions site is compatible', { retry: 0 }, async () => {
92+
await smokeTest(selfCleaningFixtureFactories.yarnMonorepoMultipleNextVersionsSiteCompatible)
93+
})
94+
95+
test(
96+
'yarn monorepo multiple next versions site is incompatible should not deploy',
97+
{ retry: 0 },
98+
async () => {
99+
await expect(
100+
selfCleaningFixtureFactories.yarnMonorepoMultipleNextVersionsSiteIncompatible(),
101+
).rejects.toThrow(
102+
new RegExp(
103+
`@netlify/plugin-next@5 requires Next.js version >=13.5.0, but found 13.4.1. Please upgrade your project's Next.js version.`,
104+
),
105+
)
106+
},
107+
)
108+
109+
test(
110+
'npm nested site multiple next versions site is compatible (currently broken for different reason)',
111+
{ retry: 0 },
112+
async () => {
113+
// this should pass version validation, but fails with Error: ENOENT: no such file or directory, open
114+
// '<fixture_dir>/apps/site/.next/standalone/apps/site/.next/required-server-files.json'
115+
// while actual location is
116+
// '<fixture_dir>/apps/site/.next/standalone/.next/required-server-files.json'
117+
// so this is another case of directories setup that needs to be handled
118+
await expect(
119+
selfCleaningFixtureFactories.npmNestedSiteMultipleNextVersionsCompatible(),
120+
).rejects.toThrow(
121+
new RegExp(
122+
'Failed creating server handler. required-server-files.json file not found at expected location ".+/apps/site/.next/standalone/apps/site/.next/required-server-files.json". Your repository setup is currently not yet supported.',
123+
),
124+
)
125+
126+
// TODO: above test body should be removed and following line uncommented and test title updated once the issue is fixed
127+
// await smokeTest(fixtureFactories.npmNestedSiteMultipleNextVersionsCompatible)
128+
},
129+
)
130+
131+
test(
132+
'npm nested site multiple next versions site is incompatible should not deploy (currently broken for different reason)',
133+
{ retry: 0 },
134+
async () => {
135+
// this shouldn't pass version validation, but currently fails before that
136+
// with Error: ENOENT: no such file or directory, open
137+
// '<fixture_dir>/apps/site/.next/standalone/apps/site/.next/required-server-files.json'
138+
// while actual location is
139+
// '<fixture_dir>/apps/site/.next/standalone/.next/required-server-files.json'
140+
// so this is another case of directories setup that needs to be handled
141+
await expect(
142+
selfCleaningFixtureFactories.npmNestedSiteMultipleNextVersionsIncompatible(),
143+
).rejects.toThrow(
144+
new RegExp(
145+
'Failed creating server handler. required-server-files.json file not found at expected location ".+/apps/site/.next/standalone/apps/site/.next/required-server-files.json". Your repository setup is currently not yet supported.',
146+
),
147+
)
148+
149+
// TODO: above test body should be removed and following line uncommented and test title updated once the issue is fixed
150+
// await expect(
151+
// fixtureFactories.npmNestedSiteMultipleNextVersionsIncompatible(),
152+
// ).rejects.toThrow(
153+
// new RegExp(
154+
// `@netlify/plugin-next@5 requires Next.js version >=13.5.0, but found 13.4.1. Please upgrade your project's Next.js version.`,
155+
// ),
156+
// )
157+
},
158+
)
159+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
eslint: {
4+
ignoreDuringBuilds: true,
5+
},
6+
}
7+
8+
module.exports = nextConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "next-12.0.3",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "next build"
7+
},
8+
"dependencies": {
9+
"next": "12.0.3",
10+
"react": "^18.2.0",
11+
"react-dom": "^18.2.0"
12+
},
13+
"test": {
14+
"dependencies": {
15+
"next": "12.0.3"
16+
}
17+
}
18+
}

0 commit comments

Comments
 (0)