fix(nextjs): support relative imports in next.config.js (#17127)

This commit is contained in:
Jack Hsu 2023-05-23 14:34:12 -04:00 committed by GitHub
parent 9e3596abe5
commit b6361738a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 277 additions and 55 deletions

View File

@ -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');

View File

@ -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"/);
});
});

View File

@ -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<string>();
const collected = new Set<string>();
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(
', '
)}`
);
}
});
}

View File

@ -0,0 +1 @@
module.exports = require('./b');

View File

@ -0,0 +1,3 @@
require('./a'); // Circular dependency should be handled
require('../nested-c'); // one level up
module.exports = {};

View File

@ -0,0 +1 @@
require('./nested/a');

View File

@ -0,0 +1 @@
export const a = [];

View File

@ -0,0 +1,2 @@
import './a.mjs';
export default {};

View File

@ -0,0 +1 @@
export const bar = 'bar';

View File

@ -0,0 +1 @@
module.exports = 'faz';

View File

@ -0,0 +1 @@
module.exports = 'foo';