fix(nextjs): remove the need to install @nx/next for production builds (#16469)

This commit is contained in:
Jack Hsu 2023-04-21 16:13:16 -04:00 committed by GitHub
parent a10b6b1290
commit 564ffaeebd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 302 additions and 134 deletions

View File

@ -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';

View File

@ -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;
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 { 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';
}
} 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 };

View File

@ -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()`);
});
});

View File

@ -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;
}

View File

@ -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');

View File

@ -0,0 +1,59 @@
import { NextConfig } from 'next';
import { composePlugins } from './compose-plugins';
import { NextConfigFn } from './config';
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',
},
});
});
});

View File

@ -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<NextConfig> {
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;
};
};
}

View File

@ -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',
},
});
});
});
});

View File

@ -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> | 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> | 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<NextConfig> {
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;
};
};
}