diff --git a/.circleci/config.yml b/.circleci/config.yml index 56a6364fe701..2a6ea47ef7e0 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -204,6 +204,14 @@ jobs: command: | rm -rf /mnt/ramdisk/*test-project PATH=~/.npm-global/bin:$PATH node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.ve >>--ve<> <<# parameters.snapshots >>--ng-snapshots<> --yarn --tmpdir=/mnt/ramdisk --glob="{tests/basic/**,tests/update/**}" + - unless: + condition: << parameters.ve >> + steps: + - run: + name: Execute CLI E2E Test Subset for Ivy-only plugin + command: | + rm -rf /mnt/ramdisk/*test-project + PATH=~/.npm-global/bin:$PATH NG_BUILD_IVY_EXPERIMENTAL=1 node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.snapshots >>--ng-snapshots<> --tmpdir=/mnt/ramdisk --glob="{tests/basic/**,tests/build/**}" e2e-cli-node-10: executor: diff --git a/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts b/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts index ffb07abf2026..72d4932bec04 100644 --- a/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts +++ b/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts @@ -61,6 +61,12 @@ describe('Browser Builder lazy modules', () => { for (const [name, imports] of cases) { describe(`Load children ${name} syntax`, () => { it('supports lazy bundle for lazy routes with JIT', async () => { + if (name === 'string' && !veEnabled) { + pending('Does not apply to Ivy.'); + + return; + } + host.writeMultipleFiles(lazyModuleFiles); host.writeMultipleFiles(imports); @@ -73,6 +79,12 @@ describe('Browser Builder lazy modules', () => { }); it('supports lazy bundle for lazy routes with AOT', async () => { + if (name === 'string' && !veEnabled) { + pending('Does not apply to Ivy.'); + + return; + } + host.writeMultipleFiles(lazyModuleFiles); host.writeMultipleFiles(imports); addLazyLoadedModulesInTsConfig(host, lazyModuleFiles); diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts b/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts index 6e854baf8130..cd09ade41f08 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts @@ -13,9 +13,95 @@ import { AngularCompilerPlugin, AngularCompilerPluginOptions, NgToolsLoader, - PLATFORM + PLATFORM, + ivy, } from '@ngtools/webpack'; import { WebpackConfigOptions, BuildOptions } from '../../utils/build-options'; +import { CompilerOptions } from '@angular/compiler-cli'; +import { RuleSetLoader } from 'webpack'; + +function canUseIvyPlugin(wco: WebpackConfigOptions): boolean { + // Can only be used with Ivy + if (!wco.tsConfig.options.enableIvy) { + return false; + } + + // Allow new ivy build system via environment variable ('NG_BUILD_IVY_EXPERIMENTAL=1') + // TODO: Remove this section and adjust warnings below once the Ivy plugin is the default + const flag = process.env['NG_BUILD_IVY_EXPERIMENTAL']; + if (flag === undefined || flag === '0' || flag.toLowerCase() === 'false') { + return false; + } + + // Allow fallback to legacy build system via environment variable ('NG_BUILD_IVY_LEGACY=1') + // TODO: Enable this section once the Ivy plugin is the default + // const flag = process.env['NG_BUILD_IVY_LEGACY']; + // if (flag !== undefined && flag !== '0' && flag.toLowerCase() !== 'false') { + // wco.logger.warn( + // '"NG_BUILD_IVY_LEGACY" environment variable detected. Using legacy Ivy build system.', + // ); + + // return false; + // } + + // Lazy modules option uses the deprecated string format for lazy routes which is not supported + if (wco.buildOptions.lazyModules && wco.buildOptions.lazyModules.length > 0) { + wco.logger.warn( + '"lazyModules" option is deprecated and not supported by the experimental Ivy build system. ' + + 'Using original Ivy build system.' + ); + + return false; + } + + // This pass relies on internals of the original plugin + if (wco.buildOptions.experimentalRollupPass) { + wco.logger.warn( + 'The experimental rollup pass is not supported by the experimental Ivy build system. ' + + 'Using original Ivy build system.' + ); + + return false; + } + + wco.logger.warn( + '"NG_BUILD_IVY_EXPERIMENTAL" environment variable detected. Using experimental Ivy build system.', + ); + + return true; +} + +function createIvyPlugin( + wco: WebpackConfigOptions, + aot: boolean, + tsconfig: string, +): ivy.AngularWebpackPlugin { + const { buildOptions } = wco; + const optimize = buildOptions.optimization.scripts; + + const compilerOptions: CompilerOptions = { + skipTemplateCodegen: !aot, + sourceMap: buildOptions.sourceMap.scripts, + }; + + if (buildOptions.preserveSymlinks !== undefined) { + compilerOptions.preserveSymlinks = buildOptions.preserveSymlinks; + } + + const fileReplacements: Record = {}; + if (buildOptions.fileReplacements) { + for (const replacement of buildOptions.fileReplacements) { + fileReplacements[replacement.replace] = replacement.with; + } + } + + return new ivy.AngularWebpackPlugin({ + tsconfig, + compilerOptions, + fileReplacements, + emitNgModuleScope: !optimize, + }); +} function _pluginOptionsOverrides( buildOptions: BuildOptions, @@ -103,40 +189,78 @@ function _createAotPlugin( export function getNonAotConfig(wco: WebpackConfigOptions) { const { tsConfigPath } = wco; + const useIvyOnlyPlugin = canUseIvyPlugin(wco); return { - module: { rules: [{ test: /\.tsx?$/, loader: NgToolsLoader }] }, - plugins: [_createAotPlugin(wco, { tsConfigPath, skipCodeGeneration: true })] + module: { + rules: [ + { + test: useIvyOnlyPlugin ? /\.[jt]sx?$/ : /\.tsx?$/, + loader: useIvyOnlyPlugin + ? ivy.AngularWebpackLoaderPath + : NgToolsLoader, + }, + ], + }, + plugins: [ + useIvyOnlyPlugin + ? createIvyPlugin(wco, false, tsConfigPath) + : _createAotPlugin(wco, { tsConfigPath, skipCodeGeneration: true }), + ], }; } export function getAotConfig(wco: WebpackConfigOptions, i18nExtract = false) { const { tsConfigPath, buildOptions } = wco; + const optimize = buildOptions.optimization.scripts; + const useIvyOnlyPlugin = canUseIvyPlugin(wco) && !i18nExtract; - const loaders: any[] = [NgToolsLoader]; + let buildOptimizerRules: RuleSetLoader[] = []; if (buildOptions.buildOptimizer) { - loaders.unshift({ + buildOptimizerRules = [{ loader: buildOptimizerLoaderPath, options: { sourceMap: buildOptions.sourceMap.scripts } - }); + }]; } - const test = /(?:\.ngfactory\.js|\.ngstyle\.js|\.tsx?)$/; - const optimize = wco.buildOptions.optimization.scripts; - return { - module: { rules: [{ test, use: loaders }] }, + module: { + rules: [ + { + test: useIvyOnlyPlugin ? /\.tsx?$/ : /(?:\.ngfactory\.js|\.ngstyle\.js|\.tsx?)$/, + use: [ + ...buildOptimizerRules, + useIvyOnlyPlugin ? ivy.AngularWebpackLoaderPath : NgToolsLoader, + ], + }, + // "allowJs" support with ivy plugin - ensures build optimizer is not run twice + ...(useIvyOnlyPlugin + ? [ + { + test: /\.jsx?$/, + use: [ivy.AngularWebpackLoaderPath], + }, + ] + : []), + ], + }, plugins: [ - _createAotPlugin( - wco, - { tsConfigPath, emitClassMetadata: !optimize, emitNgModuleScope: !optimize }, - i18nExtract, - ), + useIvyOnlyPlugin + ? createIvyPlugin(wco, true, tsConfigPath) + : _createAotPlugin( + wco, + { tsConfigPath, emitClassMetadata: !optimize, emitNgModuleScope: !optimize }, + i18nExtract, + ), ], }; } export function getTypescriptWorkerPlugin(wco: WebpackConfigOptions, workerTsConfigPath: string) { + if (canUseIvyPlugin(wco)) { + return createIvyPlugin(wco, false, workerTsConfigPath); + } + const { buildOptions } = wco; let pluginOptions: AngularCompilerPluginOptions = { diff --git a/packages/ngtools/webpack/src/index.ts b/packages/ngtools/webpack/src/index.ts index bba971febb90..35de314a7db3 100644 --- a/packages/ngtools/webpack/src/index.ts +++ b/packages/ngtools/webpack/src/index.ts @@ -14,3 +14,5 @@ export const NgToolsLoader = __filename; // We shouldn't need to export this, but webpack-rollup-loader uses it. export type { VirtualFileSystemDecorator } from './virtual_file_system_decorator'; + +export * as ivy from './ivy'; diff --git a/packages/ngtools/webpack/src/ivy/diagnostics.ts b/packages/ngtools/webpack/src/ivy/diagnostics.ts new file mode 100644 index 000000000000..4ddd0b739ba9 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/diagnostics.ts @@ -0,0 +1,27 @@ +/** + * @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 { Diagnostics, formatDiagnostics } from '@angular/compiler-cli'; +import { DiagnosticCategory } from 'typescript'; +import { addError, addWarning } from '../webpack-diagnostics'; + +export type DiagnosticsReporter = (diagnostics: Diagnostics) => void; + +export function createDiagnosticsReporter( + compilation: import('webpack').compilation.Compilation, +): DiagnosticsReporter { + return (diagnostics) => { + for (const diagnostic of diagnostics) { + const text = formatDiagnostics([diagnostic]); + if (diagnostic.category === DiagnosticCategory.Error) { + addError(compilation, text); + } else { + addWarning(compilation, text); + } + } + }; +} diff --git a/packages/ngtools/webpack/src/ivy/host.ts b/packages/ngtools/webpack/src/ivy/host.ts new file mode 100644 index 000000000000..b85cc279151d --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/host.ts @@ -0,0 +1,246 @@ +/** + * @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 { CompilerHost } from '@angular/compiler-cli'; +import { createHash } from 'crypto'; +import * as path from 'path'; +import * as ts from 'typescript'; +import { NgccProcessor } from '../ngcc_processor'; +import { WebpackResourceLoader } from '../resource_loader'; +import { forwardSlashPath } from '../utils'; + +export function augmentHostWithResources( + host: ts.CompilerHost, + resourceLoader: WebpackResourceLoader, + options: { directTemplateLoading?: boolean } = {}, +) { + const resourceHost = host as CompilerHost; + + resourceHost.readResource = function (fileName: string) { + const filePath = forwardSlashPath(fileName); + + if ( + options.directTemplateLoading && + (filePath.endsWith('.html') || filePath.endsWith('.svg')) + ) { + const content = this.readFile(filePath); + if (content === undefined) { + throw new Error('Unable to locate component resource: ' + fileName); + } + + resourceLoader.setAffectedResources(filePath, [filePath]); + + return content; + } else { + return resourceLoader.get(filePath); + } + }; + + resourceHost.resourceNameToFileName = function (resourceName: string, containingFile: string) { + return forwardSlashPath(path.join(path.dirname(containingFile), resourceName)); + }; + + resourceHost.getModifiedResourceFiles = function () { + return resourceLoader.getModifiedResourceFiles(); + }; +} + +function augmentResolveModuleNames( + host: ts.CompilerHost, + resolvedModuleModifier: ( + resolvedModule: ts.ResolvedModule | undefined, + moduleName: string, + ) => ts.ResolvedModule | undefined, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (host.resolveModuleNames) { + const baseResolveModuleNames = host.resolveModuleNames; + host.resolveModuleNames = function (moduleNames: string[], ...parameters) { + return moduleNames.map((name) => { + const result = baseResolveModuleNames.call(host, [name], ...parameters); + + return resolvedModuleModifier(result[0], name); + }); + }; + } else { + host.resolveModuleNames = function ( + moduleNames: string[], + containingFile: string, + _reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { + return moduleNames.map((name) => { + const result = ts.resolveModuleName( + name, + containingFile, + options, + host, + moduleResolutionCache, + redirectedReference, + ).resolvedModule; + + return resolvedModuleModifier(result, name); + }); + }; + } +} + +export function augmentHostWithNgcc( + host: ts.CompilerHost, + ngcc: NgccProcessor, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + augmentResolveModuleNames( + host, + (resolvedModule, moduleName) => { + if (resolvedModule && ngcc) { + ngcc.processModule(moduleName, resolvedModule); + } + + return resolvedModule; + }, + moduleResolutionCache, + ); + + if (host.resolveTypeReferenceDirectives) { + const baseResolveTypeReferenceDirectives = host.resolveTypeReferenceDirectives; + host.resolveTypeReferenceDirectives = function (names: string[], ...parameters) { + return names.map((name) => { + const result = baseResolveTypeReferenceDirectives.call(host, [name], ...parameters); + + if (result[0] && ngcc) { + ngcc.processModule(name, result[0]); + } + + return result[0]; + }); + }; + } else { + host.resolveTypeReferenceDirectives = function ( + moduleNames: string[], + containingFile: string, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { + return moduleNames.map((name) => { + const result = ts.resolveTypeReferenceDirective( + name, + containingFile, + options, + host, + redirectedReference, + ).resolvedTypeReferenceDirective; + + if (result && ngcc) { + ngcc.processModule(name, result); + } + + return result; + }); + }; + } +} + +export function augmentHostWithReplacements( + host: ts.CompilerHost, + replacements: Record, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (Object.keys(replacements).length === 0) { + return; + } + + const tryReplace = (resolvedModule: ts.ResolvedModule | undefined) => { + const replacement = resolvedModule && replacements[resolvedModule.resolvedFileName]; + if (replacement) { + return { + resolvedFileName: replacement, + isExternalLibraryImport: /[\/\\]node_modules[\/\\]/.test(replacement), + }; + } else { + return resolvedModule; + } + }; + + augmentResolveModuleNames(host, tryReplace, moduleResolutionCache); +} + +export function augmentHostWithSubstitutions( + host: ts.CompilerHost, + substitutions: Record, +): void { + const regexSubstitutions: [RegExp, string][] = []; + for (const [key, value] of Object.entries(substitutions)) { + regexSubstitutions.push([new RegExp(`\\b${key}\\b`, 'g'), value]); + } + + if (regexSubstitutions.length === 0) { + return; + } + + const baseReadFile = host.readFile; + host.readFile = function (...parameters) { + let file: string | undefined = baseReadFile.call(host, ...parameters); + if (file) { + for (const entry of regexSubstitutions) { + file = file.replace(entry[0], entry[1]); + } + } + + return file; + }; +} + +export function augmentHostWithVersioning(host: ts.CompilerHost): void { + const baseGetSourceFile = host.getSourceFile; + host.getSourceFile = function (...parameters) { + const file: (ts.SourceFile & { version?: string }) | undefined = baseGetSourceFile.call( + host, + ...parameters, + ); + if (file && file.version === undefined) { + file.version = createHash('sha256').update(file.text).digest('hex'); + } + + return file; + }; +} + +export function augmentHostWithCaching( + host: ts.CompilerHost, + cache: Map, +): void { + const baseGetSourceFile = host.getSourceFile; + host.getSourceFile = function ( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + // tslint:disable-next-line: trailing-comma + ...parameters + ) { + if (!shouldCreateNewSourceFile && cache.has(fileName)) { + return cache.get(fileName); + } + + const file = baseGetSourceFile.call( + host, + fileName, + languageVersion, + onError, + true, + ...parameters, + ); + + if (file) { + cache.set(fileName, file); + } + + return file; + }; +} diff --git a/packages/ngtools/webpack/src/ivy/index.ts b/packages/ngtools/webpack/src/ivy/index.ts new file mode 100644 index 000000000000..b5e2985745ad --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/index.ts @@ -0,0 +1,11 @@ +/** + * @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 + */ +export { angularWebpackLoader as default } from './loader'; +export { AngularPluginOptions, AngularWebpackPlugin } from './plugin'; + +export const AngularWebpackLoaderPath = __filename; diff --git a/packages/ngtools/webpack/src/ivy/loader.ts b/packages/ngtools/webpack/src/ivy/loader.ts new file mode 100644 index 000000000000..11a09a4dbbf0 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/loader.ts @@ -0,0 +1,77 @@ +/** + * @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 * as path from 'path'; +import { AngularPluginSymbol, FileEmitter } from './symbol'; + +export function angularWebpackLoader( + this: import('webpack').loader.LoaderContext, + content: string, + // Source map types are broken in the webpack type definitions + // tslint:disable-next-line: no-any + map: any, +) { + if (this.loaderIndex !== this.loaders.length - 1) { + this.emitWarning('The Angular Webpack loader does not support chaining prior to the loader.'); + } + + const callback = this.async(); + if (!callback) { + throw new Error('Invalid webpack version'); + } + + const emitFile = this._compilation[AngularPluginSymbol] as FileEmitter; + if (typeof emitFile !== 'function') { + if (this.resourcePath.endsWith('.js')) { + // Passthrough for JS files when no plugin is used + this.callback(undefined, content, map); + + return; + } + + callback(new Error('The Angular Webpack loader requires the AngularWebpackPlugin.')); + + return; + } + + emitFile(this.resourcePath) + .then((result) => { + if (!result) { + if (this.resourcePath.endsWith('.js')) { + // Return original content for JS files if not compiled by TypeScript ("allowJs") + this.callback(undefined, content, map); + } else { + // File is not part of the compilation + const message = + `${this.resourcePath} is missing from the TypeScript compilation. ` + + `Please make sure it is in your tsconfig via the 'files' or 'include' property.`; + callback(new Error(message)); + } + + return; + } + + result.dependencies.forEach((dependency) => this.addDependency(dependency)); + + let resultContent = result.content || ''; + let resultMap; + if (result.map) { + resultContent = resultContent.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + resultMap = JSON.parse(result.map); + resultMap.sources = resultMap.sources.map((source: string) => + path.join(path.dirname(this.resourcePath), source), + ); + } + + callback(undefined, resultContent, resultMap); + }) + .catch((err) => { + callback(err); + }); +} + +export { angularWebpackLoader as default }; diff --git a/packages/ngtools/webpack/src/ivy/plugin.ts b/packages/ngtools/webpack/src/ivy/plugin.ts new file mode 100644 index 000000000000..d810cbd90c3f --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/plugin.ts @@ -0,0 +1,486 @@ +/** + * @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 { + CompilerHost, + CompilerOptions, + NgTscPlugin, + readConfiguration, +} from '@angular/compiler-cli'; +import * as path from 'path'; +import * as ts from 'typescript'; +import { + Compiler, + ContextReplacementPlugin, + NormalModuleReplacementPlugin, + compilation, +} from 'webpack'; +import { NgccProcessor } from '../ngcc_processor'; +import { TypeScriptPathsPlugin } from '../paths-plugin'; +import { WebpackResourceLoader } from '../resource_loader'; +import { forwardSlashPath } from '../utils'; +import { addError, addWarning } from '../webpack-diagnostics'; +import { DiagnosticsReporter, createDiagnosticsReporter } from './diagnostics'; +import { + augmentHostWithCaching, + augmentHostWithNgcc, + augmentHostWithReplacements, + augmentHostWithResources, + augmentHostWithSubstitutions, + augmentHostWithVersioning, +} from './host'; +import { AngularPluginSymbol, FileEmitter } from './symbol'; +import { createWebpackSystem } from './system'; +import { createAotTransformers, createJitTransformers, mergeTransformers } from './transformation'; + +export interface AngularPluginOptions { + tsconfig: string; + compilerOptions?: CompilerOptions; + fileReplacements: Record; + substitutions: Record; + directTemplateLoading: boolean; + emitClassMetadata: boolean; + emitNgModuleScope: boolean; +} + +// Add support for missing properties in Webpack types as well as the loader's file emitter +interface WebpackCompilation extends compilation.Compilation { + compilationDependencies: Set; + [AngularPluginSymbol]: FileEmitter; +} + +function initializeNgccProcessor( + compiler: Compiler, + tsconfig: string, +): { processor: NgccProcessor; errors: string[]; warnings: string[] } { + const { inputFileSystem, options: webpackOptions } = compiler; + const mainFields = ([] as string[]).concat(...(webpackOptions.resolve?.mainFields || [])); + + const fileWatchPurger = (path: string) => { + if (inputFileSystem.purge) { + // Webpack typings do not contain the string parameter overload for purge + (inputFileSystem as { purge(path: string): void }).purge(path); + } + }; + + const errors: string[] = []; + const warnings: string[] = []; + const processor = new NgccProcessor( + mainFields, + fileWatchPurger, + warnings, + errors, + compiler.context, + tsconfig, + ); + + return { processor, errors, warnings }; +} + +const PLUGIN_NAME = 'angular-compiler'; + +export class AngularWebpackPlugin { + private readonly pluginOptions: AngularPluginOptions; + private watchMode?: boolean; + private ngtscNextProgram?: ts.Program; + private builder?: ts.EmitAndSemanticDiagnosticsBuilderProgram; + private sourceFileCache?: Map; + private buildTimestamp!: number; + + constructor(options: Partial = {}) { + this.pluginOptions = { + emitClassMetadata: false, + emitNgModuleScope: false, + fileReplacements: {}, + substitutions: {}, + directTemplateLoading: true, + tsconfig: 'tsconfig.json', + ...options, + }; + } + + get options(): AngularPluginOptions { + return this.pluginOptions; + } + + apply(compiler: Compiler & { watchMode?: boolean }): void { + // Setup file replacements with webpack + for (const [key, value] of Object.entries(this.pluginOptions.fileReplacements)) { + new NormalModuleReplacementPlugin( + new RegExp('^' + key.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&') + '$'), + value, + ).apply(compiler); + } + + // Mimic VE plugin's systemjs module loader resource location for consistency + new ContextReplacementPlugin( + /\@angular[\\\/]core[\\\/]/, + path.join(compiler.context, '$$_lazy_route_resource'), + ).apply(compiler); + + // Set resolver options + const pathsPlugin = new TypeScriptPathsPlugin(); + compiler.hooks.afterResolvers.tap('angular-compiler', (compiler) => { + // 'resolverFactory' is not present in the Webpack typings + // tslint:disable-next-line: no-any + const resolverFactoryHooks = (compiler as any).resolverFactory.hooks; + + // When Ivy is enabled we need to add the fields added by NGCC + // to take precedence over the provided mainFields. + // NGCC adds fields in package.json suffixed with '_ivy_ngcc' + // Example: module -> module__ivy_ngcc + resolverFactoryHooks.resolveOptions + .for('normal') + .tap(PLUGIN_NAME, (resolveOptions: { mainFields: string[] }) => { + const originalMainFields = resolveOptions.mainFields; + const ivyMainFields = originalMainFields.map((f) => `${f}_ivy_ngcc`); + + return { + ...resolveOptions, + mainFields: [...ivyMainFields, ...originalMainFields], + }; + }); + + resolverFactoryHooks.resolver.for('normal').tap(PLUGIN_NAME, (resolver: {}) => { + pathsPlugin.apply(resolver); + }); + }); + + let ngccProcessor: NgccProcessor | undefined; + const resourceLoader = new WebpackResourceLoader(); + let previousUnused: Set | undefined; + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (thisCompilation) => { + const compilation = thisCompilation as WebpackCompilation; + + // Store watch mode; assume true if not present (webpack < 4.23.0) + this.watchMode = compiler.watchMode ?? true; + + // Initialize and process eager ngcc if not already setup + if (!ngccProcessor) { + const { processor, errors, warnings } = initializeNgccProcessor( + compiler, + this.pluginOptions.tsconfig, + ); + + processor.process(); + warnings.forEach((warning) => addWarning(compilation, warning)); + errors.forEach((error) => addError(compilation, error)); + + ngccProcessor = processor; + } + + // Setup and read TypeScript and Angular compiler configuration + const { options: compilerOptions, rootNames, errors } = readConfiguration( + this.pluginOptions.tsconfig, + this.pluginOptions.compilerOptions, + ); + compilerOptions.noEmitOnError = false; + compilerOptions.suppressOutputPathCheck = true; + compilerOptions.outDir = undefined; + compilerOptions.inlineSources = compilerOptions.sourceMap; + compilerOptions.inlineSourceMap = false; + compilerOptions.mapRoot = undefined; + compilerOptions.sourceRoot = undefined; + compilerOptions.allowEmptyCodegenFiles = false; + compilerOptions.annotationsAs = 'decorators'; + compilerOptions.enableResourceInlining = false; + + // Create diagnostics reporter and report configuration file errors + const diagnosticsReporter = createDiagnosticsReporter(compilation); + diagnosticsReporter(errors); + + // Update TypeScript path mapping plugin with new configuration + pathsPlugin.update(compilerOptions); + + // Create a Webpack-based TypeScript compiler host + const system = createWebpackSystem( + compiler.inputFileSystem, + forwardSlashPath(compiler.context), + ); + const host = ts.createIncrementalCompilerHost(compilerOptions, system); + + // Setup source file caching and reuse cache from previous compilation if present + let cache = this.sourceFileCache; + if (cache) { + // Invalidate existing cache based on compilation file timestamps + for (const [file, time] of compilation.fileTimestamps) { + if (this.buildTimestamp < time) { + cache.delete(forwardSlashPath(file)); + } + } + } else { + // Initialize a new cache + cache = new Map(); + // Only store cache if in watch mode + if (this.watchMode) { + this.sourceFileCache = cache; + } + } + this.buildTimestamp = Date.now(); + augmentHostWithCaching(host, cache); + + const moduleResolutionCache = ts.createModuleResolutionCache( + host.getCurrentDirectory(), + host.getCanonicalFileName.bind(host), + compilerOptions, + ); + + // Setup on demand ngcc + augmentHostWithNgcc(host, ngccProcessor, moduleResolutionCache); + + // Setup resource loading + resourceLoader.update(compilation); + augmentHostWithResources(host, resourceLoader, { + directTemplateLoading: this.pluginOptions.directTemplateLoading, + }); + + // Setup source file adjustment options + augmentHostWithReplacements(host, this.pluginOptions.fileReplacements, moduleResolutionCache); + augmentHostWithSubstitutions(host, this.pluginOptions.substitutions); + + // Create the file emitter used by the webpack loader + const { fileEmitter, builder, internalFiles } = compilerOptions.skipTemplateCodegen + ? this.updateJitProgram(compilerOptions, rootNames, host, diagnosticsReporter) + : this.updateAotProgram( + compilerOptions, + rootNames, + host, + diagnosticsReporter, + resourceLoader, + ); + + const allProgramFiles = builder + .getSourceFiles() + .filter((sourceFile) => !internalFiles || !internalFiles.has(sourceFile)) + .map((sourceFile) => sourceFile.fileName); + + // Ensure all program files are considered part of the compilation and will be watched + allProgramFiles.forEach((sourceFile) => compilation.compilationDependencies.add(sourceFile)); + + // Analyze program for unused files + compilation.hooks.finishModules.tap(PLUGIN_NAME, (modules) => { + if (compilation.errors.length > 0) { + return; + } + + const currentUnused = new Set( + allProgramFiles.filter((sourceFile) => !sourceFile.endsWith('.d.ts')), + ); + modules.forEach((module) => { + const { resource } = module as { resource?: string }; + const sourceFile = resource && builder.getSourceFile(forwardSlashPath(resource)); + if (!sourceFile) { + return; + } + + builder.getAllDependencies(sourceFile).forEach((dep) => currentUnused.delete(dep)); + }); + for (const unused of currentUnused) { + if (previousUnused && previousUnused.has(unused)) { + continue; + } + compilation.warnings.push( + `${unused} is part of the TypeScript compilation but it's unused.\n` + + `Add only entry points to the 'files' or 'include' properties in your tsconfig.`, + ); + } + previousUnused = currentUnused; + }); + + // Store file emitter for loader usage + compilation[AngularPluginSymbol] = fileEmitter; + }); + } + + private updateAotProgram( + compilerOptions: CompilerOptions, + rootNames: string[], + host: CompilerHost, + diagnosticsReporter: DiagnosticsReporter, + resourceLoader: WebpackResourceLoader, + ) { + // Create an ngtsc plugin instance used to initialize a TypeScript host and program + const ngtsc = new NgTscPlugin(compilerOptions); + + const wrappedHost = ngtsc.wrapHost(host, rootNames, compilerOptions); + + // SourceFile versions are required for builder programs. + // The wrapped host adds additional files that will not have versions + augmentHostWithVersioning(wrappedHost); + + const oldProgram = this.ngtscNextProgram; + const program = ts.createProgram({ + options: compilerOptions, + oldProgram, + rootNames: wrappedHost.inputFiles, + host: wrappedHost, + }); + + // The `ignoreForEmit` return value can be safely ignored when emitting. Only files + // that will be bundled (requested by Webpack) will be emitted. Combined with TypeScript's + // eliding of type only imports, this will cause type only files to be automatically ignored. + // Internal Angular type check files are also not resolvable by the bundler. Even if they + // were somehow errantly imported, the bundler would error before an emit was attempted. + // Diagnostics are still collected for all files which requires using `ignoreForDiagnostics`. + const { ignoreForDiagnostics, ignoreForEmit } = ngtsc.setupCompilation(program, oldProgram); + + const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + program, + wrappedHost, + this.builder, + ); + + // Save for next rebuild + if (this.watchMode) { + this.ngtscNextProgram = ngtsc.getNextProgram(); + this.builder = builder; + } + + // Update semantic diagnostics cache + while (true) { + const result = builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => + ignoreForDiagnostics.has(sourceFile), + ); + if (!result) { + break; + } + } + + // Collect non-semantic diagnostics + const diagnostics = [ + ...ngtsc.getOptionDiagnostics(), + ...builder.getOptionsDiagnostics(), + ...builder.getGlobalDiagnostics(), + ...builder.getSyntacticDiagnostics(), + ]; + diagnosticsReporter(diagnostics); + + // Collect semantic diagnostics + for (const sourceFile of builder.getSourceFiles()) { + if (!ignoreForDiagnostics.has(sourceFile)) { + diagnosticsReporter(builder.getSemanticDiagnostics(sourceFile)); + } + } + + const transformers = createAotTransformers(builder, this.pluginOptions); + + const getDependencies = (sourceFile: ts.SourceFile) => { + // TODO: Integrate getModuleDependencies once implemented + const dependencies = []; + for (const resourceDependency of ngtsc.compiler.getResourceDependencies(sourceFile)) { + const resourcePath = forwardSlashPath(resourceDependency); + dependencies.push( + resourcePath, + // Retrieve all dependencies of the resource (stylesheet imports, etc.) + ...resourceLoader.getResourceDependencies(resourcePath), + ); + } + + return dependencies; + }; + + // Required to support asynchronous resource loading + // Must be done before creating transformers or getting template diagnostics + const pendingAnalysis = ngtsc.compiler.analyzeAsync().then(() => { + // Collect Angular template diagnostics + for (const sourceFile of builder.getSourceFiles()) { + if (!ignoreForDiagnostics.has(sourceFile)) { + diagnosticsReporter(ngtsc.getDiagnostics(sourceFile)); + } + } + + return this.createFileEmitter( + builder, + mergeTransformers(ngtsc.createTransformers(), transformers), + getDependencies, + ); + }); + const analyzingFileEmitter: FileEmitter = async (file) => { + const innerFileEmitter = await pendingAnalysis; + + return innerFileEmitter(file); + }; + + return { + fileEmitter: analyzingFileEmitter, + builder, + internalFiles: ignoreForEmit, + }; + } + + private updateJitProgram( + compilerOptions: CompilerOptions, + rootNames: readonly string[], + host: CompilerHost, + diagnosticsReporter: DiagnosticsReporter, + ) { + const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + rootNames, + compilerOptions, + host, + this.builder, + ); + + // Save for next rebuild + if (this.watchMode) { + this.builder = builder; + } + + const diagnostics = [ + ...builder.getOptionsDiagnostics(), + ...builder.getGlobalDiagnostics(), + ...builder.getSyntacticDiagnostics(), + // Gather incremental semantic diagnostics + ...builder.getSemanticDiagnostics(), + ]; + diagnosticsReporter(diagnostics); + + const transformers = createJitTransformers(builder, this.pluginOptions); + + return { + fileEmitter: this.createFileEmitter(builder, transformers, () => []), + builder, + internalFiles: undefined, + }; + } + + private createFileEmitter( + program: ts.BuilderProgram, + transformers: ts.CustomTransformers = {}, + getExtraDependencies: (sourceFile: ts.SourceFile) => Iterable, + ): FileEmitter { + return async (file: string) => { + const sourceFile = program.getSourceFile(forwardSlashPath(file)); + if (!sourceFile) { + return undefined; + } + + let content: string | undefined = undefined; + let map: string | undefined = undefined; + program.emit( + sourceFile, + (filename, data) => { + if (filename.endsWith('.map')) { + map = data; + } else if (filename.endsWith('.js')) { + content = data; + } + }, + undefined, + undefined, + transformers, + ); + + const dependencies = [ + ...program.getAllDependencies(sourceFile), + ...getExtraDependencies(sourceFile), + ]; + + return { content, map, dependencies }; + }; + } +} diff --git a/packages/ngtools/webpack/src/ivy/symbol.ts b/packages/ngtools/webpack/src/ivy/symbol.ts new file mode 100644 index 000000000000..b3dd0faafe68 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/symbol.ts @@ -0,0 +1,16 @@ +/** + * @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 + */ +export const AngularPluginSymbol = Symbol.for('@angular-devkit/build-angular[angular-compiler]'); + +export interface EmitFileResult { + content?: string; + map?: string; + dependencies: readonly string[]; +} + +export type FileEmitter = (file: string) => Promise; diff --git a/packages/ngtools/webpack/src/ivy/system.ts b/packages/ngtools/webpack/src/ivy/system.ts new file mode 100644 index 000000000000..ac51e8d546b1 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/system.ts @@ -0,0 +1,99 @@ +/** + * @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 * as ts from 'typescript'; +import { InputFileSystem } from 'webpack'; + +function shouldNotWrite(): never { + throw new Error('Webpack TypeScript System should not write.'); +} + +// Webpack's CachedInputFileSystem uses the default directory separator in the paths it uses +// for keys to its cache. If the keys do not match then the file watcher will not purge outdated +// files and cause stale data to be used in the next rebuild. TypeScript always uses a `/` (POSIX) +// directory separator internally which is also supported with Windows system APIs. However, +// if file operations are performed with the non-default directory separator, the Webpack cache +// will contain a key that will not be purged. +function createToSystemPath(): (path: string) => string { + if (process.platform === 'win32') { + const cache = new Map(); + + return (path) => { + let value = cache.get(path); + if (value === undefined) { + value = path.replace(/\//g, '\\'); + cache.set(path, value); + } + + return value; + }; + } + + // POSIX-like platforms retain the existing directory separator + return (path) => path; +} + +export function createWebpackSystem(input: InputFileSystem, currentDirectory: string): ts.System { + const toSystemPath = createToSystemPath(); + + const system: ts.System = { + ...ts.sys, + readFile(path: string) { + let data; + try { + data = input.readFileSync(toSystemPath(path)); + } catch { + return undefined; + } + + // Strip BOM if present + let start = 0; + if (data.length > 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) { + start = 3; + } + + return data.toString('utf8', start); + }, + getFileSize(path: string) { + try { + return input.statSync(toSystemPath(path)).size; + } catch { + return 0; + } + }, + fileExists(path: string) { + try { + return input.statSync(toSystemPath(path)).isFile(); + } catch { + return false; + } + }, + directoryExists(path: string) { + try { + return input.statSync(toSystemPath(path)).isDirectory(); + } catch { + return false; + } + }, + getModifiedTime(path: string) { + try { + return input.statSync(toSystemPath(path)).mtime; + } catch { + return undefined; + } + }, + getCurrentDirectory() { + return currentDirectory; + }, + writeFile: shouldNotWrite, + createDirectory: shouldNotWrite, + deleteFile: shouldNotWrite, + setModifiedTime: shouldNotWrite, + }; + + return system; +} diff --git a/packages/ngtools/webpack/src/ivy/transformation.ts b/packages/ngtools/webpack/src/ivy/transformation.ts new file mode 100644 index 000000000000..a76f3ccd0d0d --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/transformation.ts @@ -0,0 +1,141 @@ +/** + * @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 { constructorParametersDownlevelTransform } from '@angular/compiler-cli'; +import * as ts from 'typescript'; +import { elideImports } from '../transformers/elide_imports'; +import { removeIvyJitSupportCalls } from '../transformers/remove-ivy-jit-support-calls'; +import { replaceResources } from '../transformers/replace_resources'; + +export function createAotTransformers( + builder: ts.BuilderProgram, + options: { emitClassMetadata?: boolean; emitNgModuleScope?: boolean }, +): ts.CustomTransformers { + const getTypeChecker = () => builder.getProgram().getTypeChecker(); + const transformers: ts.CustomTransformers = { + before: [replaceBootstrap(getTypeChecker)], + after: [], + }; + + const removeClassMetadata = !options.emitClassMetadata; + const removeNgModuleScope = !options.emitNgModuleScope; + if (removeClassMetadata || removeNgModuleScope) { + // tslint:disable-next-line: no-non-null-assertion + transformers.after!.push( + removeIvyJitSupportCalls(removeClassMetadata, removeNgModuleScope, getTypeChecker), + ); + } + + return transformers; +} + +export function createJitTransformers( + builder: ts.BuilderProgram, + options: { directTemplateLoading?: boolean }, +): ts.CustomTransformers { + const getTypeChecker = () => builder.getProgram().getTypeChecker(); + + return { + before: [ + replaceResources(() => true, getTypeChecker, options.directTemplateLoading), + constructorParametersDownlevelTransform(builder.getProgram()), + ], + }; +} + +export function mergeTransformers( + first: ts.CustomTransformers, + second: ts.CustomTransformers, +): ts.CustomTransformers { + const result: ts.CustomTransformers = {}; + + if (first.before || second.before) { + result.before = [...(first.before || []), ...(second.before || [])]; + } + + if (first.after || second.after) { + result.after = [...(first.after || []), ...(second.after || [])]; + } + + if (first.afterDeclarations || second.afterDeclarations) { + result.afterDeclarations = [ + ...(first.afterDeclarations || []), + ...(second.afterDeclarations || []), + ]; + } + + return result; +} + +export function replaceBootstrap( + getTypeChecker: () => ts.TypeChecker, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + let bootstrapImport: ts.ImportDeclaration | undefined; + let bootstrapNamespace: ts.Identifier | undefined; + const replacedNodes: ts.Node[] = []; + + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const target = node.expression; + if (target.text === 'platformBrowserDynamic') { + if (!bootstrapNamespace) { + bootstrapNamespace = ts.createUniqueName('__NgCli_bootstrap_'); + bootstrapImport = ts.createImportDeclaration( + undefined, + undefined, + ts.createImportClause(undefined, ts.createNamespaceImport(bootstrapNamespace)), + ts.createLiteral('@angular/platform-browser'), + ); + } + replacedNodes.push(target); + + return ts.updateCall( + node, + ts.createPropertyAccess(bootstrapNamespace, 'platformBrowser'), + node.typeArguments, + node.arguments, + ); + } + } + + return ts.visitEachChild(node, visitNode, context); + }; + + return (sourceFile: ts.SourceFile) => { + let updatedSourceFile = ts.visitEachChild(sourceFile, visitNode, context); + + if (bootstrapImport) { + // Remove any unused platform browser dynamic imports + const removals = elideImports( + updatedSourceFile, + replacedNodes, + getTypeChecker, + context.getCompilerOptions(), + ).map((op) => op.target); + if (removals.length > 0) { + updatedSourceFile = ts.visitEachChild( + updatedSourceFile, + (node) => (removals.includes(node) ? undefined : node), + context, + ); + } + + // Add new platform browser import + return ts.updateSourceFileNode( + updatedSourceFile, + ts.setTextRange( + ts.createNodeArray([bootstrapImport, ...updatedSourceFile.statements]), + sourceFile.statements, + ), + ); + } else { + return updatedSourceFile; + } + }; + }; +} diff --git a/packages/ngtools/webpack/src/paths-plugin.ts b/packages/ngtools/webpack/src/paths-plugin.ts index 46bf71ebe1cb..4b461cf91f1d 100644 --- a/packages/ngtools/webpack/src/paths-plugin.ts +++ b/packages/ngtools/webpack/src/paths-plugin.ts @@ -16,14 +16,14 @@ export interface TypeScriptPathsPluginOptions extends Pick { return new Promise((resolve, reject) => { @@ -46,6 +46,10 @@ export class TypeScriptPathsPlugin { resolver.getHook('described-resolve').tapPromise( 'TypeScriptPathsPlugin', async (request: NormalModuleFactoryRequest, resolveContext: {}) => { + if (!this.options) { + throw new Error('TypeScriptPathsPlugin options were not provided.'); + } + if (!request || request.typescriptPathMapped) { return; } @@ -70,11 +74,11 @@ export class TypeScriptPathsPlugin { return; } - const replacements = findReplacements(originalRequest, this._options.paths || {}); + const replacements = findReplacements(originalRequest, this.options.paths || {}); for (const potential of replacements) { const potentialRequest = { ...request, - request: path.resolve(this._options.baseUrl || '', potential), + request: path.resolve(this.options.baseUrl || '', potential), typescriptPathMapped: true, }; const result = await resolveAsync(potentialRequest, resolveContext); diff --git a/packages/ngtools/webpack/src/resource_loader.ts b/packages/ngtools/webpack/src/resource_loader.ts index cb3cbc234cdb..978eecddf029 100644 --- a/packages/ngtools/webpack/src/resource_loader.ts +++ b/packages/ngtools/webpack/src/resource_loader.ts @@ -32,11 +32,34 @@ export class WebpackResourceLoader { private _cachedSources = new Map(); private _cachedEvaluatedSources = new Map(); - constructor() {} + private buildTimestamp?: number; + public changedFiles = new Set(); - update(parentCompilation: any) { + update(parentCompilation: import('webpack').compilation.Compilation) { this._parentCompilation = parentCompilation; this._context = parentCompilation.context; + + // Update changed file list + if (this.buildTimestamp !== undefined) { + this.changedFiles.clear(); + for (const [file, time] of parentCompilation.fileTimestamps) { + if (this.buildTimestamp < time) { + this.changedFiles.add(file); + } + } + } + this.buildTimestamp = Date.now(); + } + + getModifiedResourceFiles() { + const modifiedResources = new Set(); + for (const changedFile of this.changedFiles) { + this.getAffectedResources( + changedFile, + ).forEach((affected: string) => modifiedResources.add(affected)); + } + + return modifiedResources; } getResourceDependencies(filePath: string) { @@ -47,6 +70,10 @@ export class WebpackResourceLoader { return this._reverseDependencies.get(file) || []; } + setAffectedResources(file: string, resources: Iterable) { + this._reverseDependencies.set(file, new Set(resources)); + } + private async _compile(filePath: string): Promise { if (!this._parentCompilation) { diff --git a/tests/legacy-cli/e2e/tests/build/lazy-load-syntax.ts b/tests/legacy-cli/e2e/tests/build/lazy-load-syntax.ts index 8426c8a354d0..0754ec3ba54b 100644 --- a/tests/legacy-cli/e2e/tests/build/lazy-load-syntax.ts +++ b/tests/legacy-cli/e2e/tests/build/lazy-load-syntax.ts @@ -86,8 +86,13 @@ export default async function () { await ng('e2e'); await ng('e2e', '--prod'); + if (!(getGlobalVariable('argv')['ve'])) { + // Only applicable to VE. Does not apply to Ivy. + return; + } + // Test string import. - // Both Ivy and View Engine should support it. + // View Engine should support it. await updateJsonFile('tsconfig.app.json', tsConfig => { tsConfig.files.push('src/app/lazy/lazy.module.ts'); });