diff --git a/packages/angular_devkit/build_angular/package.json b/packages/angular_devkit/build_angular/package.json index 95c87733a05f..f3eb3930c348 100644 --- a/packages/angular_devkit/build_angular/package.json +++ b/packages/angular_devkit/build_angular/package.json @@ -42,6 +42,7 @@ "postcss-loader": "3.0.0", "raw-loader": "3.1.0", "regenerator-runtime": "0.13.3", + "rollup": "1.21.4", "rxjs": "6.5.3", "sass": "1.23.0", "sass-loader": "8.0.0", diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts index 0f92cacb4a83..09259ebda49f 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/build-options.ts @@ -81,6 +81,8 @@ export interface BuildOptions { /* When specified it will be used instead of the script target in the tsconfig.json. */ scriptTargetOverride?: ScriptTarget; + + experimentalRollupPass?: boolean; } export interface WebpackTestOptions extends BuildOptions { diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts index 892fb6c6cf0c..3bb39b216dd3 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/models/webpack-configs/common.ts @@ -12,12 +12,14 @@ import { import { tags } from '@angular-devkit/core'; import * as CopyWebpackPlugin from 'copy-webpack-plugin'; import * as path from 'path'; +import { RollupOptions } from 'rollup'; import { ScriptTarget } from 'typescript'; import { Compiler, Configuration, ContextReplacementPlugin, HashedModuleIdsPlugin, + Rule, compilation, debug, } from 'webpack'; @@ -29,6 +31,7 @@ import { BundleBudgetPlugin } from '../../plugins/bundle-budget'; import { CleanCssWebpackPlugin } from '../../plugins/cleancss-webpack-plugin'; import { NamedLazyChunksPlugin } from '../../plugins/named-chunks-plugin'; import { ScriptsWebpackPlugin } from '../../plugins/scripts-webpack-plugin'; +import { WebpackRollupLoader } from '../../plugins/webpack'; import { findAllNodeModules, findUp } from '../../utilities/find-up'; import { WebpackConfigOptions } from '../build-options'; import { getEsVersionForFileName, getOutputHashFormat, normalizeExtraEntryPoints } from './utils'; @@ -57,6 +60,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { // tslint:disable-next-line:no-any const extraPlugins: any[] = []; + const extraRules: Rule[] = []; const entryPoints: { [key: string]: string[] } = {}; const targetInFileName = getEsVersionForFileName( @@ -65,7 +69,51 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { ); if (buildOptions.main) { - entryPoints['main'] = [path.resolve(root, buildOptions.main)]; + const mainPath = path.resolve(root, buildOptions.main); + entryPoints['main'] = [mainPath]; + + if (buildOptions.experimentalRollupPass) { + // NOTE: the following are known problems with experimentalRollupPass + // - vendorChunk, commonChunk, namedChunks: these won't work, because by the time webpack + // sees the chunks, the context of where they came from is lost. + // - webWorkerTsConfig: workers must be imported via a root relative path (e.g. + // `app/search/search.worker`) instead of a relative path (`/search.worker`) because + // of the same reason as above. + // - loadChildren string syntax: doesn't work because rollup cannot follow the imports. + + // Rollup options, except entry module, which is automatically inferred. + const rollupOptions: RollupOptions = {}; + + // Add rollup plugins/rules. + extraRules.push({ + test: mainPath, + // Ensure rollup loader executes after other loaders. + enforce: 'post', + use: [{ + loader: WebpackRollupLoader, + options: rollupOptions, + }], + }); + + // Rollup bundles will include the dynamic System.import that was inside Angular and webpack + // will emit warnings because it can't resolve it. We just ignore it. + // TODO: maybe use https://webpack.js.org/configuration/stats/#statswarningsfilter instead. + + // Ignore all "Critical dependency: the request of a dependency is an expression" warnings. + extraPlugins.push(new ContextReplacementPlugin(/./)); + // Ignore "System.import() is deprecated" warnings for the main file and js files. + // Might still get them if @angular/core gets split into a lazy module. + extraRules.push({ + test: mainPath, + enforce: 'post', + parser: { system: true }, + }); + extraRules.push({ + test: /\.js$/, + enforce: 'post', + parser: { system: true }, + }); + } } let differentialLoadingNeeded = false; @@ -482,6 +530,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { enforce: 'pre', ...sourceMapUseRule, }, + ...extraRules, ], }, optimization: { diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack-rollup-loader.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack-rollup-loader.ts new file mode 100644 index 000000000000..72db6a989aae --- /dev/null +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack-rollup-loader.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * @license + * @author Erik Desjardins + * Forked as of SHA 10fb020f997a146725963b202d79290c8798a7a0 from https://github.com/erikdesjardins/webpack-rollup-loader. + * Licensed under a MIT license. + * See https://github.com/erikdesjardins/webpack-rollup-loader/blob/10fb020f997a146725963b202d79290c8798a7a0/LICENSE for full license. + */ + +import { VirtualFileSystemDecorator } from '@ngtools/webpack/src/virtual_file_system_decorator'; +import { dirname, join } from 'path'; +import { OutputAsset, OutputChunk, rollup } from 'rollup'; +import { RawSourceMap } from 'source-map'; +import webpack = require('webpack'); + +function splitRequest(request: string) { + const inx = request.lastIndexOf('!'); + if (inx === -1) { + return { + loaders: '', + resource: request, + }; + } else { + return { + loaders: request.slice(0, inx + 1), + resource: request.slice(inx + 1), + }; + } +} + +// Load resolve paths using Webpack. +function webpackResolutionPlugin( + loaderContext: webpack.loader.LoaderContext, + entryId: string, + entryIdCodeAndMap: { code: string, map: RawSourceMap }, +) { + return { + name: 'webpack-resolution-plugin', + resolveId: (id: string, importerId: string) => { + if (id === entryId) { + return entryId; + } else { + return new Promise((resolve, reject) => { + // split apart resource paths because Webpack's this.resolve() can't handle `loader!` + // prefixes + const parts = splitRequest(id); + const importerParts = splitRequest(importerId); + + // resolve the full path of the imported file with Webpack's module loader + // this will figure out node_modules imports, Webpack aliases, etc. + loaderContext.resolve( + dirname(importerParts.resource), + parts.resource, + (err, fullPath) => err ? reject(err) : resolve(parts.loaders + fullPath), + ); + }); + } + }, + load: (id: string) => { + if (id === entryId) { + return entryIdCodeAndMap; + } + + return new Promise((resolve, reject) => { + // load the module with Webpack + // this will apply all relevant loaders, etc. + loaderContext.loadModule( + id, + (err, source, map) => err ? reject(err) : resolve({ code: source, map: map }), + ); + }); + }, + }; +} + +export default function webpackRollupLoader( + this: webpack.loader.LoaderContext, + source: string, + sourceMap: RawSourceMap, +) { + // Note: this loader isn't cacheable because it will add the lazy chunks to the + // virtual file system on completion. + const callback = this.async(); + if (!callback) { + throw new Error('Async loader support is required.'); + } + const options = this.query || {}; + const entryId = this.resourcePath; + const sourcemap = this.sourceMap; + + // Get the VirtualFileSystemDecorator that AngularCompilerPlugin added so we can write to it. + // Since we use webpackRollupLoader as a post loader, this should be there. + // TODO: we should be able to do this in a more elegant way by again decorating webpacks + // input file system inside a custom WebpackRollupPlugin, modelled after AngularCompilerPlugin. + const vfs = this._compiler.inputFileSystem as VirtualFileSystemDecorator; + const virtualWrite = (path: string, data: string) => + vfs.getWebpackCompilerHost().writeFile(path, data, false); + + // Bundle with Rollup + const rollupOptions = { + ...options, + input: entryId, + plugins: [ + ...(options.plugins || []), + webpackResolutionPlugin(this, entryId, { code: source, map: sourceMap }), + ], + }; + + rollup(rollupOptions) + .then(build => build.generate({ format: 'es', sourcemap })) + .then( + (result) => { + const [mainChunk, ...otherChunksOrAssets] = result.output; + + // Write other chunks and assets to the virtual file system so that webpack can load them. + const resultDir = dirname(entryId); + otherChunksOrAssets.forEach(chunkOrAsset => { + const { fileName, type } = chunkOrAsset; + if (type == 'chunk') { + const { code, map } = chunkOrAsset as OutputChunk; + virtualWrite(join(resultDir, fileName), code); + if (map) { + // Also write the map if there's one. + // Probably need scriptsSourceMap set on CLI to load it. + virtualWrite(join(resultDir, `${fileName}.map`), map.toString()); + } + } else if (type == 'asset') { + const { source } = chunkOrAsset as OutputAsset; + // Source might be a Buffer. Just assuming it's a string for now. + virtualWrite(join(resultDir, fileName), source as string); + } + }); + + // Always return the main chunk from webpackRollupLoader. + // Cast to any here is needed because of a typings incompatibility between source-map versions. + // tslint:disable-next-line:no-any + callback(null, mainChunk.code, (mainChunk as any).map); + }, + (err) => callback(err), + ); +} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts index de7d1a2425ce..ff8505733c4c 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/webpack.ts @@ -20,3 +20,4 @@ export { import { join } from 'path'; export const RawCssLoader = require.resolve(join(__dirname, 'raw-css-loader')); +export const WebpackRollupLoader = require.resolve(join(__dirname, 'webpack-rollup-loader')); diff --git a/packages/angular_devkit/build_angular/src/browser/schema.json b/packages/angular_devkit/build_angular/src/browser/schema.json index 5d37643ec1ed..981ef483f3a5 100644 --- a/packages/angular_devkit/build_angular/src/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/browser/schema.json @@ -353,6 +353,11 @@ "anonymous", "use-credentials" ] + }, + "experimentalRollupPass": { + "type": "boolean", + "description": "Concatenate modules with Rollup before bundling them with Webpack.", + "default": false } }, "additionalProperties": false, diff --git a/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts b/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts index 203b432f6d67..6b8d0ae94787 100644 --- a/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts +++ b/packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts @@ -48,6 +48,18 @@ export async function generateWebpackConfig( throw new Error(`The 'buildOptimizer' option cannot be used without 'aot'.`); } + // Ensure Rollup Concatenation is only used with compatible options. + if (options.experimentalRollupPass) { + if (!options.aot) { + throw new Error(`The 'experimentalRollupPass' option cannot be used without 'aot'.`); + } + + if (options.vendorChunk || options.commonChunk || options.namedChunks) { + throw new Error(`The 'experimentalRollupPass' option cannot be used with the` + + `'vendorChunk', 'commonChunk', 'namedChunks' options set to true.`); + } + } + const tsConfigPath = path.resolve(workspaceRoot, options.tsConfig); const tsConfig = readTsconfig(tsConfigPath); diff --git a/packages/angular_devkit/build_angular/test/browser/rollup_spec_large.ts b/packages/angular_devkit/build_angular/test/browser/rollup_spec_large.ts new file mode 100644 index 000000000000..55e96781e284 --- /dev/null +++ b/packages/angular_devkit/build_angular/test/browser/rollup_spec_large.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { Architect } from '@angular-devkit/architect'; +import { + BrowserBuildOutput, + browserBuild, + createArchitect, + host, + lazyModuleFiles, + lazyModuleFnImport, +} from '../utils'; + + +describe('Browser Builder Rollup Concatenation test', () => { + const target = { project: 'app', target: 'build' }; + const overrides = { + experimentalRollupPass: true, + // JIT Rollup bundles will include require calls to .css and .html file, that have lost their + // path context. AOT code already inlines resources so that's not a problem. + aot: true, + // Webpack can't separate rolled-up modules into chunks. + vendorChunk: false, + commonChunk: false, + namedChunks: false, + }; + const prodOverrides = { + // Usual prod options. + fileReplacements: [{ + replace: 'src/environments/environment.ts', + with: 'src/environments/environment.prod.ts', + }], + optimization: true, + sourceMap: false, + extractCss: true, + namedChunks: false, + aot: true, + extractLicenses: true, + vendorChunk: false, + buildOptimizer: true, + // Extra prod options we need for experimentalRollupPass. + commonChunk: false, + // Just for convenience. + outputHashing: 'none', + }; + const rollupProdOverrides = { + ...prodOverrides, + experimentalRollupPass: true, + }; + let architect: Architect; + + const getOutputSize = async (output: BrowserBuildOutput) => + (await Promise.all( + Object.keys(output.files) + .filter(name => name.endsWith('.js') && + // These aren't concatenated by Rollup so no point comparing. + !['runtime.js', 'polyfills.js'].includes(name)) + .map(name => output.files[name]), + )) + .map(content => content.length) + .reduce((acc, curr) => acc + curr, 0); + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + + afterEach(async () => host.restore().toPromise()); + + it('works', async () => { + await browserBuild(architect, host, target, overrides); + }); + + it('works with lazy modules', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + await browserBuild(architect, host, target, overrides); + }); + + it('creates smaller or same size bundles for app without lazy bundles', async () => { + const prodOutput = await browserBuild(architect, host, target, prodOverrides); + const prodSize = await getOutputSize(prodOutput); + const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides); + const rollupProd = await getOutputSize(rollupProdOutput); + expect(prodSize).toBeGreaterThan(rollupProd); + }); + + it('creates smaller bundles for apps with lazy bundles', async () => { + host.writeMultipleFiles(lazyModuleFiles); + host.writeMultipleFiles(lazyModuleFnImport); + const prodOutput = await browserBuild(architect, host, target, prodOverrides); + const prodSize = await getOutputSize(prodOutput); + const rollupProdOutput = await browserBuild(architect, host, target, rollupProdOverrides); + const rollupProd = await getOutputSize(rollupProdOutput); + expect(prodSize).toBeGreaterThan(rollupProd); + }); +}); diff --git a/packages/angular_devkit/build_angular/test/utils.ts b/packages/angular_devkit/build_angular/test/utils.ts index bf9da9d183b7..aadd47ae5a68 100644 --- a/packages/angular_devkit/build_angular/test/utils.ts +++ b/packages/angular_devkit/build_angular/test/utils.ts @@ -60,13 +60,18 @@ export async function createArchitect(workspaceRoot: Path) { }; } +export interface BrowserBuildOutput { + output: BuilderOutput; + files: { [file: string]: Promise }; +} + export async function browserBuild( architect: Architect, host: virtualFs.Host, target: Target, overrides?: json.JsonObject, scheduleOptions?: ScheduleOptions, -): Promise<{ output: BuilderOutput; files: { [file: string]: Promise } }> { +): Promise { const run = await architect.scheduleTarget(target, overrides, scheduleOptions); const output = (await run.result) as BrowserBuilderOutput; expect(output.success).toBe(true); diff --git a/tests/legacy-cli/e2e/tests/build/rollup.ts b/tests/legacy-cli/e2e/tests/build/rollup.ts new file mode 100644 index 000000000000..96b16f968439 --- /dev/null +++ b/tests/legacy-cli/e2e/tests/build/rollup.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { appendToFile, prependToFile, replaceInFile, writeFile } from '../../utils/fs'; +import { ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +export default async function () { + // Add initial app routing. + const appRoutingModulePath = 'src/app/app-routing.module.ts'; + await writeFile(appRoutingModulePath, ` + import { NgModule } from '@angular/core'; + import { Routes, RouterModule } from '@angular/router'; + const routes: Routes = []; + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] + }) + export class AppRoutingModule { } + `); + await prependToFile('src/app/app.module.ts', + `import { AppRoutingModule } from './app-routing.module';`); + await replaceInFile('src/app/app.module.ts', `imports: [`, `imports: [ AppRoutingModule,`); + await appendToFile('src/app/app.component.html', ''); + + // Add a lazy route. + await ng('generate', 'module', 'lazy', '--route=lazy', '--module=app.module'); + + // Add lazy route e2e + await writeFile('e2e/src/app.e2e-spec.ts', ` + import { browser, logging, element, by } from 'protractor'; + + describe('workspace-project App', () => { + it('should display lazy route', () => { + browser.get(browser.baseUrl + '/lazy'); + expect(element(by.css('app-lazy p')).getText()).toEqual('lazy works!'); + }); + + afterEach(async () => { + // Assert that there are no errors emitted from the browser + const logs = await browser.manage().logs().get(logging.Type.BROWSER); + expect(logs).not.toContain(jasmine.objectContaining({ + level: logging.Level.SEVERE, + })); + }); + }); + `); + + // Set options needed for Rollup. + await updateJsonFile('angular.json', workspaceJson => { + const appArchitect = workspaceJson.projects['test-project'].architect; + const prodOptions = appArchitect.build.configurations.production; + prodOptions.vendorChunk = false; + prodOptions.commonChunk = false; + prodOptions.namedChunks = false; + prodOptions.experimentalRollupPass = true; + }); + + // Build for prod. + await ng('build', '--prod'); + + // E2E to make sure it's working. + await ng('e2e', '--prod'); +} diff --git a/yarn.lock b/yarn.lock index eafe4fe9ccbb..1516125549d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1192,6 +1192,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.20.tgz#c4378d9d6a62faa5c9aafffc3d726b5a1e7367c6" integrity sha512-An+MXSV8CGXz/BO9C1KKsoJ/8WDrvlNUaRMsm2h+IHZuSyQkM8U5bJJkb8ItLKA73VePG/nUK+t+EuW2IWuhsQ== +"@types/node@^12.7.5": + version "12.7.12" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.7.12.tgz#7c6c571cc2f3f3ac4a59a5f2bd48f5bdbc8653cc" + integrity sha512-KPYGmfD0/b1eXurQ59fXD1GBzhSQfz6/lKBxkaHX9dKTzjXbK68Zt7yGUxUsCS1jeTy/8aL+d9JEr+S54mpkWQ== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -1716,7 +1721,7 @@ acorn@^6.0.1, acorn@^6.1.1, acorn@^6.2.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e" integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA== -acorn@^7.1.0: +acorn@^7.0.0, acorn@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== @@ -9038,6 +9043,15 @@ rollup-pluginutils@^2.0.1, rollup-pluginutils@^2.5.0, rollup-pluginutils@^2.8.1: dependencies: estree-walker "^0.6.1" +rollup@1.21.4: + version "1.21.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.21.4.tgz#00a41a30f90095db890301b226cbe2918e4cf54d" + integrity sha512-Pl512XVCmVzgcBz5h/3Li4oTaoDcmpuFZ+kdhS/wLreALz//WuDAMfomD3QEYl84NkDu6Z6wV9twlcREb4qQsw== + dependencies: + "@types/estree" "0.0.39" + "@types/node" "^12.7.5" + acorn "^7.0.0" + rollup@^1.12.1: version "1.23.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.23.1.tgz#0315a0f5d0dfb056e6363e1dff05b89ac2da6b8e" @@ -9131,7 +9145,6 @@ sauce-connect-launcher@^1.2.4: "sauce-connect-proxy@https://saucelabs.com/downloads/sc-4.5.4-linux.tar.gz": version "0.0.0" - uid dc5efcd2be24ddb099a85b923d6e754754651fa8 resolved "https://saucelabs.com/downloads/sc-4.5.4-linux.tar.gz#dc5efcd2be24ddb099a85b923d6e754754651fa8" saucelabs@^1.5.0: