/** * @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 ng from '@angular/compiler-cli'; import {BazelOptions, CachedFileLoader, CompilerHost, constructManifest, debug, FileCache, FileLoader, parseTsconfig, resolveNormalizedPath, runAsWorker, runWorkerLoop, UncachedFileLoader} from '@bazel/typescript'; import * as fs from 'fs'; import * as path from 'path'; import * as tsickle from 'tsickle'; import * as ts from 'typescript'; const EXT = /(\.ts|\.d\.ts|\.js|\.jsx|\.tsx)$/; const NGC_GEN_FILES = /^(.*?)\.(ngfactory|ngsummary|ngstyle|shim\.ngstyle)(.*)$/; // FIXME: we should be able to add the assets to the tsconfig so FileLoader // knows about them const NGC_ASSETS = /\.(css|html|ngsummary\.json)$/; const BAZEL_BIN = /\b(blaze|bazel)-out\b.*?\bbin\b/; // Note: We compile the content of node_modules with plain ngc command line. const ALL_DEPS_COMPILED_WITH_BAZEL = false; const NODE_MODULES = 'node_modules/'; export function main(args) { if (runAsWorker(args)) { runWorkerLoop(runOneBuild); } else { return runOneBuild(args) ? 0 : 1; } return 0; } /** The one FileCache instance used in this process. */ const fileCache = new FileCache(debug); export function runOneBuild(args: string[], inputs?: {[path: string]: string}): boolean { if (args[0] === '-p') args.shift(); // Strip leading at-signs, used to indicate a params file const project = args[0].replace(/^@+/, ''); const [parsedOptions, errors] = parseTsconfig(project); if (errors && errors.length) { console.error(ng.formatDiagnostics(errors)); return false; } const {options: tsOptions, bazelOpts, files, config} = parsedOptions; const angularCompilerOptions: {[k: string]: unknown} = config['angularCompilerOptions'] || {}; // Allow Bazel users to control some of the bazel options. // Since TypeScript's "extends" mechanism applies only to "compilerOptions" // we have to repeat some of their logic to get the user's "angularCompilerOptions". if (config['extends']) { // Load the user's config file // Note: this doesn't handle recursive extends so only a user's top level // `angularCompilerOptions` will be considered. As this code is going to be // removed with Ivy, the added complication of handling recursive extends // is likely not needed. let userConfigFile = resolveNormalizedPath(path.dirname(project), config['extends']); if (!userConfigFile.endsWith('.json')) userConfigFile += '.json'; const {config: userConfig, error} = ts.readConfigFile(userConfigFile, ts.sys.readFile); if (error) { console.error(ng.formatDiagnostics([error])); return false; } // All user angularCompilerOptions values that a user has control // over should be collected here if (userConfig.angularCompilerOptions) { angularCompilerOptions['diagnostics'] = angularCompilerOptions['diagnostics'] || userConfig.angularCompilerOptions.diagnostics; angularCompilerOptions['trace'] = angularCompilerOptions['trace'] || userConfig.angularCompilerOptions.trace; angularCompilerOptions['disableExpressionLowering'] = angularCompilerOptions['disableExpressionLowering'] || userConfig.angularCompilerOptions.disableExpressionLowering; angularCompilerOptions['disableTypeScriptVersionCheck'] = angularCompilerOptions['disableTypeScriptVersionCheck'] || userConfig.angularCompilerOptions.disableTypeScriptVersionCheck; angularCompilerOptions['i18nOutLocale'] = angularCompilerOptions['i18nOutLocale'] || userConfig.angularCompilerOptions.i18nOutLocale; angularCompilerOptions['i18nOutFormat'] = angularCompilerOptions['i18nOutFormat'] || userConfig.angularCompilerOptions.i18nOutFormat; angularCompilerOptions['i18nOutFile'] = angularCompilerOptions['i18nOutFile'] || userConfig.angularCompilerOptions.i18nOutFile; angularCompilerOptions['i18nInFormat'] = angularCompilerOptions['i18nInFormat'] || userConfig.angularCompilerOptions.i18nInFormat; angularCompilerOptions['i18nInLocale'] = angularCompilerOptions['i18nInLocale'] || userConfig.angularCompilerOptions.i18nInLocale; angularCompilerOptions['i18nInFile'] = angularCompilerOptions['i18nInFile'] || userConfig.angularCompilerOptions.i18nInFile; angularCompilerOptions['i18nInMissingTranslations'] = angularCompilerOptions['i18nInMissingTranslations'] || userConfig.angularCompilerOptions.i18nInMissingTranslations; angularCompilerOptions['i18nUseExternalIds'] = angularCompilerOptions['i18nUseExternalIds'] || userConfig.angularCompilerOptions.i18nUseExternalIds; angularCompilerOptions['preserveWhitespaces'] = angularCompilerOptions['preserveWhitespaces'] || userConfig.angularCompilerOptions.preserveWhitespaces; angularCompilerOptions.createExternalSymbolFactoryReexports = angularCompilerOptions.createExternalSymbolFactoryReexports || userConfig.angularCompilerOptions.createExternalSymbolFactoryReexports; } } // These are options passed through from the `ng_module` rule which aren't supported // by the `@angular/compiler-cli` and are only intended for `ngc-wrapped`. const {expectedOut, _useManifestPathsAsModuleName} = config['angularCompilerOptions']; const {basePath} = ng.calcProjectFileAndBasePath(project); const compilerOpts = ng.createNgCompilerOptions(basePath, config, tsOptions); const tsHost = ts.createCompilerHost(compilerOpts, true); const {diagnostics} = compile({ allDepsCompiledWithBazel: ALL_DEPS_COMPILED_WITH_BAZEL, useManifestPathsAsModuleName: _useManifestPathsAsModuleName, expectedOuts: expectedOut, compilerOpts, tsHost, bazelOpts, files, inputs, }); if (diagnostics.length) { console.error(ng.formatDiagnostics(diagnostics)); } return diagnostics.every(d => d.category !== ts.DiagnosticCategory.Error); } export function relativeToRootDirs(filePath: string, rootDirs: string[]): string { if (!filePath) return filePath; // NB: the rootDirs should have been sorted longest-first for (let i = 0; i < rootDirs.length; i++) { const dir = rootDirs[i]; const rel = path.posix.relative(dir, filePath); if (rel.indexOf('.') != 0) return rel; } return filePath; } export function compile({ allDepsCompiledWithBazel = true, useManifestPathsAsModuleName, compilerOpts, tsHost, bazelOpts, files, inputs, expectedOuts, gatherDiagnostics, bazelHost }: { allDepsCompiledWithBazel?: boolean, useManifestPathsAsModuleName?: boolean, compilerOpts: ng.CompilerOptions, tsHost: ts.CompilerHost, inputs?: {[path: string]: string}, bazelOpts: BazelOptions, files: string[], expectedOuts: string[], gatherDiagnostics?: (program: ng.Program) => ng.Diagnostics, bazelHost?: CompilerHost, }): {diagnostics: ng.Diagnostics, program: ng.Program} { let fileLoader: FileLoader; if (bazelOpts.maxCacheSizeMb !== undefined) { const maxCacheSizeBytes = bazelOpts.maxCacheSizeMb * (1 << 20); fileCache.setMaxCacheSize(maxCacheSizeBytes); } else { fileCache.resetMaxCacheSize(); } if (inputs) { fileLoader = new CachedFileLoader(fileCache); // Resolve the inputs to absolute paths to match TypeScript internals const resolvedInputs = new Map(); const inputKeys = Object.keys(inputs); for (let i = 0; i < inputKeys.length; i++) { const key = inputKeys[i]; resolvedInputs.set(resolveNormalizedPath(key), inputs[key]); } fileCache.updateCache(resolvedInputs); } else { fileLoader = new UncachedFileLoader(); } if (!bazelOpts.es5Mode) { if (bazelOpts.workspaceName === 'google3') { compilerOpts.annotateForClosureCompiler = true; compilerOpts.annotationsAs = 'static fields'; } else { compilerOpts.annotateForClosureCompiler = false; compilerOpts.annotationsAs = 'decorators'; } } // Detect from compilerOpts whether the entrypoint is being invoked in Ivy mode. const isInIvyMode = !!compilerOpts.enableIvy; // Disable downleveling and Closure annotation if in Ivy mode. if (isInIvyMode) { compilerOpts.annotationsAs = 'decorators'; } if (!compilerOpts.rootDirs) { throw new Error('rootDirs is not set!'); } const bazelBin = compilerOpts.rootDirs.find(rootDir => BAZEL_BIN.test(rootDir)); if (!bazelBin) { throw new Error(`Couldn't find bazel bin in the rootDirs: ${compilerOpts.rootDirs}`); } const expectedOutsSet = new Set(expectedOuts.map(p => convertToForwardSlashPath(p))); const originalWriteFile = tsHost.writeFile.bind(tsHost); tsHost.writeFile = (fileName: string, content: string, writeByteOrderMark: boolean, onError?: (message: string) => void, sourceFiles?: ts.SourceFile[]) => { const relative = relativeToRootDirs(convertToForwardSlashPath(fileName), [compilerOpts.rootDir]); if (expectedOutsSet.has(relative)) { expectedOutsSet.delete(relative); originalWriteFile(fileName, content, writeByteOrderMark, onError, sourceFiles); } }; // Patch fileExists when resolving modules, so that CompilerHost can ask TypeScript to // resolve non-existing generated files that don't exist on disk, but are // synthetic and added to the `programWithStubs` based on real inputs. const generatedFileModuleResolverHost = Object.create(tsHost); generatedFileModuleResolverHost.fileExists = (fileName: string) => { const match = NGC_GEN_FILES.exec(fileName); if (match) { const [, file, suffix, ext] = match; // Performance: skip looking for files other than .d.ts or .ts if (ext !== '.ts' && ext !== '.d.ts') return false; if (suffix.indexOf('ngstyle') >= 0) { // Look for foo.css on disk fileName = file; } else { // Look for foo.d.ts or foo.ts on disk fileName = file + (ext || ''); } } return tsHost.fileExists(fileName); }; function generatedFileModuleResolver( moduleName: string, containingFile: string, compilerOptions: ts.CompilerOptions): ts.ResolvedModuleWithFailedLookupLocations { return ts.resolveModuleName( moduleName, containingFile, compilerOptions, generatedFileModuleResolverHost); } if (!bazelHost) { bazelHost = new CompilerHost( files, compilerOpts, bazelOpts, tsHost, fileLoader, generatedFileModuleResolver); } if (isInIvyMode) { // Also need to disable decorator downleveling in the BazelHost in Ivy mode. bazelHost.transformDecorators = false; const delegate = bazelHost.shouldSkipTsickleProcessing.bind(bazelHost); bazelHost.shouldSkipTsickleProcessing = (fileName: string) => { // The base implementation of shouldSkipTsickleProcessing checks whether `fileName` is part of // the original `srcs[]`. For Angular (Ivy) compilations, ngfactory/ngsummary files that are // shims for original .ts files in the program should be treated identically. Thus, strip the // '.ngfactory' or '.ngsummary' part of the filename away before calling the delegate. return delegate(fileName.replace(/\.(ngfactory|ngsummary)\.ts$/, '.ts')); }; } // Prevent tsickle adding any types at all if we don't want closure compiler annotations. if (compilerOpts.annotateForClosureCompiler) { bazelHost.transformTypesToClosure = true; bazelHost.transformDecorators = true; } const origBazelHostFileExist = bazelHost.fileExists; bazelHost.fileExists = (fileName: string) => { if (NGC_ASSETS.test(fileName)) { return tsHost.fileExists(fileName); } return origBazelHostFileExist.call(bazelHost, fileName); }; const origBazelHostShouldNameModule = bazelHost.shouldNameModule.bind(bazelHost); bazelHost.shouldNameModule = (fileName: string) => { const flatModuleOutPath = path.posix.join(bazelOpts.package, compilerOpts.flatModuleOutFile + '.ts'); // The bundle index file is synthesized in bundle_index_host so it's not in the // compilationTargetSrc. // However we still want to give it an AMD module name for devmode. // We can't easily tell which file is the synthetic one, so we build up the path we expect // it to have and compare against that. if (fileName === path.posix.join(compilerOpts.baseUrl, flatModuleOutPath)) return true; // Also handle the case the target is in an external repository. // Pull the workspace name from the target which is formatted as `@wksp//package:target` // if it the target is from an external workspace. If the target is from the local // workspace then it will be formatted as `//package:target`. const targetWorkspace = bazelOpts.target.split('/')[0].replace(/^@/, ''); if (targetWorkspace && fileName === path.posix.join(compilerOpts.baseUrl, 'external', targetWorkspace, flatModuleOutPath)) return true; return origBazelHostShouldNameModule(fileName) || NGC_GEN_FILES.test(fileName); }; const ngHost = ng.createCompilerHost({options: compilerOpts, tsHost: bazelHost}); const fileNameToModuleNameCache = new Map(); ngHost.fileNameToModuleName = (importedFilePath: string, containingFilePath?: string) => { const cacheKey = `${importedFilePath}:${containingFilePath}`; // Memoize this lookup to avoid expensive re-parses of the same file // When run as a worker, the actual ts.SourceFile is cached // but when we don't run as a worker, there is no cache. // For one example target in g3, we saw a cache hit rate of 7590/7695 if (fileNameToModuleNameCache.has(cacheKey)) { return fileNameToModuleNameCache.get(cacheKey); } const result = doFileNameToModuleName(importedFilePath, containingFilePath); fileNameToModuleNameCache.set(cacheKey, result); return result; }; function doFileNameToModuleName(importedFilePath: string, containingFilePath?: string): string { const relativeTargetPath = relativeToRootDirs(importedFilePath, compilerOpts.rootDirs).replace(EXT, ''); const manifestTargetPath = `${bazelOpts.workspaceName}/${relativeTargetPath}`; if (useManifestPathsAsModuleName === true) { return manifestTargetPath; } // Unless manifest paths are explicitly enforced, we initially check if a module name is // set for the given source file. The compiler host from `@bazel/typescript` sets source // file module names if the compilation targets either UMD or AMD. To ensure that the AMD // module names match, we first consider those. try { const sourceFile = ngHost.getSourceFile(importedFilePath, ts.ScriptTarget.Latest); if (sourceFile && sourceFile.moduleName) { return sourceFile.moduleName; } } catch (err) { // File does not exist or parse error. Ignore this case and continue onto the // other methods of resolving the module below. } // It can happen that the ViewEngine compiler needs to write an import in a factory file, // and is using an ngsummary file to get the symbols. // The ngsummary comes from an upstream ng_module rule. // The upstream rule based its imports on ngsummary file which was generated from a // metadata.json file that was published to npm in an Angular library. // However, the ngsummary doesn't propagate the 'importAs' from the original metadata.json // so we would normally not be able to supply the correct module name for it. // For example, if the rootDir-relative filePath is // node_modules/@angular/material/toolbar/typings/index // we would supply a module name // @angular/material/toolbar/typings/index // but there is no JavaScript file to load at this path. // This is a workaround for https://github.com/angular/angular/issues/29454 if (importedFilePath.indexOf('node_modules') >= 0) { const maybeMetadataFile = importedFilePath.replace(EXT, '') + '.metadata.json'; if (fs.existsSync(maybeMetadataFile)) { const moduleName = JSON.parse(fs.readFileSync(maybeMetadataFile, {encoding: 'utf-8'})).importAs; if (moduleName) { return moduleName; } } } if ((compilerOpts.module === ts.ModuleKind.UMD || compilerOpts.module === ts.ModuleKind.AMD) && ngHost.amdModuleName) { return ngHost.amdModuleName({fileName: importedFilePath} as ts.SourceFile); } // If no AMD module name has been set for the source file by the `@bazel/typescript` compiler // host, and the target file is not part of a flat module node module package, we use the // following rules (in order): // 1. If target file is part of `node_modules/`, we use the package module name. // 2. If no containing file is specified, or the target file is part of a different // compilation unit, we use a Bazel manifest path. Relative paths are not possible // since we don't have a containing file, and the target file could be located in the // output directory, or in an external Bazel repository. // 3. If both rules above didn't match, we compute a relative path between the source files // since they are part of the same compilation unit. // Note that we don't want to always use (2) because it could mean that compilation outputs // are always leaking Bazel-specific paths, and the output is not self-contained. This could // break `esm2015` or `esm5` output for Angular package release output // Omit the `node_modules` prefix if the module name of an NPM package is requested. if (relativeTargetPath.startsWith(NODE_MODULES)) { return relativeTargetPath.substr(NODE_MODULES.length); } else if ( containingFilePath == null || !bazelOpts.compilationTargetSrc.includes(importedFilePath)) { return manifestTargetPath; } const containingFileDir = path.dirname(relativeToRootDirs(containingFilePath, compilerOpts.rootDirs)); const relativeImportPath = path.posix.relative(containingFileDir, relativeTargetPath); return relativeImportPath.startsWith('.') ? relativeImportPath : `./${relativeImportPath}`; } ngHost.toSummaryFileName = (fileName: string, referringSrcFileName: string) => path.posix.join( bazelOpts.workspaceName, relativeToRootDirs(fileName, compilerOpts.rootDirs).replace(EXT, '')); if (allDepsCompiledWithBazel) { // Note: The default implementation would work as well, // but we can be faster as we know how `toSummaryFileName` works. // Note: We can't do this if some deps have been compiled with the command line, // as that has a different implementation of fromSummaryFileName / toSummaryFileName ngHost.fromSummaryFileName = (fileName: string, referringLibFileName: string) => { const workspaceRelative = fileName.split('/').splice(1).join('/'); return resolveNormalizedPath(bazelBin, workspaceRelative) + '.d.ts'; }; } // Patch a property on the ngHost that allows the resourceNameToModuleName function to // report better errors. (ngHost as any).reportMissingResource = (resourceName: string) => { console.error(`\nAsset not found:\n ${resourceName}`); console.error('Check that it\'s included in the `assets` attribute of the `ng_module` rule.\n'); }; const emitCallback: ng.TsEmitCallback = ({ program, targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, customTransformers = {}, }) => tsickle.emitWithTsickle( program, bazelHost, bazelHost, compilerOpts, targetSourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, { beforeTs: customTransformers.before, afterTs: customTransformers.after, afterDeclarations: customTransformers.afterDeclarations, }); if (!gatherDiagnostics) { gatherDiagnostics = (program) => gatherDiagnosticsForInputsOnly(compilerOpts, bazelOpts, program); } const {diagnostics, emitResult, program} = ng.performCompilation({ rootNames: files, options: compilerOpts, host: ngHost, emitCallback, mergeEmitResultsCallback: tsickle.mergeEmitResults, gatherDiagnostics }); const tsickleEmitResult = emitResult as tsickle.EmitResult; let externs = '/** @externs */\n'; if (!diagnostics.length) { if (bazelOpts.tsickleGenerateExterns) { externs += tsickle.getGeneratedExterns(tsickleEmitResult.externs); } if (bazelOpts.manifest) { const manifest = constructManifest(tsickleEmitResult.modulesManifest, bazelHost); fs.writeFileSync(bazelOpts.manifest, manifest); } } // If compilation fails unexpectedly, performCompilation returns no program. // Make sure not to crash but report the diagnostics. if (!program) return {program, diagnostics}; if (!bazelOpts.nodeModulesPrefix) { // If there is no node modules, then metadata.json should be emitted since // there is no other way to obtain the information generateMetadataJson(program.getTsProgram(), files, compilerOpts.rootDirs, bazelBin, tsHost); } if (bazelOpts.tsickleExternsPath) { // Note: when tsickleExternsPath is provided, we always write a file as a // marker that compilation succeeded, even if it's empty (just containing an // @externs). fs.writeFileSync(bazelOpts.tsickleExternsPath, externs); } // There might be some expected output files that are not written by the // compiler. In this case, just write an empty file. for (const fileName of expectedOutsSet) { originalWriteFile(fileName, '', false); } return {program, diagnostics}; } /** * Generate metadata.json for the specified `files`. By default, metadata.json * is only generated by the compiler if --flatModuleOutFile is specified. But * if compiled under blaze, we want the metadata to be generated for each * Angular component. */ function generateMetadataJson( program: ts.Program, files: string[], rootDirs: string[], bazelBin: string, tsHost: ts.CompilerHost) { const collector = new ng.MetadataCollector(); for (let i = 0; i < files.length; i++) { const file = files[i]; const sourceFile = program.getSourceFile(file); if (sourceFile) { const metadata = collector.getMetadata(sourceFile); if (metadata) { const relative = relativeToRootDirs(file, rootDirs); const shortPath = relative.replace(EXT, '.metadata.json'); const outFile = resolveNormalizedPath(bazelBin, shortPath); const data = JSON.stringify(metadata); tsHost.writeFile(outFile, data, false, undefined, []); } } } } function isCompilationTarget(bazelOpts: BazelOptions, sf: ts.SourceFile): boolean { return !NGC_GEN_FILES.test(sf.fileName) && (bazelOpts.compilationTargetSrc.indexOf(sf.fileName) !== -1); } function convertToForwardSlashPath(filePath: string): string { return filePath.replace(/\\/g, '/'); } function gatherDiagnosticsForInputsOnly( options: ng.CompilerOptions, bazelOpts: BazelOptions, ngProgram: ng.Program): (ng.Diagnostic|ts.Diagnostic)[] { const tsProgram = ngProgram.getTsProgram(); const diagnostics: (ng.Diagnostic|ts.Diagnostic)[] = []; // These checks mirror ts.getPreEmitDiagnostics, with the important // exception of avoiding b/30708240, which is that if you call // program.getDeclarationDiagnostics() it somehow corrupts the emit. diagnostics.push(...tsProgram.getOptionsDiagnostics()); diagnostics.push(...tsProgram.getGlobalDiagnostics()); const programFiles = tsProgram.getSourceFiles().filter(f => isCompilationTarget(bazelOpts, f)); for (let i = 0; i < programFiles.length; i++) { const sf = programFiles[i]; // Note: We only get the diagnostics for individual files // to e.g. not check libraries. diagnostics.push(...tsProgram.getSyntacticDiagnostics(sf)); diagnostics.push(...tsProgram.getSemanticDiagnostics(sf)); } if (!diagnostics.length) { // only gather the angular diagnostics if we have no diagnostics // in any other files. diagnostics.push(...ngProgram.getNgStructuralDiagnostics()); diagnostics.push(...ngProgram.getNgSemanticDiagnostics()); } return diagnostics; } if (require.main === module) { process.exitCode = main(process.argv.slice(2)); }