feat(nextjs): Use next.js cli for build and serve targets (#16896)

This commit is contained in:
Nicholas Cunningham 2023-05-17 09:09:11 -06:00 committed by GitHub
parent 7de80ddb62
commit 3d76d95b29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 222 additions and 164 deletions

View File

@ -68,8 +68,15 @@
}, },
"debug": { "debug": {
"type": "boolean", "type": "boolean",
"description": "Enable Next.js debug build logging", "description": "Enable Next.js debug build logging"
"default": false },
"profile": {
"type": "boolean",
"description": "Used to enable React Production Profiling"
},
"experimentalAppOnly": {
"type": "boolean",
"description": "Only build 'app' routes"
} }
}, },
"required": ["root", "outputPath"], "required": ["root", "outputPath"],

View File

@ -33,6 +33,7 @@
"presets": [] "presets": []
}, },
"description": "Export a Next.js application. The exported application is located at `dist/$outputPath/exported`.", "description": "Export a Next.js application. The exported application is located at `dist/$outputPath/exported`.",
"x-deprecated": "Use static exports in next.config.js instead. See: https://nextjs.org/docs/pages/building-your-application/deploying/static-exports.",
"aliases": [], "aliases": [],
"hidden": false, "hidden": false,
"path": "/packages/next/src/executors/export/schema.json", "path": "/packages/next/src/executors/export/schema.json",

View File

@ -53,6 +53,10 @@
"type": "boolean", "type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.", "description": "Read buildable libraries from source instead of building them separately.",
"default": true "default": true
},
"keepAliveTimeout": {
"type": "number",
"description": "Max milliseconds to wait before closing inactive connection."
} }
}, },
"required": ["buildTarget"], "required": ["buildTarget"],

View File

@ -1,6 +1,7 @@
import { execSync } from 'child_process';
import { import {
checkFilesExist, checkFilesExist,
killPorts, killPort,
readJson, readJson,
runCLI, runCLI,
runCLIAsync, runCLIAsync,
@ -42,10 +43,10 @@ export async function checkApp(
if (opts.checkE2E && runCypressTests()) { if (opts.checkE2E && runCypressTests()) {
const e2eResults = runCLI( const e2eResults = runCLI(
`e2e ${appName}-e2e --no-watch --configuration=production` `e2e ${appName}-e2e --no-watch --configuration=production --port=9000`
); );
expect(e2eResults).toContain('All specs passed!'); expect(e2eResults).toContain('All specs passed!');
expect(await killPorts()).toBeTruthy(); await killPort(9000);
} }
if (opts.checkExport) { if (opts.checkExport) {

View File

@ -13,7 +13,8 @@
"export": { "export": {
"implementation": "./src/executors/export/export.impl", "implementation": "./src/executors/export/export.impl",
"schema": "./src/executors/export/schema.json", "schema": "./src/executors/export/schema.json",
"description": "Export a Next.js application. The exported application is located at `dist/$outputPath/exported`." "description": "Export a Next.js application. The exported application is located at `dist/$outputPath/exported`.",
"x-deprecated": "Use static exports in next.config.js instead. See: https://nextjs.org/docs/pages/building-your-application/deploying/static-exports."
} }
}, },
"builders": { "builders": {
@ -30,7 +31,8 @@
"export": { "export": {
"implementation": "./src/executors/export/compat", "implementation": "./src/executors/export/compat",
"schema": "./src/executors/export/schema.json", "schema": "./src/executors/export/schema.json",
"description": "Export a Next.js application. The exported application is located at `dist/$outputPath/exported`." "description": "Export a Next.js application. The exported application is located at `dist/$outputPath/exported`.",
"x-deprecated": "Use static exports in next.config.js instead. See: https://nextjs.org/docs/pages/building-your-application/deploying/static-exports."
} }
} }
} }

View File

@ -31,7 +31,7 @@
"migrations": "./migrations.json" "migrations": "./migrations.json"
}, },
"peerDependencies": { "peerDependencies": {
"next": "^13.0.0" "next": ">=13.0.0"
}, },
"dependencies": { "dependencies": {
"@babel/plugin-proposal-decorators": "^7.14.5", "@babel/plugin-proposal-decorators": "^7.14.5",

View File

@ -113,55 +113,10 @@ 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). * 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).
*/ */
async function determineDistDirForProdServer(
nextConfig: NextConfig
): Promise<string> {
const project = process.env.NX_TASK_TARGET_PROJECT;
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 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';
}
function withNx( function withNx(
_nextConfig = {} as WithNxOptions, _nextConfig = {} as WithNxOptions,
context: WithNxContext = getWithNxContext() context: WithNxContext = getWithNxContext()
): NextConfigFn { ): NextConfigFn {
// If this is not set user will see compile errors in Next.js 13.4.
// See: https://github.com/nrwl/nx/issues/16692, https://github.com/vercel/next.js/issues/49169
// TODO(jack): Remove this once Nx is refactored to invoke CLI directly.
forNextVersion('>=13.4.0', () => {
process.env['__NEXT_PRIVATE_PREBUNDLED_REACT'] =
// Not in Next 13.3 or earlier, so need to access config via string
_nextConfig.experimental?.['serverActions'] ? 'experimental' : 'next';
});
return async (phase: string) => { return async (phase: string) => {
const { PHASE_PRODUCTION_SERVER } = await import('next/constants'); const { PHASE_PRODUCTION_SERVER } = await import('next/constants');
if (phase === PHASE_PRODUCTION_SERVER) { if (phase === PHASE_PRODUCTION_SERVER) {
@ -169,8 +124,8 @@ function withNx(
// NOTE: Avoid any `require(...)` or `import(...)` statements here. Development dependencies are not available at production runtime. // NOTE: Avoid any `require(...)` or `import(...)` statements here. Development dependencies are not available at production runtime.
const { nx, ...validNextConfig } = _nextConfig; const { nx, ...validNextConfig } = _nextConfig;
return { return {
distDir: '.next',
...validNextConfig, ...validNextConfig,
distDir: await determineDistDirForProdServer(_nextConfig),
}; };
} else { } else {
const { const {

View File

@ -4,9 +4,9 @@ import {
readJsonFile, readJsonFile,
workspaceRoot, workspaceRoot,
writeJsonFile, writeJsonFile,
logger,
} from '@nx/devkit'; } from '@nx/devkit';
import { createLockFile, createPackageJson, getLockFileName } from '@nx/js'; import { createLockFile, createPackageJson, getLockFileName } from '@nx/js';
import build from 'next/dist/build';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import { copySync, existsSync, mkdir, writeFileSync } from 'fs-extra'; import { copySync, existsSync, mkdir, writeFileSync } from 'fs-extra';
import { lt, gte } from 'semver'; import { lt, gte } from 'semver';
@ -17,6 +17,8 @@ import { updatePackageJson } from './lib/update-package-json';
import { createNextConfigFile } from './lib/create-next-config-file'; import { createNextConfigFile } from './lib/create-next-config-file';
import { checkPublicDirectory } from './lib/check-project'; import { checkPublicDirectory } from './lib/check-project';
import { NextBuildBuilderOptions } from '../../utils/types'; import { NextBuildBuilderOptions } from '../../utils/types';
import { ExecSyncOptions, execSync } from 'child_process';
import { createCliOptions } from '../../utils/create-cli-options';
export default async function buildExecutor( export default async function buildExecutor(
options: NextBuildBuilderOptions, options: NextBuildBuilderOptions,
@ -42,22 +44,24 @@ export default async function buildExecutor(
reactDomVersion && reactDomVersion &&
gte(checkAndCleanWithSemver('react-dom', reactDomVersion), '18.0.0'); gte(checkAndCleanWithSemver('react-dom', reactDomVersion), '18.0.0');
if (hasReact18) { if (hasReact18) {
(process.env as any).__NEXT_REACT_ROOT ||= 'true'; process.env['__NEXT_REACT_ROOT'] ||= 'true';
} }
// Get the installed Next.js version (will be removed after Nx 16 and Next.js update) const { experimentalAppOnly, profile, debug } = options;
const nextVersion = require('next/package.json').version;
const debug = !!process.env.NX_VERBOSE_LOGGING || options.debug; const args = createCliOptions({ experimentalAppOnly, profile, debug });
const command = `npx next build ${args}`;
// Check the major and minor version numbers const execSyncOptions: ExecSyncOptions = {
if (lt(nextVersion, '13.2.0')) { stdio: 'inherit',
// If the version is lower than 13.2.0, use the second parameter as the config object encoding: 'utf-8',
await build(root, null, false, debug); cwd: root,
} else { };
// Otherwise, use the third parameter as a boolean flag for verbose logging try {
// @ts-ignore execSync(command, execSyncOptions);
await build(root, false, debug); } catch (error) {
logger.error(`Error occurred while trying to run the ${command}`);
logger.error(error);
return { success: false };
} }
if (!directoryExists(options.outputPath)) { if (!directoryExists(options.outputPath)) {

View File

@ -65,8 +65,15 @@
}, },
"debug": { "debug": {
"type": "boolean", "type": "boolean",
"description": "Enable Next.js debug build logging", "description": "Enable Next.js debug build logging"
"default": false },
"profile": {
"type": "boolean",
"description": "Used to enable React Production Profiling"
},
"experimentalAppOnly": {
"type": "boolean",
"description": "Only build 'app' routes"
} }
}, },
"required": ["root", "outputPath"] "required": ["root", "outputPath"]

View File

@ -16,7 +16,7 @@ import {
NextBuildBuilderOptions, NextBuildBuilderOptions,
NextExportBuilderOptions, NextExportBuilderOptions,
} from '../../utils/types'; } from '../../utils/types';
import { PHASE_EXPORT } from '../../utils/constants';
import nextTrace = require('next/dist/trace'); import nextTrace = require('next/dist/trace');
import { platform } from 'os'; import { platform } from 'os';
import { execFileSync } from 'child_process'; import { execFileSync } from 'child_process';
@ -25,6 +25,18 @@ import * as chalk from 'chalk';
// platform specific command name // platform specific command name
const pmCmd = platform() === 'win32' ? `npx.cmd` : 'npx'; const pmCmd = platform() === 'win32' ? `npx.cmd` : 'npx';
/**
* @deprecated use output inside of your next.config.js
* Example
* const nextConfig = {
nx: {
svgr: false,
},
output: 'export'
};
* Read https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
**/
export default async function exportExecutor( export default async function exportExecutor(
options: NextExportBuilderOptions, options: NextExportBuilderOptions,
context: ExecutorContext context: ExecutorContext
@ -41,7 +53,6 @@ export default async function exportExecutor(
dependencies = result.dependencies; dependencies = result.dependencies;
} }
const libsDir = join(context.root, workspaceLayout().libsDir);
const buildTarget = parseTargetString( const buildTarget = parseTargetString(
options.buildTarget, options.buildTarget,
context.projectGraph context.projectGraph

View File

@ -0,0 +1,67 @@
import 'dotenv/config';
import {
ExecutorContext,
parseTargetString,
readTargetOptions,
runExecutor,
} from '@nx/devkit';
import { join, resolve } from 'path';
import {
NextBuildBuilderOptions,
NextServeBuilderOptions,
} from '../../utils/types';
export default async function* serveExecutor(
options: NextServeBuilderOptions,
context: ExecutorContext
) {
// Cast to any to overwrite NODE_ENV
(process.env as any).NODE_ENV = process.env.NODE_ENV
? process.env.NODE_ENV
: options.dev
? 'development'
: 'production';
// Setting port that the custom server should use.
(process.env as any).PORT = options.port;
const buildOptions = readTargetOptions<NextBuildBuilderOptions>(
parseTargetString(options.buildTarget, context.projectGraph),
context
);
const root = resolve(context.root, buildOptions.root);
yield* runCustomServer(root, options, context);
}
async function* runCustomServer(
root: string,
options: NextServeBuilderOptions,
context: ExecutorContext
) {
process.env.NX_NEXT_DIR = root;
process.env.NX_NEXT_PUBLIC_DIR = join(root, 'public');
const baseUrl = `http://${options.hostname || 'localhost'}:${options.port}`;
const customServerBuild = await runExecutor(
parseTargetString(options.customServerTarget, context.projectGraph),
{
watch: options.dev ? true : false,
},
context
);
for await (const result of customServerBuild) {
if (!result.success) {
return result;
}
yield {
success: true,
baseUrl,
};
}
return { success: true };
}

View File

@ -50,6 +50,10 @@
"type": "boolean", "type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.", "description": "Read buildable libraries from source instead of building them separately.",
"default": true "default": true
},
"keepAliveTimeout": {
"type": "number",
"description": "Max milliseconds to wait before closing inactive connection."
} }
}, },
"required": ["buildTarget"] "required": ["buildTarget"]

View File

@ -1,27 +1,29 @@
import 'dotenv/config'; import 'dotenv/config';
import * as net from 'net';
import { import {
ExecutorContext, ExecutorContext,
logger, logger,
parseTargetString, parseTargetString,
readTargetOptions, readTargetOptions,
runExecutor,
} from '@nx/devkit'; } from '@nx/devkit';
import * as chalk from 'chalk'; import { resolve } from 'path';
import { existsSync } from 'fs';
import { join, resolve } from 'path';
import { import {
NextBuildBuilderOptions, NextBuildBuilderOptions,
NextServeBuilderOptions, NextServeBuilderOptions,
NextServerOptions,
ProxyConfig,
} from '../../utils/types'; } from '../../utils/types';
import { defaultServer } from './lib/default-server'; import { spawn } from 'child_process';
import customServer from './custom-server.impl';
import { createCliOptions } from '../../utils/create-cli-options';
import { createAsyncIterable } from '@nx/devkit/src/utils/async-iterable';
export default async function* serveExecutor( export default async function* serveExecutor(
options: NextServeBuilderOptions, options: NextServeBuilderOptions,
context: ExecutorContext context: ExecutorContext
) { ) {
if (options.customServerTarget) {
return yield* customServer(options, context);
}
// Cast to any to overwrite NODE_ENV // Cast to any to overwrite NODE_ENV
(process.env as any).NODE_ENV = process.env.NODE_ENV (process.env as any).NODE_ENV = process.env.NODE_ENV
? process.env.NODE_ENV ? process.env.NODE_ENV
@ -38,96 +40,74 @@ export default async function* serveExecutor(
); );
const root = resolve(context.root, buildOptions.root); const root = resolve(context.root, buildOptions.root);
if (options.customServerTarget) { const { port, keepAliveTimeout, hostname } = options;
yield* runCustomServer(root, options, buildOptions, context);
} else {
yield* runNextDevServer(root, options, context);
}
}
async function* runNextDevServer( const args = createCliOptions({ port, keepAliveTimeout, hostname });
root: string, const nextDir = resolve(context.root, buildOptions.outputPath);
options: NextServeBuilderOptions,
context: ExecutorContext const command = `npx next ${options.dev ? `dev ${args}` : `start ${args}`}`;
) {
const baseUrl = `http://${options.hostname || 'localhost'}:${options.port}`; yield* createAsyncIterable<{ success: boolean; baseUrl: string }>(
const settings: NextServerOptions = { ({ done, next, error }) => {
dev: options.dev, // Client to check if server is ready.
dir: root, const client = new net.Socket();
staticMarkup: options.staticMarkup, const cleanupClient = () => {
quiet: options.quiet, client.removeAllListeners('connect');
port: options.port, client.removeAllListeners('error');
customServer: !!options.customServerTarget, client.end();
hostname: options.hostname || 'localhost', client.destroy();
client.unref();
}; };
// look for the proxy.conf.json const waitForServerReady = (retries = 30) => {
let proxyConfig: ProxyConfig; const allowedErrorCodes = ['ECONNREFUSED', 'ECONNRESET'];
const proxyConfigPath = options.proxyConfig
? join(context.root, options.proxyConfig)
: join(root, 'proxy.conf.json');
// TODO(v16): Remove proxy support. client.once('connect', () => {
if (existsSync(proxyConfigPath)) { cleanupClient();
logger.warn( next({
`The "proxyConfig" option will be removed in Nx 16. Use the "rewrites" feature from Next.js instead. See: https://nextjs.org/docs/api-reference/next.config.js/rewrites`
);
proxyConfig = require(proxyConfigPath);
}
try {
await defaultServer(settings, proxyConfig);
logger.info(`[ ${chalk.green('ready')} ] on ${baseUrl}`);
yield {
baseUrl,
success: true, success: true,
}; baseUrl: `http://${options.hostname ?? 'localhost'}:${port}`,
});
});
// This Promise intentionally never resolves, leaving the process running client.on('error', (err) => {
await new Promise<{ success: boolean }>(() => {}); if (retries === 0 || !allowedErrorCodes.includes(err['code'])) {
} catch (e) { cleanupClient();
if (options.dev) { error(err);
throw e;
} else { } else {
if (process.env.NX_VERBOSE_LOGGING) { setTimeout(() => waitForServerReady(retries - 1), 1000);
console.error(e);
}
throw new Error(
`Could not start production server. Try building your app with \`nx build ${context.projectName}\`.`
);
}
}
} }
});
async function* runCustomServer( client.connect({ port, host: '127.0.0.1' });
root: string,
options: NextServeBuilderOptions,
buildOptions: NextBuildBuilderOptions,
context: ExecutorContext
) {
process.env.NX_NEXT_DIR = root;
process.env.NX_NEXT_PUBLIC_DIR = join(root, 'public');
const baseUrl = `http://${options.hostname || 'localhost'}:${options.port}`;
const customServerBuild = await runExecutor(
parseTargetString(options.customServerTarget, context.projectGraph),
{
watch: options.dev ? true : false,
},
context
);
for await (const result of customServerBuild) {
if (!result.success) {
return result;
}
yield {
success: true,
baseUrl,
}; };
}
return { success: true }; const server = spawn(command, {
cwd: options.dev ? root : nextDir,
stdio: 'inherit',
shell: true,
});
waitForServerReady();
server.once('exit', (code) => {
cleanupClient();
if (code === 0) {
done();
} else {
error(new Error(`Next.js app exited with code ${code}`));
}
});
process.on('exit', async (code) => {
if (code === 128 + 2) {
server.kill('SIGINT');
} else if (code === 128 + 1) {
server.kill('SIGHUP');
} else {
server.kill('SIGTERM');
}
});
}
);
} }

View File

@ -41,7 +41,7 @@ export function createWebpackConfig(
): Configuration { ): Configuration {
const mainFields = ['es2015', 'module', 'main']; const mainFields = ['es2015', 'module', 'main'];
const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx']; const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
let tsConfigPath = join(projectRoot, 'tsconfig.json'); let tsConfigPath = join(workspaceRoot, projectRoot, 'tsconfig.json');
if (dependencies.length > 0) { if (dependencies.length > 0) {
tsConfigPath = createTmpTsConfig( tsConfigPath = createTmpTsConfig(
join(workspaceRoot, tsConfigPath), join(workspaceRoot, tsConfigPath),

View File

@ -0,0 +1,12 @@
export function createCliOptions(obj: { [key: string]: any }): string {
return Object.entries(obj)
.reduce((arr, [key, value]) => {
if (value !== undefined) {
const kebabCase = key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
return `${arr}--${kebabCase}=${value} `;
} else {
return arr;
}
}, '')
.trim();
}

View File

@ -38,6 +38,8 @@ export interface NextBuildBuilderOptions {
generateLockfile?: boolean; generateLockfile?: boolean;
watch?: boolean; watch?: boolean;
debug?: boolean; debug?: boolean;
profile?: boolean;
experimentalAppOnly?: boolean;
} }
export interface NextServeBuilderOptions { export interface NextServeBuilderOptions {
@ -50,6 +52,7 @@ export interface NextServeBuilderOptions {
hostname?: string; hostname?: string;
proxyConfig?: string; proxyConfig?: string;
buildLibsFromSource?: boolean; buildLibsFromSource?: boolean;
keepAliveTimeout?: number;
} }
export interface NextExportBuilderOptions { export interface NextExportBuilderOptions {