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') + ); }