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, cleanupProject,
getPackageManagerCommand, getPackageManagerCommand,
isNotWindows, isNotWindows,
killPort,
killPorts, killPorts,
newProject, newProject,
packageManagerLockFile, packageManagerLockFile,
@ -20,25 +21,22 @@ import {
} from '@nrwl/e2e/utils'; } from '@nrwl/e2e/utils';
import * as http from 'http'; import * as http from 'http';
import { checkApp } from './utils'; import { checkApp } from './utils';
import { removeSync } from 'fs-extra';
describe('Next.js Applications', () => { describe('Next.js Applications', () => {
let proj: string; let proj: string;
let originalEnv: string; let originalEnv: string;
let packageManager; let packageManager;
beforeAll(() => { beforeEach(() => {
proj = newProject(); proj = newProject();
packageManager = detectPackageManager(tmpProjPath()); packageManager = detectPackageManager(tmpProjPath());
});
afterAll(() => cleanupProject());
beforeEach(() => {
originalEnv = process.env.NODE_ENV; originalEnv = process.env.NODE_ENV;
}); });
afterEach(() => { afterEach(() => {
process.env.NODE_ENV = originalEnv; process.env.NODE_ENV = originalEnv;
cleanupProject();
}); });
it('should generate app + libs', async () => { 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/a/b.txt`,
`dist/apps/${appName}/public/shared/ui/hello.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); }, 300_000);
it('should build and install pruned lock file', () => { it('should build and install pruned lock file', () => {
@ -399,9 +413,10 @@ describe('Next.js Applications', () => {
}, 300_000); }, 300_000);
}); });
function getData(port: number, path = ''): Promise<any> { function getData(port, path = ''): Promise<any> {
return new Promise((resolve) => { return new Promise((resolve, reject) => {
http.get(`http://localhost:${port}${path}`, (res) => { http
.get(`http://localhost:${port}${path}`, (res) => {
expect(res.statusCode).toEqual(200); expect(res.statusCode).toEqual(200);
let data = ''; let data = '';
res.on('data', (chunk) => { res.on('data', (chunk) => {
@ -410,6 +425,7 @@ function getData(port: number, path = ''): Promise<any> {
res.once('end', () => { res.once('end', () => {
resolve(data); resolve(data);
}); });
}); })
.on('error', (err) => reject(err));
}); });
} }

View File

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

View File

@ -13,8 +13,9 @@ import {
DependentBuildableProjectNode, DependentBuildableProjectNode,
} from '@nrwl/js/src/utils/buildable-libs-utils'; } from '@nrwl/js/src/utils/buildable-libs-utils';
import type { NextConfig } from 'next'; 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 { createWebpackConfig } from '../src/utils/config';
import { NextBuildBuilderOptions } from '../src/utils/types'; import { NextBuildBuilderOptions } from '../src/utils/types';
export interface WithNxOptions extends NextConfig { export interface WithNxOptions extends NextConfig {
@ -113,8 +114,14 @@ function getNxContext(
export function withNx( export function withNx(
_nextConfig = {} as WithNxOptions, _nextConfig = {} as WithNxOptions,
context: WithNxContext = getWithNxContext() context: WithNxContext = getWithNxContext()
): () => Promise<NextConfig> { ): (phase: string) => Promise<NextConfig> {
return async () => { 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[] = []; let dependencies: DependentBuildableProjectNode[] = [];
const graph = await createProjectGraphAsync(); const graph = await createProjectGraphAsync();
@ -169,6 +176,7 @@ export function withNx(
)(userWebpackConfig ? userWebpackConfig(a, b) : a, b); )(userWebpackConfig ? userWebpackConfig(a, b) : a, b);
return nextConfig; return nextConfig;
}
}; };
} }

View File

@ -1,17 +1,9 @@
import type { ExecutorContext } from '@nrwl/devkit'; import { ExecutorContext } from '@nrwl/devkit';
import {
applyChangesToString, import { copyFileSync, existsSync } from 'fs';
ChangeType,
workspaceLayout,
workspaceRoot,
stripIndents,
} from '@nrwl/devkit';
import * as ts from 'typescript';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import type { NextBuildBuilderOptions } from '../../../utils/types'; import type { NextBuildBuilderOptions } from '../../../utils/types';
import { findNodes } from 'nx/src/utils/typescript';
export function createNextConfigFile( export function createNextConfigFile(
options: NextBuildBuilderOptions, options: NextBuildBuilderOptions,
@ -21,53 +13,7 @@ export function createNextConfigFile(
? join(context.root, options.nextConfig) ? join(context.root, options.nextConfig)
: join(context.root, options.root, 'next.config.js'); : 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)) { if (existsSync(nextConfigPath)) {
writeFileSync(join(options.outputPath, 'with-nx.js'), getWithNxContent()); copyFileSync(nextConfigPath, join(options.outputPath, 'next.config.js'));
writeFileSync(
join(options.outputPath, 'next.config.js'),
readFileSync(nextConfigPath)
.toString()
.replace('@nrwl/next/plugins/with-nx', './with-nx.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 { ensurePackage, Tree } from '@nrwl/devkit';
import { Tree } from '@nrwl/devkit';
import { NormalizedSchema } from './normalize-options';
import { Linter } from '@nrwl/linter'; import { Linter } from '@nrwl/linter';
import { nxVersion } from '../../../utils/versions';
import { NormalizedSchema } from './normalize-options';
export async function addCypress(host: Tree, options: NormalizedSchema) { export async function addCypress(host: Tree, options: NormalizedSchema) {
if (options.e2eTestRunner !== 'cypress') { if (options.e2eTestRunner !== 'cypress') {
return () => {}; return () => {};
} }
const { cypressProjectGenerator } = ensurePackage<
typeof import('@nrwl/cypress')
>('@nrwl/cypress', nxVersion);
return cypressProjectGenerator(host, { return cypressProjectGenerator(host, {
...options, ...options,
linter: Linter.EsLint, linter: Linter.EsLint,

View File

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

View File

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

View File

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