From b4f298b184d39bf614ebfccbdef7cc70a33ecbc2 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 6 Apr 2022 09:23:58 -0400 Subject: [PATCH 1/2] feat(@angular-devkit/build-angular): add initial experimental esbuild-based application browser builder An experimental browser application builder (`browser-esbuild`) has been introduced that leverages esbuild as the bundler. This new builder is compatible with options of the current browser application builder (`browser`) and can be enabled for experimentation purposes by replacing the `builder` field of `@angular-devkit/build-angular:browser` from an existing project to `@angular-devkit/build-angular:browser-esbuild`. The builder will generate an ESM-based application and provides support for ES2015+ compatible output with ES2020 as the default. This builder is considered experimental and is not recommended for production applications. Currently not all `browser` builder options and capabilities are supported with this experimental builder. Additional support for these options may be added in the future. The following options and capabilities are not currently supported: * Stylesheet Preprocessors (only CSS styles are supported) * Angular JIT mode (only AOT is supported) * Localization [`localize`] * Watch and dev-server modes [`watch`, `poll`, etc.] * File replacements [`fileReplacements`] * License text extraction [`extractLicenses`] * Bundle budgets [`budgets`] * Global scripts [`scripts`] * Build stats JSON output [`statsJson`] * Deploy URL [`deployURL`] * CommonJS module warnings (no warnings will be generated for CommonJS package usage) * Web Workers * Service workers [`serviceWorker`, `ngswConfigPath`] --- .../build_angular/builders.json | 5 + .../build_angular/src/babel/webpack-loader.ts | 2 +- .../browser-esbuild/compiler-plugin.ts | 416 ++++++++++++++++++ .../src/builders/browser-esbuild/esbuild.ts | 72 +++ .../browser-esbuild/experimental-warnings.ts | 80 ++++ .../src/builders/browser-esbuild/index.ts | 314 +++++++++++++ .../src/builders/browser-esbuild/options.ts | 74 ++++ .../builders/browser-esbuild/stylesheets.ts | 113 +++++ .../src/webpack/configs/styles.ts | 2 +- 9 files changed, 1076 insertions(+), 2 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts diff --git a/packages/angular_devkit/build_angular/builders.json b/packages/angular_devkit/build_angular/builders.json index ca2606b06bf1..02e68abd3124 100644 --- a/packages/angular_devkit/build_angular/builders.json +++ b/packages/angular_devkit/build_angular/builders.json @@ -11,6 +11,11 @@ "schema": "./src/builders/browser/schema.json", "description": "Build a browser application." }, + "browser-esbuild": { + "implementation": "./src/builders/browser-esbuild", + "schema": "./src/builders/browser/schema.json", + "description": "Build a browser application." + }, "dev-server": { "implementation": "./src/builders/dev-server", "schema": "./src/builders/dev-server/schema.json", diff --git a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts index abe66ea9967b..a19f499141b9 100644 --- a/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts +++ b/packages/angular_devkit/build_angular/src/babel/webpack-loader.ts @@ -39,7 +39,7 @@ let linkerPluginCreator: */ let i18nPluginCreators: I18nPluginCreators | undefined; -async function requiresLinking(path: string, source: string): Promise { +export async function requiresLinking(path: string, source: string): Promise { // @angular/core and @angular/compiler will cause false positives // Also, TypeScript files do not require linking if (/[\\/]@angular[\\/](?:compiler|core)|\.tsx?$/.test(path)) { diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts new file mode 100644 index 000000000000..9d079f5160bb --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts @@ -0,0 +1,416 @@ +/** + * @license + * Copyright Google LLC 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 type { CompilerHost } from '@angular/compiler-cli'; +import { transformAsync } from '@babel/core'; +import * as assert from 'assert'; +import type { OnStartResult, PartialMessage, PartialNote, Plugin, PluginBuild } from 'esbuild'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import ts from 'typescript'; +import angularApplicationPreset from '../../babel/presets/application'; +import { requiresLinking } from '../../babel/webpack-loader'; +import { loadEsmModule } from '../../utils/load-esm'; +import { BundleStylesheetOptions, bundleStylesheetText } from './stylesheets'; + +interface EmitFileResult { + content?: string; + map?: string; + dependencies: readonly string[]; + hash?: Uint8Array; +} +type FileEmitter = (file: string) => Promise; + +/** + * Converts TypeScript Diagnostic related information into an esbuild compatible note object. + * Related information is a subset of a full TypeScript Diagnostic and also used for diagnostic + * notes associated with the main Diagnostic. + * @param diagnostic The TypeScript diagnostic relative information to convert. + * @param host A TypeScript FormatDiagnosticsHost instance to use during conversion. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +function convertTypeScriptDiagnosticInfo( + info: ts.DiagnosticRelatedInformation, + host: ts.FormatDiagnosticsHost, + textPrefix?: string, +): PartialNote { + let text = ts.flattenDiagnosticMessageText(info.messageText, host.getNewLine()); + if (textPrefix) { + text = textPrefix + text; + } + + const note: PartialNote = { text }; + + if (info.file) { + note.location = { + file: info.file.fileName, + length: info.length, + }; + + // Calculate the line/column location and extract the full line text that has the diagnostic + if (info.start) { + const { line, character } = ts.getLineAndCharacterOfPosition(info.file, info.start); + note.location.line = line + 1; + note.location.column = character; + + // The start position for the slice is the first character of the error line + const lineStartPosition = ts.getPositionOfLineAndCharacter(info.file, line, 0); + + // The end position for the slice is the first character of the next line or the length of + // the entire file if the line is the last line of the file (getPositionOfLineAndCharacter + // will error if a nonexistent line is passed). + const { line: lastLineOfFile } = ts.getLineAndCharacterOfPosition( + info.file, + info.file.text.length - 1, + ); + const lineEndPosition = + line < lastLineOfFile + ? ts.getPositionOfLineAndCharacter(info.file, line + 1, 0) + : info.file.text.length; + + note.location.lineText = info.file.text.slice(lineStartPosition, lineEndPosition).trimEnd(); + } + } + + return note; +} + +/** + * Converts a TypeScript Diagnostic message into an esbuild compatible message object. + * @param diagnostic The TypeScript diagnostic to convert. + * @param host A TypeScript FormatDiagnosticsHost instance to use during conversion. + * @returns An esbuild diagnostic message as a PartialMessage object + */ +function convertTypeScriptDiagnostic( + diagnostic: ts.Diagnostic, + host: ts.FormatDiagnosticsHost, +): PartialMessage { + let codePrefix = 'TS'; + let code = `${diagnostic.code}`; + if (diagnostic.source === 'ngtsc') { + codePrefix = 'NG'; + // Remove `-99` Angular prefix from diagnostic code + code = code.slice(3); + } + + const message: PartialMessage = { + ...convertTypeScriptDiagnosticInfo(diagnostic, host, `${codePrefix}${code}: `), + // Store original diagnostic for reference if needed downstream + detail: diagnostic, + }; + + if (diagnostic.relatedInformation?.length) { + message.notes = diagnostic.relatedInformation.map((info) => + convertTypeScriptDiagnosticInfo(info, host), + ); + } + + return message; +} + +// This is a non-watch version of the compiler code from `@ngtools/webpack` augmented for esbuild +// eslint-disable-next-line max-lines-per-function +export function createCompilerPlugin( + pluginOptions: { sourcemap: boolean; tsconfig: string; advancedOptimizations?: boolean }, + styleOptions: BundleStylesheetOptions, +): Plugin { + return { + name: 'angular-compiler', + // eslint-disable-next-line max-lines-per-function + async setup(build: PluginBuild): Promise { + // This uses a wrapped dynamic import to load `@angular/compiler-cli` which is ESM. + // Once TypeScript provides support for retaining dynamic imports this workaround can be dropped. + const compilerCli = await loadEsmModule( + '@angular/compiler-cli', + ); + + // Temporary deep import for transformer support + const { + createAotTransformers, + mergeTransformers, + } = require('@ngtools/webpack/src/ivy/transformation'); + + // Setup defines based on the values provided by the Angular compiler-cli + build.initialOptions.define ??= {}; + for (const [key, value] of Object.entries(compilerCli.GLOBAL_DEFS_FOR_TERSER_WITH_AOT)) { + if (key in build.initialOptions.define) { + // Skip keys that have been manually provided + continue; + } + // esbuild requires values to be a string (actual strings need to be quoted). + // In this case, all provided values are booleans. + build.initialOptions.define[key] = value.toString(); + } + + // The tsconfig is loaded in setup instead of in start to allow the esbuild target build option to be modified. + // esbuild build options can only be modified in setup prior to starting the build. + const { + options: compilerOptions, + rootNames, + errors: configurationDiagnostics, + } = compilerCli.readConfiguration(pluginOptions.tsconfig, { + enableIvy: true, + noEmitOnError: false, + suppressOutputPathCheck: true, + outDir: undefined, + inlineSources: pluginOptions.sourcemap, + inlineSourceMap: pluginOptions.sourcemap, + sourceMap: false, + mapRoot: undefined, + sourceRoot: undefined, + declaration: false, + declarationMap: false, + allowEmptyCodegenFiles: false, + annotationsAs: 'decorators', + enableResourceInlining: false, + }); + + // Adjust the esbuild output target based on the tsconfig target + if ( + compilerOptions.target === undefined || + compilerOptions.target <= ts.ScriptTarget.ES2015 + ) { + build.initialOptions.target = 'es2015'; + } else if (compilerOptions.target >= ts.ScriptTarget.ESNext) { + build.initialOptions.target = 'esnext'; + } else { + build.initialOptions.target = ts.ScriptTarget[compilerOptions.target].toLowerCase(); + } + + // The file emitter created during `onStart` that will be used during the build in `onLoad` callbacks for TS files + let fileEmitter: FileEmitter | undefined; + + build.onStart(async () => { + const result: OnStartResult = {}; + + // Create TypeScript compiler host + const host = ts.createIncrementalCompilerHost(compilerOptions); + + // Temporarily add a readResource hook to allow for a transformResource hook. + // Once the AOT compiler allows only a transformResource hook this can be removed. + (host as CompilerHost).readResource = function (fileName) { + // Provide same no file found behavior as @ngtools/webpack + return this.readFile(fileName) ?? ''; + }; + + // Add an AOT compiler resource transform hook + (host as CompilerHost).transformResource = async function (data, context) { + // Only style resources are transformed currently + if (context.type !== 'style') { + return null; + } + + // The file with the resource content will either be an actual file (resourceFile) + // or the file containing the inline component style text (containingFile). + const file = context.resourceFile ?? context.containingFile; + + const { contents, errors, warnings } = await bundleStylesheetText( + data, + { + resolvePath: path.dirname(file), + virtualName: file, + }, + styleOptions, + ); + + (result.errors ??= []).push(...errors); + (result.warnings ??= []).push(...warnings); + + return { content: contents }; + }; + + // Create the Angular specific program that contains the Angular compiler + const angularProgram = new compilerCli.NgtscProgram(rootNames, compilerOptions, host); + const angularCompiler = angularProgram.compiler; + const { ignoreForDiagnostics, ignoreForEmit } = angularCompiler; + const typeScriptProgram = angularProgram.getTsProgram(); + + const builder = ts.createAbstractBuilder(typeScriptProgram, host); + + await angularCompiler.analyzeAsync(); + + function* collectDiagnostics() { + // Collect program level diagnostics + yield* configurationDiagnostics; + yield* angularCompiler.getOptionDiagnostics(); + yield* builder.getOptionsDiagnostics(); + yield* builder.getGlobalDiagnostics(); + + // Collect source file specific diagnostics + const OptimizeFor = compilerCli.OptimizeFor; + for (const sourceFile of builder.getSourceFiles()) { + if (ignoreForDiagnostics.has(sourceFile)) { + continue; + } + + yield* builder.getSyntacticDiagnostics(sourceFile); + yield* builder.getSemanticDiagnostics(sourceFile); + + const angularDiagnostics = angularCompiler.getDiagnosticsForFile( + sourceFile, + OptimizeFor.WholeProgram, + ); + yield* angularDiagnostics; + } + } + + for (const diagnostic of collectDiagnostics()) { + const message = convertTypeScriptDiagnostic(diagnostic, host); + if (diagnostic.category === ts.DiagnosticCategory.Error) { + (result.errors ??= []).push(message); + } else { + (result.warnings ??= []).push(message); + } + } + + fileEmitter = createFileEmitter( + builder, + mergeTransformers( + angularCompiler.prepareEmit().transformers, + createAotTransformers(builder, {}), + ), + () => [], + ); + + return result; + }); + + build.onLoad( + { filter: compilerOptions.allowJs ? /\.[cm]?[jt]sx?$/ : /\.[cm]?tsx?$/ }, + async (args) => { + assert.ok(fileEmitter, 'Invalid plugin execution order'); + + const typescriptResult = await fileEmitter(args.path); + if (!typescriptResult) { + // No TS result indicates the file is not part of the TypeScript program. + // If allowJs is enabled and the file is JS then defer to the next load hook. + if (compilerOptions.allowJs && /\.[cm]?js$/.test(args.path)) { + return undefined; + } + + // Otherwise return an error + return { + errors: [ + { + text: 'File is missing from the TypeScript compilation.', + location: { file: args.path }, + notes: [ + { + text: `Ensure the file is part of the TypeScript program via the 'files' or 'include' property.`, + }, + ], + }, + ], + }; + } + + const data = typescriptResult.content ?? ''; + const babelResult = await transformAsync(data, { + filename: args.path, + inputSourceMap: (pluginOptions.sourcemap ? undefined : false) as undefined, + sourceMaps: pluginOptions.sourcemap ? 'inline' : false, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins: [], + presets: [ + [ + angularApplicationPreset, + { + forceAsyncTransformation: data.includes('async'), + optimize: pluginOptions.advancedOptimizations && {}, + }, + ], + ], + }); + + return { + contents: babelResult?.code ?? '', + loader: 'js', + }; + }, + ); + + build.onLoad({ filter: /\.[cm]?js$/ }, async (args) => { + const angularPackage = /[\\/]node_modules[\\/]@angular[\\/]/.test(args.path); + + const linkerPluginCreator = ( + await loadEsmModule( + '@angular/compiler-cli/linker/babel', + ) + ).createEs2015LinkerPlugin; + + const data = await fs.readFile(args.path, 'utf-8'); + const result = await transformAsync(data, { + filename: args.path, + inputSourceMap: (pluginOptions.sourcemap ? undefined : false) as undefined, + sourceMaps: pluginOptions.sourcemap ? 'inline' : false, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins: [], + presets: [ + [ + angularApplicationPreset, + { + angularLinker: { + shouldLink: await requiresLinking(args.path, data), + jitMode: false, + linkerPluginCreator, + }, + forceAsyncTransformation: + !/[\\/][_f]?esm2015[\\/]/.test(args.path) && data.includes('async'), + optimize: pluginOptions.advancedOptimizations && { + looseEnums: angularPackage, + pureTopLevel: angularPackage, + }, + }, + ], + ], + }); + + return { + contents: result?.code ?? data, + loader: 'js', + }; + }); + }, + }; +} + +function createFileEmitter( + program: ts.BuilderProgram, + transformers: ts.CustomTransformers = {}, + onAfterEmit?: (sourceFile: ts.SourceFile) => void, +): FileEmitter { + return async (file: string) => { + const sourceFile = program.getSourceFile(file); + if (!sourceFile) { + return undefined; + } + + let content: string | undefined; + program.emit( + sourceFile, + (filename, data) => { + if (/\.[cm]?js$/.test(filename)) { + content = data; + } + }, + undefined /* cancellationToken */, + undefined /* emitOnlyDtsFiles */, + transformers, + ); + + onAfterEmit?.(sourceFile); + + return { content, dependencies: [] }; + }; +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts new file mode 100644 index 000000000000..62f592cd6b54 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC 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 } from '@angular-devkit/architect'; +import { + BuildFailure, + BuildOptions, + BuildResult, + Message, + OutputFile, + build, + formatMessages, +} from 'esbuild'; + +/** + * Determines if an unknown value is an esbuild BuildFailure error object thrown by esbuild. + * @param value A potential esbuild BuildFailure error object. + * @returns `true` if the object is determined to be a BuildFailure object; otherwise, `false`. + */ +export function isEsBuildFailure(value: unknown): value is BuildFailure { + return !!value && typeof value === 'object' && 'errors' in value && 'warnings' in value; +} + +/** + * Executes the esbuild build function and normalizes the build result in the event of a + * build failure that results in no output being generated. + * All builds use the `write` option with a value of `false` to allow for the output files + * build result array to be populated. + * + * @param options The esbuild options object to use when building. + * @returns If output files are generated, the full esbuild BuildResult; if not, the + * warnings and errors for the attempted build. + */ +export async function bundle( + options: BuildOptions, +): Promise< + (BuildResult & { outputFiles: OutputFile[] }) | (BuildFailure & { outputFiles?: never }) +> { + try { + return await build({ + ...options, + write: false, + }); + } catch (failure) { + // Build failures will throw an exception which contains errors/warnings + if (isEsBuildFailure(failure)) { + return failure; + } else { + throw failure; + } + } +} + +export async function logMessages( + context: BuilderContext, + { errors, warnings }: { errors: Message[]; warnings: Message[] }, +): Promise { + if (warnings.length) { + const warningMessages = await formatMessages(warnings, { kind: 'warning', color: true }); + context.logger.warn(warningMessages.join('\n')); + } + + if (errors.length) { + const errorMessages = await formatMessages(errors, { kind: 'error', color: true }); + context.logger.error(errorMessages.join('\n')); + } +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts new file mode 100644 index 000000000000..ae10d094a336 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/experimental-warnings.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC 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 } from '@angular-devkit/architect'; +import { Schema as BrowserBuilderOptions } from '../browser/schema'; + +const UNSUPPORTED_OPTIONS: Array = [ + 'allowedCommonJsDependencies', + 'budgets', + 'extractLicenses', + 'fileReplacements', + 'progress', + 'scripts', + 'statsJson', + + // * i18n support + 'localize', + // The following two have no effect when localize is not enabled + // 'i18nDuplicateTranslation', + // 'i18nMissingTranslation', + + // * Serviceworker support + 'ngswConfigPath', + 'serviceWorker', + + // * Stylesheet preprocessor support + 'inlineStyleLanguage', + // The following option has no effect until preprocessors are supported + // 'stylePreprocessorOptions', + + // * Watch mode + 'watch', + 'poll', + + // * Deprecated + 'deployUrl', + + // * Always enabled with esbuild + // 'commonChunk', + + // * Currently unsupported by esbuild + 'namedChunks', + 'vendorChunk', + 'webWorkerTsConfig', +]; + +export function logExperimentalWarnings(options: BrowserBuilderOptions, context: BuilderContext) { + // Warn about experimental status of this builder + context.logger.warn( + `The esbuild browser application builder ('browser-esbuild') is currently experimental.`, + ); + + // Validate supported options + // Currently only a subset of the Webpack-based browser builder options are supported. + for (const unsupportedOption of UNSUPPORTED_OPTIONS) { + const value = options[unsupportedOption]; + + if (value === undefined || value === false) { + continue; + } + if (Array.isArray(value) && value.length === 0) { + continue; + } + if (typeof value === 'object' && Object.keys(value).length === 0) { + continue; + } + if (unsupportedOption === 'inlineStyleLanguage' && value === 'css') { + continue; + } + + context.logger.warn( + `The '${unsupportedOption}' option is currently unsupported by this experimental builder and will be ignored.`, + ); + } +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts new file mode 100644 index 000000000000..d4da21e528b9 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -0,0 +1,314 @@ +/** + * @license + * Copyright Google LLC 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 * as assert from 'assert'; +import type { OutputFile } from 'esbuild'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { NormalizedOptimizationOptions, deleteOutputDir } from '../../utils'; +import { copyAssets } from '../../utils/copy-assets'; +import { FileInfo } from '../../utils/index-file/augment-index-html'; +import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; +import { generateEntryPoints } from '../../utils/package-chunk-sort'; +import { getIndexInputFile, getIndexOutputFile } from '../../utils/webpack-browser-config'; +import { resolveGlobalStyles } from '../../webpack/configs'; +import { Schema as BrowserBuilderOptions, SourceMapClass } from '../browser/schema'; +import { createCompilerPlugin } from './compiler-plugin'; +import { bundle, logMessages } from './esbuild'; +import { logExperimentalWarnings } from './experimental-warnings'; +import { normalizeOptions } from './options'; +import { bundleStylesheetText } from './stylesheets'; + +/** + * Main execution function for the esbuild-based application builder. + * The options are compatible with the Webpack-based builder. + * @param options The browser builder options to use when setting up the application build + * @param context The Architect builder context object + * @returns A promise with the builder result output + */ +// eslint-disable-next-line max-lines-per-function +export async function execute( + options: BrowserBuilderOptions, + context: BuilderContext, +): Promise { + const startTime = Date.now(); + + // Only AOT is currently supported + if (options.aot !== true) { + context.logger.error( + 'JIT mode is currently not supported by this experimental builder. AOT mode must be used.', + ); + + return { success: false }; + } + + // Inform user of experimental status of builder and options + logExperimentalWarnings(options, context); + + // Determine project name from builder context target + const projectName = context.target?.project; + if (!projectName) { + context.logger.error(`The 'browser-esbuild' builder requires a target to be specified.`); + + return { success: false }; + } + + const { + workspaceRoot, + mainEntryPoint, + polyfillsEntryPoint, + optimizationOptions, + outputPath, + sourcemapOptions, + tsconfig, + assets, + outputNames, + } = await normalizeOptions(context, projectName, options); + + // Clean output path if enabled + if (options.deleteOutputPath) { + deleteOutputDir(workspaceRoot, options.outputPath); + } + + // Setup bundler entry points + const entryPoints: Record = { + main: mainEntryPoint, + }; + if (polyfillsEntryPoint) { + entryPoints['polyfills'] = polyfillsEntryPoint; + } + // Create reverse lookup used during index HTML generation + const entryPointNameLookup: ReadonlyMap = new Map( + Object.entries(entryPoints).map( + ([name, filePath]) => [path.relative(workspaceRoot, filePath), name] as const, + ), + ); + + // Execute esbuild + const result = await bundleCode( + workspaceRoot, + entryPoints, + outputNames, + options, + optimizationOptions, + sourcemapOptions, + tsconfig, + ); + + // Log all warnings and errors generated during bundling + await logMessages(context, result); + + // Return if the bundling failed to generate output files or there are errors + if (!result.outputFiles || result.errors.length) { + return { success: false }; + } + + // Structure the bundling output files + const initialFiles: FileInfo[] = []; + const outputFiles: OutputFile[] = []; + for (const outputFile of result.outputFiles) { + // Entries in the metafile are relative to the `absWorkingDir` option which is set to the workspaceRoot + const relativeFilePath = path.relative(workspaceRoot, outputFile.path); + const entryPoint = result.metafile?.outputs[relativeFilePath]?.entryPoint; + if (entryPoint) { + // An entryPoint value indicates an initial file + initialFiles.push({ + // Remove leading directory separator + file: outputFile.path.slice(1), + name: entryPointNameLookup.get(entryPoint) ?? '', + extension: path.extname(outputFile.path), + }); + } + outputFiles.push(outputFile); + } + + // Create output directory if needed + try { + await fs.mkdir(outputPath, { recursive: true }); + } catch (e) { + const reason = 'message' in e ? e.message : 'Unknown error'; + context.logger.error('Unable to create output directory: ' + reason); + + return { success: false }; + } + + // Process global stylesheets + if (options.styles) { + // resolveGlobalStyles is temporarily reused from the Webpack builder code + const { entryPoints: stylesheetEntrypoints, noInjectNames } = resolveGlobalStyles( + options.styles, + workspaceRoot, + !!options.preserveSymlinks, + ); + for (const [name, files] of Object.entries(stylesheetEntrypoints)) { + const virtualEntryData = files.map((file) => `@import '${file}';`).join('\n'); + const sheetResult = await bundleStylesheetText( + virtualEntryData, + { virtualName: `angular:style/global;${name}`, resolvePath: workspaceRoot }, + { + optimization: !!optimizationOptions.styles.minify, + sourcemap: !!sourcemapOptions.styles, + outputNames: noInjectNames.includes(name) ? { media: outputNames.media } : outputNames, + }, + ); + + await logMessages(context, sheetResult); + if (!sheetResult.path) { + // Failed to process the stylesheet + assert.ok( + sheetResult.errors.length, + `Global stylesheet processing for '${name}' failed with no errors.`, + ); + + return { success: false }; + } + + // The virtual stylesheets will be named `stdin` by esbuild. This must be replaced + // with the actual name of the global style and the leading directory separator must + // also be removed to make the path relative. + const sheetPath = sheetResult.path.replace('stdin', name).slice(1); + outputFiles.push(createOutputFileFromText(sheetPath, sheetResult.contents)); + if (sheetResult.map) { + outputFiles.push(createOutputFileFromText(sheetPath + '.map', sheetResult.map)); + } + if (!noInjectNames.includes(name)) { + initialFiles.push({ + file: sheetPath, + name, + extension: '.css', + }); + } + outputFiles.push(...sheetResult.resourceFiles); + } + } + + // Generate index HTML file + if (options.index) { + const entrypoints = generateEntryPoints({ + scripts: options.scripts ?? [], + styles: options.styles ?? [], + }); + + // Create an index HTML generator that reads from the in-memory output files + const indexHtmlGenerator = new IndexHtmlGenerator({ + indexPath: path.join(context.workspaceRoot, getIndexInputFile(options.index)), + entrypoints, + sri: options.subresourceIntegrity, + optimization: optimizationOptions, + crossOrigin: options.crossOrigin, + }); + indexHtmlGenerator.readAsset = async function (path: string): Promise { + // Remove leading directory separator + path = path.slice(1); + const file = outputFiles.find((file) => file.path === path); + if (file) { + return file.text; + } + + throw new Error(`Output file does not exist: ${path}`); + }; + + const { content, warnings, errors } = await indexHtmlGenerator.process({ + baseHref: options.baseHref, + lang: undefined, + outputPath: '/', // Virtual output path to support reading in-memory files + files: initialFiles, + }); + + for (const error of errors) { + context.logger.error(error); + } + for (const warning of warnings) { + context.logger.warn(warning); + } + + outputFiles.push(createOutputFileFromText(getIndexOutputFile(options.index), content)); + } + + // Copy assets + if (assets) { + await copyAssets(assets, [outputPath], workspaceRoot); + } + + // Write output files + await Promise.all( + outputFiles.map((file) => fs.writeFile(path.join(outputPath, file.path), file.contents)), + ); + + context.logger.info(`Complete. [${(Date.now() - startTime) / 1000} seconds]`); + + return { success: true }; +} + +function createOutputFileFromText(path: string, text: string): OutputFile { + return { + path, + text, + get contents() { + return Buffer.from(this.text, 'utf-8'); + }, + }; +} + +async function bundleCode( + workspaceRoot: string, + entryPoints: Record, + outputNames: { bundles: string; media: string }, + options: BrowserBuilderOptions, + optimizationOptions: NormalizedOptimizationOptions, + sourcemapOptions: SourceMapClass, + tsconfig: string, +) { + return bundle({ + absWorkingDir: workspaceRoot, + bundle: true, + format: 'esm', + entryPoints, + entryNames: outputNames.bundles, + assetNames: outputNames.media, + target: 'es2020', + mainFields: ['es2020', 'browser', 'module', 'main'], + conditions: ['es2020', 'module'], + resolveExtensions: ['.ts', '.tsx', '.mjs', '.js'], + logLevel: options.verbose ? 'debug' : 'silent', + metafile: true, + minify: optimizationOptions.scripts, + pure: ['forwardRef'], + outdir: '/', + sourcemap: sourcemapOptions.scripts && (sourcemapOptions.hidden ? 'external' : true), + splitting: true, + tsconfig, + write: false, + platform: 'browser', + preserveSymlinks: options.preserveSymlinks, + plugins: [ + createCompilerPlugin( + // JS/TS options + { + sourcemap: !!sourcemapOptions.scripts, + tsconfig, + advancedOptimizations: options.buildOptimizer, + }, + // Component stylesheet options + { + workspaceRoot, + optimization: !!optimizationOptions.styles.minify, + sourcemap: !!sourcemapOptions.styles, + outputNames, + }, + ), + ], + define: { + 'ngDevMode': optimizationOptions.scripts ? 'false' : 'true', + 'ngJitMode': 'false', + }, + }); +} + +export default createBuilder(execute); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts new file mode 100644 index 000000000000..c07a5a1588ac --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC 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 } from '@angular-devkit/architect'; +import * as path from 'path'; +import { normalizeAssetPatterns, normalizeOptimization, normalizeSourceMaps } from '../../utils'; +import { Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema'; + +/** + * Normalize the user provided options by creating full paths for all path based options + * and converting multi-form options into a single form that can be directly used + * by the build process. + * + * @param context The context for current builder execution. + * @param projectName The name of the project for the current execution. + * @param options An object containing the options to use for the build. + * @returns An object containing normalized options required to perform the build. + */ +export async function normalizeOptions( + context: BuilderContext, + projectName: string, + options: BrowserBuilderOptions, +) { + const workspaceRoot = context.workspaceRoot; + const projectMetadata = await context.getProjectMetadata(projectName); + const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? ''); + const projectSourceRoot = path.join( + workspaceRoot, + (projectMetadata.sourceRoot as string | undefined) ?? 'src', + ); + + // Normalize options + const mainEntryPoint = path.join(workspaceRoot, options.main); + const polyfillsEntryPoint = options.polyfills && path.join(workspaceRoot, options.polyfills); + const tsconfig = path.join(workspaceRoot, options.tsConfig); + const outputPath = path.join(workspaceRoot, options.outputPath); + const optimizationOptions = normalizeOptimization(options.optimization); + const sourcemapOptions = normalizeSourceMaps(options.sourceMap ?? false); + const assets = options.assets?.length + ? normalizeAssetPatterns(options.assets, workspaceRoot, projectRoot, projectSourceRoot) + : undefined; + + const outputNames = { + bundles: + options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Bundles + ? '[name].[hash]' + : '[name]', + media: + options.outputHashing === OutputHashing.All || options.outputHashing === OutputHashing.Media + ? '[name].[hash]' + : '[name]', + }; + if (options.resourcesOutputPath) { + outputNames.media = path.join(options.resourcesOutputPath, outputNames.media); + } + + return { + workspaceRoot, + mainEntryPoint, + polyfillsEntryPoint, + optimizationOptions, + outputPath, + sourcemapOptions, + tsconfig, + projectRoot, + assets, + outputNames, + }; +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts new file mode 100644 index 000000000000..215eac7e8474 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC 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 type { BuildOptions, OutputFile } from 'esbuild'; +import * as path from 'path'; +import { bundle } from './esbuild'; + +export interface BundleStylesheetOptions { + workspaceRoot?: string; + optimization: boolean; + preserveSymlinks?: boolean; + sourcemap: boolean | 'external'; + outputNames?: { bundles?: string; media?: string }; +} + +async function bundleStylesheet( + entry: Required | Pick>, + options: BundleStylesheetOptions, +) { + // Execute esbuild + const result = await bundle({ + ...entry, + absWorkingDir: options.workspaceRoot, + bundle: true, + entryNames: options.outputNames?.bundles, + assetNames: options.outputNames?.media, + logLevel: 'silent', + minify: options.optimization, + sourcemap: options.sourcemap, + outdir: '/', + write: false, + platform: 'browser', + preserveSymlinks: options.preserveSymlinks, + conditions: ['style'], + mainFields: ['style'], + plugins: [ + // TODO: preprocessor plugins + ], + }); + + // Extract the result of the bundling from the output files + let contents = ''; + let map; + let outputPath; + const resourceFiles: OutputFile[] = []; + if (result.outputFiles) { + for (const outputFile of result.outputFiles) { + const filename = path.basename(outputFile.path); + if (filename.endsWith('.css')) { + outputPath = outputFile.path; + contents = outputFile.text; + } else if (filename.endsWith('.css.map')) { + map = outputFile.text; + } else { + // The output files could also contain resources (images/fonts/etc.) that were referenced + resourceFiles.push(outputFile); + } + } + } + + return { + errors: result.errors, + warnings: result.warnings, + contents, + map, + path: outputPath, + resourceFiles, + }; +} + +/** + * Bundle a stylesheet that exists as a file on the filesystem. + * + * @param filename The path to the file to bundle. + * @param options The stylesheet bundling options to use. + * @returns The bundle result object. + */ +export async function bundleStylesheetFile(filename: string, options: BundleStylesheetOptions) { + return bundleStylesheet({ entryPoints: [filename] }, options); +} + +/** + * Bundle stylesheet text data from a string. + * + * @param data The string content of a stylesheet to bundle. + * @param dataOptions The options to use to resolve references and name output of the stylesheet data. + * @param bundleOptions The stylesheet bundling options to use. + * @returns The bundle result object. + */ +export async function bundleStylesheetText( + data: string, + dataOptions: { resolvePath: string; virtualName?: string }, + bundleOptions: BundleStylesheetOptions, +) { + const result = bundleStylesheet( + { + stdin: { + contents: data, + sourcefile: dataOptions.virtualName, + resolveDir: dataOptions.resolvePath, + loader: 'css', + }, + }, + bundleOptions, + ); + + return result; +} diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts b/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts index 62c373fd36ed..91669dfa097a 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/styles.ts @@ -26,7 +26,7 @@ import { normalizeExtraEntryPoints, } from '../utils/helpers'; -function resolveGlobalStyles( +export function resolveGlobalStyles( styleEntrypoints: StyleElement[], root: string, preserveSymlinks: boolean, From c1b1c499ff44cbbc19f5f77fb7c1630516a1179b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 14 Apr 2022 13:57:38 -0400 Subject: [PATCH 2/2] ci: add initial E2E test subset for experimental esbuild builder The basic suite of E2E tests are now run against the newly introduced experimental esbuild-based builder (`browser-esbuild`). Several tests are currently ignored based on the current feature set of the builder. --- .circleci/config.yml | 5 +++++ tests/legacy-cli/e2e/setup/500-create-project.ts | 9 +++++++++ tests/legacy-cli/e2e/tests/basic/build.ts | 11 ++++++++++- tests/legacy-cli/e2e/tests/basic/styles-array.ts | 6 ++++++ tests/legacy-cli/e2e/tests/build/prod-build.ts | 12 ++++++++---- tests/legacy-cli/e2e_runner.ts | 2 +- 6 files changed, 39 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index f319006a21f6..a76ddba69adb 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -220,6 +220,11 @@ jobs: command: | mkdir /mnt/ramdisk/e2e-yarn node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.snapshots >>--ng-snapshots<> --yarn --tmpdir=/mnt/ramdisk/e2e-yarn --glob="{tests/basic/**,tests/update/**,tests/commands/add/**}" + - run: + name: Execute CLI E2E Tests Subset with esbuild builder + command: | + mkdir /mnt/ramdisk/e2e-esbuild + node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.snapshots >>--ng-snapshots<> --esbuild --tmpdir=/mnt/ramdisk/e2e-esbuild --glob="{tests/basic/**,tests/build/prod-build.ts}" --ignore="tests/basic/{environment,rebuild,serve,scripts-array}.ts" - fail_fast test-browsers: diff --git a/tests/legacy-cli/e2e/setup/500-create-project.ts b/tests/legacy-cli/e2e/setup/500-create-project.ts index b8cb4b058fb4..1d6ec58f2beb 100644 --- a/tests/legacy-cli/e2e/setup/500-create-project.ts +++ b/tests/legacy-cli/e2e/setup/500-create-project.ts @@ -33,6 +33,15 @@ export default async function () { // Ensure local test registry is used inside a project await writeFile('.npmrc', `registry=${testRegistry}`); } + + // Setup esbuild builder if requested on the commandline + const useEsbuildBuilder = !!getGlobalVariable('argv')['esbuild']; + if (useEsbuildBuilder) { + await updateJsonFile('angular.json', (json) => { + json['projects']['test-project']['architect']['build']['builder'] = + '@angular-devkit/build-angular:browser-esbuild'; + }); + } } await prepareProjectForE2e('test-project'); diff --git a/tests/legacy-cli/e2e/tests/basic/build.ts b/tests/legacy-cli/e2e/tests/basic/build.ts index 5f1ed9eae130..390797ebf6b8 100644 --- a/tests/legacy-cli/e2e/tests/basic/build.ts +++ b/tests/legacy-cli/e2e/tests/basic/build.ts @@ -1,3 +1,4 @@ +import { getGlobalVariable } from '../../utils/env'; import { expectFileToMatch } from '../../utils/fs'; import { ng } from '../../utils/process'; @@ -18,7 +19,15 @@ export default async function () { // Production build const { stderr: stderrProgress, stdout } = await ng('build', '--progress'); - await expectFileToMatch('dist/test-project/index.html', /main\.[a-zA-Z0-9]{16}\.js/); + if (getGlobalVariable('argv')['esbuild']) { + // esbuild uses an 8 character hash + await expectFileToMatch('dist/test-project/index.html', /main\.[a-zA-Z0-9]{8}\.js/); + + // EXPERIMENTAL_ESBUILD: esbuild does not yet output build stats + return; + } else { + await expectFileToMatch('dist/test-project/index.html', /main\.[a-zA-Z0-9]{16}\.js/); + } if (!stdout.includes('Initial Total')) { throw new Error(`Expected stdout to contain 'Initial Total' but it did not.\n${stdout}`); diff --git a/tests/legacy-cli/e2e/tests/basic/styles-array.ts b/tests/legacy-cli/e2e/tests/basic/styles-array.ts index f6c05c43ef01..7466fb640759 100644 --- a/tests/legacy-cli/e2e/tests/basic/styles-array.ts +++ b/tests/legacy-cli/e2e/tests/basic/styles-array.ts @@ -1,3 +1,4 @@ +import { getGlobalVariable } from '../../utils/env'; import { expectFileToMatch, writeMultipleFiles } from '../../utils/fs'; import { ng } from '../../utils/process'; import { updateJsonFile } from '../../utils/project'; @@ -38,6 +39,11 @@ export default async function () { '', ); + if (getGlobalVariable('argv')['esbuild']) { + // EXPERIMENTAL_ESBUILD: esbuild does not yet output build stats + return; + } + // Non injected styles should be listed under lazy chunk files if (!/Lazy Chunk Files.*\srenamed-lazy-style\.css/m.test(stdout)) { throw new Error(`Expected "renamed-lazy-style.css" to be listed under "Lazy Chunk Files".`); diff --git a/tests/legacy-cli/e2e/tests/build/prod-build.ts b/tests/legacy-cli/e2e/tests/build/prod-build.ts index d40a35a26716..f180be4138c6 100644 --- a/tests/legacy-cli/e2e/tests/build/prod-build.ts +++ b/tests/legacy-cli/e2e/tests/build/prod-build.ts @@ -1,5 +1,6 @@ import { statSync } from 'fs'; import { join } from 'path'; +import { getGlobalVariable } from '../../utils/env'; import { expectFileToExist, expectFileToMatch, readFile } from '../../utils/fs'; import { noSilentNg } from '../../utils/process'; @@ -32,12 +33,15 @@ export default async function () { await noSilentNg('build'); await expectFileToExist(join(process.cwd(), 'dist')); // Check for cache busting hash script src - await expectFileToMatch('dist/test-project/index.html', /main\.[0-9a-f]{16}\.js/); - await expectFileToMatch('dist/test-project/index.html', /styles\.[0-9a-f]{16}\.css/); - await expectFileToMatch('dist/test-project/3rdpartylicenses.txt', /MIT/); + await expectFileToMatch('dist/test-project/index.html', /main\.[0-9a-zA-Z]{8,16}\.js/); + await expectFileToMatch('dist/test-project/index.html', /styles\.[0-9a-zA-Z]{8,16}\.css/); + if (!getGlobalVariable('argv')['esbuild']) { + // EXPERIMENTAL_ESBUILD: esbuild does not yet extract license text + await expectFileToMatch('dist/test-project/3rdpartylicenses.txt', /MIT/); + } const indexContent = await readFile('dist/test-project/index.html'); - const mainPath = indexContent.match(/src="(main\.[a-z0-9]{0,32}\.js)"/)[1]; + const mainPath = indexContent.match(/src="(main\.[0-9a-zA-Z]{0,32}\.js)"/)[1]; // Content checks await expectFileToMatch(`dist/test-project/${mainPath}`, bootstrapRegExp); diff --git a/tests/legacy-cli/e2e_runner.ts b/tests/legacy-cli/e2e_runner.ts index 8827e36d7dc6..e4386cd324b6 100644 --- a/tests/legacy-cli/e2e_runner.ts +++ b/tests/legacy-cli/e2e_runner.ts @@ -38,7 +38,7 @@ Error.stackTraceLimit = Infinity; * If unnamed flags are passed in, the list of tests will be filtered to include only those passed. */ const argv = yargsParser(process.argv.slice(2), { - boolean: ['debug', 'ng-snapshots', 'noglobal', 'nosilent', 'noproject', 'verbose'], + boolean: ['debug', 'esbuild', 'ng-snapshots', 'noglobal', 'nosilent', 'noproject', 'verbose'], string: ['devkit', 'glob', 'ignore', 'reuse', 'ng-tag', 'tmpdir', 'ng-version'], configuration: { 'dot-notation': false,