From adade40d13659a416d7d00302a199fa24a1ea799 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 19 Sep 2019 11:13:34 -0400 Subject: [PATCH 1/5] refactor(@angular-devkit/build-angular): account for disabled mangling in downlevel cache --- packages/angular_devkit/build_angular/src/browser/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index a5b6b6e5a11d..674eafcf8008 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -63,6 +63,7 @@ import { normalizeOptimization, normalizeSourceMaps, } from '../utils'; +import { manglingDisabled } from '../utils/mangle-options'; import { CacheKey, ProcessBundleOptions } from '../utils/process-bundle'; import { assertCompatibleAngularVersion } from '../utils/version'; import { @@ -374,7 +375,10 @@ export function buildWebpackBrowser( const codeHash = createHash('sha1') .update(action.code) .digest('hex'); - const baseCacheKey = `${packageVersion}|${action.code.length}|${codeHash}`; + let baseCacheKey = `${packageVersion}|${action.code.length}|${codeHash}`; + if (manglingDisabled) { + baseCacheKey += '|MD'; + } // Postfix added to sourcemap cache keys when vendor sourcemaps are present // Allows non-destructive caching of both variants From 0b164e471c413c4efe55686dafac2014f9f53b9b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 27 Aug 2019 09:45:46 -0400 Subject: [PATCH 2/5] fix(@angular-devkit/build-angular): inject correct SRI values in downlevel bundles Fixes #15468 --- .../build_angular/src/browser/index.ts | 131 +++++++++++++--- .../build_angular/src/utils/process-bundle.ts | 148 ++++++++++++++++-- 2 files changed, 238 insertions(+), 41 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index 674eafcf8008..f712a782c8d9 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -64,7 +64,7 @@ import { normalizeSourceMaps, } from '../utils'; import { manglingDisabled } from '../utils/mangle-options'; -import { CacheKey, ProcessBundleOptions } from '../utils/process-bundle'; +import { CacheKey, ProcessBundleOptions, ProcessBundleResult } from '../utils/process-bundle'; import { assertCompatibleAngularVersion } from '../utils/version'; import { generateBrowserWebpackConfigFromContext, @@ -269,11 +269,12 @@ export function buildWebpackBrowser( // Common options for all bundle process actions const sourceMapOptions = normalizeSourceMaps(options.sourceMap || false); - const actionOptions = { + const actionOptions: Partial = { optimize: normalizeOptimization(options.optimization).scripts, sourceMaps: sourceMapOptions.scripts, hiddenSourceMaps: sourceMapOptions.hidden, vendorSourceMaps: sourceMapOptions.vendor, + integrityAlgorithm: options.subresourceIntegrity ? 'sha384' : undefined, }; const actions: ProcessBundleOptions[] = []; @@ -303,8 +304,10 @@ export function buildWebpackBrowser( seen.add(file.file); // All files at this point except ES5 polyfills are module scripts - const es5Polyfills = file.file.startsWith('polyfills-es5'); - if (!es5Polyfills && !file.file.startsWith('polyfills-nomodule-es5')) { + const es5Polyfills = + file.file.startsWith('polyfills-es5') || + file.file.startsWith('polyfills-nomodule-es5'); + if (!es5Polyfills) { moduleFiles.push(file); } // If not optimizing then ES2015 polyfills do not need processing @@ -339,6 +342,7 @@ export function buildWebpackBrowser( filename, code, map, + name: file.name, optimizeOnly: true, }); @@ -352,6 +356,7 @@ export function buildWebpackBrowser( filename, code, map, + name: file.name, runtime: file.file.startsWith('runtime'), ignoreOriginal: es5Polyfills, }); @@ -367,15 +372,18 @@ export function buildWebpackBrowser( context.logger.info('Generating ES5 bundles for differential loading...'); const processActions: typeof actions = []; + let processRuntimeAction: ProcessBundleOptions | undefined; const cacheActions: { src: string; dest: string }[] = []; + const processResults: ProcessBundleResult[] = []; for (const action of actions) { // Create base cache key with elements: // * package version - different build-angular versions cause different final outputs // * code length/hash - ensure cached version matches the same input code - const codeHash = createHash('sha1') + const algorithm = action.integrityAlgorithm || 'sha1'; + const codeHash = createHash(algorithm) .update(action.code) - .digest('hex'); - let baseCacheKey = `${packageVersion}|${action.code.length}|${codeHash}`; + .digest('base64'); + let baseCacheKey = `${packageVersion}|${action.code.length}|${algorithm}-${codeHash}`; if (manglingDisabled) { baseCacheKey += '|MD'; } @@ -430,31 +438,86 @@ export function buildWebpackBrowser( // If all required cached entries are present, use the cached entries // Otherwise process the files - if (cached) { - if (cacheEntries[CacheKey.OriginalCode]) { - cacheActions.push({ - src: cacheEntries[CacheKey.OriginalCode].path, - dest: action.filename, - }); + // If SRI is enabled always process the runtime bundle + // Lazy route integrity values are stored in the runtime bundle + if (action.integrityAlgorithm && action.runtime) { + processRuntimeAction = action; + } else if (cached) { + const result: ProcessBundleResult = { name: action.name }; + if (action.integrityAlgorithm) { + result.integrity = `${action.integrityAlgorithm}-${codeHash}`; } - if (cacheEntries[CacheKey.OriginalMap]) { + + let cacheEntry = cacheEntries[CacheKey.OriginalCode]; + if (cacheEntry) { cacheActions.push({ - src: cacheEntries[CacheKey.OriginalMap].path, - dest: action.filename + '.map', + src: cacheEntry.path, + dest: action.filename, }); + result.original = { + filename: action.filename, + size: cacheEntry.size, + integrity: cacheEntry.metadata && cacheEntry.metadata.integrity, + }; + + cacheEntry = cacheEntries[CacheKey.OriginalMap]; + if (cacheEntry) { + cacheActions.push({ + src: cacheEntry.path, + dest: action.filename + '.map', + }); + result.original.map = { + filename: action.filename + '.map', + size: cacheEntry.size, + }; + } + } else if (!action.ignoreOriginal) { + // If the original wasn't processed (and therefore not cached), add info + result.original = { + filename: action.filename, + size: Buffer.byteLength(action.code, 'utf8'), + map: + action.map === undefined + ? undefined + : { + filename: action.filename + '.map', + size: Buffer.byteLength(action.map, 'utf8'), + }, + }; } - if (cacheEntries[CacheKey.DownlevelCode]) { + + cacheEntry = cacheEntries[CacheKey.DownlevelCode]; + if (cacheEntry) { cacheActions.push({ - src: cacheEntries[CacheKey.DownlevelCode].path, + src: cacheEntry.path, dest: action.filename.replace('es2015', 'es5'), }); + result.downlevel = { + filename: action.filename.replace('es2015', 'es5'), + size: cacheEntry.size, + integrity: cacheEntry.metadata && cacheEntry.metadata.integrity, + }; + + cacheEntry = cacheEntries[CacheKey.DownlevelMap]; + if (cacheEntry) { + cacheActions.push({ + src: cacheEntry.path, + dest: action.filename.replace('es2015', 'es5') + '.map', + }); + result.downlevel.map = { + filename: action.filename.replace('es2015', 'es5') + '.map', + size: cacheEntry.size, + }; + } } - if (cacheEntries[CacheKey.DownlevelMap]) { - cacheActions.push({ - src: cacheEntries[CacheKey.DownlevelMap].path, - dest: action.filename.replace('es2015', 'es5') + '.map', - }); - } + + processResults.push(result); + } else if (action.runtime) { + processRuntimeAction = { + ...action, + cacheKeys, + cachePath: cacheDownlevelPath || undefined, + }; } else { processActions.push({ ...action, @@ -506,11 +569,16 @@ export function buildWebpackBrowser( ['process'], ); let completed = 0; - const workCallback = (error: Error | null) => { + const workCallback = (error: Error | null, result: ProcessBundleResult) => { if (error) { workerFarm.end(workers); reject(error); - } else if (++completed === processActions.length) { + + return; + } + + processResults.push(result); + if (++completed === processActions.length) { workerFarm.end(workers); resolve(); } @@ -520,6 +588,17 @@ export function buildWebpackBrowser( }); } + // Runtime must be processed after all other files + if (processRuntimeAction) { + const runtimeOptions = { + ...processRuntimeAction, + runtimeData: processResults, + }; + processResults.push( + await import('../utils/process-bundle').then(m => m.processAsync(runtimeOptions)), + ); + } + context.logger.info('ES5 bundle generation complete.'); } else { const { emittedFiles = [] } = firstBuild; diff --git a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts index f2f062121a09..28ec55d4cd96 100644 --- a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts +++ b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts @@ -5,6 +5,7 @@ * 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 { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { SourceMapConsumer, SourceMapGenerator } from 'source-map'; @@ -18,15 +19,35 @@ export interface ProcessBundleOptions { filename: string; code: string; map?: string; + name?: string; sourceMaps?: boolean; hiddenSourceMaps?: boolean; vendorSourceMaps?: boolean; runtime?: boolean; - optimize: boolean; + optimize?: boolean; optimizeOnly?: boolean; ignoreOriginal?: boolean; cacheKeys?: (string | null)[]; cachePath?: string; + integrityAlgorithm?: 'sha256' | 'sha384' | 'sha512'; + runtimeData?: ProcessBundleResult[]; +} + +export interface ProcessBundleResult { + name?: string; + integrity?: string; + original?: ProcessBundleFile; + downlevel?: ProcessBundleFile; +} + +export interface ProcessBundleFile { + filename: string; + size: number; + integrity?: string; + map?: { + filename: string; + size: number; + }; } export const enum CacheKey { @@ -38,19 +59,38 @@ export const enum CacheKey { export function process( options: ProcessBundleOptions, - callback: (error: Error | null, result?: {}) => void, + callback: (error: Error | null, result?: ProcessBundleResult) => void, ): void { - processWorker(options).then(() => callback(null, {}), error => callback(error)); + processAsync(options).then(result => callback(null, result), error => callback(error)); } -async function processWorker(options: ProcessBundleOptions): Promise { +export async function processAsync(options: ProcessBundleOptions): Promise { if (!options.cacheKeys) { options.cacheKeys = []; } // If no downlevelling required than just mangle code and return if (options.optimizeOnly) { - return mangleOriginal(options); + const result: ProcessBundleResult = { name: options.name }; + if (options.integrityAlgorithm) { + result.integrity = generateIntegrityValue(options.integrityAlgorithm, options.code); + } + + // Replace integrity hashes with updated values + // NOTE: This should eventually be a babel plugin + if (options.runtime && options.integrityAlgorithm && options.runtimeData) { + for (const data of options.runtimeData) { + if (!data.integrity || !data.original || !data.original.integrity) { + continue; + } + + options.code = options.code.replace(data.integrity, data.original.integrity); + } + } + + result.original = await mangleOriginal(options); + + return result; } // if code size is larger than 500kB, manually handle sourcemaps with newer source-map package. @@ -64,13 +104,15 @@ async function processWorker(options: ProcessBundleOptions): Promise { filename: options.filename, inputSourceMap: !manualSourceMaps && options.map !== undefined && JSON.parse(options.map), babelrc: false, - // modules aren't needed since the bundles use webpacks custom module loading + // modules aren't needed since the bundles use webpack's custom module loading // loose generates more ES5-like code but does not strictly adhere to the ES2015 spec (Typescript is loose) // 'transform-typeof-symbol' generates slower code presets: [ ['@babel/preset-env', { modules: false, loose: true, exclude: ['transform-typeof-symbol'] }], ], - minified: true, + minified: options.optimize, + // `false` ensures it is disabled and prevents large file warnings + compact: options.optimize || false, sourceMaps: options.sourceMaps, }); @@ -80,6 +122,18 @@ async function processWorker(options: ProcessBundleOptions): Promise { // Extra spacing is intentional to align source line positions if (options.runtime) { code = code.replace('"-es2015.', ' "-es5.'); + + // Replace integrity hashes with updated values + // NOTE: This should eventually be a babel plugin + if (options.integrityAlgorithm && options.runtimeData) { + for (const data of options.runtimeData) { + if (!data.integrity || !data.downlevel || !data.downlevel.integrity) { + continue; + } + + code = code.replace(data.integrity, data.downlevel.integrity); + } + } } if (options.sourceMaps && manualSourceMaps && options.map) { @@ -128,12 +182,14 @@ async function processWorker(options: ProcessBundleOptions): Promise { map.sourceRoot = sourceRoot; } + const result: ProcessBundleResult = { name: options.name }; + if (options.optimize) { // Note: Investigate converting the AST instead of re-parsing // estree -> terser is already supported; need babel -> estree/terser // Mangle downlevel code - const result = minify(code, { + const minifyOutput = minify(code, { compress: true, ecma: 5, mangle: !manglingDisabled, @@ -148,16 +204,16 @@ async function processWorker(options: ProcessBundleOptions): Promise { }, }); - if (result.error) { - throw result.error; + if (minifyOutput.error) { + throw minifyOutput.error; } - code = result.code; - map = result.map; + code = minifyOutput.code; + map = minifyOutput.map; // Mangle original code if (!options.ignoreOriginal) { - await mangleOriginal(options); + result.original = await mangleOriginal(options); } } else if (map) { map = JSON.stringify(map); @@ -175,13 +231,33 @@ async function processWorker(options: ProcessBundleOptions): Promise { fs.writeFileSync(newFilePath + '.map', map); } + result.downlevel = createFileEntry(newFilePath, code, map, options.integrityAlgorithm); + if (options.cachePath && options.cacheKeys[CacheKey.DownlevelCode]) { - await cacache.put(options.cachePath, options.cacheKeys[CacheKey.DownlevelCode], code); + await cacache.put(options.cachePath, options.cacheKeys[CacheKey.DownlevelCode], code, { + metadata: { integrity: result.downlevel.integrity }, + }); } fs.writeFileSync(newFilePath, code); + + // If original was not processed, add info + if (!result.original && !options.ignoreOriginal) { + result.original = createFileEntry( + options.filename, + options.code, + options.map, + options.integrityAlgorithm, + ); + } + + if (options.integrityAlgorithm) { + result.integrity = generateIntegrityValue(options.integrityAlgorithm, options.code); + } + + return result; } -async function mangleOriginal(options: ProcessBundleOptions): Promise { +async function mangleOriginal(options: ProcessBundleOptions): Promise { const resultOriginal = minify(options.code, { compress: false, ecma: 6, @@ -218,13 +294,55 @@ async function mangleOriginal(options: ProcessBundleOptions): Promise { fs.writeFileSync(options.filename + '.map', resultOriginal.map); } + const fileResult = createFileEntry( + options.filename, + // tslint:disable-next-line: no-non-null-assertion + resultOriginal.code!, + resultOriginal.map as string, + options.integrityAlgorithm, + ); + if (options.cachePath && options.cacheKeys && options.cacheKeys[CacheKey.OriginalCode]) { await cacache.put( options.cachePath, options.cacheKeys[CacheKey.OriginalCode], resultOriginal.code, + { + metadata: { integrity: fileResult.integrity }, + }, ); } fs.writeFileSync(options.filename, resultOriginal.code); + + return fileResult; +} + +function createFileEntry( + filename: string, + code: string, + map: string | undefined, + integrityAlgorithm?: string, +): ProcessBundleFile { + return { + filename: filename, + size: Buffer.byteLength(code), + integrity: integrityAlgorithm && generateIntegrityValue(integrityAlgorithm, code), + map: !map + ? undefined + : { + filename: filename + '.map', + size: Buffer.byteLength(map), + }, + }; +} + +function generateIntegrityValue(hashAlgorithm: string, code: string) { + return ( + hashAlgorithm + + '-' + + createHash(hashAlgorithm) + .update(code) + .digest('base64') + ); } From 878d7dd8a3feb58ff19800db598f243ba2eb86ae Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 23 Sep 2019 13:23:42 -0400 Subject: [PATCH 3/5] fix(@angular-devkit/build-webpack): provide more complete compilation stats --- packages/angular_devkit/build_webpack/src/utils.ts | 13 +++---------- .../src/webpack-dev-server/index_spec_large.ts | 1 + .../build_webpack/src/webpack/index.ts | 2 ++ .../build_webpack/src/webpack/index_spec_large.ts | 5 +++-- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/angular_devkit/build_webpack/src/utils.ts b/packages/angular_devkit/build_webpack/src/utils.ts index 9a711b9e0f82..4cf61464e1fa 100644 --- a/packages/angular_devkit/build_webpack/src/utils.ts +++ b/packages/angular_devkit/build_webpack/src/utils.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import * as webpack from 'webpack'; export interface EmittedFiles { + id?: string; name?: string; file: string; initial: boolean; @@ -20,19 +21,11 @@ export interface EmittedFiles { export function getEmittedFiles(compilation: webpack.compilation.Compilation): EmittedFiles[] { const files: EmittedFiles[] = []; - // entrypoints might have multiple outputs - // such as runtime.js - for (const [name, entrypoint] of compilation.entrypoints) { - const entryFiles: string[] = (entrypoint && entrypoint.getFiles()) || []; - for (const file of entryFiles) { - files.push({ name, file, extension: path.extname(file), initial: true }); - } - } - // adds all chunks to the list of emitted files such as lazy loaded modules - for (const chunk of Object.values(compilation.chunks)) { + for (const chunk of compilation.chunks as webpack.compilation.Chunk[]) { for (const file of chunk.files as string[]) { files.push({ + id: chunk.id.toString(), name: chunk.name, file, extension: path.extname(file), diff --git a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec_large.ts b/packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec_large.ts index 447a5f1541eb..a2e2f431c0ab 100644 --- a/packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec_large.ts +++ b/packages/angular_devkit/build_webpack/src/webpack-dev-server/index_spec_large.ts @@ -61,6 +61,7 @@ describe('Dev Server Builder', () => { expect(output.success).toBe(true); expect(output.emittedFiles).toContain({ + id: 'main', name: 'main', initial: true, file: 'bundle.js', diff --git a/packages/angular_devkit/build_webpack/src/webpack/index.ts b/packages/angular_devkit/build_webpack/src/webpack/index.ts index 73c4abcd6886..0da60ee387f4 100644 --- a/packages/angular_devkit/build_webpack/src/webpack/index.ts +++ b/packages/angular_devkit/build_webpack/src/webpack/index.ts @@ -27,6 +27,7 @@ export interface WebpackFactory { export type BuildResult = BuilderOutput & { emittedFiles?: EmittedFiles[]; + webpackStats?: webpack.Stats.ToJsonOutput; }; export function runWebpack( @@ -59,6 +60,7 @@ export function runWebpack( obs.next({ success: !stats.hasErrors(), + webpackStats: stats.toJson(), emittedFiles: getEmittedFiles(stats.compilation), } as unknown as BuildResult); diff --git a/packages/angular_devkit/build_webpack/src/webpack/index_spec_large.ts b/packages/angular_devkit/build_webpack/src/webpack/index_spec_large.ts index 1c173f088296..d81a7b8c5c04 100644 --- a/packages/angular_devkit/build_webpack/src/webpack/index_spec_large.ts +++ b/packages/angular_devkit/build_webpack/src/webpack/index_spec_large.ts @@ -60,6 +60,7 @@ describe('Webpack Builder basic test', () => { expect(output.success).toBe(true); expect(output.emittedFiles).toContain({ + id: 'main', name: 'main', initial: true, file: 'bundle.js', @@ -94,8 +95,8 @@ describe('Webpack Builder basic test', () => { expect(output.success).toBe(true); expect(output.emittedFiles).toContain( - { name: 'main', initial: true, file: 'main.js', extension: '.js' }, - { name: 'polyfills', initial: true, file: 'polyfills.js', extension: '.js' }, + { id: 'main', name: 'main', initial: true, file: 'main.js', extension: '.js' }, + { id: 'polyfills', name: 'polyfills', initial: true, file: 'polyfills.js', extension: '.js' }, ); await run.stop(); From 9ae534ba9d835cb2fdba95f7eb83a98d3b0b960e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 23 Sep 2019 13:24:56 -0400 Subject: [PATCH 4/5] fix(@angular-devkit/build-angular): display accurate sizes for downlevelled files Fixes #15425 --- .../utilities/package-chunk-sort.ts | 2 + .../src/angular-cli-files/utilities/stats.ts | 43 +++++-- .../build_angular/src/browser/index.ts | 115 ++++++++++++++++-- .../build_angular/src/utils/process-bundle.ts | 4 +- .../e2e/tests/basic/scripts-array.ts | 6 +- .../e2e/tests/basic/styles-array.ts | 6 +- .../e2e/tests/misc/support-safari-10.1.ts | 6 +- 7 files changed, 151 insertions(+), 31 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/package-chunk-sort.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/package-chunk-sort.ts index 29e5e17ac34e..442b80ebb34f 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/package-chunk-sort.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/package-chunk-sort.ts @@ -27,11 +27,13 @@ export function generateEntryPoints(appConfig: { const entryPoints = [ 'polyfills-nomodule-es5', + 'runtime', 'polyfills-es5', 'polyfills', 'sw-register', ...extraEntryPoints(appConfig.styles, 'styles'), ...extraEntryPoints(appConfig.scripts, 'scripts'), + 'vendor', 'main', ]; diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/stats.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/stats.ts index 296b4f16b34f..ac2bc7aa8fe3 100644 --- a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/stats.ts +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/stats.ts @@ -8,6 +8,7 @@ // tslint:disable // TODO: cleanup this file, it's copied as is from Angular CLI. import { tags, terminal } from '@angular-devkit/core'; +import * as path from 'path'; const { bold, green, red, reset, white, yellow } = terminal; @@ -23,27 +24,47 @@ export function formatSize(size: number): string { return `${+(size / Math.pow(1024, index)).toPrecision(3)} ${abbreviations[index]}`; } +export function generateBundleStats( + info: { + id: string | number; + size?: number; + files: string[]; + names?: string[]; + entry: boolean; + initial: boolean; + rendered?: boolean; + }, + colors: boolean, +): string { + const g = (x: string) => (colors ? bold(green(x)) : x); + const y = (x: string) => (colors ? bold(yellow(x)) : x); + + const size = typeof info.size === 'number' ? ` ${formatSize(info.size)}` : ''; + const files = info.files.map(f => path.basename(f)).join(', '); + const names = info.names ? ` (${info.names.join(', ')})` : ''; + const initial = y(info.entry ? '[entry]' : info.initial ? '[initial]' : ''); + const flags = ['rendered', 'recorded'] + .map(f => (f && (info as any)[f] ? g(` [${f}]`) : '')) + .join(''); + + return `chunk {${y(info.id.toString())}} ${g(files)}${names}${size} ${initial}${flags}`; +} + +export function generateBuildStats(hash: string, time: number, colors: boolean): string { + const w = (x: string) => colors ? bold(white(x)) : x; + return `Date: ${w(new Date().toISOString())} - Hash: ${w(hash)} - Time: ${w('' + time)}ms` +} export function statsToString(json: any, statsConfig: any) { const colors = statsConfig.colors; const rs = (x: string) => colors ? reset(x) : x; const w = (x: string) => colors ? bold(white(x)) : x; - const g = (x: string) => colors ? bold(green(x)) : x; - const y = (x: string) => colors ? bold(yellow(x)) : x; const changedChunksStats = json.chunks .filter((chunk: any) => chunk.rendered) .map((chunk: any) => { const asset = json.assets.filter((x: any) => x.name == chunk.files[0])[0]; - const size = asset ? ` ${formatSize(asset.size)}` : ''; - const files = chunk.files.join(', '); - const names = chunk.names ? ` (${chunk.names.join(', ')})` : ''; - const initial = y(chunk.entry ? '[entry]' : chunk.initial ? '[initial]' : ''); - const flags = ['rendered', 'recorded'] - .map(f => f && chunk[f] ? g(` [${f}]`) : '') - .join(''); - - return `chunk {${y(chunk.id)}} ${g(files)}${names}${size} ${initial}${flags}`; + return generateBundleStats({ ...chunk, size: asset && asset.size }, colors); }); const unchangedChunkNumber = json.chunks.length - changedChunksStats.length; diff --git a/packages/angular_devkit/build_angular/src/browser/index.ts b/packages/angular_devkit/build_angular/src/browser/index.ts index f712a782c8d9..6ad5518856bb 100644 --- a/packages/angular_devkit/build_angular/src/browser/index.ts +++ b/packages/angular_devkit/build_angular/src/browser/index.ts @@ -51,6 +51,8 @@ import { import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig'; import { augmentAppWithServiceWorker } from '../angular-cli-files/utilities/service-worker'; import { + generateBuildStats, + generateBundleStats, statsErrorsToString, statsToString, statsWarningsToString, @@ -64,7 +66,12 @@ import { normalizeSourceMaps, } from '../utils'; import { manglingDisabled } from '../utils/mangle-options'; -import { CacheKey, ProcessBundleOptions, ProcessBundleResult } from '../utils/process-bundle'; +import { + CacheKey, + ProcessBundleFile, + ProcessBundleOptions, + ProcessBundleResult, +} from '../utils/process-bundle'; import { assertCompatibleAngularVersion } from '../utils/version'; import { generateBrowserWebpackConfigFromContext, @@ -202,9 +209,6 @@ export function buildWebpackBrowser( // Check Angular version. assertCompatibleAngularVersion(context.workspaceRoot, context.logger); - const loggingFn = - transforms.logging || createBrowserLoggingCallback(!!options.verbose, context.logger); - return from(initialize(options, context, host, transforms.webpackConfiguration)).pipe( // tslint:disable-next-line: no-big-function switchMap(({ config: configs, projectRoot }) => { @@ -222,6 +226,10 @@ export function buildWebpackBrowser( `); } + const useBundleDownleveling = + isDifferentialLoadingNeeded && !(fullDifferential || options.watch); + const startTime = Date.now(); + return from(configs).pipe( // the concurrency parameter (3rd parameter of mergeScan) is deliberately // set to 1 to make sure the build steps are executed in sequence. @@ -229,7 +237,13 @@ export function buildWebpackBrowser( (lastResult, config) => { // Make sure to only run the 2nd build step, if 1st one succeeded if (lastResult.success) { - return runWebpack(config, context, { logging: loggingFn }); + return runWebpack(config, context, { + logging: + transforms.logging || + (useBundleDownleveling + ? () => {} + : createBrowserLoggingCallback(!!options.verbose, context.logger)), + }); } else { return of(); } @@ -242,7 +256,19 @@ export function buildWebpackBrowser( switchMap(async buildEvents => { configs.length = 0; const success = buildEvents.every(r => r.success); - if (success) { + if (!success && useBundleDownleveling) { + // If using bundle downleveling then there is only one build + // If it fails show any diagnostic messages and bail + const webpackStats = buildEvents[0].webpackStats; + if (webpackStats && webpackStats.warnings.length > 0) { + context.logger.warn(statsWarningsToString(webpackStats, { colors: true })); + } + if (webpackStats && webpackStats.errors.length > 0) { + context.logger.error(statsErrorsToString(webpackStats, { colors: true })); + } + + return { success }; + } else if (success) { let noModuleFiles: EmittedFiles[] | undefined; let moduleFiles: EmittedFiles[] | undefined; let files: EmittedFiles[] | undefined; @@ -263,7 +289,7 @@ export function buildWebpackBrowser( noModuleFiles = secondBuild.emittedFiles; } } else if (isDifferentialLoadingNeeded && !fullDifferential) { - const { emittedFiles = [] } = firstBuild; + const { emittedFiles = [], webpackStats } = firstBuild; moduleFiles = []; noModuleFiles = []; @@ -342,7 +368,9 @@ export function buildWebpackBrowser( filename, code, map, - name: file.name, + // id is always present for non-assets + // tslint:disable-next-line: no-non-null-assertion + name: file.id!, optimizeOnly: true, }); @@ -356,7 +384,9 @@ export function buildWebpackBrowser( filename, code, map, - name: file.name, + // id is always present for non-assets + // tslint:disable-next-line: no-non-null-assertion + name: file.id!, runtime: file.file.startsWith('runtime'), ignoreOriginal: es5Polyfills, }); @@ -600,6 +630,73 @@ export function buildWebpackBrowser( } context.logger.info('ES5 bundle generation complete.'); + + type ArrayElement = A extends ReadonlyArray ? T : never; + function generateBundleInfoStats( + id: string | number, + bundle: ProcessBundleFile, + chunk: ArrayElement | undefined, + ): string { + return generateBundleStats( + { + id, + size: bundle.size, + files: bundle.map ? [bundle.filename, bundle.map.filename] : [bundle.filename], + names: chunk && chunk.names, + entry: !!chunk && chunk.names.includes('runtime'), + initial: !!chunk && chunk.initial, + rendered: true, + }, + true, + ); + } + + let bundleInfoText = ''; + const processedNames = new Set(); + for (const result of processResults) { + processedNames.add(result.name); + + const chunk = + webpackStats && + webpackStats.chunks && + webpackStats.chunks.find(c => result.name === c.id.toString()); + if (result.original) { + bundleInfoText += + '\n' + generateBundleInfoStats(result.name, result.original, chunk); + } + if (result.downlevel) { + bundleInfoText += + '\n' + generateBundleInfoStats(result.name, result.downlevel, chunk); + } + } + + if (webpackStats && webpackStats.chunks) { + for (const chunk of webpackStats.chunks) { + if (processedNames.has(chunk.id.toString())) { + continue; + } + + const asset = + webpackStats.assets && webpackStats.assets.find(a => a.name === chunk.files[0]); + bundleInfoText += + '\n' + generateBundleStats({ ...chunk, size: asset && asset.size }, true); + } + } + + bundleInfoText += + '\n' + + generateBuildStats( + (webpackStats && webpackStats.hash) || '', + Date.now() - startTime, + true, + ); + context.logger.info(bundleInfoText); + if (webpackStats && webpackStats.warnings.length > 0) { + context.logger.warn(statsWarningsToString(webpackStats, { colors: true })); + } + if (webpackStats && webpackStats.errors.length > 0) { + context.logger.error(statsErrorsToString(webpackStats, { colors: true })); + } } else { const { emittedFiles = [] } = firstBuild; files = emittedFiles.filter(x => x.name !== 'polyfills-es5'); diff --git a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts index 28ec55d4cd96..1313660cc610 100644 --- a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts +++ b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts @@ -19,7 +19,7 @@ export interface ProcessBundleOptions { filename: string; code: string; map?: string; - name?: string; + name: string; sourceMaps?: boolean; hiddenSourceMaps?: boolean; vendorSourceMaps?: boolean; @@ -34,7 +34,7 @@ export interface ProcessBundleOptions { } export interface ProcessBundleResult { - name?: string; + name: string; integrity?: string; original?: ProcessBundleFile; downlevel?: ProcessBundleFile; diff --git a/tests/legacy-cli/e2e/tests/basic/scripts-array.ts b/tests/legacy-cli/e2e/tests/basic/scripts-array.ts index d6c8698e7532..fd7f869b6cc9 100644 --- a/tests/legacy-cli/e2e/tests/basic/scripts-array.ts +++ b/tests/legacy-cli/e2e/tests/basic/scripts-array.ts @@ -73,15 +73,15 @@ export default async function () { await expectFileToMatch( 'dist/test-project/index.html', oneLineTrim` + + - - - + `, ); diff --git a/tests/legacy-cli/e2e/tests/basic/styles-array.ts b/tests/legacy-cli/e2e/tests/basic/styles-array.ts index feea3e79acf8..72b082f3d18c 100644 --- a/tests/legacy-cli/e2e/tests/basic/styles-array.ts +++ b/tests/legacy-cli/e2e/tests/basic/styles-array.ts @@ -61,13 +61,13 @@ export default async function() { await expectFileToMatch( 'dist/test-project/index.html', oneLineTrim` + + - - - + `, ); diff --git a/tests/legacy-cli/e2e/tests/misc/support-safari-10.1.ts b/tests/legacy-cli/e2e/tests/misc/support-safari-10.1.ts index bf5dcd375ce2..f790ba83180e 100644 --- a/tests/legacy-cli/e2e/tests/misc/support-safari-10.1.ts +++ b/tests/legacy-cli/e2e/tests/misc/support-safari-10.1.ts @@ -44,15 +44,15 @@ export default async function () { } else { await expectFileToMatch('dist/test-project/index.html', oneLineTrim` + + - - - + `); } From 2c452e6d54d684044e648cf2c635551d982365f8 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 25 Sep 2019 11:18:24 -0400 Subject: [PATCH 5/5] fix(@angular-devkit/core): json visitors should only set writable properties --- .../core/src/json/schema/visitor.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/angular_devkit/core/src/json/schema/visitor.ts b/packages/angular_devkit/core/src/json/schema/visitor.ts index a00ed14a0247..a1f1bdb6fc8c 100644 --- a/packages/angular_devkit/core/src/json/schema/visitor.ts +++ b/packages/angular_devkit/core/src/json/schema/visitor.ts @@ -13,21 +13,20 @@ import { buildJsonPointer, joinJsonPointer } from './pointer'; import { JsonSchema } from './schema'; export interface ReferenceResolver { - (ref: string, context?: ContextT): { context?: ContextT, schema?: JsonObject }; + (ref: string, context?: ContextT): { context?: ContextT; schema?: JsonObject }; } -function _getObjectSubSchema( - schema: JsonSchema | undefined, - key: string, -): JsonObject | undefined { +function _getObjectSubSchema(schema: JsonSchema | undefined, key: string): JsonObject | undefined { if (typeof schema !== 'object' || schema === null) { return undefined; } // Is it an object schema? if (typeof schema.properties == 'object' || schema.type == 'object') { - if (typeof schema.properties == 'object' - && typeof (schema.properties as JsonObject)[key] == 'object') { + if ( + typeof schema.properties == 'object' && + typeof (schema.properties as JsonObject)[key] == 'object' + ) { return (schema.properties as JsonObject)[key] as JsonObject; } if (typeof schema.additionalProperties == 'object') { @@ -51,7 +50,7 @@ function _visitJsonRecursive( ptr: JsonPointer, schema?: JsonSchema, refResolver?: ReferenceResolver, - context?: ContextT, // tslint:disable-line:no-any + context?: ContextT, root?: JsonObject | JsonArray, ): Observable { if (schema === true || schema === false) { @@ -82,7 +81,7 @@ function _visitJsonRecursive( refResolver, context, root || value, - ).pipe(tap(x => value[i] = x)); + ).pipe(tap(x => (value[i] = x))); }), ignoreElements(), ), @@ -100,11 +99,18 @@ function _visitJsonRecursive( refResolver, context, root || value, - ).pipe(tap(x => value[key] = x)); + ).pipe( + tap(x => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && descriptor.writable && value[key] !== x) { + value[key] = x; + } + }), + ); }), ignoreElements(), - ), - observableOf(value), + ), + observableOf(value), ); } else { return observableOf(value); @@ -133,12 +139,11 @@ export function visitJson( visitor: JsonVisitor, schema?: JsonSchema, refResolver?: ReferenceResolver, - context?: ContextT, // tslint:disable-line:no-any + context?: ContextT, ): Observable { return _visitJsonRecursive(json, visitor, buildJsonPointer([]), schema, refResolver, context); } - export function visitJsonSchema(schema: JsonSchema, visitor: JsonSchemaVisitor) { if (schema === false || schema === true) { // Nothing to visit.