'use strict'; const Plugin = require('broccoli-caching-writer'); const fs = require('fs'); const fse = require('fs-extra'); const path = require('path'); const ts = require('typescript'); const FS_OPTS = { encoding: 'utf-8' }; /** * Broccoli plugin that implements incremental Typescript compiler. * * It instantiates a typescript compiler instance that keeps all the state about the project and * can re-emit only the files that actually changed. * * Limitations: only files that map directly to the changed source file via naming conventions are * re-emitted. This primarily affects code that uses `const enum`s, because changing the enum value * requires global emit, which can affect many files. */ class BroccoliTypeScriptCompiler extends Plugin { constructor(inputPath, options) { super([inputPath], {}); this._fileRegistry = Object.create(null); this._rootfilePaths = []; this._tsOpts = null; this._tsServiceHost = null; this._tsService = null; this._options = options; if (options.files) { this._rootFilePaths = options.files.splice(0); } } build() { if (!this._tsServiceHost) { this._createServiceHost(); this._doFullBuild(); } else { this._doIncrementalBuild(); } } _doIncrementalBuild() { var pathsWithErrors = []; var errorMessages = []; var entries = this.listEntries(); const inputPath = this.inputPaths[0]; const pathsToEmit = []; entries.forEach(entry => { const tsFilePath = path.join(inputPath, entry.relativePath); if (!tsFilePath.match(/\.ts$/) || !fs.existsSync(tsFilePath)) { return; } if (!this._fileRegistry[tsFilePath]) { // Not in the registry? Add it. this._addNewFileEntry(entry); // We need to add the file to the rootFiles as well, as otherwise it _might_ // not get compiled. It needs to be referenced at some point, and unless we // add the spec files first (which we don't know the order), it won't. // So make every new files an entry point instead. // TODO(hansl): We need to investigate if we should emit files that are not // referenced. This doesn't take that into account. this._tsServiceHost.fileNames.push(tsFilePath); pathsToEmit.push(tsFilePath); } else if (this._fileRegistry[tsFilePath].version >= entry.mtime) { // Nothing to do for this file. Just link the cached outputs. this._fileRegistry[tsFilePath].outputs.forEach(absoluteFilePath => { const outputFilePath = absoluteFilePath.replace(this.cachePath, this.outputPath); fse.mkdirsSync(path.dirname(outputFilePath)); fs.linkSync(absoluteFilePath, outputFilePath); }); return; } else { this._fileRegistry[tsFilePath].version = entry.mtime; pathsToEmit.push(tsFilePath); } }); if (pathsToEmit.length > 0) { // Force the TS Service to recreate the program (ie. call synchronizeHostData). this._tsServiceHost.projectVersion++; pathsToEmit.forEach(tsFilePath => { var output = this._tsService.getEmitOutput(tsFilePath); if (output.emitSkipped) { var errorFound = this.collectErrors(tsFilePath); if (errorFound) { pathsWithErrors.push(tsFilePath); errorMessages.push(errorFound); } } else { output.outputFiles.forEach(o => { this._outputFile(o.name, o.text, this._fileRegistry[tsFilePath]); }); } }); } if (pathsWithErrors.length) { this.previousRunFailed = true; var error = new Error('Typescript found the following errors:\n' + errorMessages.join('\n')); error['showStack'] = false; throw error; } else if (this.previousRunFailed) { this._doFullBuild(); } } _createServiceHost() { // the conversion is a bit awkward, see https://github.com/Microsoft/TypeScript/issues/5276 // in 1.8 use convertCompilerOptionsFromJson this._tsOpts = ts.parseJsonConfigFileContent(this._options, null, null).options; this._tsOpts.rootDir = ''; this._tsOpts.outDir = ''; this._tsServiceHost = new CustomLanguageServiceHost( this._tsOpts, this._rootFilePaths, this._fileRegistry, this.inputPaths[0]); this._tsService = ts.createLanguageService(this._tsServiceHost, ts.createDocumentRegistry()); } collectErrors(tsFilePath) { var allDiagnostics = this._tsService.getCompilerOptionsDiagnostics() .concat(this._tsService.getSyntacticDiagnostics(tsFilePath)) .concat(this._tsService.getSemanticDiagnostics(tsFilePath)); var errors = []; allDiagnostics.forEach(diagnostic => { var message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); if (diagnostic.file) { var _a = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start), line = _a.line, character = _a.character; errors.push(' ' + diagnostic.file.fileName + ' (' + (line + 1) + ',' + (character + 1) + '): ' + message); } else { errors.push(' Error: ' + message); } }); if (errors.length) { return errors.join('\n'); } } _doFullBuild() { var program = this._tsService.getProgram(); // Add file registry. var allFiles = program.getSourceFiles(); // Find the entry, update the registry. let allEntries = this.listEntries(); allEntries.forEach(entry => this._addNewFileEntry(entry)); allFiles.forEach(sourceFile => { const registry = this._fileRegistry[path.resolve(this.inputPaths[0], sourceFile.fileName)]; const emitResult = program.emit(sourceFile, (absoluteFilePath, fileContent) => { this._outputFile(absoluteFilePath, fileContent, registry); }); if (emitResult.emitSkipped) { var allDiagnostics = ts.getPreEmitDiagnostics(program).concat(emitResult.diagnostics); var errorMessages_1 = []; allDiagnostics.forEach(function (diagnostic) { var pos = ''; if (diagnostic.file) { var _a = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start), line = _a.line, character = _a.character; pos = diagnostic.file.fileName + ' (' + (line + 1) + ', ' + (character + 1) + '): '; } var message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); errorMessages_1.push(' ' + pos + message); }); if (errorMessages_1.length) { this.previousRunFailed = true; var error = new Error('Typescript found the following errors:\n' + errorMessages_1.join('\n')); error['showStack'] = false; throw error; } else { this.previousRunFailed = false; } } }); } _outputFile(absoluteFilePath, fileContent, registry) { absoluteFilePath = path.resolve(this.cachePath, absoluteFilePath); // Replace the input path by the output. absoluteFilePath = absoluteFilePath.replace(this.inputPaths[0], this.cachePath); const outputFilePath = absoluteFilePath.replace(this.cachePath, this.outputPath); if (registry) { registry.outputs.add(absoluteFilePath); } fse.mkdirsSync(path.dirname(absoluteFilePath)); const content = this.fixSourceMapSources(fileContent); fs.writeFileSync(absoluteFilePath, content, FS_OPTS); fse.mkdirsSync(path.dirname(outputFilePath)); fs.linkSync(absoluteFilePath, outputFilePath); } _addNewFileEntry(entry) { const p = path.join(this.inputPaths[0], entry.relativePath); if (this._fileRegistry[p]) { throw new Error('Trying to add a new entry to an already existing one: ' + p); } this._fileRegistry[p] = { version: entry.mtime, outputs: new Set() }; } /** * There is a bug in TypeScript 1.6, where the sourceRoot and inlineSourceMap properties * are exclusive. This means that the sources property always contains relative paths * (e.g, ../../../../angular2/src/di/injector.ts). * * Here, we normalize the sources property and remove the ../../../ * * This issue is fixed in https://github.com/Microsoft/TypeScript/pull/5620. * Once we switch to TypeScript 1.8, we can remove this method. */ fixSourceMapSources(content) { try { var marker = '//# sourceMappingURL=data:application/json;base64,'; var index = content.indexOf(marker); if (index == -1) return content; var base = content.substring(0, index + marker.length); var sourceMapBit = new Buffer(content.substring(index + marker.length), 'base64').toString('utf8'); var sourceMaps = JSON.parse(sourceMapBit); var source = sourceMaps.sources[0]; sourceMaps.sources = [source.substring(source.lastIndexOf('../') + 3)]; return '' + base + new Buffer(JSON.stringify(sourceMaps)).toString('base64'); } catch (e) { return content; } } } class CustomLanguageServiceHost { constructor(compilerOptions, fileNames, fileRegistry, treeInputPath) { this.compilerOptions = compilerOptions; this.fileNames = fileNames; this.fileRegistry = fileRegistry; this.treeInputPath = treeInputPath; this.currentDirectory = treeInputPath; this.defaultLibFilePath = ts.getDefaultLibFilePath(compilerOptions).replace(/\\/g, '/'); this.projectVersion = 0; } getScriptFileNames() { return this.fileNames; } getScriptVersion(fileName) { fileName = path.resolve(this.treeInputPath, fileName); return this.fileRegistry[fileName] && this.fileRegistry[fileName].version.toString(); } getProjectVersion() { return this.projectVersion.toString(); } /** * This method is called quite a bit to lookup 3 kinds of paths: * 1/ files in the fileRegistry * - these are the files in our project that we are watching for changes * - in the future we could add caching for these files and invalidate the cache when * the file is changed lazily during lookup * 2/ .d.ts and library files not in the fileRegistry * - these are not our files, they come from tsd or typescript itself * - these files change only rarely but since we need them very rarely, it's not worth the * cache invalidation hassle to cache them * 3/ bogus paths that typescript compiler tries to lookup during import resolution * - these paths are tricky to cache since files come and go and paths that was bogus in the * past might not be bogus later * * In the initial experiments the impact of this caching was insignificant (single digit %) and * not worth the potential issues with stale cache records. */ getScriptSnapshot(tsFilePath) { var absoluteTsFilePath; if (tsFilePath == this.defaultLibFilePath || path.isAbsolute(tsFilePath)) { absoluteTsFilePath = tsFilePath; } else if (this.compilerOptions.moduleResolution === 2 /* NodeJs */ && tsFilePath.match(/^node_modules/)) { absoluteTsFilePath = path.resolve(tsFilePath); } else if (tsFilePath.match(/^rxjs/)) { absoluteTsFilePath = path.resolve('node_modules', tsFilePath); } else { absoluteTsFilePath = path.join(this.treeInputPath, tsFilePath); } if (!fs.existsSync(absoluteTsFilePath)) { // TypeScript seems to request lots of bogus paths during import path lookup and resolution, // so we we just return undefined when the path is not correct. return undefined; } return ts.ScriptSnapshot.fromString(fs.readFileSync(absoluteTsFilePath, FS_OPTS)); } getCurrentDirectory() { return this.currentDirectory; } getCompilationSettings() { return this.compilerOptions; } getDefaultLibFileName(/* options */) { // ignore options argument, options should not change during the lifetime of the plugin return this.defaultLibFilePath; } } module.exports = BroccoliTypeScriptCompiler;