feat(react): migrate @nrwl/web:dev-server to devkit (#4682)

This commit is contained in:
Jason Jean 2021-02-03 16:33:51 -05:00 committed by GitHub
parent a941961bd4
commit 34781a11c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 334 additions and 188 deletions

View File

@ -14,7 +14,7 @@ export {
} from '@nrwl/tao/src/shared/nx';
export { logger } from '@nrwl/tao/src/shared/logger';
export { getPackageManagerCommand } from '@nrwl/tao/src/shared/package-manager';
export { runExecutor } from '@nrwl/tao/src/commands/run';
export { runExecutor, Target } from '@nrwl/tao/src/commands/run';
export { formatFiles } from './src/generators/format-files';
export { generateFiles } from './src/generators/generate-files';
@ -30,6 +30,9 @@ export {
export { toJS } from './src/generators/to-js';
export { visitNotIgnoredFiles } from './src/generators/visit-not-ignored-files';
export { parseTargetString } from './src/executors/parse-target-string';
export { readTargetOptions } from './src/executors/read-target-options';
export { readJson, writeJson, updateJson } from './src/utils/json';
export { addDependenciesToPackageJson } from './src/utils/package-json';
export { installPackagesTask } from './src/tasks/install-packages-task';

View File

@ -0,0 +1,11 @@
export function parseTargetString(targetString: string) {
const [project, target, configuration] = targetString.split(':');
if (!project || !target) {
throw new Error(`Invalid Target String: ${targetString}`);
}
return {
project,
target,
configuration,
};
}

View File

@ -0,0 +1,29 @@
import { Target } from '@nrwl/tao/src/commands/run';
import { ExecutorContext, Workspaces } from '@nrwl/tao/src/shared/workspace';
import { combineOptionsForExecutor } from '@nrwl/tao/src/shared/params';
export function readTargetOptions<T = any>(
{ project, target, configuration }: Target,
context: ExecutorContext
): T {
const projectConfiguration = context.workspace.projects[project];
const targetConfiguration = projectConfiguration.targets[target];
const ws = new Workspaces(context.root);
const [nodeModule, executorName] = targetConfiguration.executor.split(':');
const { schema } = ws.readExecutor(nodeModule, executorName);
const defaultProject = ws.calculateDefaultProjectName(
context.cwd,
context.workspace
);
return combineOptionsForExecutor(
{},
configuration,
targetConfiguration,
schema,
defaultProject,
ws.relativeCwd(context.cwd)
) as T;
}

View File

@ -19,10 +19,13 @@ import * as chalk from 'chalk';
import { logger } from '../shared/logger';
import { eachValueFrom } from 'rxjs-for-await';
export interface RunOptions {
export interface Target {
project: string;
target: string;
configuration: string;
configuration?: string;
}
export interface RunOptions extends Target {
help: boolean;
runOptions: Options;
}

View File

@ -12,7 +12,7 @@
"description": "Package a library"
},
"dev-server": {
"implementation": "./src/builders/dev-server/dev-server.impl",
"implementation": "./src/builders/dev-server/compat",
"schema": "./src/builders/dev-server/schema.json",
"description": "Serve a web application"
},

View File

@ -0,0 +1,5 @@
import { convertNxExecutor } from '@nrwl/devkit';
import devServerExecutor from './dev-server.impl';
export default convertNxExecutor(devServerExecutor);

View File

@ -1,37 +1,57 @@
import { MockBuilderContext } from '@nrwl/workspace/testing';
import { getMockContext } from '../../utils/testing';
import { EMPTY } from 'rxjs';
import * as normalizeUtils from '../../utils/normalize';
import { WebBuildBuilderOptions } from '../build/build.impl';
import { run, WebDevServerOptions } from './dev-server.impl';
import webDevServerImpl, { WebDevServerOptions } from './dev-server.impl';
jest.mock('@angular-devkit/build-webpack', () => ({
runWebpackDevServer: () => EMPTY,
}));
jest.mock('@nrwl/devkit');
import { readTargetOptions, ExecutorContext } from '@nrwl/devkit';
jest.mock('../../utils/devserver.config', () => ({
getDevServerConfig: jest.fn().mockReturnValue({}),
}));
describe('Web Server Builder', () => {
let context: MockBuilderContext;
let context: ExecutorContext;
let options: WebDevServerOptions;
beforeEach(async () => {
jest.clearAllMocks();
context = await getMockContext();
context.getProjectMetadata = jest
.fn()
.mockReturnValue({ sourceRoot: '/root/app/src' });
context.getTargetOptions = jest.fn().mockReturnValue({});
context = {
root: '/root',
cwd: '/root',
projectName: 'proj',
targetName: 'serve',
workspace: {
version: 2,
projects: {
proj: {
root: 'proj',
sourceRoot: 'proj/src',
targets: {
serve: {
executor: '@nrwl/web:dev-server',
options: {
buildTarget: 'proj:build',
},
},
build: {
executor: 'build',
options: {},
},
},
},
},
},
isVerbose: false,
};
options = {
buildTarget: 'app:build',
buildTarget: 'proj:build',
port: 4200,
} as WebDevServerOptions;
(readTargetOptions as any).mockImplementation(() => {});
jest
.spyOn(normalizeUtils, 'normalizeWebBuildOptions')
.mockReturnValue({} as WebBuildBuilderOptions);
@ -39,14 +59,14 @@ describe('Web Server Builder', () => {
it('should pass `baseHref` to build', async () => {
const baseHref = '/my-domain';
await run({ ...options, baseHref }, context).toPromise();
await webDevServerImpl({ ...options, baseHref }, context);
expect(normalizeUtils.normalizeWebBuildOptions).toHaveBeenCalledWith(
expect.objectContaining({
baseHref,
}),
'/root',
'/root/app/src'
'proj/src'
);
});
});

View File

@ -1,27 +1,19 @@
import {
BuilderContext,
createBuilder,
targetFromTargetString,
} from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';
import { Observable, from, forkJoin } from 'rxjs';
import { normalizeWebBuildOptions } from '../../utils/normalize';
import { map, switchMap } from 'rxjs/operators';
import { WebBuildBuilderOptions } from '../build/build.impl';
ExecutorContext,
parseTargetString,
readTargetOptions,
} from '@nrwl/devkit';
import { Configuration } from 'webpack';
import * as opn from 'opn';
import * as url from 'url';
import { stripIndents } from '@angular-devkit/core/src/utils/literals';
import { getDevServerConfig } from '../../utils/devserver.config';
import { buildServePath } from '../../utils/serve-path';
import { getSourceRoot } from '../../utils/source-root';
import {
runWebpackDevServer,
DevServerBuildOutput,
} from '@angular-devkit/build-webpack';
export interface WebDevServerOptions extends JsonObject {
import { eachValueFrom } from 'rxjs-for-await';
import { map, tap } from 'rxjs/operators';
import { runWebpackDevServer } from '@nrwl/workspace/src/utilities/run-webpack';
import { normalizeWebBuildOptions } from '../../utils/normalize';
import { WebBuildBuilderOptions } from '../build/build.impl';
import { getDevServerConfig } from '../../utils/devserver.config';
export interface WebDevServerOptions {
host: string;
port: number;
publicHost?: string;
@ -39,111 +31,67 @@ export interface WebDevServerOptions extends JsonObject {
baseHref?: string;
}
export default createBuilder<WebDevServerOptions>(run);
export function run(
export default function devServerExecutor(
serveOptions: WebDevServerOptions,
context: BuilderContext
): Observable<DevServerBuildOutput> {
return forkJoin(
context: ExecutorContext
) {
const sourceRoot = context.workspace.projects[context.projectName].sourceRoot;
const buildOptions = normalizeWebBuildOptions(
getBuildOptions(serveOptions, context),
from(getSourceRoot(context))
).pipe(
map(([buildOptions, sourceRoot]) => {
buildOptions = normalizeWebBuildOptions(
buildOptions,
context.workspaceRoot,
sourceRoot
);
let webpackConfig: Configuration = getDevServerConfig(
context.workspaceRoot,
sourceRoot,
buildOptions,
serveOptions,
context.logger
);
if (buildOptions.webpackConfig) {
webpackConfig = require(buildOptions.webpackConfig)(webpackConfig, {
buildOptions,
configuration: serveOptions.buildTarget.split(':')[2],
});
}
return [webpackConfig, buildOptions] as [
Configuration,
WebBuildBuilderOptions
];
}),
map(([_, options]) => {
const path = buildServePath(options);
const serverUrl = url.format({
protocol: serveOptions.ssl ? 'https' : 'http',
hostname: serveOptions.host,
port: serveOptions.port.toString(),
pathname: path,
});
context.root,
sourceRoot
);
let webpackConfig: Configuration = getDevServerConfig(
context.root,
sourceRoot,
buildOptions,
serveOptions
);
if (buildOptions.webpackConfig) {
webpackConfig = require(buildOptions.webpackConfig)(webpackConfig, {
buildOptions,
configuration: serveOptions.buildTarget.split(':')[2],
});
}
context.logger.info(stripIndents`
**
Web Development Server is listening at ${serverUrl}
**
`);
if (serveOptions.open) {
opn(serverUrl, {
wait: false,
});
}
return [_, options, serverUrl] as [
Configuration,
WebBuildBuilderOptions,
string
];
}),
switchMap(([config, options, serverUrl]) => {
return runWebpackDevServer(config, context, {
logging: (stats) => {
context.logger.info(stats.toString(config.stats));
},
webpackFactory: require('webpack'),
webpackDevServerFactory: require('webpack-dev-server'),
}).pipe(
map((output) => {
output.baseUrl = serverUrl;
return output;
})
);
})
return eachValueFrom(
runWebpackDevServer(webpackConfig).pipe(
tap(({ stats }) => {
console.info(stats.toString(webpackConfig.stats));
}),
map(({ baseUrl, stats }) => {
return {
stats,
baseUrl,
success: !stats.hasErrors(),
};
})
)
);
}
function getBuildOptions(
options: WebDevServerOptions,
context: BuilderContext
): Observable<WebBuildBuilderOptions> {
const target = targetFromTargetString(options.buildTarget);
const overrides: Partial<WebBuildBuilderOptions> = {};
context: ExecutorContext
): WebBuildBuilderOptions {
const target = parseTargetString(options.buildTarget);
const overrides: Partial<WebBuildBuilderOptions> = {
watch: false,
};
if (options.maxWorkers) {
overrides.maxWorkers = options.maxWorkers;
}
if (options.memoryLimit) {
overrides.memoryLimit = options.memoryLimit;
}
return from(
Promise.all([
context.getTargetOptions(target),
context.getBuilderNameForTarget(target),
])
.then(([targetOptions, builderName]) => {
if (options.baseHref) {
targetOptions.baseHref = options.baseHref;
}
return context.validateOptions<WebBuildBuilderOptions & JsonObject>(
targetOptions,
builderName
);
})
.then((options) => ({
...options,
...overrides,
}))
);
if (options.baseHref) {
overrides.baseHref = options.baseHref;
}
const buildOptions = readTargetOptions(target, context);
return {
...buildOptions,
...overrides,
};
}

View File

@ -1,6 +1,7 @@
{
"title": "Web Dev Server",
"description": "Web Dev Server",
"cli": "nx",
"type": "object",
"properties": {
"buildTarget": {

View File

@ -1,5 +1,4 @@
import { getDevServerConfig } from './devserver.config';
import { Logger } from '@angular-devkit/core/src/logger';
import TsConfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import * as ts from 'typescript';
import * as fs from 'fs';
@ -9,12 +8,14 @@ import { join } from 'path';
jest.mock('tsconfig-paths-webpack-plugin');
import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
import { logger } from '@nrwl/devkit';
jest.mock('opn');
import * as opn from 'opn';
describe('getDevServerConfig', () => {
let buildInput: WebBuildBuilderOptions;
let serveInput: WebDevServerOptions;
let mockCompilerOptions: any;
let logger: Logger;
let root: string;
let sourceRoot: string;
@ -78,8 +79,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.headers['Access-Control-Allow-Origin']).toEqual('*');
@ -90,8 +90,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.overlay.warnings).toEqual(false);
@ -102,8 +101,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.stats).toEqual(false);
@ -114,12 +112,76 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.contentBase).toEqual(false);
});
describe('onListening', () => {
let mockServer;
beforeEach(() => {
mockServer = {
options: {
https: false,
},
hostname: 'example.com',
listeningApp: {
address: () => ({
port: 9999,
}),
},
};
spyOn(logger, 'info');
opn.mockImplementation(() => {});
});
it('should print out the URL of the server', () => {
const { devServer: result } = getDevServerConfig(
root,
sourceRoot,
buildInput,
serveInput
) as any;
result.onListening(mockServer);
expect(logger.info).toHaveBeenCalledWith(
jasmine.stringMatching(new RegExp('http://example.com:9999/'))
);
});
it('should not open the url by default', () => {
const { devServer: result } = getDevServerConfig(
root,
sourceRoot,
buildInput,
serveInput
) as any;
result.onListening(mockServer);
expect(opn).not.toHaveBeenCalled();
});
it('should open the url if --open is passed', () => {
mockServer.options.open = true;
const { devServer: result } = getDevServerConfig(
root,
sourceRoot,
buildInput,
serveInput
) as any;
result.onListening(mockServer);
expect(opn).toHaveBeenCalledWith('http://example.com:9999/', {
wait: false,
});
});
});
});
describe('host option', () => {
@ -128,8 +190,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.host).toEqual('localhost');
@ -142,8 +203,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.port).toEqual(4200);
@ -156,8 +216,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.historyApiFallback).toEqual({
@ -173,8 +232,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.compress).toEqual(false);
@ -191,8 +249,7 @@ describe('getDevServerConfig', () => {
styles: false,
},
},
serveInput,
logger
serveInput
) as any;
expect(result.compress).toEqual(true);
@ -209,8 +266,7 @@ describe('getDevServerConfig', () => {
styles: true,
},
},
serveInput,
logger
serveInput
) as any;
expect(result.compress).toEqual(true);
@ -227,8 +283,7 @@ describe('getDevServerConfig', () => {
styles: true,
},
},
serveInput,
logger
serveInput
) as any;
expect(result.compress).toEqual(true);
@ -245,8 +300,7 @@ describe('getDevServerConfig', () => {
styles: false,
},
},
serveInput,
logger
serveInput
) as any;
expect(result.overlay.errors).toEqual(true);
@ -263,8 +317,7 @@ describe('getDevServerConfig', () => {
styles: true,
},
},
serveInput,
logger
serveInput
) as any;
expect(result.overlay.errors).toEqual(false);
@ -277,8 +330,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
);
expect(result.liveReload).toEqual(true);
@ -289,8 +341,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
{ ...serveInput, liveReload: false },
logger
{ ...serveInput, liveReload: false }
);
expect(result.liveReload).toEqual(false);
@ -309,8 +360,7 @@ describe('getDevServerConfig', () => {
styles: true,
},
},
serveInput,
logger
serveInput
) as any;
expect(result.https).toEqual(false);
@ -334,8 +384,7 @@ describe('getDevServerConfig', () => {
ssl: true,
sslKey: 'ssl.key',
sslCert: 'ssl.cert',
},
logger
}
) as any;
expect(result.https).toEqual({
@ -364,8 +413,7 @@ describe('getDevServerConfig', () => {
{
...serveInput,
proxyConfig: 'proxy.conf',
},
logger
}
) as any;
expect(result.proxy).toEqual({
@ -383,8 +431,7 @@ describe('getDevServerConfig', () => {
{
...serveInput,
allowedHosts: 'host.com,subdomain.host.com',
},
logger
}
) as any;
expect(result.allowedHosts).toEqual(['host.com', 'subdomain.host.com']);
@ -398,8 +445,7 @@ describe('getDevServerConfig', () => {
{
...serveInput,
allowedHosts: 'host.com',
},
logger
}
) as any;
expect(result.allowedHosts).toEqual(['host.com']);
@ -410,8 +456,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
buildInput,
serveInput,
logger
serveInput
) as any;
expect(result.allowedHosts).toEqual([]);
@ -423,8 +468,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
{ ...buildInput, maxWorkers: 1 },
serveInput,
logger
serveInput
) as any;
const typeCheckerPlugin = result.plugins.find(
@ -440,8 +484,7 @@ describe('getDevServerConfig', () => {
root,
sourceRoot,
{ ...buildInput, memoryLimit: 1024 },
serveInput,
logger
serveInput
) as any;
const typeCheckerPlugin = result.plugins.find(

View File

@ -1,9 +1,13 @@
import { logger } from '@nrwl/devkit';
import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
import * as opn from 'opn';
import * as url from 'url';
import { readFileSync } from 'fs';
import * as path from 'path';
import { getWebConfig } from './web.config';
import { Configuration } from 'webpack';
import { LoggerApi } from '@angular-devkit/core/src/logger';
import { WebBuildBuilderOptions } from '../builders/build/build.impl';
import { WebDevServerOptions } from '../builders/dev-server/dev-server.impl';
import { buildServePath } from './serve-path';
@ -13,8 +17,7 @@ export function getDevServerConfig(
root: string,
sourceRoot: string,
buildOptions: WebBuildBuilderOptions,
serveOptions: WebDevServerOptions,
logger: LoggerApi
serveOptions: WebDevServerOptions
) {
const webpackConfig: Configuration = getWebConfig(
root,
@ -53,6 +56,23 @@ function getDevServerPartial(
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
},
noInfo: true,
onListening: function (server: any) {
// Depend on the info in the server for this function because the user might adjust the webpack config
const serverUrl = url.format({
protocol: server.options.https ? 'https' : 'http',
hostname: server.hostname,
port: server.listeningApp.address().port,
pathname: buildServePath(buildOptions),
});
logger.info(`NX Web Development Server is listening at ${serverUrl}`);
if (server.options.open) {
opn(serverUrl, {
wait: false,
});
}
},
stats: false,
compress: scriptsOptimization || stylesOptimization,
https: options.ssl,

View File

@ -1,7 +1,12 @@
import * as webpack from 'webpack';
import { Observable } from 'rxjs';
import { Stats, Configuration } from 'webpack';
import * as WebpackDevServer from 'webpack-dev-server';
import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
import { Observable } from 'rxjs';
import { extname } from 'path';
import * as url from 'url';
export function runWebpack(config: Configuration): Observable<Stats> {
return new Observable((subscriber) => {
@ -16,7 +21,11 @@ export function runWebpack(config: Configuration): Observable<Stats> {
if (config.watch) {
const watchOptions = config.watchOptions || {};
webpackCompiler.watch(watchOptions, callback);
const watching = webpackCompiler.watch(watchOptions, callback);
return () => {
watching.close(() => {});
};
} else {
webpackCompiler.run((err, stats) => {
callback(err, stats);
@ -26,6 +35,59 @@ export function runWebpack(config: Configuration): Observable<Stats> {
});
}
export function runWebpackDevServer(
config: Configuration
): Observable<{ stats: Stats; baseUrl: string }> {
return new Observable((subscriber) => {
const webpackCompiler = webpack(config);
let baseUrl: string;
webpackCompiler.hooks.done.tap('build-webpack', (stats) => {
subscriber.next({ stats, baseUrl });
});
const devServerConfig = config.devServer || {};
const originalOnListen = devServerConfig.onListening;
devServerConfig.onListening = function (server: any) {
originalOnListen(server);
const devServerOptions: WebpackDevServerConfiguration = server.options;
baseUrl = url.format({
protocol: devServerOptions.https ? 'https' : 'http',
hostname: server.hostname,
port: server.listeningApp.address().port,
pathname: devServerOptions.publicPath,
});
};
const webpackServer = new WebpackDevServer(
webpackCompiler,
devServerConfig
);
try {
const server = webpackServer.listen(
devServerConfig.port ?? 8080,
devServerConfig.host ?? 'localhost',
function (err) {
if (err) {
subscriber.error(err);
}
}
);
return () => {
server.close();
};
} catch (e) {
throw new Error('Could not start start dev server');
}
});
}
export interface EmittedFile {
id?: string;
name?: string;

View File

@ -71,6 +71,7 @@ const IGNORE_MATCHES = {
'karma-jasmine',
'karma-jasmine-html-reporter',
'webpack',
'webpack-dev-server',
],
};