From b8d24e6d0e7a7d0773a49cdb1b1785fd8a9d5cbc Mon Sep 17 00:00:00 2001 From: Nicholas Cunningham Date: Wed, 6 Dec 2023 16:52:09 -0700 Subject: [PATCH] feat(nextjs): Add support for create nodes for nextjs (#20193) --- e2e/next-core/src/next-pcv3.test.ts | 63 +++++ e2e/next-core/src/next-webpack.test.ts | 2 +- .../src/next-experimental.test.ts | 31 +++ packages/cypress/plugins/cypress-preset.ts | 2 +- packages/next/package.json | 2 +- packages/next/plugin.ts | 1 + packages/next/plugins/with-nx.ts | 60 +++-- .../next/src/executors/export/export.impl.ts | 9 +- .../next/src/executors/server/server.impl.ts | 2 +- .../application/application.spec.ts | 43 ++++ .../src/generators/application/application.ts | 2 + .../src/generators/application/lib/add-e2e.ts | 21 +- .../generators/application/lib/add-plugin.ts | 27 +++ .../generators/application/lib/add-project.ts | 83 ++++--- packages/next/src/generators/init/init.ts | 5 + .../src/generators/init/lib/add-plugin.ts | 27 +++ .../plugins/__snapshots__/plugin.spec.ts.snap | 5 + packages/next/src/plugins/plugin.spec.ts | 95 ++++++++ packages/next/src/plugins/plugin.ts | 219 ++++++++++++++++++ packages/next/src/utils/versions.ts | 4 +- 20 files changed, 619 insertions(+), 84 deletions(-) create mode 100644 e2e/next-core/src/next-pcv3.test.ts create mode 100644 packages/next/plugin.ts create mode 100644 packages/next/src/generators/application/lib/add-plugin.ts create mode 100644 packages/next/src/generators/init/lib/add-plugin.ts create mode 100644 packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap create mode 100644 packages/next/src/plugins/plugin.spec.ts create mode 100644 packages/next/src/plugins/plugin.ts diff --git a/e2e/next-core/src/next-pcv3.test.ts b/e2e/next-core/src/next-pcv3.test.ts new file mode 100644 index 0000000000..9963fa3cef --- /dev/null +++ b/e2e/next-core/src/next-pcv3.test.ts @@ -0,0 +1,63 @@ +import { + runCLI, + cleanupProject, + newProject, + uniq, + updateJson, + runE2ETests, + directoryExists, + readJson, +} from 'e2e/utils'; + +describe('@nx/next/plugin', () => { + let project: string; + let appName: string; + + beforeAll(() => { + project = newProject(); + appName = uniq('app'); + runCLI( + `generate @nx/next:app ${appName} --project-name-and-root-format=as-provided --no-interactive`, + { env: { NX_PCV3: 'true' } } + ); + + // update package.json to add next as a script + updateJson(`package.json`, (json) => { + json.scripts = json.scripts || {}; + json.scripts.next = 'next'; + return json; + }); + }); + + afterAll(() => cleanupProject()); + + it('nx.json should contain plugin configuration', () => { + const nxJson = readJson('nx.json'); + const nextPlugin = nxJson.plugins.find( + (plugin) => plugin.plugin === '@nx/next/plugin' + ); + expect(nextPlugin).toBeDefined(); + expect(nextPlugin.options).toBeDefined(); + expect(nextPlugin.options.buildTargetName).toEqual('build'); + expect(nextPlugin.options.startTargetName).toEqual('start'); + expect(nextPlugin.options.devTargetName).toEqual('dev'); + }); + + it('should build the app', async () => { + const result = runCLI(`build ${appName}`); + // check build output for PCV3 artifacts (e.g. .next directory) are inside the project directory + directoryExists(`${appName}/.next`); + + expect(result).toContain( + `Successfully ran target build for project ${appName}` + ); + }, 200_000); + + it('should serve the app', async () => { + if (runE2ETests()) { + const e2eResult = runCLI(`run ${appName}-e2e:e2e --verbose`); + + expect(e2eResult).toContain('All specs passed!'); + } + }, 500_000); +}); diff --git a/e2e/next-core/src/next-webpack.test.ts b/e2e/next-core/src/next-webpack.test.ts index 6881944aff..cfcfad76df 100644 --- a/e2e/next-core/src/next-webpack.test.ts +++ b/e2e/next-core/src/next-webpack.test.ts @@ -94,6 +94,6 @@ describe('Next.js Webpack', () => { expect(() => { runCLI(`build ${appName}`); }).not.toThrow(); - checkFilesExist(`apps/${appName}/.next/build-manifest.json`); + checkFilesExist(`dist/apps/${appName}/.next/build-manifest.json`); }, 300_000); }); diff --git a/e2e/next-extensions/src/next-experimental.test.ts b/e2e/next-extensions/src/next-experimental.test.ts index 91062d7a03..109b645fa6 100644 --- a/e2e/next-extensions/src/next-experimental.test.ts +++ b/e2e/next-extensions/src/next-experimental.test.ts @@ -57,6 +57,37 @@ describe('Next.js Experimental Features', () => { ` ); + updateFile( + `apps/${appName}/next.config.js`, + ` + //@ts-check + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { composePlugins, withNx } = require('@nx/next'); + + /** + * @type {import('@nx/next/plugins/with-nx').WithNxOptions} + **/ + const nextConfig = { + nx: { + // Set this to true if you would like to use SVGR + // See: https://github.com/gregberge/svgr + svgr: false, + }, + experimental: { + serverActions: true + } + }; + + const plugins = [ + // Add more Next.js plugins to this list if needed. + withNx, + ]; + + module.exports = composePlugins(...plugins)(nextConfig); + ` + ); + await checkApp(appName, { checkUnitTest: false, checkLint: true, diff --git a/packages/cypress/plugins/cypress-preset.ts b/packages/cypress/plugins/cypress-preset.ts index 470094b476..3ef5576251 100644 --- a/packages/cypress/plugins/cypress-preset.ts +++ b/packages/cypress/plugins/cypress-preset.ts @@ -147,7 +147,7 @@ function waitForServer( let pollTimeout: NodeJS.Timeout | null; const { protocol } = new URL(url); - const timeoutDuration = webServerConfig?.timeout ?? 5 * 1000; + const timeoutDuration = webServerConfig?.timeout ?? 10 * 1000; const timeout = setTimeout(() => { clearTimeout(pollTimeout); reject( diff --git a/packages/next/package.json b/packages/next/package.json index 9da4307ea2..83c19090bb 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -34,6 +34,7 @@ "next": ">=13.0.0" }, "dependencies": { + "@nx/devkit": "file:../devkit", "@babel/plugin-proposal-decorators": "^7.22.7", "@svgr/webpack": "^8.0.1", "chalk": "^4.1.0", @@ -44,7 +45,6 @@ "url-loader": "^4.1.1", "tslib": "^2.3.0", "webpack-merge": "^5.8.0", - "@nx/devkit": "file:../devkit", "@nx/js": "file:../js", "@nx/eslint": "file:../eslint", "@nx/react": "file:../react", diff --git a/packages/next/plugin.ts b/packages/next/plugin.ts new file mode 100644 index 0000000000..df1726b40d --- /dev/null +++ b/packages/next/plugin.ts @@ -0,0 +1 @@ +export { createNodes, NextPluginOptions } from './src/plugins/plugin'; diff --git a/packages/next/plugins/with-nx.ts b/packages/next/plugins/with-nx.ts index d311c15599..3e08c4541d 100644 --- a/packages/next/plugins/with-nx.ts +++ b/packages/next/plugins/with-nx.ts @@ -5,12 +5,11 @@ import type { NextConfig } from 'next'; import type { NextConfigFn } from '../src/utils/config'; import type { NextBuildBuilderOptions } from '../src/utils/types'; -import type { DependentBuildableProjectNode } from '@nx/js/src/utils/buildable-libs-utils'; -import type { - ExecutorContext, - ProjectGraph, - ProjectGraphProjectNode, - Target, +import { + type ExecutorContext, + type ProjectGraph, + type ProjectGraphProjectNode, + type Target, } from '@nx/devkit'; const baseNXEnvironmentVariables = [ @@ -48,6 +47,7 @@ const baseNXEnvironmentVariables = [ 'NX_MAPPINGS', 'NX_FILE_TO_RUN', 'NX_NEXT_PUBLIC_DIR', + 'NX_CYPRESS_COMPONENT_TEST', ]; export interface WithNxOptions extends NextConfig { @@ -150,7 +150,10 @@ function withNx( const { PHASE_PRODUCTION_SERVER, PHASE_DEVELOPMENT_SERVER } = await import( 'next/constants' ); - if (PHASE_PRODUCTION_SERVER === phase) { + if ( + PHASE_PRODUCTION_SERVER === phase || + !process.env.NX_TASK_TARGET_TARGET + ) { // If we are running an already built production server, just return the configuration. // NOTE: Avoid any `require(...)` or `import(...)` statements here. Development dependencies are not available at production runtime. const { nx, ...validNextConfig } = _nextConfig; @@ -161,15 +164,22 @@ function withNx( } else { const { createProjectGraphAsync, + readCachedProjectGraph, joinPathFragments, offsetFromRoot, workspaceRoot, } = require('@nx/devkit'); - // Otherwise, add in webpack and eslint configuration for build or test. - let dependencies: DependentBuildableProjectNode[] = []; - - const graph = await createProjectGraphAsync(); + let graph = readCachedProjectGraph(); + if (!graph) { + try { + graph = await createProjectGraphAsync(); + } catch (e) { + throw new Error( + 'Could not create project graph. Please ensure that your workspace is valid.' + ); + } + } const originalTarget = { project: process.env.NX_TASK_TARGET_PROJECT, @@ -181,25 +191,9 @@ function withNx( node: projectNode, options, projectName: project, - targetName, - configurationName, } = getNxContext(graph, originalTarget); const projectDirectory = projectNode.data.root; - if (options.buildLibsFromSource === false && targetName) { - const { - calculateProjectDependencies, - } = require('@nx/js/src/utils/buildable-libs-utils'); - const result = calculateProjectDependencies( - graph, - workspaceRoot, - project, - targetName, - configurationName - ); - dependencies = result.dependencies; - } - // Get next config const nextConfig = getNextConfig(_nextConfig, context); @@ -229,18 +223,16 @@ function withNx( // outputPath may be undefined if using run-commands or other executors other than @nx/next:build. // In this case, the user should set distDir in their next.config.js. - if (options.outputPath) { + if (options.outputPath && phase !== PHASE_DEVELOPMENT_SERVER) { const outputDir = `${offsetFromRoot(projectDirectory)}${ options.outputPath }`; // If running dev-server, we should keep `.next` inside project directory since Turbopack expects this. // See: https://github.com/nrwl/nx/issues/19365 - if (phase !== PHASE_DEVELOPMENT_SERVER) { - nextConfig.distDir = - nextConfig.distDir && nextConfig.distDir !== '.next' - ? joinPathFragments(outputDir, nextConfig.distDir) - : joinPathFragments(outputDir, '.next'); - } + nextConfig.distDir = + nextConfig.distDir && nextConfig.distDir !== '.next' + ? joinPathFragments(outputDir, nextConfig.distDir) + : joinPathFragments(outputDir, '.next'); } const userWebpackConfig = nextConfig.webpack; diff --git a/packages/next/src/executors/export/export.impl.ts b/packages/next/src/executors/export/export.impl.ts index 5892555c77..6646af3a10 100644 --- a/packages/next/src/executors/export/export.impl.ts +++ b/packages/next/src/executors/export/export.impl.ts @@ -2,6 +2,7 @@ import { ExecutorContext, parseTargetString, readTargetOptions, + targetToTargetString, workspaceLayout, } from '@nx/devkit'; import exportApp from 'next/dist/export'; @@ -53,10 +54,12 @@ export default async function exportExecutor( dependencies = result.dependencies; } + // Returns { project: ProjectGraphNode; target: string; configuration?: string;} const buildTarget = parseTargetString(options.buildTarget, context); try { - const args = getBuildTargetCommand(options); + const buildTargetName = targetToTargetString(buildTarget); + const args = getBuildTargetCommand(buildTargetName); execFileSync(pmCmd, args, { stdio: [0, 1, 2], }); @@ -88,7 +91,7 @@ export default async function exportExecutor( return { success: true }; } -function getBuildTargetCommand(options: NextExportBuilderOptions) { - const cmd = ['nx', 'run', options.buildTarget]; +function getBuildTargetCommand(buildTarget: string) { + const cmd = ['nx', 'run', buildTarget]; return cmd; } diff --git a/packages/next/src/executors/server/server.impl.ts b/packages/next/src/executors/server/server.impl.ts index 50aed2c417..f770dfa245 100644 --- a/packages/next/src/executors/server/server.impl.ts +++ b/packages/next/src/executors/server/server.impl.ts @@ -24,7 +24,7 @@ export default async function* serveExecutor( } const buildOptions = readTargetOptions( - parseTargetString(options.buildTarget, context.projectGraph), + parseTargetString(options.buildTarget, context), context ); const projectRoot = context.workspace.projects[context.projectName].root; diff --git a/packages/next/src/generators/application/application.spec.ts b/packages/next/src/generators/application/application.spec.ts index 1af81e7b32..4f451d4d49 100644 --- a/packages/next/src/generators/application/application.spec.ts +++ b/packages/next/src/generators/application/application.spec.ts @@ -6,6 +6,7 @@ import { Tree, } from '@nx/devkit'; +import { Schema } from './schema'; import { applicationGenerator } from './application'; describe('app', () => { @@ -731,6 +732,48 @@ describe('app', () => { }); }); +describe('app with Project Configuration V3 enabeled', () => { + let tree: Tree; + let originalPVC3; + + const schema: Schema = { + name: 'app', + appDir: true, + unitTestRunner: 'jest', + style: 'css', + e2eTestRunner: 'cypress', + projectNameAndRootFormat: 'as-provided', + }; + + beforeAll(() => { + tree = createTreeWithEmptyWorkspace(); + originalPVC3 = process.env['NX_PCV3']; + process.env['NX_PCV3'] = 'true'; + }); + + afterAll(() => { + if (originalPVC3) { + process.env['NX_PCV3'] = originalPVC3; + } else { + delete process.env['NX_PCV3']; + } + }); + + it('should not generate build serve and export targets', async () => { + const name = uniq(); + + await applicationGenerator(tree, { + ...schema, + name, + }); + + const projectConfiguration = readProjectConfiguration(tree, name); + expect(projectConfiguration.targets.build).toBeUndefined(); + expect(projectConfiguration.targets.serve).toBeUndefined(); + expect(projectConfiguration.targets.export).toBeUndefined(); + }); +}); + function uniq() { return `str-${(Math.random() * 10000).toFixed(0)}`; } diff --git a/packages/next/src/generators/application/application.ts b/packages/next/src/generators/application/application.ts index 854b3daca3..803e63b997 100644 --- a/packages/next/src/generators/application/application.ts +++ b/packages/next/src/generators/application/application.ts @@ -20,6 +20,7 @@ import { addLinting } from './lib/add-linting'; import { customServerGenerator } from '../custom-server/custom-server'; import { updateCypressTsConfig } from './lib/update-cypress-tsconfig'; import { showPossibleWarnings } from './lib/show-possible-warnings'; +import { addPlugin } from './lib/add-plugin'; export async function applicationGenerator(host: Tree, schema: Schema) { return await applicationGeneratorInternal(host, { @@ -41,6 +42,7 @@ export async function applicationGeneratorInternal(host: Tree, schema: Schema) { tasks.push(nextTask); createApplicationFiles(host, options); + addProject(host, options); const e2eTask = await addE2e(host, options); diff --git a/packages/next/src/generators/application/lib/add-e2e.ts b/packages/next/src/generators/application/lib/add-e2e.ts index 3298b3874b..090c9b5cd0 100644 --- a/packages/next/src/generators/application/lib/add-e2e.ts +++ b/packages/next/src/generators/application/lib/add-e2e.ts @@ -3,6 +3,7 @@ import { ensurePackage, getPackageManagerCommand, joinPathFragments, + readNxJson, Tree, } from '@nx/devkit'; import { Linter } from '@nx/eslint'; @@ -11,6 +12,12 @@ import { nxVersion } from '../../../utils/versions'; import { NormalizedSchema } from './normalize-options'; export async function addE2e(host: Tree, options: NormalizedSchema) { + const nxJson = readNxJson(host); + const hasPlugin = nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/next/plugin' + : p.plugin === '@nx/next/plugin' + ); if (options.e2eTestRunner === 'cypress') { const { configurationGenerator } = ensurePackage< typeof import('@nx/cypress') @@ -28,8 +35,10 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { project: options.e2eProjectName, directory: 'src', skipFormat: true, - devServerTarget: `${options.projectName}:serve`, - baseUrl: 'http://localhost:4200', + devServerTarget: `${options.projectName}:${ + hasPlugin ? 'start' : 'serve' + }`, + baseUrl: `http://localhost:${hasPlugin ? '3000' : '4200'}`, jsx: true, }); } else if (options.e2eTestRunner === 'playwright') { @@ -50,10 +59,10 @@ export async function addE2e(host: Tree, options: NormalizedSchema) { js: false, linter: options.linter, setParserOptionsProject: options.setParserOptionsProject, - webServerAddress: 'http://127.0.0.1:4200', - webServerCommand: `${getPackageManagerCommand().exec} nx serve ${ - options.projectName - }`, + webServerAddress: `http://127.0.0.1:${hasPlugin ? '3000' : '4200'}`, + webServerCommand: `${getPackageManagerCommand().exec} nx ${ + hasPlugin ? 'start' : 'serve' + } ${options.projectName}`, }); } return () => {}; diff --git a/packages/next/src/generators/application/lib/add-plugin.ts b/packages/next/src/generators/application/lib/add-plugin.ts new file mode 100644 index 0000000000..7d75c5ba15 --- /dev/null +++ b/packages/next/src/generators/application/lib/add-plugin.ts @@ -0,0 +1,27 @@ +import { Tree, readNxJson, updateNxJson } from '@nx/devkit'; + +export function addPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + + for (const plugin of nxJson.plugins) { + if ( + typeof plugin === 'string' + ? plugin === '@nx/next/plugin' + : plugin.plugin === '@nx/next/plugin' + ) { + return; + } + } + + nxJson.plugins.push({ + plugin: '@nx/next/plugin', + options: { + buildTargetName: 'build', + serveTargetName: 'serve', + exportTargetName: 'export', + }, + }); + + updateNxJson(tree, nxJson); +} diff --git a/packages/next/src/generators/application/lib/add-project.ts b/packages/next/src/generators/application/lib/add-project.ts index 3b401463a1..b137b6f6a1 100644 --- a/packages/next/src/generators/application/lib/add-project.ts +++ b/packages/next/src/generators/application/lib/add-project.ts @@ -2,52 +2,65 @@ import { NormalizedSchema } from './normalize-options'; import { addProjectConfiguration, ProjectConfiguration, + readNxJson, Tree, } from '@nx/devkit'; export function addProject(host: Tree, options: NormalizedSchema) { const targets: Record = {}; - targets.build = { - executor: '@nx/next:build', - outputs: ['{options.outputPath}'], - defaultConfiguration: 'production', - options: { - outputPath: options.outputPath, - }, - configurations: { - development: { - outputPath: options.appProjectRoot, - }, - production: {}, - }, - }; + // Check if plugin exists in nx.json and if it doesn't then we can continue + // with the default targets. - targets.serve = { - executor: '@nx/next:server', - defaultConfiguration: 'development', - options: { - buildTarget: `${options.projectName}:build`, - dev: true, - }, - configurations: { - development: { - buildTarget: `${options.projectName}:build:development`, + const nxJson = readNxJson(host); + const hasPlugin = nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/next/plugin' + : p.plugin === '@nx/next/plugin' + ); + + if (!hasPlugin) { + targets.build = { + executor: '@nx/next:build', + outputs: ['{options.outputPath}'], + defaultConfiguration: 'production', + options: { + outputPath: options.outputPath, + }, + configurations: { + development: { + outputPath: options.appProjectRoot, + }, + production: {}, + }, + }; + + targets.serve = { + executor: '@nx/next:server', + defaultConfiguration: 'development', + options: { + buildTarget: `${options.projectName}:build`, dev: true, }, - production: { - buildTarget: `${options.projectName}:build:production`, - dev: false, + configurations: { + development: { + buildTarget: `${options.projectName}:build:development`, + dev: true, + }, + production: { + buildTarget: `${options.projectName}:build:production`, + dev: false, + }, }, - }, - }; + }; - targets.export = { - executor: '@nx/next:export', - options: { - buildTarget: `${options.projectName}:build:production`, - }, - }; + targets.export = { + executor: '@nx/next:export', + options: { + buildTarget: `${options.projectName}:build:production`, + }, + }; + } const project: ProjectConfiguration = { root: options.appProjectRoot, diff --git a/packages/next/src/generators/init/init.ts b/packages/next/src/generators/init/init.ts index 39f4afa0a4..c5cb1ec1a2 100644 --- a/packages/next/src/generators/init/init.ts +++ b/packages/next/src/generators/init/init.ts @@ -2,6 +2,7 @@ import { addDependenciesToPackageJson, ensurePackage, GeneratorCallback, + readNxJson, runTasksInSerial, Tree, } from '@nx/devkit'; @@ -18,6 +19,7 @@ import { } from '../../utils/versions'; import { InitSchema } from './schema'; import { addGitIgnoreEntry } from '../../utils/add-gitignore-entry'; +import { addPlugin } from './lib/add-plugin'; function updateDependencies(host: Tree) { return addDependenciesToPackageJson( @@ -85,6 +87,9 @@ export async function nextInitGenerator(host: Tree, schema: InitSchema) { } addGitIgnoreEntry(host); + if (process.env.NX_PCV3 === 'true') { + addPlugin(host); + } return runTasksInSerial(...tasks); } diff --git a/packages/next/src/generators/init/lib/add-plugin.ts b/packages/next/src/generators/init/lib/add-plugin.ts new file mode 100644 index 0000000000..2835c14968 --- /dev/null +++ b/packages/next/src/generators/init/lib/add-plugin.ts @@ -0,0 +1,27 @@ +import { Tree, readNxJson, updateNxJson } from '@nx/devkit'; + +export function addPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + + for (const plugin of nxJson.plugins) { + if ( + typeof plugin === 'string' + ? plugin === '@nx/next/plugin' + : plugin.plugin === '@nx/next/plugin' + ) { + return; + } + } + + nxJson.plugins.push({ + plugin: '@nx/next/plugin', + options: { + buildTargetName: 'build', + devTargetName: 'dev', + startTargetName: 'start', + }, + }); + + updateNxJson(tree, nxJson); +} diff --git a/packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap b/packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap new file mode 100644 index 0000000000..6c22aef8bd --- /dev/null +++ b/packages/next/src/plugins/__snapshots__/plugin.spec.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`@nx/next/plugin integrated projects should create nodes 1`] = `Promise {}`; + +exports[`@nx/next/plugin root projects should create nodes 1`] = `Promise {}`; diff --git a/packages/next/src/plugins/plugin.spec.ts b/packages/next/src/plugins/plugin.spec.ts new file mode 100644 index 0000000000..b539ad8f0a --- /dev/null +++ b/packages/next/src/plugins/plugin.spec.ts @@ -0,0 +1,95 @@ +import { CreateNodesContext } from '@nx/devkit'; +import type { NextConfig } from 'next'; + +import { createNodes } from './plugin'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; + +describe('@nx/next/plugin', () => { + let createNodesFunction = createNodes[1]; + let context: CreateNodesContext; + + describe('root projects', () => { + beforeEach(async () => { + context = { + nxJsonConfiguration: { + namedInputs: { + default: ['{projectRoot}/**/*'], + production: ['!{projectRoot}/**/*.spec.ts'], + }, + }, + workspaceRoot: '', + }; + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('should create nodes', () => { + const nextConfigPath = 'next.config.js'; + mockNextConfig(nextConfigPath, {}); + const nodes = createNodesFunction( + nextConfigPath, + { + buildTargetName: 'build', + devTargetName: 'dev', + startTargetName: 'start', + }, + context + ); + + expect(nodes).toMatchSnapshot(); + }); + }); + describe('integrated projects', () => { + const tempFs = new TempFs('test'); + beforeEach(() => { + context = { + nxJsonConfiguration: { + namedInputs: { + default: ['{projectRoot}/**/*'], + production: ['!{projectRoot}/**/*.spec.ts'], + }, + }, + workspaceRoot: tempFs.tempDir, + }; + + tempFs.createFileSync( + 'my-app/project.json', + JSON.stringify({ name: 'my-app' }) + ); + tempFs.createFileSync('my-app/next.config.js', ''); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('should create nodes', () => { + mockNextConfig('my-app/next.config.js', {}); + const nodes = createNodesFunction( + 'my-app/next.config.js', + { + buildTargetName: 'my-build', + devTargetName: 'my-serve', + startTargetName: 'my-start', + }, + context + ); + + expect(nodes).toMatchSnapshot(); + }); + }); +}); + +function mockNextConfig(path: string, config: NextConfig) { + jest.mock( + path, + () => ({ + default: config, + }), + { + virtual: true, + } + ); +} diff --git a/packages/next/src/plugins/plugin.ts b/packages/next/src/plugins/plugin.ts new file mode 100644 index 0000000000..b30fa478ce --- /dev/null +++ b/packages/next/src/plugins/plugin.ts @@ -0,0 +1,219 @@ +import { + CreateDependencies, + CreateNodes, + CreateNodesContext, + NxJsonConfiguration, + TargetConfiguration, + detectPackageManager, + readJsonFile, + writeJsonFile, +} from '@nx/devkit'; +import { dirname, join } from 'path'; + +import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; +import { existsSync, readdirSync } from 'fs'; + +import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; +import { type NextConfig } from 'next'; +import { PHASE_PRODUCTION_BUILD } from 'next/constants'; +import { getLockFileName } from '@nx/js'; + +export interface NextPluginOptions { + buildTargetName?: string; + devTargetName?: string; + startTargetName?: string; +} + +const cachePath = join(projectGraphCacheDirectory, 'next.hash'); +const targetsCache = existsSync(cachePath) ? readTargetsCache() : {}; + +const calculatedTargets: Record< + string, + Record +> = {}; + +function readTargetsCache(): Record< + string, + Record +> { + return readJsonFile(cachePath); +} + +function writeTargetsToCache( + targets: Record> +) { + writeJsonFile(cachePath, targets); +} + +export const createDependencies: CreateDependencies = () => { + writeTargetsToCache(calculatedTargets); + return []; +}; + +// TODO(nicholas): Add support for .mjs files +export const createNodes: CreateNodes = [ + '**/next.config.{js, cjs}', + async (configFilePath, options, context) => { + const projectRoot = dirname(configFilePath); + + // Do not create a project if package.json and project.json isn't there. + const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot)); + if ( + !siblingFiles.includes('package.json') && + !siblingFiles.includes('project.json') + ) { + return {}; + } + + options = normalizeOptions(options); + + const hash = calculateHashForCreateNodes(projectRoot, options, context, [ + getLockFileName(detectPackageManager(context.workspaceRoot)), + ]); + + const targets = + targetsCache[hash] ?? + (await buildNextTargets(configFilePath, projectRoot, options, context)); + + calculatedTargets[hash] = targets; + + return { + projects: { + [projectRoot]: { + root: projectRoot, + targets, + }, + }, + }; + }, +]; + +async function buildNextTargets( + nextConfigPath: string, + projectRoot: string, + options: NextPluginOptions, + context: CreateNodesContext +) { + const nextConfig = getNextConfig(nextConfigPath, context); + const namedInputs = getNamedInputs(projectRoot, context); + + const targets: Record = {}; + + targets[options.buildTargetName] = await getBuildTargetConfig( + namedInputs, + projectRoot, + nextConfig + ); + + targets[options.devTargetName] = getDevTargetConfig(projectRoot); + + targets[options.startTargetName] = getStartTargetConfig(options, projectRoot); + return targets; +} + +async function getBuildTargetConfig( + namedInputs: { [inputName: string]: any[] }, + projectRoot: string, + nextConfig: NextConfig +) { + // Set output path here so that `withNx` can pick it up. + const targetConfig: TargetConfiguration = { + command: `next build`, + options: { + cwd: projectRoot, + }, + dependsOn: ['^build'], + cache: true, + inputs: getInputs(namedInputs), + outputs: [await getOutputs(projectRoot, nextConfig)], + }; + return targetConfig; +} + +function getDevTargetConfig(projectRoot: string) { + const targetConfig: TargetConfiguration = { + command: `next dev`, + options: { + cwd: projectRoot, + }, + }; + + return targetConfig; +} + +function getStartTargetConfig(options: NextPluginOptions, projectRoot: string) { + const targetConfig: TargetConfiguration = { + command: `next start`, + options: { + cwd: projectRoot, + }, + dependsOn: [options.buildTargetName], + }; + + return targetConfig; +} + +async function getOutputs(projectRoot, nextConfig) { + let dir = '.next'; + + if (typeof nextConfig === 'function') { + // Works for both async and sync functions. + const configResult = await Promise.resolve( + nextConfig(PHASE_PRODUCTION_BUILD, { defaultConfig: {} }) + ); + if (configResult?.distDir) { + dir = configResult?.distDir; + } + } else if (typeof nextConfig === 'object' && nextConfig?.distDir) { + // If nextConfig is an object, directly use its 'distDir' property. + dir = nextConfig.distDir; + } + return `{workspaceRoot}/${projectRoot}/${dir}`; +} + +function getNextConfig( + configFilePath: string, + context: CreateNodesContext +): Promise { + const resolvedPath = join(context.workspaceRoot, configFilePath); + + const module = load(resolvedPath); + return module.default ?? module; +} + +function normalizeOptions(options: NextPluginOptions): NextPluginOptions { + options ??= {}; + options.buildTargetName ??= 'build'; + options.devTargetName ??= 'dev'; + options.startTargetName ??= 'start'; + return options; +} + +function getInputs( + namedInputs: NxJsonConfiguration['namedInputs'] +): TargetConfiguration['inputs'] { + return [ + ...('production' in namedInputs + ? ['default', '^production'] + : ['default', '^default']), + { + externalDependencies: ['next'], + }, + ]; +} + +/** + * Load the module after ensuring that the require cache is cleared. + */ +function load(path: string): any { + // Clear cache if the path is in the cache + if (require.cache[path]) { + for (const k of Object.keys(require.cache)) { + delete require.cache[k]; + } + } + + // Then require + return require(path); +} diff --git a/packages/next/src/utils/versions.ts b/packages/next/src/utils/versions.ts index 3644988e3e..32c30fdb47 100644 --- a/packages/next/src/utils/versions.ts +++ b/packages/next/src/utils/versions.ts @@ -1,7 +1,7 @@ export const nxVersion = require('../../package.json').version; -export const nextVersion = '13.4.1'; -export const eslintConfigNextVersion = '13.4.1'; +export const nextVersion = '13.4.4'; +export const eslintConfigNextVersion = '13.4.4'; export const sassVersion = '1.62.1'; export const lessLoader = '11.1.0'; export const emotionServerVersion = '11.11.0';