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": {
"type": "boolean",
"description": "Enable Next.js debug build logging",
"default": false
"description": "Enable Next.js debug build logging"
},
"profile": {
"type": "boolean",
"description": "Used to enable React Production Profiling"
},
"experimentalAppOnly": {
"type": "boolean",
"description": "Only build 'app' routes"
}
},
"required": ["root", "outputPath"],

View File

@ -33,6 +33,7 @@
"presets": []
},
"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": [],
"hidden": false,
"path": "/packages/next/src/executors/export/schema.json",

View File

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

View File

@ -1,6 +1,7 @@
import { execSync } from 'child_process';
import {
checkFilesExist,
killPorts,
killPort,
readJson,
runCLI,
runCLIAsync,
@ -42,10 +43,10 @@ export async function checkApp(
if (opts.checkE2E && runCypressTests()) {
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(await killPorts()).toBeTruthy();
await killPort(9000);
}
if (opts.checkExport) {

View File

@ -13,7 +13,8 @@
"export": {
"implementation": "./src/executors/export/export.impl",
"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": {
@ -30,7 +31,8 @@
"export": {
"implementation": "./src/executors/export/compat",
"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"
},
"peerDependencies": {
"next": "^13.0.0"
"next": ">=13.0.0"
},
"dependencies": {
"@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).
*/
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(
_nextConfig = {} as WithNxOptions,
context: WithNxContext = getWithNxContext()
): 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) => {
const { PHASE_PRODUCTION_SERVER } = await import('next/constants');
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.
const { nx, ...validNextConfig } = _nextConfig;
return {
distDir: '.next',
...validNextConfig,
distDir: await determineDistDirForProdServer(_nextConfig),
};
} else {
const {

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import {
NextBuildBuilderOptions,
NextExportBuilderOptions,
} from '../../utils/types';
import { PHASE_EXPORT } from '../../utils/constants';
import nextTrace = require('next/dist/trace');
import { platform } from 'os';
import { execFileSync } from 'child_process';
@ -25,6 +25,18 @@ import * as chalk from 'chalk';
// platform specific command name
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(
options: NextExportBuilderOptions,
context: ExecutorContext
@ -41,7 +53,6 @@ export default async function exportExecutor(
dependencies = result.dependencies;
}
const libsDir = join(context.root, workspaceLayout().libsDir);
const buildTarget = parseTargetString(
options.buildTarget,
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",
"description": "Read buildable libraries from source instead of building them separately.",
"default": true
},
"keepAliveTimeout": {
"type": "number",
"description": "Max milliseconds to wait before closing inactive connection."
}
},
"required": ["buildTarget"]

View File

@ -1,27 +1,29 @@
import 'dotenv/config';
import * as net from 'net';
import {
ExecutorContext,
logger,
parseTargetString,
readTargetOptions,
runExecutor,
} from '@nx/devkit';
import * as chalk from 'chalk';
import { existsSync } from 'fs';
import { join, resolve } from 'path';
import { resolve } from 'path';
import {
NextBuildBuilderOptions,
NextServeBuilderOptions,
NextServerOptions,
ProxyConfig,
} 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(
options: NextServeBuilderOptions,
context: ExecutorContext
) {
if (options.customServerTarget) {
return yield* customServer(options, context);
}
// Cast to any to overwrite NODE_ENV
(process.env as any).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);
if (options.customServerTarget) {
yield* runCustomServer(root, options, buildOptions, context);
} else {
yield* runNextDevServer(root, options, context);
}
}
const { port, keepAliveTimeout, hostname } = options;
async function* runNextDevServer(
root: string,
options: NextServeBuilderOptions,
context: ExecutorContext
) {
const baseUrl = `http://${options.hostname || 'localhost'}:${options.port}`;
const settings: NextServerOptions = {
dev: options.dev,
dir: root,
staticMarkup: options.staticMarkup,
quiet: options.quiet,
port: options.port,
customServer: !!options.customServerTarget,
hostname: options.hostname || 'localhost',
};
const args = createCliOptions({ port, keepAliveTimeout, hostname });
const nextDir = resolve(context.root, buildOptions.outputPath);
// look for the proxy.conf.json
let proxyConfig: ProxyConfig;
const proxyConfigPath = options.proxyConfig
? join(context.root, options.proxyConfig)
: join(root, 'proxy.conf.json');
const command = `npx next ${options.dev ? `dev ${args}` : `start ${args}`}`;
// TODO(v16): Remove proxy support.
if (existsSync(proxyConfigPath)) {
logger.warn(
`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);
}
yield* createAsyncIterable<{ success: boolean; baseUrl: string }>(
({ done, next, error }) => {
// Client to check if server is ready.
const client = new net.Socket();
const cleanupClient = () => {
client.removeAllListeners('connect');
client.removeAllListeners('error');
client.end();
client.destroy();
client.unref();
};
try {
await defaultServer(settings, proxyConfig);
logger.info(`[ ${chalk.green('ready')} ] on ${baseUrl}`);
const waitForServerReady = (retries = 30) => {
const allowedErrorCodes = ['ECONNREFUSED', 'ECONNRESET'];
yield {
baseUrl,
success: true,
};
client.once('connect', () => {
cleanupClient();
next({
success: true,
baseUrl: `http://${options.hostname ?? 'localhost'}:${port}`,
});
});
// This Promise intentionally never resolves, leaving the process running
await new Promise<{ success: boolean }>(() => {});
} catch (e) {
if (options.dev) {
throw e;
} else {
if (process.env.NX_VERBOSE_LOGGING) {
console.error(e);
}
throw new Error(
`Could not start production server. Try building your app with \`nx build ${context.projectName}\`.`
);
client.on('error', (err) => {
if (retries === 0 || !allowedErrorCodes.includes(err['code'])) {
cleanupClient();
error(err);
} else {
setTimeout(() => waitForServerReady(retries - 1), 1000);
}
});
client.connect({ port, host: '127.0.0.1' });
};
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');
}
});
}
}
}
async function* runCustomServer(
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 };
}

View File

@ -41,7 +41,7 @@ export function createWebpackConfig(
): Configuration {
const mainFields = ['es2015', 'module', 'main'];
const extensions = ['.ts', '.tsx', '.mjs', '.js', '.jsx'];
let tsConfigPath = join(projectRoot, 'tsconfig.json');
let tsConfigPath = join(workspaceRoot, projectRoot, 'tsconfig.json');
if (dependencies.length > 0) {
tsConfigPath = createTmpTsConfig(
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;
watch?: boolean;
debug?: boolean;
profile?: boolean;
experimentalAppOnly?: boolean;
}
export interface NextServeBuilderOptions {
@ -50,6 +52,7 @@ export interface NextServeBuilderOptions {
hostname?: string;
proxyConfig?: string;
buildLibsFromSource?: boolean;
keepAliveTimeout?: number;
}
export interface NextExportBuilderOptions {