From b6361738a409bf1bfca153d564243f79da2ceef3 Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Tue, 23 May 2023 14:34:12 -0400 Subject: [PATCH] fix(nextjs): support relative imports in next.config.js (#17127) --- e2e/next/src/next.test.ts | 32 +-- .../build/lib/create-next-config-file.spec.ts | 81 ++++++- .../build/lib/create-next-config-file.ts | 208 +++++++++++++++--- .../lib/test-fixtures/config-js/nested-c.cjs | 0 .../lib/test-fixtures/config-js/nested/a.cjs | 1 + .../lib/test-fixtures/config-js/nested/b.cjs | 3 + .../test-fixtures/config-js/next.config.cjs | 1 + .../build/lib/test-fixtures/config-mjs/a.mjs | 1 + .../test-fixtures/config-mjs/next.config.mjs | 2 + .../lib/test-fixtures/ensure-exts/bar.mjs | 1 + .../lib/test-fixtures/ensure-exts/faz.cjs | 1 + .../lib/test-fixtures/ensure-exts/foo.cjs | 1 + .../test-fixtures/ensure-exts/nested/baz.cjs | 0 13 files changed, 277 insertions(+), 55 deletions(-) create mode 100644 packages/next/src/executors/build/lib/test-fixtures/config-js/nested-c.cjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/config-js/nested/a.cjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/config-js/nested/b.cjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/config-js/next.config.cjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/config-mjs/a.mjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/config-mjs/next.config.mjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/ensure-exts/bar.mjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/ensure-exts/faz.cjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/ensure-exts/foo.cjs create mode 100644 packages/next/src/executors/build/lib/test-fixtures/ensure-exts/nested/baz.cjs diff --git a/e2e/next/src/next.test.ts b/e2e/next/src/next.test.ts index 6a1de7893d..1c709fe4a2 100644 --- a/e2e/next/src/next.test.ts +++ b/e2e/next/src/next.test.ts @@ -443,26 +443,26 @@ describe('Next.js Applications', () => { }); }, 300_000); - it('should create a generate a next.js app with app layout enabled', async () => { + it('should copy relative modules needed by the next.config.js file', async () => { const appName = uniq('app'); - runCLI( - `generate @nx/next:app ${appName} --style=css --appDir --no-interactive` + runCLI(`generate @nx/next:app ${appName} --style=css --no-interactive`); + + updateFile(`apps/${appName}/redirects.js`, 'module.exports = [];'); + updateFile( + `apps/${appName}/nested/headers.js`, + `module.exports = require('./headers-2');` ); - - checkFilesExist(`apps/${appName}/app/api/hello/route.ts`); - checkFilesExist(`apps/${appName}/app/page.tsx`); - checkFilesExist(`apps/${appName}/app/layout.tsx`); - checkFilesExist(`apps/${appName}/app/global.css`); - checkFilesExist(`apps/${appName}/app/page.module.css`); - - await checkApp(appName, { - checkUnitTest: false, - checkLint: false, - checkE2E: false, - checkExport: false, + updateFile(`apps/${appName}/nested/headers-2.js`, 'module.exports = [];'); + updateFile(`apps/${appName}/next.config.js`, (content) => { + return `const redirects = require('./redirects');\nconst headers = require('./nested/headers.js');\n${content}`; }); - }, 300_000); + + runCLI(`build ${appName}`); + checkFilesExist(`dist/apps/${appName}/redirects.js`); + checkFilesExist(`dist/apps/${appName}/nested/headers.js`); + checkFilesExist(`dist/apps/${appName}/nested/headers-2.js`); + }, 120_000); it('should support --turbo to enable Turbopack', async () => { const appName = uniq('app'); diff --git a/packages/next/src/executors/build/lib/create-next-config-file.spec.ts b/packages/next/src/executors/build/lib/create-next-config-file.spec.ts index 8863565c3c..3f6b493547 100644 --- a/packages/next/src/executors/build/lib/create-next-config-file.spec.ts +++ b/packages/next/src/executors/build/lib/create-next-config-file.spec.ts @@ -1,11 +1,18 @@ -import { getWithNxContent } from './create-next-config-file'; +import { + ensureFileExtensions, + findNextConfigPath, + getRelativeFilesToCopy, + getRelativeImports, + getWithNxContent, +} from './create-next-config-file'; import { stripIndents } from '@nx/devkit'; +import { join } from 'path'; describe('Next.js config: getWithNxContent', () => { it('should swap distDir and getWithNxContext with static values', () => { const result = getWithNxContent({ - withNxFile: `with-nx.js`, - withNxContent: stripIndents` + file: `with-nx.js`, + content: stripIndents` // SHOULD BE LEFT INTACT const constants = require("next/constants"); @@ -56,4 +63,72 @@ describe('Next.js config: getWithNxContent', () => { expect(result).toContain(`libsDir: ''`); expect(result).not.toContain(`libsDir: workspaceLayout.libsDir()`); }); + + it('should return relative module paths used in next.config.js when calling getRelativeFilesToCopy', () => { + const modulePaths = getRelativeFilesToCopy( + findNextConfigPath(join(__dirname, 'test-fixtures/config-js')), + join(__dirname, 'test-fixtures/config-js') + ); + + expect(modulePaths).toEqual([ + 'nested/a.cjs', + 'nested/b.cjs', + 'nested-c.cjs', + ]); + }); + + it('should return relative module paths used in next.config.mjs when calling getRelativeFilesToCopy', () => { + const modulePaths = getRelativeFilesToCopy( + findNextConfigPath(join(__dirname, 'test-fixtures/config-mjs')), + join(__dirname, 'test-fixtures/config-mjs') + ); + + expect(modulePaths).toEqual(['a.mjs']); + }); + + it('should return relative requires when calling getRelativeImports', () => { + const result = getRelativeImports({ + file: 'next.config.js', + content: stripIndents` + const w = require('@scoped/w'); + const x = require('x'); + const y = require("./y"); + const z = require('./nested/z'); + `, + }); + + expect(result).toEqual(['./y', './nested/z']); + }); + + it('should return relative imports when calling getRelativeImports', () => { + const result = getRelativeImports({ + file: 'next.config.js', + content: stripIndents` + import { w } from '@scoped/w'; + import { x } from 'x'; + import { y } from "./y"; + import { z } from './nested/z' + `, + }); + + expect(result).toEqual(['./y', './nested/z']); + }); + + it('should return files with their extensions when calling ensureFileExtensions', () => { + const result = ensureFileExtensions( + ['bar', 'foo', 'faz', 'nested/baz.cjs'], + join(__dirname, 'test-fixtures/ensure-exts') + ); + + expect(result).toEqual(['bar.mjs', 'foo.cjs', 'faz.cjs', 'nested/baz.cjs']); + }); + + it('should throw an error if a path cannot be found when calling ensureFileExtensions', () => { + expect(() => + ensureFileExtensions( + ['not-found'], + join(__dirname, 'test-fixtures/ensure-exts') + ) + ).toThrow(/Cannot find file "not-found"/); + }); }); diff --git a/packages/next/src/executors/build/lib/create-next-config-file.ts b/packages/next/src/executors/build/lib/create-next-config-file.ts index 561126e3d9..4863952309 100644 --- a/packages/next/src/executors/build/lib/create-next-config-file.ts +++ b/packages/next/src/executors/build/lib/create-next-config-file.ts @@ -9,67 +9,89 @@ import { import * as ts from 'typescript'; import { copyFileSync, + ensureDirSync, existsSync, mkdirSync, readFileSync, writeFileSync, -} from 'fs'; -import { join } from 'path'; +} from 'fs-extra'; +import { dirname, extname, join, relative } from 'path'; +import { findNodes } from 'nx/src/utils/typescript'; import type { NextBuildBuilderOptions } from '../../../utils/types'; -import { findNodes } from 'nx/src/utils/typescript'; export function createNextConfigFile( options: NextBuildBuilderOptions, context: ExecutorContext ) { - const nextConfigPath = options.nextConfig - ? join(context.root, options.nextConfig) - : join(context.root, options.root, 'next.config.js'); + const configRelativeToProjectRoot = findNextConfigPath( + options.root, + // If user passed a config then it is relative to the workspace root, need to normalize it to be relative to the project root. + options.nextConfig ? relative(options.root, options.nextConfig) : undefined + ); + const configAbsolutePath = join(options.root, configRelativeToProjectRoot); + + if (!existsSync(configAbsolutePath)) { + throw new Error('next.config.js not found'); + } // Copy config file and our `.nx-helpers` folder to remove dependency on @nrwl/next for production build. - if (existsSync(nextConfigPath)) { - const helpersPath = join(options.outputPath, '.nx-helpers'); - mkdirSync(helpersPath, { recursive: true }); - copyFileSync( - join(__dirname, '../../../utils/compose-plugins.js'), - join(helpersPath, 'compose-plugins.js') - ); - writeFileSync(join(helpersPath, 'with-nx.js'), getWithNxContent()); - writeFileSync( - join(helpersPath, 'compiled.js'), - ` + const helpersPath = join(options.outputPath, '.nx-helpers'); + mkdirSync(helpersPath, { recursive: true }); + copyFileSync( + join(__dirname, '../../../utils/compose-plugins.js'), + join(helpersPath, 'compose-plugins.js') + ); + writeFileSync(join(helpersPath, 'with-nx.js'), getWithNxContent()); + writeFileSync( + join(helpersPath, 'compiled.js'), + ` const withNx = require('./with-nx'); module.exports = withNx; module.exports.withNx = withNx; module.exports.composePlugins = require('./compose-plugins').composePlugins; ` - ); - writeFileSync( - join(options.outputPath, 'next.config.js'), - readFileSync(nextConfigPath) - .toString() - .replace(/["']@nx\/next["']/, `'./.nx-helpers/compiled.js'`) - // TODO(v17): Remove this once users have all migrated to new @nx scope and import from '@nx/next' not the deep import paths. - .replace('@nx/next/plugins/with-nx', './.nx-helpers/compiled.js') - .replace('@nrwl/next/plugins/with-nx', './.nx-helpers/compiled.js') + ); + writeFileSync( + join(options.outputPath, configRelativeToProjectRoot), + readFileSync(configAbsolutePath) + .toString() + .replace(/["']@nx\/next["']/, `'./.nx-helpers/compiled.js'`) + // TODO(v17): Remove this once users have all migrated to new @nx scope and import from '@nx/next' not the deep import paths. + .replace('@nx/next/plugins/with-nx', './.nx-helpers/compiled.js') + .replace('@nrwl/next/plugins/with-nx', './.nx-helpers/compiled.js') + ); + + // Find all relative imports needed by next.config.js and copy them to the dist folder. + const moduleFilesToCopy = getRelativeFilesToCopy( + configRelativeToProjectRoot, + options.root + ); + for (const moduleFile of moduleFilesToCopy) { + ensureDirSync(dirname(join(context.root, options.outputPath, moduleFile))); + copyFileSync( + join(context.root, options.root, moduleFile), + join(context.root, options.outputPath, moduleFile) ); } } -function readSource() { - const withNxFile = join(__dirname, '../../../../plugins/with-nx.js'); - const withNxContent = readFileSync(withNxFile).toString(); + +function readSource(getFile: () => string): { file: string; content: string } { return { - withNxFile, - withNxContent, + file: getFile(), + content: readFileSync(getFile()).toString(), }; } // Exported for testing -export function getWithNxContent({ withNxFile, withNxContent } = readSource()) { +export function getWithNxContent( + { file, content } = readSource(() => + join(__dirname, '../../../../plugins/with-nx.js') + ) +) { const withNxSource = ts.createSourceFile( - withNxFile, - withNxContent, + file, + content, ts.ScriptTarget.Latest, true ); @@ -80,7 +102,7 @@ export function getWithNxContent({ withNxFile, withNxContent } = readSource()) { (node: ts.FunctionDeclaration) => node.name?.text === 'getWithNxContext' ); if (getWithNxContextDeclaration) { - withNxContent = applyChangesToString(withNxContent, [ + content = applyChangesToString(content, [ { type: ChangeType.Delete, start: getWithNxContextDeclaration.getStart(withNxSource), @@ -99,5 +121,119 @@ export function getWithNxContent({ withNxFile, withNxContent } = readSource()) { ]); } - return withNxContent; + return content; +} + +export function findNextConfigPath( + dirname: string, + userDefinedConfigPath?: string +): string { + if (userDefinedConfigPath) { + const file = userDefinedConfigPath; + if (existsSync(file)) return file; + throw new Error( + `Cannot find the Next.js config file: ${userDefinedConfigPath}. Is the path correct in project.json?` + ); + } + + const candidates = ['next.config.js', 'next.config.cjs', 'next.config.mjs']; + for (const candidate of candidates) { + if (existsSync(join(dirname, candidate))) return candidate; + } + throw new Error( + `Cannot find any of the following files in your project: ${candidates.join( + ', ' + )}. Is this a Next.js project?` + ); +} + +// Exported for testing +export function getRelativeFilesToCopy( + fileName: string, + cwd: string +): string[] { + const seen = new Set(); + const collected = new Set(); + + function doCollect(currFile: string): void { + // Prevent circular dependencies from causing infinite loop + if (seen.has(currFile)) return; + seen.add(currFile); + + const absoluteFilePath = join(cwd, currFile); + const content = readFileSync(absoluteFilePath).toString(); + const files = getRelativeImports({ file: currFile, content }); + const modules = ensureFileExtensions(files, dirname(absoluteFilePath)); + + const relativeDirPath = dirname(currFile); + + for (const moduleName of modules) { + const relativeModulePath = join(relativeDirPath, moduleName); + collected.add(relativeModulePath); + doCollect(relativeModulePath); + } + } + + doCollect(fileName); + + return Array.from(collected); +} + +// Exported for testing +export function getRelativeImports({ + file, + content, +}: { + file: string; + content: string; +}): string[] { + const source = ts.createSourceFile( + file, + content, + ts.ScriptTarget.Latest, + true + ); + const callExpressionsOrImportDeclarations = findNodes(source, [ + ts.SyntaxKind.CallExpression, + ts.SyntaxKind.ImportDeclaration, + ]) as (ts.CallExpression | ts.ImportDeclaration)[]; + const modulePaths: string[] = []; + for (const node of callExpressionsOrImportDeclarations) { + if (node.kind === ts.SyntaxKind.ImportDeclaration) { + modulePaths.push(stripOuterQuotes(node.moduleSpecifier.getText(source))); + } else { + if (node.expression.getText(source) === 'require') { + modulePaths.push(stripOuterQuotes(node.arguments[0].getText(source))); + } + } + } + return modulePaths.filter((path) => path.startsWith('.')); +} + +function stripOuterQuotes(str: string): string { + return str.match(/^["'](.*)["']/)?.[1] ?? str; +} + +// Exported for testing +export function ensureFileExtensions( + files: string[], + absoluteDir: string +): string[] { + const extensions = ['.js', '.cjs', '.mjs']; + return files.map((file) => { + if (extname(file)) return file; + + const ext = extensions.find((ext) => + existsSync(join(absoluteDir, file + ext)) + ); + if (ext) { + return file + ext; + } else { + throw new Error( + `Cannot find file "${file}" with any of the following extensions: ${extensions.join( + ', ' + )}` + ); + } + }); } diff --git a/packages/next/src/executors/build/lib/test-fixtures/config-js/nested-c.cjs b/packages/next/src/executors/build/lib/test-fixtures/config-js/nested-c.cjs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/next/src/executors/build/lib/test-fixtures/config-js/nested/a.cjs b/packages/next/src/executors/build/lib/test-fixtures/config-js/nested/a.cjs new file mode 100644 index 0000000000..0952f7666a --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/config-js/nested/a.cjs @@ -0,0 +1 @@ +module.exports = require('./b'); diff --git a/packages/next/src/executors/build/lib/test-fixtures/config-js/nested/b.cjs b/packages/next/src/executors/build/lib/test-fixtures/config-js/nested/b.cjs new file mode 100644 index 0000000000..aecb13a294 --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/config-js/nested/b.cjs @@ -0,0 +1,3 @@ +require('./a'); // Circular dependency should be handled +require('../nested-c'); // one level up +module.exports = {}; diff --git a/packages/next/src/executors/build/lib/test-fixtures/config-js/next.config.cjs b/packages/next/src/executors/build/lib/test-fixtures/config-js/next.config.cjs new file mode 100644 index 0000000000..6e17d7633e --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/config-js/next.config.cjs @@ -0,0 +1 @@ +require('./nested/a'); diff --git a/packages/next/src/executors/build/lib/test-fixtures/config-mjs/a.mjs b/packages/next/src/executors/build/lib/test-fixtures/config-mjs/a.mjs new file mode 100644 index 0000000000..a0af6e3cc0 --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/config-mjs/a.mjs @@ -0,0 +1 @@ +export const a = []; diff --git a/packages/next/src/executors/build/lib/test-fixtures/config-mjs/next.config.mjs b/packages/next/src/executors/build/lib/test-fixtures/config-mjs/next.config.mjs new file mode 100644 index 0000000000..481baecdbd --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/config-mjs/next.config.mjs @@ -0,0 +1,2 @@ +import './a.mjs'; +export default {}; diff --git a/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/bar.mjs b/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/bar.mjs new file mode 100644 index 0000000000..9f17386851 --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/bar.mjs @@ -0,0 +1 @@ +export const bar = 'bar'; diff --git a/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/faz.cjs b/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/faz.cjs new file mode 100644 index 0000000000..8a40049ae4 --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/faz.cjs @@ -0,0 +1 @@ +module.exports = 'faz'; diff --git a/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/foo.cjs b/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/foo.cjs new file mode 100644 index 0000000000..2651774ae6 --- /dev/null +++ b/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/foo.cjs @@ -0,0 +1 @@ +module.exports = 'foo'; diff --git a/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/nested/baz.cjs b/packages/next/src/executors/build/lib/test-fixtures/ensure-exts/nested/baz.cjs new file mode 100644 index 0000000000..e69de29bb2