diff --git a/.prettierignore b/.prettierignore index beacca8ea4..04fe28d531 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ package.json edge-runtime/vendor/ deno.lock tests/fixtures/dist-dir/cool/output +tests/fixtures/output-export-custom-dist/custom-dist .nx custom-dist-dir pnpm.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 177849c4eb..11855caa41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [5.1.2](https://github.com/netlify/next-runtime-minimal/compare/v5.1.1...v5.1.2) (2024-04-18) + + +### Bug Fixes + +* more robust handling of export output ([#418](https://github.com/netlify/next-runtime-minimal/issues/418)) ([d66e30b](https://github.com/netlify/next-runtime-minimal/commit/d66e30b8099971e4db10bd460433923d1b1e9e40)) + ## [5.1.1](https://github.com/netlify/next-runtime-minimal/compare/v5.1.0...v5.1.1) (2024-04-17) diff --git a/package-lock.json b/package-lock.json index 84df3e8250..76918613c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@netlify/plugin-nextjs", - "version": "5.1.1", + "version": "5.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@netlify/plugin-nextjs", - "version": "5.1.1", + "version": "5.1.2", "license": "MIT", "devDependencies": { "@fastly/http-compute-js": "1.1.4", "@netlify/blobs": "^7.0.1", "@netlify/build": "^29.37.2", "@netlify/edge-bundler": "^11.4.0", - "@netlify/edge-functions": "^2.3.1", + "@netlify/edge-functions": "^2.5.1", "@netlify/eslint-config-node": "^7.0.1", "@netlify/functions": "^2.5.1", "@netlify/serverless-functions-api": "^1.10.1", @@ -4061,9 +4061,9 @@ "dev": true }, "node_modules/@netlify/edge-functions": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.3.1.tgz", - "integrity": "sha512-3sJzP1DmzMZppkAZpUixdHA4ryiKD1NSpLMRViYStE9Oe10rZPSnM8yl6A90vTBqCYvbAF5S7W9oPf2ucrCCIQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.5.1.tgz", + "integrity": "sha512-6YGlbzxPaSqc/D2LhP4T4PXrim/vRmqpO1RwQKqVod6WCWlkdtJcAd3mGoI7efrjfND8twh7TqXtL7RRCI23qA==", "dev": true }, "node_modules/@netlify/eslint-config-node": { @@ -20792,9 +20792,9 @@ } }, "@netlify/edge-functions": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.3.1.tgz", - "integrity": "sha512-3sJzP1DmzMZppkAZpUixdHA4ryiKD1NSpLMRViYStE9Oe10rZPSnM8yl6A90vTBqCYvbAF5S7W9oPf2ucrCCIQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.5.1.tgz", + "integrity": "sha512-6YGlbzxPaSqc/D2LhP4T4PXrim/vRmqpO1RwQKqVod6WCWlkdtJcAd3mGoI7efrjfND8twh7TqXtL7RRCI23qA==", "dev": true }, "@netlify/eslint-config-node": { diff --git a/package.json b/package.json index 61699d5123..744bb59c08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netlify/plugin-nextjs", - "version": "5.1.1", + "version": "5.1.2", "description": "Run Next.js seamlessly on Netlify", "main": "./dist/index.js", "type": "module", @@ -52,7 +52,7 @@ "@netlify/blobs": "^7.0.1", "@netlify/build": "^29.37.2", "@netlify/edge-bundler": "^11.4.0", - "@netlify/edge-functions": "^2.3.1", + "@netlify/edge-functions": "^2.5.1", "@netlify/eslint-config-node": "^7.0.1", "@netlify/functions": "^2.5.1", "@netlify/serverless-functions-api": "^1.10.1", diff --git a/src/build/content/static.ts b/src/build/content/static.ts index 5a418af4b1..7733c318ef 100644 --- a/src/build/content/static.ts +++ b/src/build/content/static.ts @@ -68,9 +68,12 @@ export const copyStaticAssets = async (ctx: PluginContext): Promise => { export const copyStaticExport = async (ctx: PluginContext): Promise => { await tracer.withActiveSpan('copyStaticExport', async () => { + if (!ctx.exportDetail?.outDirectory) { + ctx.failBuild('Export directory not found') + } try { await rm(ctx.staticDir, { recursive: true, force: true }) - await cp(ctx.resolveFromSiteDir('out'), ctx.staticDir, { recursive: true }) + await cp(ctx.exportDetail.outDirectory, ctx.staticDir, { recursive: true }) } catch (error) { ctx.failBuild('Failed copying static export', error) } diff --git a/src/build/plugin-context.ts b/src/build/plugin-context.ts index 246afd5698..90a071e24b 100644 --- a/src/build/plugin-context.ts +++ b/src/build/plugin-context.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' // Here we need to actually import `resolve` from node:path as we want to resolve the paths // eslint-disable-next-line no-restricted-imports @@ -32,6 +32,11 @@ export interface RequiredServerFilesManifest { ignore: string[] } +export interface ExportDetail { + success: boolean + outDirectory: string +} + export class PluginContext { utils: NetlifyPluginUtils netlifyConfig: NetlifyPluginOptions['netlifyConfig'] @@ -200,6 +205,28 @@ export class PluginContext { return JSON.parse(await readFile(join(this.publishDir, 'prerender-manifest.json'), 'utf-8')) } + /** + * Uses various heuristics to try to find the .next dir. + * Works by looking for BUILD_ID, so requires the site to have been built + */ + findDotNext(): string | false { + for (const dir of [ + // The publish directory + this.publishDir, + // In the root + resolve(DEFAULT_PUBLISH_DIR), + // The sibling of the publish directory + resolve(this.publishDir, '..', DEFAULT_PUBLISH_DIR), + // In the package dir + resolve(this.constants.PACKAGE_PATH || '', DEFAULT_PUBLISH_DIR), + ]) { + if (existsSync(join(dir, 'BUILD_ID'))) { + return dir + } + } + return false + } + /** * Get Next.js middleware config from the build output */ @@ -215,13 +242,45 @@ export class PluginContext { /** Get RequiredServerFiles manifest from build output **/ get requiredServerFiles(): RequiredServerFilesManifest { if (!this._requiredServerFiles) { + let requiredServerFilesJson = join(this.publishDir, 'required-server-files.json') + + if (!existsSync(requiredServerFilesJson)) { + const dotNext = this.findDotNext() + if (dotNext) { + requiredServerFilesJson = join(dotNext, 'required-server-files.json') + } + } + this._requiredServerFiles = JSON.parse( - readFileSync(join(this.publishDir, 'required-server-files.json'), 'utf-8'), + readFileSync(requiredServerFilesJson, 'utf-8'), ) as RequiredServerFilesManifest } return this._requiredServerFiles } + #exportDetail: ExportDetail | null = null + + /** Get metadata when output = export */ + get exportDetail(): ExportDetail | null { + if (this.buildConfig.output !== 'export') { + return null + } + if (!this.#exportDetail) { + const detailFile = join( + this.requiredServerFiles.appDir, + this.buildConfig.distDir, + 'export-detail.json', + ) + if (!existsSync(detailFile)) { + return null + } + try { + this.#exportDetail = JSON.parse(readFileSync(detailFile, 'utf-8')) + } catch {} + } + return this.#exportDetail + } + /** Get Next Config from build output **/ get buildConfig(): NextConfigComplete { return this.requiredServerFiles.config diff --git a/src/build/verification.ts b/src/build/verification.ts index 658d712311..3531873762 100644 --- a/src/build/verification.ts +++ b/src/build/verification.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' +import { join } from 'node:path' import { satisfies } from 'semver' @@ -10,7 +11,7 @@ const SUPPORTED_NEXT_VERSIONS = '>=13.5.0' export function verifyPublishDir(ctx: PluginContext) { if (!existsSync(ctx.publishDir)) { ctx.failBuild( - `Your publish directory was not found at: ${ctx.publishDir}, please check your build settings`, + `Your publish directory was not found at: ${ctx.publishDir}. Please check your build settings`, ) } // 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) { // that directory will be above packagePath if (ctx.publishDir === ctx.resolveFromPackagePath('')) { ctx.failBuild( - `Your publish directory cannot be the same as the base directory of your site, please check your build settings`, + `Your publish directory cannot be the same as the base directory of your site. Please check your build settings`, ) } try { - // `PluginContext.buildConfig` is getter and we only test wether it throws + // `PluginContext.buildConfig` is getter and we only test whether it throws // and don't actually need to use its value // eslint-disable-next-line no-unused-expressions ctx.buildConfig } catch { ctx.failBuild( - 'Your publish directory does not contain expected Next.js build output, please check your build settings', + 'Your publish directory does not contain expected Next.js build output. Please check your build settings', ) } - if ( - (ctx.buildConfig.output === 'standalone' || ctx.buildConfig.output === undefined) && - !existsSync(ctx.standaloneRootDir) - ) { - ctx.failBuild( - `Your publish directory does not contain expected Next.js build output, please make sure you are using Next.js version (${SUPPORTED_NEXT_VERSIONS})`, - ) + if (ctx.buildConfig.output === 'standalone' || ctx.buildConfig.output === undefined) { + if (!existsSync(join(ctx.publishDir, 'BUILD_ID'))) { + ctx.failBuild( + 'Your publish directory does not contain expected Next.js build output. Please check your build settings', + ) + } + if (!existsSync(ctx.standaloneRootDir)) { + ctx.failBuild( + `Your publish directory does not contain expected Next.js build output. Please make sure you are using Next.js version (${SUPPORTED_NEXT_VERSIONS})`, + ) + } } - if (ctx.buildConfig.output === 'export' && !existsSync(ctx.resolveFromSiteDir('out'))) { - ctx.failBuild( - `Your export directory was not found at: ${ctx.resolveFromSiteDir( - 'out', - )}, please check your build settings`, - ) + if (ctx.buildConfig.output === 'export') { + if (!ctx.exportDetail?.success) { + ctx.failBuild(`Your export failed to build. Please check your build settings`) + } + if (!existsSync(ctx.exportDetail?.outDirectory)) { + ctx.failBuild( + `Your export directory was not found at: ${ctx.exportDetail?.outDirectory}. Please check your build settings`, + ) + } } } diff --git a/tests/e2e/export.test.ts b/tests/e2e/export.test.ts new file mode 100644 index 0000000000..8c56f288fe --- /dev/null +++ b/tests/e2e/export.test.ts @@ -0,0 +1,54 @@ +import { expect, type Locator } from '@playwright/test' +import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs' +import { test } from '../utils/playwright-helpers.js' + +const expectImageWasLoaded = async (locator: Locator) => { + expect(await locator.evaluate((img: HTMLImageElement) => img.naturalHeight)).toBeGreaterThan(0) +} +test('Renders the Home page correctly with output export', async ({ page, outputExport }) => { + const response = await page.goto(outputExport.url) + const headers = response?.headers() || {} + + await expect(page).toHaveTitle('Simple Next App') + + expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') + + const h1 = page.locator('h1') + await expect(h1).toHaveText('Home') + + await expectImageWasLoaded(page.locator('img')) +}) + +test('Renders the Home page correctly with output export and publish set to out', async ({ + page, + ouputExportPublishOut, +}) => { + const response = await page.goto(ouputExportPublishOut.url) + const headers = response?.headers() || {} + + await expect(page).toHaveTitle('Simple Next App') + + expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') + + const h1 = page.locator('h1') + await expect(h1).toHaveText('Home') + + await expectImageWasLoaded(page.locator('img')) +}) + +test('Renders the Home page correctly with output export and custom dist dir', async ({ + page, + outputExportCustomDist, +}) => { + const response = await page.goto(outputExportCustomDist.url) + const headers = response?.headers() || {} + + await expect(page).toHaveTitle('Simple Next App') + + expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') + + const h1 = page.locator('h1') + await expect(h1).toHaveText('Home') + + await expectImageWasLoaded(page.locator('img')) +}) diff --git a/tests/e2e/simple-app.test.ts b/tests/e2e/simple-app.test.ts index c07d39c43d..d205f8db57 100644 --- a/tests/e2e/simple-app.test.ts +++ b/tests/e2e/simple-app.test.ts @@ -25,20 +25,6 @@ test('Renders the Home page correctly', async ({ page, simple }) => { expect(body).toBe('{"words":"hello world"}') }) -test('Renders the Home page correctly with output export', async ({ page, outputExport }) => { - const response = await page.goto(outputExport.url) - const headers = response?.headers() || {} - - await expect(page).toHaveTitle('Simple Next App') - - expect(headers['cache-status']).toBe('"Netlify Edge"; fwd=miss') - - const h1 = page.locator('h1') - await expect(h1).toHaveText('Home') - - await expectImageWasLoaded(page.locator('img')) -}) - test('Renders the Home page correctly with distDir', async ({ page, distDir }) => { await page.goto(distDir.url) diff --git a/tests/fixtures/output-export-custom-dist/.gitignore b/tests/fixtures/output-export-custom-dist/.gitignore new file mode 100644 index 0000000000..829cfcaaef --- /dev/null +++ b/tests/fixtures/output-export-custom-dist/.gitignore @@ -0,0 +1 @@ +custom-dist \ No newline at end of file diff --git a/tests/fixtures/output-export-custom-dist/app/layout.js b/tests/fixtures/output-export-custom-dist/app/layout.js new file mode 100644 index 0000000000..6565e7bafd --- /dev/null +++ b/tests/fixtures/output-export-custom-dist/app/layout.js @@ -0,0 +1,12 @@ +export const metadata = { + title: 'Simple Next App', + description: 'Description for Simple Next App', +} + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} diff --git a/tests/fixtures/output-export-custom-dist/app/page.js b/tests/fixtures/output-export-custom-dist/app/page.js new file mode 100644 index 0000000000..17b1ff6c6b --- /dev/null +++ b/tests/fixtures/output-export-custom-dist/app/page.js @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

Home

+ a cute squirrel +
+ ) +} diff --git a/tests/fixtures/output-export-custom-dist/next.config.js b/tests/fixtures/output-export-custom-dist/next.config.js new file mode 100644 index 0000000000..84a4fdcbcd --- /dev/null +++ b/tests/fixtures/output-export-custom-dist/next.config.js @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'export', + distDir: 'custom-dist', + eslint: { + ignoreDuringBuilds: true, + }, +} + +module.exports = nextConfig diff --git a/tests/fixtures/output-export-custom-dist/package.json b/tests/fixtures/output-export-custom-dist/package.json new file mode 100644 index 0000000000..e5f70c4185 --- /dev/null +++ b/tests/fixtures/output-export-custom-dist/package.json @@ -0,0 +1,15 @@ +{ + "name": "output-export", + "version": "0.1.0", + "private": true, + "scripts": { + "postinstall": "next build", + "dev": "next dev", + "build": "next build" + }, + "dependencies": { + "next": "latest", + "react": "18.2.0", + "react-dom": "18.2.0" + } +} diff --git a/tests/fixtures/output-export-custom-dist/public/next.svg b/tests/fixtures/output-export-custom-dist/public/next.svg new file mode 100644 index 0000000000..5174b28c56 --- /dev/null +++ b/tests/fixtures/output-export-custom-dist/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/output-export-custom-dist/public/squirrel.jpg b/tests/fixtures/output-export-custom-dist/public/squirrel.jpg new file mode 100644 index 0000000000..520ca1b5fc Binary files /dev/null and b/tests/fixtures/output-export-custom-dist/public/squirrel.jpg differ diff --git a/tests/integration/simple-app.test.ts b/tests/integration/simple-app.test.ts index 60d541a140..f03e589910 100644 --- a/tests/integration/simple-app.test.ts +++ b/tests/integration/simple-app.test.ts @@ -95,14 +95,14 @@ describe('verification', () => { test("Should warn if publish dir doesn't exist", async (ctx) => { await createFixture('simple', ctx) expect(() => runPlugin(ctx, { PUBLISH_DIR: 'no-such-directory' })).rejects.toThrowError( - /Your publish directory was not found at: \S+no-such-directory, please check your build settings/, + /Your publish directory was not found at: \S+no-such-directory. Please check your build settings/, ) }) test('Should warn if publish dir is root', async (ctx) => { await createFixture('simple', ctx) expect(() => runPlugin(ctx, { PUBLISH_DIR: '.' })).rejects.toThrowError( - 'Your publish directory cannot be the same as the base directory of your site, please check your build settings', + 'Your publish directory cannot be the same as the base directory of your site. Please check your build settings', ) }) @@ -111,16 +111,25 @@ describe('verification', () => { expect(() => runPlugin(ctx, { PUBLISH_DIR: 'app/.', PACKAGE_PATH: 'app' }), ).rejects.toThrowError( - 'Your publish directory cannot be the same as the base directory of your site, please check your build settings', + 'Your publish directory cannot be the same as the base directory of your site. Please check your build settings', ) }) test('Should warn if publish dir is not set to Next.js output directory', async (ctx) => { await createFixture('simple', ctx) expect(() => runPlugin(ctx, { PUBLISH_DIR: 'public' })).rejects.toThrowError( - 'Your publish directory does not contain expected Next.js build output, please check your build settings', + 'Your publish directory does not contain expected Next.js build output. Please check your build settings', ) }) + test('Should not warn if using "out" as publish dir when output is "export"', async (ctx) => { + await createFixture('output-export', ctx) + await expect(runPlugin(ctx, { PUBLISH_DIR: 'out' })).resolves.not.toThrow() + }) + + test('Should not throw when using custom distDir and output is "export', async (ctx) => { + await createFixture('output-export-custom-dist', ctx) + await expect(runPlugin(ctx, { PUBLISH_DIR: 'custom-dist' })).resolves.not.toThrow() + }) }) test('Should add cache-tags to prerendered app pages', async (ctx) => { diff --git a/tests/smoke/deploy.test.ts b/tests/smoke/deploy.test.ts index 199ae75c1b..331470784c 100644 --- a/tests/smoke/deploy.test.ts +++ b/tests/smoke/deploy.test.ts @@ -56,7 +56,7 @@ describe('version check', () => { async () => { // we are not able to get far enough to extract concrete next version, so this error message lack used Next.js version await expect(selfCleaningFixtureFactories.next12_0_3()).rejects.toThrow( - /Your publish directory does not contain expected Next.js build output, please make sure you are using Next.js version \(>=13.5.0\)/, + /Your publish directory does not contain expected Next.js build output. Please make sure you are using Next.js version \(>=13.5.0\)/, ) }, ) diff --git a/tests/utils/create-e2e-fixture.ts b/tests/utils/create-e2e-fixture.ts index 85c324c94c..92d775b971 100644 --- a/tests/utils/create-e2e-fixture.ts +++ b/tests/utils/create-e2e-fixture.ts @@ -273,6 +273,14 @@ async function cleanup(dest: string, deployId?: string): Promise { export const fixtureFactories = { simple: () => createE2EFixture('simple'), outputExport: () => createE2EFixture('output-export'), + ouputExportPublishOut: () => + createE2EFixture('output-export', { + publishDirectory: 'out', + }), + outputExportCustomDist: () => + createE2EFixture('output-export-custom-dist', { + publishDirectory: 'custom-dist', + }), distDir: () => createE2EFixture('dist-dir', { publishDirectory: 'cool/output',