diff --git a/e2e/next/src/next.test.ts b/e2e/next/src/next.test.ts index 932dd116cca406..05b0d546fca6b1 100644 --- a/e2e/next/src/next.test.ts +++ b/e2e/next/src/next.test.ts @@ -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 { - 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 { + 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)); }); } diff --git a/packages/next/package.json b/packages/next/package.json index 6e37247e68c9d7..782c8b10517ed3 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -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" }, diff --git a/packages/next/plugins/with-nx.ts b/packages/next/plugins/with-nx.ts index 211850bbe94ad4..e0e94bd0f86710 100644 --- a/packages/next/plugins/with-nx.ts +++ b/packages/next/plugins/with-nx.ts @@ -13,8 +13,15 @@ import { DependentBuildableProjectNode, } from '@nrwl/js/src/utils/buildable-libs-utils'; import type { NextConfig } from 'next'; - -import path = require('path'); +import { + PHASE_DEVELOPMENT_SERVER, + PHASE_PRODUCTION_BUILD, + PHASE_PRODUCTION_SERVER, + PHASE_EXPORT, + PHASE_TEST, +} from 'next/constants'; + +import * as path from 'path'; import { createWebpackConfig } from '../src/utils/config'; import { NextBuildBuilderOptions } from '../src/utils/types'; export interface WithNxOptions extends NextConfig { @@ -110,65 +117,79 @@ function getNxContext( } } +type Phase = + | typeof PHASE_DEVELOPMENT_SERVER + | typeof PHASE_PRODUCTION_BUILD + | typeof PHASE_PRODUCTION_SERVER + | typeof PHASE_EXPORT + | typeof PHASE_TEST; + export function withNx( _nextConfig = {} as WithNxOptions, context: WithNxContext = getWithNxContext() -): () => Promise { - return async () => { - let dependencies: DependentBuildableProjectNode[] = []; - - 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 { - 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, +): (phase: Phase) => Promise { + return async (phase: Phase) => { + if (phase === PHASE_PRODUCTION_SERVER) { + // If we are running an already built production server, just return the configuration. + const { nx, ...validNextConfig } = _nextConfig; + return { ...validNextConfig, distDir: '.next' }; + } else { + // Otherwise, add in webpack and eslint configuration for build or test. + let dependencies: DependentBuildableProjectNode[] = []; + + 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 { + 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; + } }; } 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 39d13be27882fa..535aa218543e7f 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 @@ -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') - ); - } -} - -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}' - } - }`, - }, - ]); + copyFileSync(nextConfigPath, join(options.outputPath, 'next.config.js')); } - - return withNxContent; } diff --git a/packages/next/src/generators/application/lib/add-cypress.ts b/packages/next/src/generators/application/lib/add-cypress.ts index 33ac31ffa759b7..fb3be4b8aeae34 100644 --- a/packages/next/src/generators/application/lib/add-cypress.ts +++ b/packages/next/src/generators/application/lib/add-cypress.ts @@ -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, diff --git a/packages/next/src/generators/application/lib/add-jest.ts b/packages/next/src/generators/application/lib/add-jest.ts index 3237a4ebb664ff..5e6e52e5de28df 100644 --- a/packages/next/src/generators/application/lib/add-jest.ts +++ b/packages/next/src/generators/application/lib/add-jest.ts @@ -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( + '@nrwl/jest', + nxVersion + ); const jestTask = await jestProjectGenerator(host, { ...options, project: options.projectName, diff --git a/packages/next/src/generators/init/init.ts b/packages/next/src/generators/init/init.ts index acdeb2034390db..dd7c4e225c6f95 100644 --- a/packages/next/src/generators/init/init.ts +++ b/packages/next/src/generators/init/init.ts @@ -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( + '@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); } diff --git a/packages/next/src/utils/config.ts b/packages/next/src/utils/config.ts index 9b5cb25b72280b..3e6a0cc885bf1b 100644 --- a/packages/next/src/utils/config.ts +++ b/packages/next/src/utils/config.ts @@ -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; diff --git a/packages/next/src/utils/create-copy-plugin.ts b/packages/next/src/utils/create-copy-plugin.ts new file mode 100644 index 00000000000000..01294f2f0be174 --- /dev/null +++ b/packages/next/src/utils/create-copy-plugin.ts @@ -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, + }, + }; + }), + }); +}