753 lines
27 KiB
TypeScript

/**
* @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 { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { EmittedFiles, WebpackLoggingCallback, runWebpack } from '@angular-devkit/build-webpack';
import { join, json, logging, normalize, tags, virtualFs } from '@angular-devkit/core';
import { NodeJsSyncHost } from '@angular-devkit/core/node';
import * as fs from 'fs';
import * as path from 'path';
import { Observable, from, of } from 'rxjs';
import { concatMap, map, switchMap } from 'rxjs/operators';
import { ScriptTarget } from 'typescript';
import * as webpack from 'webpack';
import { NgBuildAnalyticsPlugin } from '../../plugins/webpack/analytics';
import { WebpackConfigOptions } from '../angular-cli-files/models/build-options';
import {
getAotConfig,
getBrowserConfig,
getCommonConfig,
getNonAotConfig,
getStatsConfig,
getStylesConfig,
getWorkerConfig,
normalizeExtraEntryPoints,
} from '../angular-cli-files/models/webpack-configs';
import {
IndexHtmlTransform,
writeIndexHtml,
} from '../angular-cli-files/utilities/index-file/write-index-html';
import { readTsconfig } from '../angular-cli-files/utilities/read-tsconfig';
import { augmentAppWithServiceWorker } from '../angular-cli-files/utilities/service-worker';
import {
generateBuildStats,
generateBundleStats,
statsErrorsToString,
statsToString,
statsWarningsToString,
} from '../angular-cli-files/utilities/stats';
import { ExecutionTransformer } from '../transforms';
import {
BuildBrowserFeatures,
deleteOutputDir,
normalizeAssetPatterns,
normalizeOptimization,
normalizeSourceMaps,
} from '../utils';
import { BundleActionExecutor } from '../utils/action-executor';
import { findCachePath } from '../utils/cache-path';
import { copyAssets } from '../utils/copy-assets';
import { cachingDisabled } from '../utils/environment-options';
import { i18nInlineEmittedFiles } from '../utils/i18n-inlining';
import { I18nOptions } from '../utils/i18n-options';
import { ensureOutputPaths } from '../utils/output-paths';
import {
InlineOptions,
ProcessBundleFile,
ProcessBundleOptions,
ProcessBundleResult,
} from '../utils/process-bundle';
import { assertCompatibleAngularVersion } from '../utils/version';
import {
BrowserWebpackConfigOptions,
generateBrowserWebpackConfigFromContext,
generateI18nBrowserWebpackConfigFromContext,
getIndexInputFile,
getIndexOutputFile,
} from '../utils/webpack-browser-config';
import { Schema as BrowserBuilderSchema } from './schema';
const cacheDownlevelPath = cachingDisabled ? undefined : findCachePath('angular-build-dl');
export type BrowserBuilderOutput = json.JsonObject &
BuilderOutput & {
outputPath: string;
};
export function createBrowserLoggingCallback(
verbose: boolean,
logger: logging.LoggerApi,
): WebpackLoggingCallback {
return (stats, config) => {
// config.stats contains our own stats settings, added during buildWebpackConfig().
const json = stats.toJson(config.stats);
if (verbose) {
logger.info(stats.toString(config.stats));
} else {
logger.info(statsToString(json, config.stats));
}
if (stats.hasWarnings()) {
logger.warn(statsWarningsToString(json, config.stats));
}
if (stats.hasErrors()) {
logger.error(statsErrorsToString(json, config.stats));
}
};
}
// todo: the below should be cleaned once dev-server support the new i18n
interface ConfigFromContextReturn {
config: webpack.Configuration;
projectRoot: string;
projectSourceRoot?: string;
}
export async function buildBrowserWebpackConfigFromContext(
options: BrowserBuilderSchema,
context: BuilderContext,
host: virtualFs.Host<fs.Stats>,
i18n: boolean,
): Promise<ConfigFromContextReturn & { i18n: I18nOptions }>;
export async function buildBrowserWebpackConfigFromContext(
options: BrowserBuilderSchema,
context: BuilderContext,
host?: virtualFs.Host<fs.Stats>,
): Promise<ConfigFromContextReturn>;
export async function buildBrowserWebpackConfigFromContext(
options: BrowserBuilderSchema,
context: BuilderContext,
host: virtualFs.Host<fs.Stats> = new NodeJsSyncHost(),
i18n = false,
): Promise<ConfigFromContextReturn & { i18n?: I18nOptions }> {
const webpackPartialGenerator = (wco: BrowserWebpackConfigOptions) => [
getCommonConfig(wco),
getBrowserConfig(wco),
getStylesConfig(wco),
getStatsConfig(wco),
getAnalyticsConfig(wco, context),
getCompilerConfig(wco),
wco.buildOptions.webWorkerTsConfig ? getWorkerConfig(wco) : {},
];
if (i18n) {
return generateI18nBrowserWebpackConfigFromContext(
options,
context,
webpackPartialGenerator,
host,
);
}
return generateBrowserWebpackConfigFromContext(
options,
context,
webpackPartialGenerator,
host,
);
}
function getAnalyticsConfig(
wco: WebpackConfigOptions,
context: BuilderContext,
): webpack.Configuration {
if (context.analytics) {
// If there's analytics, add our plugin. Otherwise no need to slow down the build.
let category = 'build';
if (context.builder) {
// We already vetted that this is a "safe" package, otherwise the analytics would be noop.
category =
context.builder.builderName.split(':')[1] || context.builder.builderName || 'build';
}
// The category is the builder name if it's an angular builder.
return {
plugins: [new NgBuildAnalyticsPlugin(wco.projectRoot, context.analytics, category)],
};
}
return {};
}
function getCompilerConfig(wco: WebpackConfigOptions): webpack.Configuration {
if (wco.buildOptions.main || wco.buildOptions.polyfills) {
return wco.buildOptions.aot ? getAotConfig(wco) : getNonAotConfig(wco);
}
return {};
}
async function initialize(
options: BrowserBuilderSchema,
context: BuilderContext,
host: virtualFs.Host<fs.Stats>,
webpackConfigurationTransform?: ExecutionTransformer<webpack.Configuration>,
): Promise<{
config: webpack.Configuration;
projectRoot: string;
projectSourceRoot?: string;
i18n: I18nOptions;
}> {
const originalOutputPath = options.outputPath;
const { config, projectRoot, projectSourceRoot, i18n } = await buildBrowserWebpackConfigFromContext(
options,
context,
host,
true,
);
let transformedConfig;
if (webpackConfigurationTransform) {
transformedConfig = await webpackConfigurationTransform(config);
}
if (options.deleteOutputPath) {
deleteOutputDir(
context.workspaceRoot,
originalOutputPath,
);
}
return { config: transformedConfig || config, projectRoot, projectSourceRoot, i18n };
}
// tslint:disable-next-line: no-big-function
export function buildWebpackBrowser(
options: BrowserBuilderSchema,
context: BuilderContext,
transforms: {
webpackConfiguration?: ExecutionTransformer<webpack.Configuration>;
logging?: WebpackLoggingCallback;
indexHtml?: IndexHtmlTransform;
} = {},
): Observable<BrowserBuilderOutput> {
const host = new NodeJsSyncHost();
const root = normalize(context.workspaceRoot);
const baseOutputPath = path.resolve(context.workspaceRoot, options.outputPath);
// Check Angular version.
assertCompatibleAngularVersion(context.workspaceRoot, context.logger);
return from(initialize(options, context, host, transforms.webpackConfiguration)).pipe(
// tslint:disable-next-line: no-big-function
switchMap(({ config, projectRoot, projectSourceRoot, i18n }) => {
const tsConfig = readTsconfig(options.tsConfig, context.workspaceRoot);
const target = tsConfig.options.target || ScriptTarget.ES5;
const buildBrowserFeatures = new BuildBrowserFeatures(projectRoot, target);
const isDifferentialLoadingNeeded = buildBrowserFeatures.isDifferentialLoadingNeeded();
if (target > ScriptTarget.ES2015 && isDifferentialLoadingNeeded) {
context.logger.warn(tags.stripIndent`
WARNING: Using differential loading with targets ES5 and ES2016 or higher may
cause problems. Browsers with support for ES2015 will load the ES2016+ scripts
referenced with script[type="module"] but they may not support ES2016+ syntax.
`);
}
const useBundleDownleveling = isDifferentialLoadingNeeded && !options.watch;
const startTime = Date.now();
return runWebpack(config, context, {
logging:
transforms.logging ||
(useBundleDownleveling
? () => {}
: createBrowserLoggingCallback(!!options.verbose, context.logger)),
}).pipe(
// tslint:disable-next-line: no-big-function
concatMap(async buildEvent => {
const { webpackStats, success, emittedFiles = [] } = buildEvent;
if (!webpackStats) {
throw new Error('Webpack stats build result is required.');
}
if (!success && useBundleDownleveling) {
// If using bundle downleveling then there is only one build
// If it fails show any diagnostic messages and bail
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) {
const outputPaths = ensureOutputPaths(baseOutputPath, i18n);
let noModuleFiles: EmittedFiles[] | undefined;
let moduleFiles: EmittedFiles[] | undefined;
let files: EmittedFiles[] | undefined;
const scriptsEntryPointName = normalizeExtraEntryPoints(
options.scripts || [],
'scripts',
).map(x => x.bundleName);
if (isDifferentialLoadingNeeded && options.watch) {
moduleFiles = emittedFiles;
files = moduleFiles.filter(
x => x.extension === '.css' || (x.name && scriptsEntryPointName.includes(x.name)),
);
if (i18n.shouldInline) {
const success = await i18nInlineEmittedFiles(
context,
emittedFiles,
i18n,
baseOutputPath,
outputPaths,
scriptsEntryPointName,
// tslint:disable-next-line: no-non-null-assertion
webpackStats.outputPath!,
target <= ScriptTarget.ES5,
options.i18nMissingTranslation,
);
if (!success) {
return { success: false };
}
}
} else if (isDifferentialLoadingNeeded) {
moduleFiles = [];
noModuleFiles = [];
// Common options for all bundle process actions
const sourceMapOptions = normalizeSourceMaps(options.sourceMap || false);
const actionOptions: Partial<ProcessBundleOptions> = {
optimize: normalizeOptimization(options.optimization).scripts,
sourceMaps: sourceMapOptions.scripts,
hiddenSourceMaps: sourceMapOptions.hidden,
vendorSourceMaps: sourceMapOptions.vendor,
integrityAlgorithm: options.subresourceIntegrity ? 'sha384' : undefined,
};
let mainChunkId;
const actions: ProcessBundleOptions[] = [];
const seen = new Set<string>();
for (const file of emittedFiles) {
// Assets are not processed nor injected into the index
if (file.asset) {
continue;
}
// Scripts and non-javascript files are not processed
if (
file.extension !== '.js' ||
(file.name && scriptsEntryPointName.includes(file.name))
) {
if (files === undefined) {
files = [];
}
files.push(file);
continue;
}
// Ignore already processed files; emittedFiles can contain duplicates
if (seen.has(file.file)) {
continue;
}
seen.add(file.file);
if (file.name === 'main') {
// tslint:disable-next-line: no-non-null-assertion
mainChunkId = file.id!.toString();
}
// All files at this point except ES5 polyfills are module scripts
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
// Unlike other module scripts, it is never downleveled
const es2015Polyfills = file.file.startsWith('polyfills-es2015');
if (!actionOptions.optimize && es2015Polyfills) {
continue;
}
// Retrieve the content/map for the file
// NOTE: Additional future optimizations will read directly from memory
// tslint:disable-next-line: no-non-null-assertion
let filename = path.join(webpackStats.outputPath!, file.file);
const code = fs.readFileSync(filename, 'utf8');
let map;
if (actionOptions.sourceMaps) {
try {
map = fs.readFileSync(filename + '.map', 'utf8');
if (es5Polyfills) {
fs.unlinkSync(filename + '.map');
}
} catch {}
}
if (es5Polyfills) {
fs.unlinkSync(filename);
filename = filename.replace('-es2015', '');
}
// Record the bundle processing action
// The runtime chunk gets special processing for lazy loaded files
actions.push({
...actionOptions,
filename,
code,
map,
// 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,
optimizeOnly: es2015Polyfills,
});
// ES2015 polyfills are only optimized; optimization check was performed above
if (es2015Polyfills) {
continue;
}
// Add the newly created ES5 bundles to the index as nomodule scripts
const newFilename = es5Polyfills
? file.file.replace('-es2015', '')
: file.file.replace('es2015', 'es5');
noModuleFiles.push({ ...file, file: newFilename });
}
const processActions: typeof actions = [];
let processRuntimeAction: ProcessBundleOptions | undefined;
const processResults: ProcessBundleResult[] = [];
for (const action of actions) {
// 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 {
processActions.push(action);
}
}
const executor = new BundleActionExecutor(
{ cachePath: cacheDownlevelPath, i18n },
options.subresourceIntegrity ? 'sha384' : undefined,
);
// Execute the bundle processing actions
try {
context.logger.info('Generating ES5 bundles for differential loading...');
for await (const result of executor.processAll(processActions)) {
processResults.push(result);
}
// 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.process(runtimeOptions)),
);
}
context.logger.info('ES5 bundle generation complete.');
if (i18n.shouldInline) {
context.logger.info('Generating localized bundles...');
const inlineActions: InlineOptions[] = [];
const processedFiles = new Set<string>();
for (const result of processResults) {
if (result.original) {
inlineActions.push({
filename: path.basename(result.original.filename),
code: fs.readFileSync(result.original.filename, 'utf8'),
map:
result.original.map &&
fs.readFileSync(result.original.map.filename, 'utf8'),
outputPath: baseOutputPath,
es5: false,
missingTranslation: options.i18nMissingTranslation,
setLocale: result.name === mainChunkId,
});
processedFiles.add(result.original.filename);
}
if (result.downlevel) {
inlineActions.push({
filename: path.basename(result.downlevel.filename),
code: fs.readFileSync(result.downlevel.filename, 'utf8'),
map:
result.downlevel.map &&
fs.readFileSync(result.downlevel.map.filename, 'utf8'),
outputPath: baseOutputPath,
es5: true,
missingTranslation: options.i18nMissingTranslation,
setLocale: result.name === mainChunkId,
});
processedFiles.add(result.downlevel.filename);
}
}
let hasErrors = false;
try {
for await (const result of executor.inlineAll(inlineActions)) {
if (options.verbose) {
context.logger.info(
`Localized "${result.file}" [${result.count} translation(s)].`,
);
}
for (const diagnostic of result.diagnostics) {
if (diagnostic.type === 'error') {
hasErrors = true;
context.logger.error(diagnostic.message);
} else {
context.logger.warn(diagnostic.message);
}
}
}
// Copy any non-processed files into the output locations
await copyAssets(
[
{
glob: '**/*',
// tslint:disable-next-line: no-non-null-assertion
input: webpackStats.outputPath!,
output: '',
ignore: [...processedFiles].map(f =>
// tslint:disable-next-line: no-non-null-assertion
path.relative(webpackStats.outputPath!, f),
),
},
],
outputPaths,
'',
);
} catch (err) {
context.logger.error('Localized bundle generation failed: ' + err.message);
return { success: false };
}
context.logger.info(
`Localized bundle generation ${hasErrors ? 'failed' : 'complete'}.`,
);
if (hasErrors) {
return { success: false };
}
}
} finally {
executor.stop();
}
// Copy assets
if (options.assets) {
try {
await copyAssets(
normalizeAssetPatterns(
options.assets,
new virtualFs.SyncDelegateHost(host),
root,
normalize(projectRoot),
projectSourceRoot === undefined ? undefined : normalize(projectSourceRoot),
),
outputPaths,
context.workspaceRoot,
);
} catch (err) {
context.logger.error('Unable to copy assets: ' + err.message);
return { success: false };
}
}
type ArrayElement<A> = A extends ReadonlyArray<infer T> ? T : never;
function generateBundleInfoStats(
id: string | number,
bundle: ProcessBundleFile,
chunk: ArrayElement<webpack.Stats.ToJsonOutput['chunks']> | 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<string>();
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) || '<unknown>',
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 {
files = emittedFiles.filter(x => x.name !== 'polyfills-es5');
noModuleFiles = emittedFiles.filter(x => x.name === 'polyfills-es5');
if (i18n.shouldInline) {
const success = await i18nInlineEmittedFiles(
context,
emittedFiles,
i18n,
baseOutputPath,
outputPaths,
scriptsEntryPointName,
// tslint:disable-next-line: no-non-null-assertion
webpackStats.outputPath!,
target <= ScriptTarget.ES5,
options.i18nMissingTranslation,
);
if (!success) {
return { success: false };
}
}
}
if (options.index) {
for (const outputPath of outputPaths) {
try {
await generateIndex(
outputPath,
options,
root,
files,
noModuleFiles,
moduleFiles,
transforms.indexHtml,
);
} catch (err) {
return { success: false, error: mapErrorToMessage(err) };
}
}
}
}
return { success };
}),
concatMap(buildEvent => {
if (buildEvent.success && !options.watch && options.serviceWorker) {
return from(
augmentAppWithServiceWorker(
host,
root,
normalize(projectRoot),
normalize(baseOutputPath),
options.baseHref || '/',
options.ngswConfigPath,
).then(
() => ({ success: true }),
error => ({ success: false, error: mapErrorToMessage(error) }),
),
);
} else {
return of(buildEvent);
}
}),
map(
event =>
({
...event,
// If we use differential loading, both configs have the same outputs
outputPath: baseOutputPath,
} as BrowserBuilderOutput),
),
);
}),
);
}
function generateIndex(
baseOutputPath: string,
options: BrowserBuilderSchema,
root: string,
files: EmittedFiles[] | undefined,
noModuleFiles: EmittedFiles[] | undefined,
moduleFiles: EmittedFiles[] | undefined,
transformer?: IndexHtmlTransform,
): Promise<void> {
const host = new NodeJsSyncHost();
return writeIndexHtml({
host,
outputPath: join(normalize(baseOutputPath), getIndexOutputFile(options)),
indexPath: join(normalize(root), getIndexInputFile(options)),
files,
noModuleFiles,
moduleFiles,
baseHref: options.baseHref,
deployUrl: options.deployUrl,
sri: options.subresourceIntegrity,
scripts: options.scripts,
styles: options.styles,
postTransform: transformer,
crossOrigin: options.crossOrigin,
lang: options.i18nLocale,
}).toPromise();
}
function mapErrorToMessage(error: unknown): string | undefined {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return undefined;
}
export default createBuilder<json.JsonObject & BrowserBuilderSchema>(buildWebpackBrowser);