Skip to content

Commit 0bf71ec

Browse files
committed
feat(@angular-devkit/build-angular): add experimental Rollup concatenation option
1 parent b4d5921 commit 0bf71ec

File tree

11 files changed

+390
-4
lines changed

11 files changed

+390
-4
lines changed

packages/angular_devkit/build_angular/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"postcss-loader": "3.0.0",
4242
"raw-loader": "3.1.0",
4343
"regenerator-runtime": "0.13.3",
44+
"rollup": "1.21.4",
4445
"rxjs": "6.5.3",
4546
"sass": "1.22.12",
4647
"sass-loader": "8.0.0",

packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export interface BuildOptions {
8181

8282
/* When specified it will be used instead of the script target in the tsconfig.json. */
8383
scriptTargetOverride?: ScriptTarget;
84+
85+
experimentalRollupConcatenation?: boolean;
8486
}
8587

8688
export interface WebpackTestOptions extends BuildOptions {

packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
import { tags } from '@angular-devkit/core';
1313
import * as CopyWebpackPlugin from 'copy-webpack-plugin';
1414
import * as path from 'path';
15+
import { RollupOptions } from 'rollup';
1516
import { ScriptTarget } from 'typescript';
1617
import {
1718
Compiler,
1819
Configuration,
1920
ContextReplacementPlugin,
2021
HashedModuleIdsPlugin,
22+
Rule,
2123
compilation,
2224
debug,
2325
} from 'webpack';
@@ -29,6 +31,7 @@ import { BundleBudgetPlugin } from '../../plugins/bundle-budget';
2931
import { CleanCssWebpackPlugin } from '../../plugins/cleancss-webpack-plugin';
3032
import { NamedLazyChunksPlugin } from '../../plugins/named-chunks-plugin';
3133
import { ScriptsWebpackPlugin } from '../../plugins/scripts-webpack-plugin';
34+
import { WebpackRollupLoader } from '../../plugins/webpack';
3235
import { findAllNodeModules, findUp } from '../../utilities/find-up';
3336
import { WebpackConfigOptions } from '../build-options';
3437
import { getEsVersionForFileName, getOutputHashFormat, normalizeExtraEntryPoints } from './utils';
@@ -57,6 +60,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
5760

5861
// tslint:disable-next-line:no-any
5962
const extraPlugins: any[] = [];
63+
const extraRules: Rule[] = [];
6064
const entryPoints: { [key: string]: string[] } = {};
6165

6266
const targetInFileName = getEsVersionForFileName(
@@ -65,7 +69,51 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
6569
);
6670

6771
if (buildOptions.main) {
68-
entryPoints['main'] = [path.resolve(root, buildOptions.main)];
72+
const mainPath = path.resolve(root, buildOptions.main);
73+
entryPoints['main'] = [mainPath];
74+
75+
if (buildOptions.experimentalRollupConcatenation) {
76+
// NOTE: the following are known problems with experimentalRollupConcatenation
77+
// - vendorChunk, commonChunk, namedChunks: these won't work, because by the time webpack
78+
// sees the chunks, the context of where they came from is lost.
79+
// - webWorkerTsConfig: workers must be imported via a root relative path (e.g.
80+
// `app/search/search.worker`) instead of a relative path (`/search.worker`) because
81+
// of the same reason as above.
82+
// - loadChildren string syntax: doesn't work because rollup cannot follow the imports.
83+
84+
// Rollup options, except entry module, which is automatically inferred.
85+
const rollupOptions: RollupOptions = {};
86+
87+
// Add rollup plugins/rules.
88+
extraRules.push({
89+
test: mainPath,
90+
// Ensure rollup loader executes after other loaders.
91+
enforce: 'post',
92+
use: [{
93+
loader: WebpackRollupLoader,
94+
options: rollupOptions,
95+
}],
96+
});
97+
98+
// Rollup bundles will include the dynamic System.import that was inside Angular and webpack
99+
// will emit warnings because it can't resolve it. We just ignore it.
100+
// TODO: maybe use https://webpack.js.org/configuration/stats/#statswarningsfilter instead.
101+
102+
// Ignore all "Critical dependency: the request of a dependency is an expression" warnings.
103+
extraPlugins.push(new ContextReplacementPlugin(/./));
104+
// Ignore "System.import() is deprecated" warnings for the main file and js files.
105+
// Might still get them if @angular/core gets split into a lazy module.
106+
extraRules.push({
107+
test: mainPath,
108+
enforce: 'post',
109+
parser: { system: true },
110+
});
111+
extraRules.push({
112+
test: /\.js$/,
113+
enforce: 'post',
114+
parser: { system: true },
115+
});
116+
}
69117
}
70118

71119
let differentialLoadingNeeded = false;
@@ -476,6 +524,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration {
476524
enforce: 'pre',
477525
...sourceMapUseRule,
478526
},
527+
...extraRules,
479528
],
480529
},
481530
optimization: {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// Adapted from https://github.com/erikdesjardins/webpack-rollup-loader/blob/master/index.js
10+
11+
import { VirtualFileSystemDecorator } from '@ngtools/webpack/src/virtual_file_system_decorator';
12+
import { dirname, join } from 'path';
13+
import { OutputAsset, OutputChunk, rollup } from 'rollup';
14+
import { RawSourceMap } from 'source-map';
15+
import webpack = require('webpack');
16+
17+
function splitRequest(request: string) {
18+
const inx = request.lastIndexOf('!');
19+
if (inx === -1) {
20+
return {
21+
loaders: '',
22+
resource: request,
23+
};
24+
} else {
25+
return {
26+
loaders: request.slice(0, inx + 1),
27+
resource: request.slice(inx + 1),
28+
};
29+
}
30+
}
31+
32+
// Load resolve paths using Webpack.
33+
function webpackResolutionPlugin(
34+
loaderContext: webpack.loader.LoaderContext,
35+
entryId: string,
36+
entryIdCodeAndMap: { code: string, map: RawSourceMap },
37+
) {
38+
return {
39+
name: 'webpack-resolution-plugin',
40+
resolveId: (id: string, importerId: string) => {
41+
if (id === entryId) {
42+
return entryId;
43+
} else {
44+
return new Promise((resolve, reject) => {
45+
// split apart resource paths because Webpack's this.resolve() can't handle `loader!`
46+
// prefixes
47+
const parts = splitRequest(id);
48+
const importerParts = splitRequest(importerId);
49+
50+
// resolve the full path of the imported file with Webpack's module loader
51+
// this will figure out node_modules imports, Webpack aliases, etc.
52+
loaderContext.resolve(
53+
dirname(importerParts.resource),
54+
parts.resource,
55+
(err, fullPath) => err ? reject(err) : resolve(parts.loaders + fullPath),
56+
);
57+
});
58+
}
59+
},
60+
load: (id: string) => {
61+
if (id === entryId) {
62+
return entryIdCodeAndMap;
63+
}
64+
65+
return new Promise((resolve, reject) => {
66+
// load the module with Webpack
67+
// this will apply all relevant loaders, etc.
68+
loaderContext.loadModule(
69+
id,
70+
(err, source, map) => err ? reject(err) : resolve({ code: source, map: map }),
71+
);
72+
});
73+
},
74+
};
75+
}
76+
77+
export default function webpackRollupLoader(
78+
this: webpack.loader.LoaderContext,
79+
source: string,
80+
sourceMap: RawSourceMap,
81+
) {
82+
// Note: this loader isn't cacheable because it will add the lazy chunks to the
83+
// virtual file system on completion.
84+
const callback = this.async();
85+
if (!callback) {
86+
throw new Error('Async loader support is required.');
87+
}
88+
const options = this.query || {};
89+
const entryId = this.resourcePath;
90+
const sourcemap = this.sourceMap;
91+
92+
// Get the VirtualFileSystemDecorator that AngularCompilerPlugin added so we can write to it.
93+
// Since we use webpackRollupLoader as a post loader, this should be there.
94+
// TODO: we should be able to do this in a more elegant way by again decorating webpacks
95+
// input file system inside a custom WebpackRollupPlugin, modelled after AngularCompilerPlugin.
96+
const vfs = this._compiler.inputFileSystem as VirtualFileSystemDecorator;
97+
const virtualWrite = (path: string, data: string) =>
98+
vfs.getWebpackCompilerHost().writeFile(path, data, false);
99+
100+
// Bundle with Rollup
101+
const rollupOptions = {
102+
...options,
103+
input: entryId,
104+
plugins: [
105+
...(options.plugins || []),
106+
webpackResolutionPlugin(this, entryId, { code: source, map: sourceMap }),
107+
],
108+
};
109+
110+
rollup(rollupOptions)
111+
.then(build => build.generate({ format: 'es', sourcemap }))
112+
.then(
113+
(result) => {
114+
const [mainChunk, ...otherChunksOrAssets] = result.output;
115+
116+
// Write other chunks and assets to the virtual file system so that webpack can load them.
117+
const resultDir = dirname(entryId);
118+
otherChunksOrAssets.forEach(chunkOrAsset => {
119+
const { fileName, type } = chunkOrAsset;
120+
if (type == 'chunk') {
121+
const { code, map } = chunkOrAsset as OutputChunk;
122+
virtualWrite(join(resultDir, fileName), code);
123+
if (map) {
124+
// Also write the map if there's one.
125+
// Probably need scriptsSourceMap set on CLI to load it.
126+
virtualWrite(join(resultDir, `${fileName}.map`), map.toString());
127+
}
128+
} else if (type == 'asset') {
129+
const { source } = chunkOrAsset as OutputAsset;
130+
// Source might be a Buffer. Just assuming it's a string for now.
131+
virtualWrite(join(resultDir, fileName), source as string);
132+
}
133+
});
134+
135+
// Always return the main chunk from webpackRollupLoader.
136+
// Cast to any here is needed because of a typings incompatibility between source-map versions.
137+
// tslint:disable-next-line:no-any
138+
callback(null, mainChunk.code, (mainChunk as any).map);
139+
},
140+
(err) => callback(err),
141+
);
142+
}

packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export {
2020

2121
import { join } from 'path';
2222
export const RawCssLoader = require.resolve(join(__dirname, 'raw-css-loader'));
23+
export const WebpackRollupLoader = require.resolve(join(__dirname, 'webpack-rollup-loader'));

packages/angular_devkit/build_angular/src/browser/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,11 @@
353353
"anonymous",
354354
"use-credentials"
355355
]
356+
},
357+
"experimentalRollupConcatenation": {
358+
"type": "boolean",
359+
"description": "Concatenate modules with Rollup before bundling them with Webpack.",
360+
"default": false
356361
}
357362
},
358363
"additionalProperties": false,

packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ export async function generateWebpackConfig(
4848
throw new Error(`The 'buildOptimizer' option cannot be used without 'aot'.`);
4949
}
5050

51+
// Ensure Rollup Concatenation is only used with compatible options.
52+
if (options.experimentalRollupConcatenation) {
53+
if (!options.aot) {
54+
throw new Error(`The 'experimentalRollupConcatenation' option cannot be used without 'aot'.`);
55+
}
56+
57+
if (options.vendorChunk || options.commonChunk || options.namedChunks) {
58+
throw new Error(`The 'experimentalRollupConcatenation' option cannot be used with the`
59+
+ `'vendorChunk', 'commonChunk', 'namedChunks' options set to true.`);
60+
}
61+
}
62+
5163
const tsConfigPath = path.resolve(workspaceRoot, options.tsConfig);
5264
const tsConfig = readTsconfig(tsConfigPath);
5365

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import { Architect } from '@angular-devkit/architect';
10+
import {
11+
BrowserBuildOutput,
12+
browserBuild,
13+
createArchitect,
14+
host,
15+
lazyModuleFiles,
16+
lazyModuleFnImport,
17+
} from '../utils';
18+
19+
20+
describe('Browser Builder Rollup Concatenation test', () => {
21+
const target = { project: 'app', target: 'build' };
22+
const overrides = {
23+
experimentalRollupConcatenation: true,
24+
// JIT Rollup bundles will include require calls to .css and .html file, that have lost their
25+
// path context. AOT code already inlines resources so that's not a problem.
26+
aot: true,
27+
// Webpack can't separate rolled-up modules into chunks.
28+
vendorChunk: false,
29+
commonChunk: false,
30+
namedChunks: false,
31+
};
32+
const prodOverrides = {
33+
// Usual prod options.
34+
fileReplacements: [{
35+
replace: 'src/environments/environment.ts',
36+
with: 'src/environments/environment.prod.ts',
37+
}],
38+
optimization: true,
39+
sourceMap: false,
40+
extractCss: true,
41+
namedChunks: false,
42+
aot: true,
43+
extractLicenses: true,
44+
vendorChunk: false,
45+
buildOptimizer: true,
46+
// Extra prod options we need for experimentalRollupConcatenation.
47+
commonChunk: false,
48+
// Just for convenience.
49+
outputHashing: 'none',
50+
};
51+
const rollupProdOverrides = {
52+
...prodOverrides,
53+
experimentalRollupConcatenation: true,
54+
};
55+
let architect: Architect;
56+
57+
const getOutputSize = async (output: BrowserBuildOutput) =>
58+
(await Promise.all(
59+
Object.keys(output.files)
60+
.filter(name => name.endsWith('.js') &&
61+
// These aren't concatenated by Rollup so no point comparing.
62+
!['runtime.js', 'polyfills.js'].includes(name))
63+
.map(name => output.files[name]),
64+
))
65+
.map(content => content.length)
66+
.reduce((acc, curr) => acc + curr, 0);
67+
68+
beforeEach(async () => {
69+
await host.initialize().toPromise();
70+
architect = (await createArchitect(host.root())).architect;
71+
});
72+
73+
afterEach(async () => host.restore().toPromise());
74+
75+
it('works', async () => {
76+
await browserBuild(architect, host, target, overrides);
77+
});
78+
79+
it('works with lazy modules', async () => {
80+
host.writeMultipleFiles(lazyModuleFiles);
81+
host.writeMultipleFiles(lazyModuleFnImport);
82+
await browserBuild(architect, host, target, overrides);
83+
});
84+
85+
it('creates smaller or same size bundles for app without lazy bundles', async () => {
86+
const prodOutput = await browserBuild(architect, host, target, prodOverrides);
87+
const prodSize = await getOutputSize(prodOutput);
88+
const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides);
89+
const rollupProd = await getOutputSize(rollupProdOutput);
90+
expect(prodSize).toBeGreaterThan(rollupProd);
91+
});
92+
93+
it('creates smaller bundles for apps with lazy bundles', async () => {
94+
host.writeMultipleFiles(lazyModuleFiles);
95+
host.writeMultipleFiles(lazyModuleFnImport);
96+
const prodOutput = await browserBuild(architect, host, target, prodOverrides);
97+
const prodSize = await getOutputSize(prodOutput);
98+
const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides);
99+
const rollupProd = await getOutputSize(rollupProdOutput);
100+
expect(prodSize).toBeGreaterThan(rollupProd);
101+
});
102+
});

0 commit comments

Comments
 (0)