Skip to content

Commit 469af6e

Browse files
FrozenPandazvsavkin
authored andcommittedOct 1, 2018
feat(builders): introduce node build and execute builders
These builders handle building and executing node applications
1 parent 039c151 commit 469af6e

16 files changed

+1416
-43
lines changed
 

Diff for: ‎.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ node_modules
22
.idea
33
.vscode
44
dist
5-
build
5+
/build
66
test
77
.DS_Store
88
tmp

Diff for: ‎.prettierignore

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
tmp
2-
build
2+
/build
33
node_modules
44
/package.json
5-
packages/schematics/src/collection/**/files/*.json
5+
packages/schematics/src/collection/**/files/*.json

Diff for: ‎package.json

+9
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
"checkformat": "prettier \"./**/*.{ts,js,json,css,md}\" \"!./**/{__name__,__directory__}/**\" --list-different"
2020
},
2121
"devDependencies": {
22+
"@angular-devkit/architect": "0.8.3",
2223
"@angular-devkit/build-angular": "0.8.3",
24+
"@angular-devkit/build-webpack": "0.8.3",
2325
"@angular-devkit/core": "0.8.3",
2426
"@angular-devkit/schematics": "0.8.3",
2527
"@angular/cli": "6.2.3",
@@ -39,15 +41,19 @@
3941
"@schematics/angular": "0.8.3",
4042
"@types/jasmine": "~2.8.6",
4143
"@types/jasminewd2": "~2.0.3",
44+
"@types/jest": "^23.3.2",
4245
"@types/node": "~8.9.4",
4346
"@types/prettier": "^1.10.0",
47+
"@types/webpack": "^4.4.11",
4448
"@types/yargs": "^11.0.0",
4549
"angular": "1.6.6",
4650
"app-root-path": "^2.0.1",
51+
"circular-dependency-plugin": "^5.0.2",
4752
"commitizen": "^2.10.1",
4853
"conventional-changelog-cli": "^1.3.21",
4954
"cosmiconfig": "^4.0.0",
5055
"cz-conventional-changelog": "^2.1.0",
56+
"fork-ts-checker-webpack-plugin": "^0.4.9",
5157
"fs-extra": "5.0.0",
5258
"graphviz": "^0.0.8",
5359
"husky": "^1.0.0-rc.13",
@@ -60,6 +66,7 @@
6066
"karma-chrome-launcher": "~2.2.0",
6167
"karma-jasmine": "~1.1.1",
6268
"karma-webpack": "2.0.4",
69+
"license-webpack-plugin": "^1.4.0",
6370
"lint-staged": "^7.2.2",
6471
"ng-packagr": "3.0.6",
6572
"npm-run-all": "4.1.2",
@@ -74,6 +81,8 @@
7481
"tslint": "5.11.0",
7582
"typescript": "~2.9.2",
7683
"viz.js": "^1.8.1",
84+
"webpack": "4.9.2",
85+
"webpack-node-externals": "^1.7.2",
7786
"yargs": "^11.0.0",
7887
"yargs-parser": "10.0.0",
7988
"zone.js": "^0.8.26"

Diff for: ‎packages/builders/package.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@
2525
"builders": "./src/builders.json",
2626
"dependencies": {
2727
"@angular-devkit/architect": "~0.8.0",
28-
"rxjs": "6.2.2"
28+
"@angular-devkit/build-webpack": "~0.8.0",
29+
"fork-ts-checker-webpack-plugin": "0.4.9",
30+
"license-webpack-plugin": "^1.4.0",
31+
"rxjs": "6.2.2",
32+
"ts-loader": "4.5.0",
33+
"webpack": "4.9.2",
34+
"webpack-node-externals": "1.7.2"
2935
}
3036
}

Diff for: ‎packages/builders/src/builders.json

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
{
2-
"$schema": "../architect/src/builders-schema.json",
2+
"$schema": "@angular-devkit/architect/src/builders-schema.json",
33
"builders": {
4+
"node-build": {
5+
"class": "./node/build/node-build.builder",
6+
"schema": "./node/build/schema.json",
7+
"description": "Build a Node application"
8+
},
9+
"node-execute": {
10+
"class": "./node/execute/node-execute.builder",
11+
"schema": "./node/execute/schema.json",
12+
"description": "Build a Node application"
13+
},
414
"jest": {
515
"class": "./jest/jest.builder",
616
"schema": "./jest/schema.json",

Diff for: ‎packages/builders/src/jest/jest.builder.spec.ts

+8-28
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import JestBuilder from './jest.builder';
22
import { normalize } from '@angular-devkit/core';
3-
import * as jestCLI from 'jest';
3+
jest.mock('jest');
4+
const { runCLI } = require('jest');
45
import * as path from 'path';
56

67
describe('Jest Builder', () => {
78
let builder: JestBuilder;
89

910
beforeEach(() => {
1011
builder = new JestBuilder();
11-
});
12-
13-
it('should send appropriate options to jestCLI', () => {
14-
const runCLI = spyOn(jestCLI, 'runCLI').and.returnValue(
12+
runCLI.mockReturnValue(
1513
Promise.resolve({
1614
results: {
1715
success: true
1816
}
1917
})
2018
);
19+
});
20+
21+
it('should send appropriate options to jestCLI', () => {
2122
const root = normalize('/root');
2223
builder
2324
.run({
@@ -46,13 +47,6 @@ describe('Jest Builder', () => {
4647
});
4748

4849
it('should send other options to jestCLI', () => {
49-
const runCLI = spyOn(jestCLI, 'runCLI').and.returnValue(
50-
Promise.resolve({
51-
results: {
52-
success: true
53-
}
54-
})
55-
);
5650
const root = normalize('/root');
5751
builder
5852
.run({
@@ -98,19 +92,12 @@ describe('Jest Builder', () => {
9892
);
9993
});
10094

101-
it('should send the main to jestCLI', () => {
102-
const runCLI = spyOn(jestCLI, 'runCLI').and.returnValue(
103-
Promise.resolve({
104-
results: {
105-
success: true
106-
}
107-
})
108-
);
95+
it('should send the main to runCLI', () => {
10996
const root = normalize('/root');
11097
builder
11198
.run({
11299
root,
113-
builder: '',
100+
builder: '@nrwl/builders:jest',
114101
projectType: 'application',
115102
options: {
116103
jestConfig: './jest.config.js',
@@ -139,13 +126,6 @@ describe('Jest Builder', () => {
139126
});
140127

141128
it('should return the proper result', async done => {
142-
spyOn(jestCLI, 'runCLI').and.returnValue(
143-
Promise.resolve({
144-
results: {
145-
success: true
146-
}
147-
})
148-
);
149129
const root = normalize('/root');
150130
const result = await builder
151131
.run({

Diff for: ‎packages/builders/src/jest/jest.builder.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { map } from 'rxjs/operators';
99

1010
import * as path from 'path';
1111

12-
import { runCLI as runJest } from 'jest';
12+
const { runCLI } = require('jest');
1313

1414
export interface JestBuilderOptions {
1515
jestConfig: string;
@@ -61,7 +61,7 @@ export default class JestBuilder implements Builder<JestBuilderOptions> {
6161
);
6262
}
6363

64-
return from(runJest(config, [options.jestConfig])).pipe(
64+
return from(runCLI(config, [options.jestConfig])).pipe(
6565
map((results: any) => {
6666
return {
6767
success: results.results.success
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { normalize } from '@angular-devkit/core';
2+
import { TestLogger } from '@angular-devkit/architect/testing';
3+
import BuildNodeBuilder from './node-build.builder';
4+
import { BuildNodeBuilderOptions } from './node-build.builder';
5+
import { of } from 'rxjs';
6+
import * as fs from 'fs';
7+
8+
describe('NodeBuildBuilder', () => {
9+
let builder: BuildNodeBuilder;
10+
let testOptions: BuildNodeBuilderOptions;
11+
12+
beforeEach(() => {
13+
builder = new BuildNodeBuilder({
14+
host: <any>{},
15+
logger: new TestLogger('test'),
16+
workspace: <any>{
17+
root: '/root'
18+
},
19+
architect: <any>{}
20+
});
21+
testOptions = {
22+
main: 'apps/nodeapp/src/main.ts',
23+
tsConfig: 'apps/nodeapp/tsconfig.app.json',
24+
outputPath: 'dist/apps/nodeapp',
25+
externalDependencies: 'all',
26+
fileReplacements: [
27+
{
28+
replace: 'apps/environment/environment.ts',
29+
with: 'apps/environment/environment.prod.ts'
30+
},
31+
{
32+
replace: 'module1.ts',
33+
with: 'module2.ts'
34+
}
35+
]
36+
};
37+
});
38+
39+
describe('run', () => {
40+
it('should call runWebpack', () => {
41+
const runWebpack = spyOn(
42+
builder.webpackBuilder,
43+
'runWebpack'
44+
).and.returnValue(
45+
of({
46+
success: true
47+
})
48+
);
49+
50+
builder.run({
51+
root: normalize('/root'),
52+
projectType: 'application',
53+
builder: '@nrwl/builders:node-build',
54+
options: testOptions
55+
});
56+
57+
expect(runWebpack).toHaveBeenCalled();
58+
});
59+
60+
it('should emit the outfile along with success', async () => {
61+
const runWebpack = spyOn(
62+
builder.webpackBuilder,
63+
'runWebpack'
64+
).and.returnValue(
65+
of({
66+
success: true
67+
})
68+
);
69+
70+
const buildEvent = await builder
71+
.run({
72+
root: normalize('/root'),
73+
projectType: 'application',
74+
builder: '@nrwl/builders:node-build',
75+
options: testOptions
76+
})
77+
.toPromise();
78+
79+
expect(buildEvent.success).toEqual(true);
80+
expect(buildEvent.outfile).toEqual('/root/dist/apps/nodeapp/main.js');
81+
});
82+
83+
describe('when stats json option is passed', () => {
84+
beforeEach(() => {
85+
const stats = {
86+
stats: 'stats'
87+
};
88+
spyOn(builder.webpackBuilder, 'runWebpack').and.callFake((opts, cb) => {
89+
cb({
90+
toJson: () => stats,
91+
toString: () => JSON.stringify(stats)
92+
});
93+
return of({
94+
success: true
95+
});
96+
});
97+
spyOn(fs, 'writeFileSync');
98+
});
99+
100+
it('should generate a stats json', async () => {
101+
await builder
102+
.run({
103+
root: normalize('/root'),
104+
projectType: 'application',
105+
builder: '@nrwl/builders:node-build',
106+
options: {
107+
...testOptions,
108+
statsJson: true
109+
}
110+
})
111+
.toPromise();
112+
113+
expect(fs.writeFileSync).toHaveBeenCalledWith(
114+
'/root/dist/apps/nodeapp/stats.json',
115+
JSON.stringify(
116+
{
117+
stats: 'stats'
118+
},
119+
null,
120+
2
121+
)
122+
);
123+
});
124+
});
125+
});
126+
127+
describe('options normalization', () => {
128+
it('should add the root', () => {
129+
const result = (<any>builder).normalizeOptions(testOptions);
130+
expect(result.root).toEqual('/root');
131+
});
132+
133+
it('should resolve main from root', () => {
134+
const result = (<any>builder).normalizeOptions(testOptions);
135+
expect(result.main).toEqual('/root/apps/nodeapp/src/main.ts');
136+
});
137+
138+
it('should resolve the output path', () => {
139+
const result = (<any>builder).normalizeOptions(testOptions);
140+
expect(result.outputPath).toEqual('/root/dist/apps/nodeapp');
141+
});
142+
143+
it('should resolve the tsConfig path', () => {
144+
const result = (<any>builder).normalizeOptions(testOptions);
145+
expect(result.tsConfig).toEqual('/root/apps/nodeapp/tsconfig.app.json');
146+
});
147+
148+
it('should resolve the file replacement paths', () => {
149+
const result = (<any>builder).normalizeOptions(testOptions);
150+
expect(result.fileReplacements).toEqual([
151+
{
152+
replace: '/root/apps/environment/environment.ts',
153+
with: '/root/apps/environment/environment.prod.ts'
154+
},
155+
{
156+
replace: '/root/module1.ts',
157+
with: '/root/module2.ts'
158+
}
159+
]);
160+
});
161+
});
162+
});
+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
Builder,
3+
BuildEvent,
4+
BuilderConfiguration,
5+
BuilderContext
6+
} from '@angular-devkit/architect';
7+
import { virtualFs, Path } from '@angular-devkit/core';
8+
import { WebpackBuilder } from '@angular-devkit/build-webpack';
9+
10+
import { Observable } from 'rxjs';
11+
import { Stats, writeFileSync } from 'fs';
12+
import { getWebpackConfig, OUT_FILENAME } from './webpack/config';
13+
import { resolve } from 'path';
14+
import { map } from 'rxjs/operators';
15+
16+
export interface BuildNodeBuilderOptions {
17+
main: string;
18+
outputPath: string;
19+
tsConfig: string;
20+
watch?: boolean;
21+
optimization?: boolean;
22+
externalDependencies: 'all' | 'none' | string[];
23+
showCircularDependencies?: boolean;
24+
maxWorkers?: number;
25+
26+
fileReplacements: FileReplacement[];
27+
28+
progress?: boolean;
29+
statsJson?: boolean;
30+
extractLicenses?: boolean;
31+
32+
root?: Path;
33+
}
34+
35+
export interface FileReplacement {
36+
replace: string;
37+
with: string;
38+
}
39+
40+
export interface NodeBuildEvent extends BuildEvent {
41+
outfile: string;
42+
}
43+
44+
export default class BuildNodeBuilder
45+
implements Builder<BuildNodeBuilderOptions> {
46+
webpackBuilder: WebpackBuilder;
47+
48+
private get root() {
49+
return this.context.workspace.root;
50+
}
51+
52+
constructor(private context: BuilderContext) {
53+
this.webpackBuilder = new WebpackBuilder(this.context);
54+
}
55+
56+
run(
57+
builderConfig: BuilderConfiguration<BuildNodeBuilderOptions>
58+
): Observable<NodeBuildEvent> {
59+
const options = this.normalizeOptions(builderConfig.options);
60+
61+
let config = getWebpackConfig(options);
62+
return this.webpackBuilder
63+
.runWebpack(config, stats => {
64+
if (options.statsJson) {
65+
writeFileSync(
66+
resolve(this.root, options.outputPath, 'stats.json'),
67+
JSON.stringify(stats.toJson(), null, 2)
68+
);
69+
}
70+
71+
this.context.logger.info(stats.toString());
72+
})
73+
.pipe(
74+
map(buildEvent => ({
75+
...buildEvent,
76+
outfile: resolve(this.root, options.outputPath, OUT_FILENAME)
77+
}))
78+
);
79+
}
80+
81+
private normalizeOptions(options: BuildNodeBuilderOptions) {
82+
return {
83+
...options,
84+
root: this.root,
85+
main: resolve(this.root, options.main),
86+
outputPath: resolve(this.root, options.outputPath),
87+
tsConfig: resolve(this.root, options.tsConfig),
88+
fileReplacements: this.normalizeFileReplacements(options.fileReplacements)
89+
};
90+
}
91+
92+
private normalizeFileReplacements(
93+
fileReplacements: FileReplacement[]
94+
): FileReplacement[] {
95+
return fileReplacements.map(fileReplacement => ({
96+
replace: resolve(this.root, fileReplacement.replace),
97+
with: resolve(this.root, fileReplacement.with)
98+
}));
99+
}
100+
}

Diff for: ‎packages/builders/src/node/build/schema.json

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
{
2+
"title": "Node Application Build Target",
3+
"description": "Node application build target options for Build Facade",
4+
"type": "object",
5+
"properties": {
6+
"main": {
7+
"type": "string",
8+
"description": "The name of the main entry-point file."
9+
},
10+
"tsConfig": {
11+
"type": "string",
12+
"description": "The name of the Typescript configuration file."
13+
},
14+
"watch": {
15+
"type": "boolean",
16+
"description": "Run build when files change.",
17+
"default": false
18+
},
19+
"progress": {
20+
"type": "boolean",
21+
"description": "Log progress to the console while building.",
22+
"default": false
23+
},
24+
"externalDependencies": {
25+
"oneOf": [
26+
{
27+
"type": "string",
28+
"enum": ["none", "all"]
29+
},
30+
{
31+
"type": "array",
32+
"items": {
33+
"type": "string"
34+
}
35+
}
36+
],
37+
"description":
38+
"Dependencies to keep external to the bundle. (\"all\" (default), \"none\", or an array of module names)",
39+
"default": "all"
40+
},
41+
"statsJson": {
42+
"type": "boolean",
43+
"description":
44+
"Generates a 'stats.json' file which can be analyzed using tools such as: #webpack-bundle-analyzer' or https: //webpack.github.io/analyse.",
45+
"default": false
46+
},
47+
"extractLicenses": {
48+
"type": "boolean",
49+
"description":
50+
"Extract all licenses in a separate file, in the case of production builds only.",
51+
"default": false
52+
},
53+
"optimization": {
54+
"type": "boolean",
55+
"description": "Defines the optimization level of the build.",
56+
"default": false
57+
},
58+
"showCircularDependencies": {
59+
"type": "boolean",
60+
"description": "Show circular dependency warnings on builds.",
61+
"default": true
62+
},
63+
"maxWorkers": {
64+
"type": "number",
65+
"description":
66+
"Number of workers to use for type checking. (defaults to # of CPUS - 2)"
67+
},
68+
"fileReplacements": {
69+
"description": "Replace files with other files in the build.",
70+
"type": "array",
71+
"items": {
72+
"type": "object",
73+
"properties": {
74+
"replace": {
75+
"type": "string"
76+
},
77+
"with": {
78+
"type": "string"
79+
}
80+
},
81+
"additionalProperties": false,
82+
"required": ["replace", "with"]
83+
},
84+
"default": []
85+
}
86+
},
87+
"required": ["tsConfig", "main"]
88+
}
+310
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { getWebpackConfig } from './config';
2+
import { BuildNodeBuilderOptions } from '../node-build.builder';
3+
import { normalize } from '@angular-devkit/core';
4+
5+
import * as ts from 'typescript';
6+
import { LicenseWebpackPlugin } from 'license-webpack-plugin';
7+
import CircularDependencyPlugin = require('circular-dependency-plugin');
8+
import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
9+
import { ProgressPlugin } from 'webpack';
10+
11+
describe('getWebpackConfig', () => {
12+
let input: BuildNodeBuilderOptions;
13+
beforeEach(() => {
14+
input = {
15+
main: 'main.ts',
16+
outputPath: 'dist',
17+
tsConfig: 'tsconfig.json',
18+
externalDependencies: 'all',
19+
fileReplacements: [],
20+
root: normalize('/root')
21+
};
22+
});
23+
24+
describe('unconditional options', () => {
25+
it('should have output options', () => {
26+
const result = getWebpackConfig(input);
27+
28+
expect(result.output.filename).toEqual('main.js');
29+
expect(result.output.libraryTarget).toEqual('commonjs');
30+
});
31+
32+
it('should have a rule for typescript', () => {
33+
const result = getWebpackConfig(input);
34+
35+
const typescriptRule = result.module.rules.find(rule =>
36+
(rule.test as RegExp).test('app/main.ts')
37+
);
38+
expect(typescriptRule).toBeTruthy();
39+
40+
expect(typescriptRule.loader).toEqual('ts-loader');
41+
});
42+
43+
it('should split typescript type checking into a separate workers', () => {
44+
const result = getWebpackConfig(input);
45+
46+
const typeCheckerPlugin = result.plugins.find(
47+
plugin => plugin instanceof ForkTsCheckerWebpackPlugin
48+
) as ForkTsCheckerWebpackPlugin;
49+
expect(typeCheckerPlugin).toBeTruthy();
50+
});
51+
52+
it('should target node', () => {
53+
const result = getWebpackConfig(input);
54+
55+
expect(result.target).toEqual('node');
56+
});
57+
58+
it('should disable performance hints', () => {
59+
const result = getWebpackConfig(input);
60+
61+
expect(result.performance).toEqual({
62+
hints: false
63+
});
64+
});
65+
66+
it('should resolve typescript and javascript', () => {
67+
const result = getWebpackConfig(input);
68+
69+
expect(result.resolve.extensions).toEqual(['.ts', '.js']);
70+
});
71+
72+
it('should not polyfill node apis', () => {
73+
const result = getWebpackConfig(input);
74+
75+
expect(result.node).toEqual(false);
76+
});
77+
});
78+
79+
describe('the main option', () => {
80+
it('should set the correct entry options', () => {
81+
const result = getWebpackConfig(input);
82+
83+
expect(result.entry).toEqual(['main.ts']);
84+
});
85+
});
86+
87+
describe('the output option', () => {
88+
it('should set the correct output options', () => {
89+
const result = getWebpackConfig(input);
90+
91+
expect(result.output.path).toEqual('dist');
92+
});
93+
});
94+
95+
describe('the tsConfig option', () => {
96+
it('should set the correct typescript rule', () => {
97+
const result = getWebpackConfig(input);
98+
99+
expect(
100+
result.module.rules.find(rule => rule.loader === 'ts-loader').options
101+
).toEqual({
102+
configFile: 'tsconfig.json',
103+
transpileOnly: true,
104+
experimentalWatchApi: true
105+
});
106+
});
107+
108+
it('should set the correct options for the type checker plugin', () => {
109+
const result = getWebpackConfig(input);
110+
111+
const typeCheckerPlugin = result.plugins.find(
112+
plugin => plugin instanceof ForkTsCheckerWebpackPlugin
113+
) as ForkTsCheckerWebpackPlugin;
114+
expect(typeCheckerPlugin.options.tsconfig).toBe('tsconfig.json');
115+
});
116+
117+
it('should set aliases for compilerOptionPaths', () => {
118+
spyOn(ts, 'parseJsonConfigFileContent').and.returnValue({
119+
options: {
120+
paths: {
121+
'@npmScope/libraryName': ['libs/libraryName/src/index.ts']
122+
}
123+
}
124+
});
125+
126+
const result = getWebpackConfig(input);
127+
expect(result.resolve.alias).toEqual({
128+
'@npmScope/libraryName': '/root/libs/libraryName/src/index.ts'
129+
});
130+
});
131+
});
132+
133+
describe('the file replacements option', () => {
134+
it('should set aliases', () => {
135+
spyOn(ts, 'parseJsonConfigFileContent').and.returnValue({
136+
options: {}
137+
});
138+
139+
const result = getWebpackConfig({
140+
...input,
141+
fileReplacements: [
142+
{
143+
replace: 'environments/environment.ts',
144+
with: 'environments/environment.prod.ts'
145+
}
146+
]
147+
});
148+
149+
expect(result.resolve.alias).toEqual({
150+
'environments/environment.ts': 'environments/environment.prod.ts'
151+
});
152+
});
153+
});
154+
155+
describe('the externalDependencies option', () => {
156+
it('should change all node_modules to commonjs imports', () => {
157+
const result = getWebpackConfig(input);
158+
const callback = jest.fn();
159+
result.externals[0](null, '@angular/core', callback);
160+
expect(callback).toHaveBeenCalledWith(null, 'commonjs @angular/core');
161+
});
162+
163+
it('should change given module names to commonjs imports but not others', () => {
164+
const result = getWebpackConfig({
165+
...input,
166+
externalDependencies: ['module1']
167+
});
168+
const callback = jest.fn();
169+
result.externals[0](null, 'module1', callback);
170+
expect(callback).toHaveBeenCalledWith(null, 'commonjs module1');
171+
result.externals[0](null, '@angular/core', callback);
172+
expect(callback).toHaveBeenCalledWith();
173+
});
174+
175+
it('should not change any modules to commonjs imports', () => {
176+
const result = getWebpackConfig({
177+
...input,
178+
externalDependencies: 'none'
179+
});
180+
181+
expect(result.externals).not.toBeDefined();
182+
});
183+
});
184+
185+
describe('the watch option', () => {
186+
it('should enable file watching', () => {
187+
const result = getWebpackConfig({
188+
...input,
189+
watch: true
190+
});
191+
192+
expect(result.watch).toEqual(true);
193+
});
194+
});
195+
196+
describe('the optimization option', () => {
197+
describe('by default', () => {
198+
it('should set the mode to development', () => {
199+
const result = getWebpackConfig(input);
200+
201+
expect(result.mode).toEqual('development');
202+
});
203+
});
204+
205+
describe('when true', () => {
206+
it('should set the mode to production', () => {
207+
const result = getWebpackConfig({
208+
...input,
209+
optimization: true
210+
});
211+
212+
expect(result.mode).toEqual('production');
213+
});
214+
215+
it('should not minify', () => {
216+
const result = getWebpackConfig({
217+
...input,
218+
optimization: true
219+
});
220+
221+
expect(result.optimization.minimize).toEqual(false);
222+
});
223+
224+
it('should not concatenate modules', () => {
225+
const result = getWebpackConfig({
226+
...input,
227+
optimization: true
228+
});
229+
230+
expect(result.optimization.concatenateModules).toEqual(false);
231+
});
232+
});
233+
});
234+
235+
describe('the max workers option', () => {
236+
it('should set the maximum workers for the type checker', () => {
237+
const result = getWebpackConfig({
238+
...input,
239+
maxWorkers: 1
240+
});
241+
242+
const typeCheckerPlugin = result.plugins.find(
243+
plugin => plugin instanceof ForkTsCheckerWebpackPlugin
244+
) as ForkTsCheckerWebpackPlugin;
245+
expect(typeCheckerPlugin.options.workers).toEqual(1);
246+
});
247+
});
248+
249+
describe('the circular dependencies option', () => {
250+
it('should show warnings for circular dependencies', () => {
251+
const result = getWebpackConfig({
252+
...input,
253+
showCircularDependencies: true
254+
});
255+
256+
expect(
257+
result.plugins.find(
258+
plugin => plugin instanceof CircularDependencyPlugin
259+
)
260+
).toBeTruthy();
261+
});
262+
263+
it('should exclude node modules', () => {
264+
const result = getWebpackConfig({
265+
...input,
266+
showCircularDependencies: true
267+
});
268+
269+
const circularDependencyPlugin: CircularDependencyPlugin = result.plugins.find(
270+
plugin => plugin instanceof CircularDependencyPlugin
271+
);
272+
expect(circularDependencyPlugin.options.exclude).toEqual(
273+
/[\\\/]node_modules[\\\/]/
274+
);
275+
});
276+
});
277+
278+
describe('the extract licenses option', () => {
279+
it('should extract licenses to a separate file', () => {
280+
const result = getWebpackConfig({
281+
...input,
282+
extractLicenses: true
283+
});
284+
285+
const licensePlugin = result.plugins.find(
286+
plugin => plugin instanceof LicenseWebpackPlugin
287+
) as LicenseWebpackPlugin;
288+
const options = (<any>licensePlugin).options;
289+
290+
expect(licensePlugin).toBeTruthy();
291+
expect(options.pattern).toEqual(/.*/);
292+
expect(options.suppressErrors).toEqual(true);
293+
expect(options.perChunkOutput).toEqual(false);
294+
expect(options.outputFilename).toEqual('3rdpartylicenses.txt');
295+
});
296+
});
297+
298+
describe('the progress option', () => {
299+
it('should show build progress', () => {
300+
const result = getWebpackConfig({
301+
...input,
302+
progress: true
303+
});
304+
305+
expect(
306+
result.plugins.find(plugin => plugin instanceof ProgressPlugin)
307+
).toBeTruthy();
308+
});
309+
});
310+
});

Diff for: ‎packages/builders/src/node/build/webpack/config.ts

+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as webpack from 'webpack';
2+
import { Configuration, ProgressPlugin } from 'webpack';
3+
4+
import * as ts from 'typescript';
5+
import { dirname, resolve } from 'path';
6+
7+
import { LicenseWebpackPlugin } from 'license-webpack-plugin';
8+
import CircularDependencyPlugin = require('circular-dependency-plugin');
9+
import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
10+
11+
import { BuildNodeBuilderOptions } from '../node-build.builder';
12+
import * as nodeExternals from 'webpack-node-externals';
13+
14+
export const OUT_FILENAME = 'main.js';
15+
16+
export function getWebpackConfig(
17+
options: BuildNodeBuilderOptions
18+
): Configuration {
19+
const webpackConfig: Configuration = {
20+
entry: [options.main],
21+
mode: options.optimization ? 'production' : 'development',
22+
output: {
23+
path: options.outputPath,
24+
filename: OUT_FILENAME,
25+
libraryTarget: 'commonjs'
26+
},
27+
module: {
28+
rules: [
29+
{
30+
test: /\.ts$/,
31+
loader: `ts-loader`,
32+
options: {
33+
configFile: options.tsConfig,
34+
transpileOnly: true,
35+
// https://github.com/TypeStrong/ts-loader/pull/685
36+
experimentalWatchApi: true
37+
}
38+
}
39+
]
40+
},
41+
resolve: {
42+
extensions: ['.ts', '.js'],
43+
alias: getAliases(options)
44+
},
45+
target: 'node',
46+
node: false,
47+
performance: {
48+
hints: false
49+
},
50+
plugins: [
51+
new ForkTsCheckerWebpackPlugin({
52+
tsconfig: options.tsConfig,
53+
workers: options.maxWorkers || ForkTsCheckerWebpackPlugin.TWO_CPUS_FREE
54+
})
55+
],
56+
watch: options.watch
57+
};
58+
59+
const extraPlugins: webpack.Plugin[] = [];
60+
61+
if (options.progress) {
62+
extraPlugins.push(new ProgressPlugin());
63+
}
64+
65+
if (options.optimization) {
66+
webpackConfig.optimization = {
67+
minimize: false,
68+
concatenateModules: false
69+
};
70+
}
71+
72+
if (options.extractLicenses) {
73+
extraPlugins.push(
74+
new LicenseWebpackPlugin({
75+
pattern: /.*/,
76+
suppressErrors: true,
77+
perChunkOutput: false,
78+
outputFilename: `3rdpartylicenses.txt`
79+
})
80+
);
81+
}
82+
83+
if (options.externalDependencies === 'all') {
84+
webpackConfig.externals = [nodeExternals()];
85+
} else if (Array.isArray(options.externalDependencies)) {
86+
webpackConfig.externals = [
87+
function(context, request, callback: Function) {
88+
if (options.externalDependencies.includes(request)) {
89+
// not bundled
90+
return callback(null, 'commonjs ' + request);
91+
}
92+
// bundled
93+
callback();
94+
}
95+
];
96+
}
97+
98+
if (options.showCircularDependencies) {
99+
extraPlugins.push(
100+
new CircularDependencyPlugin({
101+
exclude: /[\\\/]node_modules[\\\/]/
102+
})
103+
);
104+
}
105+
106+
webpackConfig.plugins = [...webpackConfig.plugins, ...extraPlugins];
107+
108+
return webpackConfig;
109+
}
110+
111+
function getAliases(
112+
options: BuildNodeBuilderOptions
113+
): { [key: string]: string } {
114+
const readResult = ts.readConfigFile(options.tsConfig, ts.sys.readFile);
115+
const tsConfig = ts.parseJsonConfigFileContent(
116+
readResult.config,
117+
ts.sys,
118+
dirname(options.tsConfig)
119+
);
120+
const compilerOptions = tsConfig.options;
121+
const replacements = [
122+
...options.fileReplacements,
123+
...(compilerOptions.paths
124+
? Object.entries(compilerOptions.paths).map(([importPath, values]) => ({
125+
replace: importPath,
126+
with: resolve(options.root, values[0])
127+
}))
128+
: [])
129+
];
130+
return replacements.reduce(
131+
(aliases, replacement) => ({
132+
...aliases,
133+
[replacement.replace]: replacement.with
134+
}),
135+
{}
136+
);
137+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import {
2+
NodeExecuteBuilder,
3+
NodeExecuteBuilderOptions
4+
} from './node-execute.builder';
5+
import { TestLogger } from '@angular-devkit/architect/testing';
6+
import { normalize } from '@angular-devkit/core';
7+
import { of } from 'rxjs';
8+
import { cold } from 'jasmine-marbles';
9+
jest.mock('child_process');
10+
let { fork } = require('child_process');
11+
jest.mock('tree-kill');
12+
let treeKill = require('tree-kill');
13+
14+
class MockArchitect {
15+
getBuilderConfiguration() {
16+
return {
17+
config: 'testConfig'
18+
};
19+
}
20+
run() {
21+
return cold('--a--b--a', {
22+
a: {
23+
success: true,
24+
outfile: 'outfile.js'
25+
},
26+
b: {
27+
success: false,
28+
outfile: 'outfile.js'
29+
}
30+
});
31+
}
32+
getBuilderDescription() {
33+
return of({
34+
description: 'testDescription'
35+
});
36+
}
37+
validateBuilderOptions() {
38+
return of({
39+
options: {}
40+
});
41+
}
42+
}
43+
44+
describe('NodeExecuteBuilder', () => {
45+
let builder: NodeExecuteBuilder;
46+
let architect: MockArchitect;
47+
let logger: TestLogger;
48+
let testOptions: NodeExecuteBuilderOptions;
49+
50+
beforeEach(() => {
51+
fork.mockReturnValue({
52+
pid: 123
53+
});
54+
treeKill.mockImplementation((pid, signal, callback) => {
55+
callback();
56+
});
57+
logger = new TestLogger('test');
58+
architect = new MockArchitect();
59+
builder = new NodeExecuteBuilder({
60+
workspace: <any>{
61+
root: '/root'
62+
},
63+
logger,
64+
host: <any>{},
65+
architect: <any>architect
66+
});
67+
testOptions = {
68+
inspect: true,
69+
args: [],
70+
buildTarget: 'nodeapp:build'
71+
};
72+
});
73+
74+
it('should build the application and start the built file', () => {
75+
const getBuilderConfiguration = spyOn(
76+
architect,
77+
'getBuilderConfiguration'
78+
).and.callThrough();
79+
expect(
80+
builder.run({
81+
root: normalize('/root'),
82+
projectType: 'application',
83+
builder: '@nrwl/builders:node-execute',
84+
options: testOptions
85+
})
86+
).toBeObservable(
87+
cold('--a--b--a', {
88+
a: {
89+
success: true,
90+
outfile: 'outfile.js'
91+
},
92+
b: {
93+
success: false,
94+
outfile: 'outfile.js'
95+
}
96+
})
97+
);
98+
expect(getBuilderConfiguration).toHaveBeenCalledWith({
99+
project: 'nodeapp',
100+
target: 'build',
101+
overrides: {
102+
watch: true
103+
}
104+
});
105+
expect(fork).toHaveBeenCalledWith('outfile.js', [], {
106+
execArgv: ['--inspect']
107+
});
108+
expect(treeKill).toHaveBeenCalledTimes(1);
109+
expect(fork).toHaveBeenCalledTimes(2);
110+
});
111+
112+
it('should build the application and start the built file with options', () => {
113+
expect(
114+
builder.run({
115+
root: normalize('/root'),
116+
projectType: 'application',
117+
builder: '@nrwl/builders:node-execute',
118+
options: {
119+
...testOptions,
120+
inspect: false,
121+
args: ['arg1', 'arg2']
122+
}
123+
})
124+
).toBeObservable(
125+
cold('--a--b--a', {
126+
a: {
127+
success: true,
128+
outfile: 'outfile.js'
129+
},
130+
b: {
131+
success: false,
132+
outfile: 'outfile.js'
133+
}
134+
})
135+
);
136+
expect(fork).toHaveBeenCalledWith('outfile.js', ['arg1', 'arg2'], {
137+
execArgv: []
138+
});
139+
});
140+
141+
it('should warn users who try to use it in production', () => {
142+
spyOn(architect, 'validateBuilderOptions').and.returnValue(
143+
of({
144+
options: {
145+
optimization: true
146+
}
147+
})
148+
);
149+
spyOn(logger, 'warn');
150+
expect(
151+
builder.run({
152+
root: normalize('/root'),
153+
projectType: 'application',
154+
builder: '@nrwl/builders:node-execute',
155+
options: {
156+
...testOptions,
157+
inspect: false,
158+
args: ['arg1', 'arg2']
159+
}
160+
})
161+
).toBeObservable(
162+
cold('--a--b--a', {
163+
a: {
164+
success: true,
165+
outfile: 'outfile.js'
166+
},
167+
b: {
168+
success: false,
169+
outfile: 'outfile.js'
170+
}
171+
})
172+
);
173+
expect(logger.warn).toHaveBeenCalled();
174+
});
175+
});
+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {
2+
BuildEvent,
3+
Builder,
4+
BuilderConfiguration,
5+
BuilderContext
6+
} from '@angular-devkit/architect';
7+
import { ChildProcess, fork } from 'child_process';
8+
import * as treeKill from 'tree-kill';
9+
10+
import { Observable, bindCallback, of } from 'rxjs';
11+
import { concatMap, tap, mapTo } from 'rxjs/operators';
12+
13+
import {
14+
BuildNodeBuilderOptions,
15+
NodeBuildEvent
16+
} from '../build/node-build.builder';
17+
import { stripIndents } from '@angular-devkit/core/src/utils/literals';
18+
19+
export interface NodeExecuteBuilderOptions {
20+
inspect: boolean;
21+
args: string[];
22+
buildTarget: string;
23+
}
24+
25+
export class NodeExecuteBuilder implements Builder<NodeExecuteBuilderOptions> {
26+
private subProcess: ChildProcess;
27+
28+
constructor(private context: BuilderContext) {}
29+
30+
run(
31+
target: BuilderConfiguration<NodeExecuteBuilderOptions>
32+
): Observable<BuildEvent> {
33+
const options = target.options;
34+
35+
return this.startBuild(options).pipe(
36+
concatMap((event: NodeBuildEvent) => {
37+
if (event.success) {
38+
return this.restartProcess(event.outfile, options).pipe(mapTo(event));
39+
} else {
40+
this.context.logger.error(
41+
'There was an error with the build. See above.'
42+
);
43+
this.context.logger.info(`${event.outfile} was not restarted.`);
44+
return of(event);
45+
}
46+
})
47+
);
48+
}
49+
50+
private runProcess(file: string, options: NodeExecuteBuilderOptions) {
51+
if (this.subProcess) {
52+
throw new Error('Already running');
53+
}
54+
this.subProcess = fork(file, options.args, {
55+
execArgv: options.inspect ? ['--inspect'] : []
56+
});
57+
}
58+
59+
private restartProcess(file: string, options: NodeExecuteBuilderOptions) {
60+
return this.killProcess().pipe(
61+
tap(() => {
62+
this.runProcess(file, options);
63+
})
64+
);
65+
}
66+
67+
private killProcess(): Observable<void | Error> {
68+
if (!this.subProcess) {
69+
return of(undefined);
70+
}
71+
72+
const observableTreeKill = bindCallback(treeKill);
73+
return observableTreeKill(this.subProcess.pid, 'SIGTERM').pipe(
74+
tap(err => {
75+
if (err) {
76+
throw err;
77+
} else {
78+
this.subProcess = null;
79+
}
80+
})
81+
);
82+
}
83+
84+
private startBuild(
85+
options: NodeExecuteBuilderOptions
86+
): Observable<NodeBuildEvent> {
87+
const builderConfig = this._getBuildBuilderConfig(options);
88+
89+
return this.context.architect.getBuilderDescription(builderConfig).pipe(
90+
concatMap(buildDescription =>
91+
this.context.architect.validateBuilderOptions(
92+
builderConfig,
93+
buildDescription
94+
)
95+
),
96+
tap(builderConfig => {
97+
if (builderConfig.options.optimization) {
98+
this.context.logger.warn(stripIndents`
99+
************************************************
100+
This is a simple process manager for use in
101+
testing or debugging Node applications locally.
102+
DO NOT USE IT FOR PRODUCTION!
103+
You should look into proper means of deploying
104+
your node application to production.
105+
************************************************`);
106+
}
107+
}),
108+
concatMap(
109+
builderConfig =>
110+
this.context.architect.run(builderConfig, this.context) as Observable<
111+
NodeBuildEvent
112+
>
113+
)
114+
);
115+
}
116+
117+
private _getBuildBuilderConfig(options: NodeExecuteBuilderOptions) {
118+
const [project, target, configuration] = options.buildTarget.split(':');
119+
120+
return this.context.architect.getBuilderConfiguration<
121+
BuildNodeBuilderOptions
122+
>({
123+
project,
124+
target,
125+
configuration,
126+
overrides: {
127+
watch: true
128+
}
129+
});
130+
}
131+
}
132+
133+
export default NodeExecuteBuilder;

Diff for: ‎packages/builders/src/node/execute/schema.json

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"title": "Schema for Executing NodeJS apps",
3+
"description": "NodeJS execution options",
4+
"type": "object",
5+
"properties": {
6+
"buildTarget": {
7+
"type": "string",
8+
"description": "The target to run to build you the app"
9+
},
10+
"inspect": {
11+
"type": "boolean",
12+
"description": "Ensures the app is starting with debugging",
13+
"default": true
14+
},
15+
"args": {
16+
"type": "array",
17+
"description": "Extra args when starting the app",
18+
"default": [],
19+
"items": {
20+
"type": "string"
21+
}
22+
}
23+
},
24+
"additionalProperties": false,
25+
"required": ["buildTarget"]
26+
}

Diff for: ‎yarn.lock

+245-8
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.