fix(nextjs): produce correct next.config.js file for production server (#15824)

This commit is contained in:
Jack Hsu 2023-03-22 14:03:09 -04:00 committed by GitHub
parent 5e5a3f7487
commit c7e49c564a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 213 additions and 147 deletions

View File

@ -5,6 +5,7 @@ import {
cleanupProject,
getPackageManagerCommand,
isNotWindows,
killPort,
killPorts,
newProject,
packageManagerLockFile,
@ -20,25 +21,22 @@ import {
} from '@nrwl/e2e/utils';
import * as http from 'http';
import { checkApp } from './utils';
import { removeSync } from 'fs-extra';
describe('Next.js Applications', () => {
let proj: string;
let originalEnv: string;
let packageManager;
beforeAll(() => {
beforeEach(() => {
proj = newProject();
packageManager = detectPackageManager(tmpProjPath());
});
afterAll(() => cleanupProject());
beforeEach(() => {
originalEnv = process.env.NODE_ENV;
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
cleanupProject();
});
it('should generate app + libs', async () => {
@ -169,6 +167,22 @@ describe('Next.js Applications', () => {
`dist/apps/${appName}/public/a/b.txt`,
`dist/apps/${appName}/public/shared/ui/hello.txt`
);
// Check that the output is self-contained (i.e. can run with its own package.json + node_modules)
const distPath = joinPathFragments(tmpProjPath(), 'dist/apps', appName);
const port = 3000;
const pmc = getPackageManagerCommand();
runCommand(`${pmc.install}`, {
cwd: distPath,
});
runCLI(
`generate @nrwl/workspace:run-commands serve-prod --project ${appName} --cwd=dist/apps/${appName} --command="npx next start --port=${port}"`
);
await runCommandUntil(`run ${appName}:serve-prod`, (output) => {
return output.includes(`localhost:${port}`);
});
await killPort(port);
}, 300_000);
it('should build and install pruned lock file', () => {
@ -399,17 +413,19 @@ describe('Next.js Applications', () => {
}, 300_000);
});
function getData(port: number, path = ''): Promise<any> {
return new Promise((resolve) => {
http.get(`http://localhost:${port}${path}`, (res) => {
expect(res.statusCode).toEqual(200);
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.once('end', () => {
resolve(data);
});
});
function getData(port, path = ''): Promise<any> {
return new Promise((resolve, reject) => {
http
.get(`http://localhost:${port}${path}`, (res) => {
expect(res.statusCode).toEqual(200);
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.once('end', () => {
resolve(data);
});
})
.on('error', (err) => reject(err));
});
}

View File

@ -35,22 +35,21 @@
},
"dependencies": {
"@babel/plugin-proposal-decorators": "^7.14.5",
"@nrwl/cypress": "file:../cypress",
"@nrwl/devkit": "file:../devkit",
"@nrwl/jest": "file:../jest",
"@nrwl/js": "file:../js",
"@nrwl/linter": "file:../linter",
"@nrwl/react": "file:../react",
"@nrwl/webpack": "file:../webpack",
"@nrwl/workspace": "file:../workspace",
"@svgr/webpack": "^6.1.2",
"chalk": "^4.1.0",
"copy-webpack-plugin": "^10.2.4",
"dotenv": "~10.0.0",
"fs-extra": "^11.1.0",
"ignore": "^5.0.4",
"semver": "7.3.4",
"ts-node": "10.9.1",
"tsconfig-paths": "^4.1.2",
"tsconfig-paths-webpack-plugin": "4.0.0",
"url-loader": "^4.1.1",
"webpack-merge": "^5.8.0"
},

View File

@ -13,8 +13,9 @@ import {
DependentBuildableProjectNode,
} from '@nrwl/js/src/utils/buildable-libs-utils';
import type { NextConfig } from 'next';
import { PHASE_PRODUCTION_SERVER } from 'next/constants';
import path = require('path');
import * as path from 'path';
import { createWebpackConfig } from '../src/utils/config';
import { NextBuildBuilderOptions } from '../src/utils/types';
export interface WithNxOptions extends NextConfig {
@ -113,62 +114,69 @@ function getNxContext(
export function withNx(
_nextConfig = {} as WithNxOptions,
context: WithNxContext = getWithNxContext()
): () => Promise<NextConfig> {
return async () => {
let dependencies: DependentBuildableProjectNode[] = [];
): (phase: string) => Promise<NextConfig> {
return async (phase: string) => {
if (phase === PHASE_PRODUCTION_SERVER) {
// If we are running an already built production server, just return the configuration.
const { nx, ...validNextConfig } = _nextConfig;
return { distDir: '.next', ...validNextConfig };
} else {
// Otherwise, add in webpack and eslint configuration for build or test.
let dependencies: DependentBuildableProjectNode[] = [];
const graph = await createProjectGraphAsync();
const graph = await createProjectGraphAsync();
const originalTarget = {
project: process.env.NX_TASK_TARGET_PROJECT,
target: process.env.NX_TASK_TARGET_TARGET,
configuration: process.env.NX_TASK_TARGET_CONFIGURATION,
};
const originalTarget = {
project: process.env.NX_TASK_TARGET_PROJECT,
target: process.env.NX_TASK_TARGET_TARGET,
configuration: process.env.NX_TASK_TARGET_CONFIGURATION,
};
const {
node: projectNode,
options,
projectName: project,
targetName,
configurationName,
} = getNxContext(graph, originalTarget);
const projectDirectory = projectNode.data.root;
if (options.buildLibsFromSource === false && targetName) {
const result = calculateProjectDependencies(
graph,
workspaceRoot,
project,
const {
node: projectNode,
options,
projectName: project,
targetName,
configurationName
);
dependencies = result.dependencies;
configurationName,
} = getNxContext(graph, originalTarget);
const projectDirectory = projectNode.data.root;
if (options.buildLibsFromSource === false && targetName) {
const result = calculateProjectDependencies(
graph,
workspaceRoot,
project,
targetName,
configurationName
);
dependencies = result.dependencies;
}
// Get next config
const nextConfig = getNextConfig(_nextConfig, context);
const outputDir = `${offsetFromRoot(projectDirectory)}${
options.outputPath
}`;
nextConfig.distDir =
nextConfig.distDir && nextConfig.distDir !== '.next'
? joinPathFragments(outputDir, nextConfig.distDir)
: joinPathFragments(outputDir, '.next');
const userWebpackConfig = nextConfig.webpack;
nextConfig.webpack = (a, b) =>
createWebpackConfig(
workspaceRoot,
options.root,
options.fileReplacements,
options.assets,
dependencies,
path.join(workspaceRoot, context.libsDir)
)(userWebpackConfig ? userWebpackConfig(a, b) : a, b);
return nextConfig;
}
// Get next config
const nextConfig = getNextConfig(_nextConfig, context);
const outputDir = `${offsetFromRoot(projectDirectory)}${
options.outputPath
}`;
nextConfig.distDir =
nextConfig.distDir && nextConfig.distDir !== '.next'
? joinPathFragments(outputDir, nextConfig.distDir)
: joinPathFragments(outputDir, '.next');
const userWebpackConfig = nextConfig.webpack;
nextConfig.webpack = (a, b) =>
createWebpackConfig(
workspaceRoot,
options.root,
options.fileReplacements,
options.assets,
dependencies,
path.join(workspaceRoot, context.libsDir)
)(userWebpackConfig ? userWebpackConfig(a, b) : a, b);
return nextConfig;
};
}

View File

@ -1,17 +1,9 @@
import type { ExecutorContext } from '@nrwl/devkit';
import {
applyChangesToString,
ChangeType,
workspaceLayout,
workspaceRoot,
stripIndents,
} from '@nrwl/devkit';
import * as ts from 'typescript';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { ExecutorContext } from '@nrwl/devkit';
import { copyFileSync, existsSync } from 'fs';
import { join } from 'path';
import type { NextBuildBuilderOptions } from '../../../utils/types';
import { findNodes } from 'nx/src/utils/typescript';
export function createNextConfigFile(
options: NextBuildBuilderOptions,
@ -21,53 +13,7 @@ export function createNextConfigFile(
? join(context.root, options.nextConfig)
: join(context.root, options.root, 'next.config.js');
// Copy config file and our `with-nx.js` file to remove dependency on @nrwl/next for production build.
if (existsSync(nextConfigPath)) {
writeFileSync(join(options.outputPath, 'with-nx.js'), getWithNxContent());
writeFileSync(
join(options.outputPath, 'next.config.js'),
readFileSync(nextConfigPath)
.toString()
.replace('@nrwl/next/plugins/with-nx', './with-nx.js')
);
copyFileSync(nextConfigPath, join(options.outputPath, 'next.config.js'));
}
}
function getWithNxContent() {
const withNxFile = join(__dirname, '../../../../plugins/with-nx.js');
let withNxContent = readFileSync(withNxFile).toString();
const withNxSource = ts.createSourceFile(
withNxFile,
withNxContent,
ts.ScriptTarget.Latest,
true
);
const getWithNxContextDeclaration = findNodes(
withNxSource,
ts.SyntaxKind.FunctionDeclaration
)?.find(
(node: ts.FunctionDeclaration) => node.name?.text === 'getWithNxContext'
);
if (getWithNxContextDeclaration) {
withNxContent = applyChangesToString(withNxContent, [
{
type: ChangeType.Delete,
start: getWithNxContextDeclaration.getStart(withNxSource),
length: getWithNxContextDeclaration.getWidth(withNxSource),
},
{
type: ChangeType.Insert,
index: getWithNxContextDeclaration.getStart(withNxSource),
text: stripIndents`function getWithNxContext() {
return {
workspaceRoot: '${workspaceRoot}',
libsDir: '${workspaceLayout().libsDir}'
}
}`,
},
]);
}
return withNxContent;
}

View File

@ -1,13 +1,17 @@
import { cypressProjectGenerator } from '@nrwl/cypress';
import { Tree } from '@nrwl/devkit';
import { NormalizedSchema } from './normalize-options';
import { ensurePackage, Tree } from '@nrwl/devkit';
import { Linter } from '@nrwl/linter';
import { nxVersion } from '../../../utils/versions';
import { NormalizedSchema } from './normalize-options';
export async function addCypress(host: Tree, options: NormalizedSchema) {
if (options.e2eTestRunner !== 'cypress') {
return () => {};
}
const { cypressProjectGenerator } = ensurePackage<
typeof import('@nrwl/cypress')
>('@nrwl/cypress', nxVersion);
return cypressProjectGenerator(host, {
...options,
linter: Linter.EsLint,

View File

@ -1,5 +1,12 @@
import { joinPathFragments, readJson, Tree, updateJson } from '@nrwl/devkit';
import { jestProjectGenerator } from '@nrwl/jest';
import {
ensurePackage,
joinPathFragments,
readJson,
Tree,
updateJson,
} from '@nrwl/devkit';
import { nxVersion } from '../../../utils/versions';
import { NormalizedSchema } from './normalize-options';
export async function addJest(host: Tree, options: NormalizedSchema) {
@ -7,6 +14,10 @@ export async function addJest(host: Tree, options: NormalizedSchema) {
return () => {};
}
const { jestProjectGenerator } = ensurePackage<typeof import('@nrwl/jest')>(
'@nrwl/jest',
nxVersion
);
const jestTask = await jestProjectGenerator(host, {
...options,
project: options.projectName,

View File

@ -1,13 +1,12 @@
import {
addDependenciesToPackageJson,
convertNxGenerator,
ensurePackage,
GeneratorCallback,
runTasksInSerial,
Tree,
} from '@nrwl/devkit';
import { jestInitGenerator } from '@nrwl/jest';
import { cypressInitGenerator } from '@nrwl/cypress';
import { reactDomVersion, reactVersion } from '@nrwl/react/src/utils/versions';
import reactInitGenerator from '@nrwl/react/src/generators/init/init';
import { initGenerator as jsInitGenerator } from '@nrwl/js';
@ -48,10 +47,17 @@ export async function nextInitGenerator(host: Tree, schema: InitSchema) {
);
if (!schema.unitTestRunner || schema.unitTestRunner === 'jest') {
const { jestInitGenerator } = ensurePackage<typeof import('@nrwl/jest')>(
'@nrwl/jest',
nxVersion
);
const jestTask = await jestInitGenerator(host, schema);
tasks.push(jestTask);
}
if (!schema.e2eTestRunner || schema.e2eTestRunner === 'cypress') {
const { cypressInitGenerator } = ensurePackage<
typeof import('@nrwl/cypress')
>('@nrwl/cypress', nxVersion);
const cypressTask = await cypressInitGenerator(host, {});
tasks.push(cypressTask);
}

View File

@ -2,7 +2,7 @@ import { join, resolve } from 'path';
import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
import { Configuration, RuleSetRule } from 'webpack';
import { FileReplacement } from './types';
import { createCopyPlugin, normalizeAssets } from '@nrwl/webpack';
import { createCopyPlugin } from './create-copy-plugin';
import {
createTmpTsConfig,
DependentBuildableProjectNode,
@ -77,14 +77,7 @@ export function createWebpackConfig(
// Copy (shared) assets to `public` folder during client-side compilation
if (!isServer && Array.isArray(assets) && assets.length > 0) {
config.plugins.push(
createCopyPlugin(
normalizeAssets(assets, workspaceRoot, projectRoot).map((asset) => ({
...asset,
output: join('../public', asset.output),
}))
)
);
config.plugins.push(createCopyPlugin(assets, workspaceRoot, projectRoot));
}
return config;

View File

@ -0,0 +1,83 @@
import * as CopyWebpackPlugin from 'copy-webpack-plugin';
import { normalizePath } from 'nx/src/utils/path';
import { basename, dirname, join, relative, resolve } from 'path';
import { statSync } from 'fs';
interface AssetGlobPattern {
glob: string;
input: string;
output: string;
ignore?: string[];
}
function normalizeAssets(
assets: any[],
root: string,
sourceRoot: string
): AssetGlobPattern[] {
return assets.map((asset) => {
if (typeof asset === 'string') {
const assetPath = normalizePath(asset);
const resolvedAssetPath = resolve(root, assetPath);
const resolvedSourceRoot = resolve(root, sourceRoot);
if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) {
throw new Error(
`The ${resolvedAssetPath} asset path must start with the project source root: ${sourceRoot}`
);
}
const isDirectory = statSync(resolvedAssetPath).isDirectory();
const input = isDirectory
? resolvedAssetPath
: dirname(resolvedAssetPath);
const output = relative(resolvedSourceRoot, resolve(root, input));
const glob = isDirectory ? '**/*' : basename(resolvedAssetPath);
return {
input,
output,
glob,
};
} else {
if (asset.output.startsWith('..')) {
throw new Error(
'An asset cannot be written to a location outside of the output path.'
);
}
const assetPath = normalizePath(asset.input);
const resolvedAssetPath = resolve(root, assetPath);
return {
...asset,
input: resolvedAssetPath,
// Now we remove starting slash to make Webpack place it from the output root.
output: asset.output.replace(/^\//, ''),
};
}
});
}
export function createCopyPlugin(
assets: any[],
root: string,
sourceRoot: string
) {
return new CopyWebpackPlugin({
patterns: normalizeAssets(assets, root, sourceRoot).map((asset) => {
return {
context: asset.input,
to: join('../public', asset.output),
from: asset.glob,
globOptions: {
ignore: [
'.gitkeep',
'**/.DS_Store',
'**/Thumbs.db',
...(asset.ignore ?? []),
],
dot: true,
},
};
}),
});
}