From 3a6f537c6df136e56a32b49278a73f1237170386 Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Fri, 21 Apr 2023 11:57:19 -0400 Subject: [PATCH] fix(nextjs): remove the need to install @nx/next for production builds --- packages/next/index.ts | 2 +- packages/next/plugins/with-nx.ts | 86 ++++++++++------- .../build/lib/create-next-config-file.spec.ts | 59 ++++++++++++ .../build/lib/create-next-config-file.ts | 92 ++++++++++++++++++- .../files/common/next.config.js__tmpl__ | 2 +- .../next/src/utils/compose-plugins.spec.ts | 58 ++++++++++++ packages/next/src/utils/compose-plugins.ts | 30 ++++++ packages/next/src/utils/config.spec.ts | 59 +----------- packages/next/src/utils/config.ts | 49 +++------- 9 files changed, 302 insertions(+), 135 deletions(-) create mode 100644 packages/next/src/executors/build/lib/create-next-config-file.spec.ts create mode 100644 packages/next/src/utils/compose-plugins.spec.ts create mode 100644 packages/next/src/utils/compose-plugins.ts diff --git a/packages/next/index.ts b/packages/next/index.ts index 1e5bc580c9a338..c7077e633280ec 100644 --- a/packages/next/index.ts +++ b/packages/next/index.ts @@ -6,4 +6,4 @@ export { componentGenerator } from './src/generators/component/component'; export { libraryGenerator } from './src/generators/library/library'; export { pageGenerator } from './src/generators/page/page'; export { withNx } from './plugins/with-nx'; -export { composePlugins } from './src/utils/config'; +export { composePlugins } from './src/utils/compose-plugins'; diff --git a/packages/next/plugins/with-nx.ts b/packages/next/plugins/with-nx.ts index 101606427d16d3..5ab0a0f4a411d4 100644 --- a/packages/next/plugins/with-nx.ts +++ b/packages/next/plugins/with-nx.ts @@ -1,23 +1,14 @@ -import { - createProjectGraphAsync, - joinPathFragments, - offsetFromRoot, - parseTargetString, - ProjectGraph, - ProjectGraphProjectNode, - Target, - workspaceRoot, -} from '@nx/devkit'; -import { - calculateProjectDependencies, - DependentBuildableProjectNode, -} from '@nx/js/src/utils/buildable-libs-utils'; +/** + * WARNING: Do not add development dependencies to top-level imports. + * Instead, `require` them inline during the build phase. + */ +import * as path from 'path'; import type { NextConfig } from 'next'; import { PHASE_PRODUCTION_SERVER } from 'next/constants'; - -import * as path from 'path'; -import { createWebpackConfig, NextConfigFn } from '../src/utils/config'; -import { NextBuildBuilderOptions } from '../src/utils/types'; +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 { ProjectGraph, ProjectGraphProjectNode, Target } from '@nx/devkit'; export interface WithNxOptions extends NextConfig { nx?: { @@ -78,6 +69,7 @@ function getNxContext( targetName: string; configurationName?: string; } { + const { parseTargetString } = require('@nx/devkit'); const targetConfig = getTargetConfig(graph, target); if ( @@ -119,7 +111,6 @@ function getNxContext( ); } } - /** * Try to read output dir from project, and default to '.next' if executing outside of Nx (e.g. dist is added to a docker image). */ @@ -130,22 +121,36 @@ async function determineDistDirForProdServer( const target = process.env.NX_TASK_TARGET_TARGET; const configuration = process.env.NX_TASK_TARGET_CONFIGURATION; - if (project && target) { - const originalTarget = { project, target, configuration }; - const graph = await createProjectGraphAsync(); - - const { options, node: projectNode } = getNxContext(graph, originalTarget); - const outputDir = `${offsetFromRoot(projectNode.data.root)}${ - options.outputPath - }`; - return nextConfig.distDir && nextConfig.distDir !== '.next' - ? joinPathFragments(outputDir, nextConfig.distDir) - : joinPathFragments(outputDir, '.next'); - } else { - return '.next'; + try { + if (project && target) { + // If NX env vars are set, then devkit must be available. + const { + createProjectGraphAsync, + joinPathFragments, + offsetFromRoot, + } = require('@nx/devkit'); + const originalTarget = { project, target, configuration }; + const graph = await createProjectGraphAsync(); + + const { options, node: projectNode } = getNxContext( + graph, + originalTarget + ); + const outputDir = `${offsetFromRoot(projectNode.data.root)}${ + options.outputPath + }`; + return nextConfig.distDir && nextConfig.distDir !== '.next' + ? joinPathFragments(outputDir, nextConfig.distDir) + : joinPathFragments(outputDir, '.next'); + } + } catch { + // ignored -- fallback to Next.js default of '.next' } + + return nextConfig.distDir || '.next'; } -export function withNx( + +function withNx( _nextConfig = {} as WithNxOptions, context: WithNxContext = getWithNxContext() ): NextConfigFn { @@ -155,9 +160,16 @@ export function withNx( const { nx, ...validNextConfig } = _nextConfig; return { ...validNextConfig, - distDir: await determineDistDirForProdServer(validNextConfig), + distDir: await determineDistDirForProdServer(_nextConfig), }; } else { + const { + createProjectGraphAsync, + joinPathFragments, + offsetFromRoot, + workspaceRoot, + } = require('@nx/devkit'); + // Otherwise, add in webpack and eslint configuration for build or test. let dependencies: DependentBuildableProjectNode[] = []; @@ -179,6 +191,9 @@ export function withNx( 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, @@ -202,6 +217,7 @@ export function withNx( const userWebpackConfig = nextConfig.webpack; + const { createWebpackConfig } = require('../src/utils/config'); nextConfig.webpack = (a, b) => createWebpackConfig( workspaceRoot, @@ -407,3 +423,5 @@ module.exports = withNx; // Support for newer generated code: `const { withNx } = require(...);` module.exports.withNx = withNx; module.exports.getNextConfig = getNextConfig; + +export { withNx }; diff --git a/packages/next/src/executors/build/lib/create-next-config-file.spec.ts b/packages/next/src/executors/build/lib/create-next-config-file.spec.ts new file mode 100644 index 00000000000000..8863565c3c2c02 --- /dev/null +++ b/packages/next/src/executors/build/lib/create-next-config-file.spec.ts @@ -0,0 +1,59 @@ +import { getWithNxContent } from './create-next-config-file'; +import { stripIndents } from '@nx/devkit'; + +describe('Next.js config: getWithNxContent', () => { + it('should swap distDir and getWithNxContext with static values', () => { + const result = getWithNxContent({ + withNxFile: `with-nx.js`, + withNxContent: stripIndents` + // SHOULD BE LEFT INTACT + const constants = require("next/constants"); + + // TO BE SWAPPED + function getWithNxContext() { + const { workspaceRoot, workspaceLayout } = require('@nx/devkit'); + return { + workspaceRoot, + libsDir: workspaceLayout().libsDir, + }; + } + + // SHOULD BE LEFT INTACT + function withNx(nextConfig = {}, context = getWithNxContext()) { + return (phase) => { + if (phase === constants.PHASE_PRODUCTION_SERVER) { + //... + } else { + // ... + } + }; + } + + // SHOULD BE LEFT INTACT + module.exports.withNx = withNx; + `, + }); + + expect(result).toContain(`const constants = require("next/constants")`); + expect(result).toContain(stripIndents` + // SHOULD BE LEFT INTACT + function withNx(nextConfig = {}, context = getWithNxContext()) { + return (phase) => { + if (phase === constants.PHASE_PRODUCTION_SERVER) { + //... + } else { + // ... + } + }; + } + + // SHOULD BE LEFT INTACT + module.exports.withNx = withNx; + `); + expect(result).not.toContain( + `const { workspaceRoot, workspaceLayout } = require('@nx/devkit');` + ); + expect(result).toContain(`libsDir: ''`); + expect(result).not.toContain(`libsDir: workspaceLayout.libsDir()`); + }); +}); 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 6c9b693a068822..561126e3d9499f 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,9 +1,23 @@ -import { ExecutorContext } from '@nx/devkit'; - -import { copyFileSync, existsSync } from 'fs'; +import type { ExecutorContext } from '@nx/devkit'; +import { + applyChangesToString, + ChangeType, + stripIndents, + workspaceLayout, + workspaceRoot, +} from '@nx/devkit'; +import * as ts from 'typescript'; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'fs'; import { join } from 'path'; import type { NextBuildBuilderOptions } from '../../../utils/types'; +import { findNodes } from 'nx/src/utils/typescript'; export function createNextConfigFile( options: NextBuildBuilderOptions, @@ -13,7 +27,77 @@ export function createNextConfigFile( ? join(context.root, options.nextConfig) : join(context.root, options.root, 'next.config.js'); + // Copy config file and our `.nx-helpers` folder to remove dependency on @nrwl/next for production build. if (existsSync(nextConfigPath)) { - copyFileSync(nextConfigPath, join(options.outputPath, 'next.config.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') + ); + } +} +function readSource() { + const withNxFile = join(__dirname, '../../../../plugins/with-nx.js'); + const withNxContent = readFileSync(withNxFile).toString(); + return { + withNxFile, + withNxContent, + }; +} + +// Exported for testing +export function getWithNxContent({ withNxFile, withNxContent } = readSource()) { + 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; } diff --git a/packages/next/src/generators/application/files/common/next.config.js__tmpl__ b/packages/next/src/generators/application/files/common/next.config.js__tmpl__ index 82dd31149f8ed8..3b589f48df9785 100644 --- a/packages/next/src/generators/application/files/common/next.config.js__tmpl__ +++ b/packages/next/src/generators/application/files/common/next.config.js__tmpl__ @@ -30,7 +30,7 @@ const plugins = [ withNx, ]; -module.exports = composePlugins(...plugins)(nextConfig)); +module.exports = composePlugins(...plugins)(nextConfig); <% } else if (style === 'styl') { %> const { withStylus } = require('@nx/next/plugins/with-stylus'); diff --git a/packages/next/src/utils/compose-plugins.spec.ts b/packages/next/src/utils/compose-plugins.spec.ts new file mode 100644 index 00000000000000..67fdddc6e222a7 --- /dev/null +++ b/packages/next/src/utils/compose-plugins.spec.ts @@ -0,0 +1,58 @@ +import { NextConfig } from 'next'; +import { composePlugins, NextConfigFn } from './compose-plugins'; + +describe('composePlugins', () => { + it('should combine multiple plugins', async () => { + const nextConfig: NextConfig = { + env: { + original: 'original', + }, + }; + const a = (config: NextConfig): NextConfig => { + config.env['a'] = 'a'; + return config; + }; + const b = (config: NextConfig): NextConfig => { + config.env['b'] = 'b'; + return config; + }; + const fn = await composePlugins(a, b); + const output = await fn(nextConfig)('test', {}); + + expect(output).toEqual({ + env: { + original: 'original', + a: 'a', + b: 'b', + }, + }); + }); + + it('should compose plugins that return an async function', async () => { + const nextConfig: NextConfig = { + env: { + original: 'original', + }, + }; + const a = (config: NextConfig): NextConfig => { + config.env['a'] = 'a'; + return config; + }; + const b = (config: NextConfig): NextConfigFn => { + return (phase: string) => { + config.env['b'] = phase; + return config; + }; + }; + const fn = await composePlugins(a, b); + const output = await fn(nextConfig)('test', {}); + + expect(output).toEqual({ + env: { + original: 'original', + a: 'a', + b: 'test', + }, + }); + }); +}); diff --git a/packages/next/src/utils/compose-plugins.ts b/packages/next/src/utils/compose-plugins.ts new file mode 100644 index 00000000000000..6a7b91832641ea --- /dev/null +++ b/packages/next/src/utils/compose-plugins.ts @@ -0,0 +1,30 @@ +import type { NextConfig } from 'next'; +import type { + NextConfigFn, + NextPlugin, + NextPluginThatReturnsConfigFn, +} from './config'; + +export function composePlugins( + ...plugins: (NextPlugin | NextPluginThatReturnsConfigFn)[] +): (baseConfig: NextConfig) => NextConfigFn { + return function (baseConfig: NextConfig) { + return async function combined( + phase: string, + context: any + ): Promise { + let config = baseConfig; + for (const plugin of plugins) { + const fn = await plugin; + const configOrFn = fn(config); + if (typeof configOrFn === 'function') { + config = await configOrFn(phase, context); + } else { + config = configOrFn; + } + } + + return config; + }; + }; +} diff --git a/packages/next/src/utils/config.spec.ts b/packages/next/src/utils/config.spec.ts index 350fb5972e6475..05bd152e39c8fd 100644 --- a/packages/next/src/utils/config.spec.ts +++ b/packages/next/src/utils/config.spec.ts @@ -1,7 +1,6 @@ -import type { NextConfig } from 'next'; import 'nx/src/utils/testing/mock-fs'; -import { composePlugins, createWebpackConfig, NextConfigFn } from './config'; import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin'; +import { createWebpackConfig } from './config'; jest.mock('@nx/webpack', () => ({})); jest.mock('tsconfig-paths-webpack-plugin'); @@ -76,60 +75,4 @@ describe('Next.js webpack config builder', () => { expect(config.module.rules.length).toBe(2); }); }); - - describe('composePlugins', () => { - it('should combine multiple plugins', async () => { - const nextConfig: NextConfig = { - env: { - original: 'original', - }, - }; - const a = (config: NextConfig): NextConfig => { - config.env['a'] = 'a'; - return config; - }; - const b = (config: NextConfig): NextConfig => { - config.env['b'] = 'b'; - return config; - }; - const fn = await composePlugins(a, b); - const output = await fn(nextConfig)('test', {}); - - expect(output).toEqual({ - env: { - original: 'original', - a: 'a', - b: 'b', - }, - }); - }); - - it('should compose plugins that return an async function', async () => { - const nextConfig: NextConfig = { - env: { - original: 'original', - }, - }; - const a = (config: NextConfig): NextConfig => { - config.env['a'] = 'a'; - return config; - }; - const b = (config: NextConfig): NextConfigFn => { - return (phase: string) => { - config.env['b'] = phase; - return config; - }; - }; - const fn = await composePlugins(a, b); - const output = await fn(nextConfig)('test', {}); - - expect(output).toEqual({ - env: { - original: 'original', - a: 'a', - b: 'test', - }, - }); - }); - }); }); diff --git a/packages/next/src/utils/config.ts b/packages/next/src/utils/config.ts index 2d9f4fa7b2d3c9..b1be952f4d079b 100644 --- a/packages/next/src/utils/config.ts +++ b/packages/next/src/utils/config.ts @@ -8,7 +8,18 @@ import { createTmpTsConfig, DependentBuildableProjectNode, } from '@nx/js/src/utils/buildable-libs-utils'; -import { NxWebpackExecutionContext } from '@nx/webpack'; + +export interface NextConfigFn { + (phase: string, context?: any): Promise | NextConfig; +} + +export interface NextPlugin { + (config: NextConfig): NextConfig; +} + +export interface NextPluginThatReturnsConfigFn { + (config: NextConfig): NextConfigFn; +} export function createWebpackConfig( workspaceRoot: string, @@ -96,39 +107,3 @@ function isTsRule(r: RuleSetRule): boolean { return r.test.test('a.ts'); } - -export interface NextConfigFn { - (phase: string, context?: any): Promise | NextConfig; -} - -export interface NextPlugin { - (config: NextConfig): NextConfig; -} - -export interface NextPluginThatReturnsConfigFn { - (config: NextConfig): NextConfigFn; -} - -export function composePlugins( - ...plugins: (NextPlugin | NextPluginThatReturnsConfigFn)[] -): (baseConfig: NextConfig) => NextConfigFn { - return function (baseConfig: NextConfig) { - return async function combined( - phase: string, - context: any - ): Promise { - let config = baseConfig; - for (const plugin of plugins) { - const fn = await plugin; - const configOrFn = fn(config); - if (typeof configOrFn === 'function') { - config = await configOrFn(phase, context); - } else { - config = configOrFn; - } - } - - return config; - }; - }; -}