diff --git a/addon/.gitkeep b/addon/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/.gitkeep b/app/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/index.js b/index.js index 2a7c510a9..20bd3fb42 100644 --- a/index.js +++ b/index.js @@ -1,155 +1,42 @@ // @ts-check /* eslint-env node */ -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const SilentError = require('silent-error'); -const TsPreprocessor = require('./lib/typescript-preprocessor'); -const buildServeCommand = require('./lib/serve-ts'); -const funnel = require('broccoli-funnel'); -const mergeTrees = require('broccoli-merge-trees'); -const mkdirp = require('mkdirp'); +const IncrementalTypescriptCompiler = require('./lib/incremental-typescript-compiler'); module.exports = { name: 'ember-cli-typescript', - _isRunningServeTS() { - return this.project._isRunningServeTS; - }, - - _tempDir() { - if (!this.project._tsTempDir) { - const tempDir = path.join(os.tmpdir(), `e-c-ts-${process.pid}`); - this.project._tsTempDir = tempDir; - mkdirp.sync(tempDir); - } - - return this.project._tsTempDir; - }, - - _inRepoAddons() { - const pkg = this.project.pkg; - if (!pkg || !pkg['ember-addon'] || !pkg['ember-addon'].paths) { - return []; - } - - return pkg['ember-addon'].paths; - }, - - includedCommands() { - return { - 'serve-ts': buildServeCommand(this.project, this._tempDir()), - }; - }, - - // Stolen from ember-cli-mirage. - included() { - let app; - - // If the addon has the _findHost() method (in ember-cli >= 2.7.0), we'll just - // use that. - if (typeof this._findHost === 'function') { - app = this._findHost(); - } else { - // Otherwise, we'll use this implementation borrowed from the _findHost() - // method in ember-cli. - let current = this; - do { - app = current.app || app; - } while (current.parent.parent && (current = current.parent)); - } - - this.app = app; - + included(includer) { this._super.included.apply(this, arguments); - }, - treeForApp(tree) { - const { include } = JSON.parse( - fs.readFileSync(path.resolve(this.app.project.root, 'tsconfig.json'), { encoding: 'utf8' }) - ); - - const includes = ['types'] - .concat(include ? include : []) - .reduce((unique, entry) => (unique.indexOf(entry) === -1 ? unique.concat(entry) : unique), []) - .map(p => path.resolve(this.app.project.root, p)) - .filter(fs.existsSync); - - const additionalTrees = includes.map(p => funnel(p, { destDir: p })); - - if (!this._isRunningServeTS()) { - return mergeTrees([tree, ...additionalTrees]); + if (includer === this.app) { + this.compiler = new IncrementalTypescriptCompiler(this.project); + this.compiler.launch(); } - - const roots = ['.', ...includes, ...this._inRepoAddons()].map(root => path.join(root, 'app/')); - - // funnel will fail if the directory doesn't exist - roots.forEach(root => { - mkdirp.sync(path.join(this._tempDir(), root)); - }); - - const ts = funnel(this._tempDir(), { - exclude: ['tests'], - getDestinationPath(relativePath) { - const prefix = roots.find(root => relativePath.startsWith(root)); - if (prefix) { - // strip any app/ or lib/in-repo-addon/app/ prefix - return relativePath.substr(prefix.length); - } - - return relativePath; - }, - }); - - return mergeTrees([tree, ts]); }, - treeForTestSupport(tree) { - if (!this._isRunningServeTS()) { - return tree; + treeForApp() { + if (this.compiler) { + let tree = this.compiler.treeForApp(); + return this._super.treeForApp.call(this, tree); } - - const tests = path.join(this._tempDir(), 'tests'); - - // funnel will fail if the directory doesn't exist - mkdirp.sync(tests); - - const ts = funnel(tests); - return tree ? mergeTrees([tree, ts]) : ts; }, - setupPreprocessorRegistry(type, registry) { - if (!fs.existsSync(path.join(this.project.root, 'tsconfig.json'))) { - // Do nothing; we just won't have the plugin available. This means that if you - // somehow end up in a state where it doesn't load, the preprocessor *will* - // fail, but this is necessary because the preprocessor depends on packages - // which aren't installed until the default blueprint is run - - this.ui.writeInfoLine( - 'Skipping TypeScript preprocessing as there is no tsconfig.json. ' + - '(If this is during installation of the add-on, this is as expected. If it is ' + - 'while building, serving, or testing the application, this is an error.)' - ); - return; - } - - if (type === 'self' || this._isRunningServeTS()) { - // TODO: still need to compile TS addons - return; + treeForAddon() { + if (this.compiler) { + // We manually invoke Babel here rather than calling _super because we're returning + // content on behalf of addons that aren't ember-cli-typescript, and the _super impl + // would namespace all the files under our own name. + let babel = this.project.addons.find(addon => addon.name === 'ember-cli-babel'); + let tree = this.compiler.treeForAddons(); + return babel.transpileTree(tree); } + }, - try { - registry.add( - 'js', - new TsPreprocessor({ - ui: this.ui, - }) - ); - } catch (ex) { - throw new SilentError( - `Failed to instantiate TypeScript preprocessor, probably due to an invalid tsconfig.json. Please fix or run \`ember generate ember-cli-typescript\`.\n${ex}` - ); + treeForTestSupport() { + if (this.compiler) { + let tree = this.compiler.treeForTests(); + return this._super.treeForTestSupport.call(this, tree); } - }, + } }; diff --git a/lib/incremental-typescript-compiler.js b/lib/incremental-typescript-compiler.js new file mode 100644 index 000000000..c2d9bfd51 --- /dev/null +++ b/lib/incremental-typescript-compiler.js @@ -0,0 +1,174 @@ +/* eslint-env node */ + +const execa = require('execa'); +const os = require('os'); +const mkdirp = require('mkdirp'); +const Funnel = require('broccoli-funnel'); +const MergeTrees = require('broccoli-merge-trees'); +const symlinkOrCopy = require('symlink-or-copy'); +const Plugin = require('broccoli-plugin'); +const RSVP = require('rsvp'); +const path = require('path'); +const fs = require('fs'); +const resolve = require('resolve'); + +module.exports = class IncrementalTypescriptCompiler { + constructor(project) { + if (project._incrementalTsCompiler) { + throw new Error( + 'Multiple IncrementalTypescriptCompiler instances may not be used with the same project.' + ); + } + + project._incrementalTsCompiler = this; + + this.project = project; + this.addons = this._discoverAddons(project, []); + this.maxBuildCount = 1; + + this._buildDeferred = RSVP.defer(); + this._isSynced = false; + } + + treeForApp() { + // This could be more efficient, but we can hopefully assume there won't be dozens + // or hundreds of TS addons in dev mode all at once. + let addonAppTrees = this.addons.map(addon => { + return new TypescriptOutput(this, { + [`${this._relativeAddonRoot(addon)}/app`]: 'app', + }); + }); + + let appTree = new TypescriptOutput(this, { app: 'app' }); + let tree = new MergeTrees([...addonAppTrees, appTree], { overwrite: true }); + + return new Funnel(tree, { srcDir: 'app' }); + } + + treeForAddons() { + let paths = {}; + for (let addon of this.addons) { + paths[`${this._relativeAddonRoot(addon)}/addon`] = addon.name; + } + return new TypescriptOutput(this, paths); + } + + treeForTests() { + return new TypescriptOutput(this, { tests: 'tests' }); + } + + buildPromise() { + return this._buildDeferred.promise; + } + + outDir() { + if (!this._outDir) { + let outDir = path.join(os.tmpdir(), `e-c-ts-${process.pid}`); + this._outDir = outDir; + mkdirp.sync(outDir); + } + + return this._outDir; + } + + launch() { + if (!fs.existsSync(`${this.project.root}/tsconfig.json`)) { + this.project.ui.writeWarnLine('No tsconfig.json found; skipping TypeScript compilation.'); + return; + } + + // argument sequence here is meaningful; don't apply prettier. + // prettier-ignore + let tsc = execa('tsc', [ + '--watch', + '--outDir', this.outDir(), + '--rootDir', this.project.root, + '--allowJs', 'false', + '--noEmit', 'false', + ]); + + tsc.stdout.on('data', data => { + this.project.ui.writeLine(data.toString().trim()); + + if (data.indexOf('Starting incremental compilation') !== -1) { + this.willRebuild(); + } + + if (data.indexOf('Compilation complete') !== -1) { + this._buildDeferred.resolve(); + this._isSynced = true; + } + }); + + tsc.stderr.on('data', data => { + this.project.ui.writeErrorLine(data.toString().trim()); + }); + } + + willRebuild() { + if (this._isSynced) { + this._isSynced = false; + this._buildDeferred = RSVP.defer(); + } + } + + _discoverAddons(node, addons) { + for (let addon of node.addons) { + let devDeps = addon.pkg.devDependencies || {}; + let deps = addon.pkg.dependencies || {}; + if ( + ('ember-cli-typescript' in deps || 'ember-cli-typescript' in devDeps) && + addon.isDevelopingAddon() + ) { + addons.push(addon); + } + this._discoverAddons(addon, addons); + } + return addons; + } + + _relativeAddonRoot(addon) { + let addonRoot = addon.root; + if (addonRoot.indexOf(this.project.root) !== 0) { + let packagePath = resolve.sync(`${addon.pkg.name}/package.json`, { + basedir: this.project.root, + }); + addonRoot = path.dirname(packagePath); + } + + return addonRoot.replace(this.project.root, ''); + } +}; + +class TypescriptOutput extends Plugin { + constructor(compiler, paths) { + super([]); + this.compiler = compiler; + this.paths = paths; + this.buildCount = 0; + } + + build() { + this.buildCount++; + + // We use this to keep track of the build state between the various + // Broccoli trees and tsc; when either tsc or broccoli notices a file + // change, we immediately invalidate the previous build output. + if (this.buildCount > this.compiler.maxBuildCount) { + this.compiler.maxBuildCount = this.buildCount; + this.compiler.willRebuild(); + } + + return this.compiler.buildPromise().then(() => { + for (let relativeSrc of Object.keys(this.paths)) { + let src = `${this.compiler.outDir()}/${relativeSrc}`; + let dest = `${this.outputPath}/${this.paths[relativeSrc]}`; + if (fs.existsSync(src)) { + symlinkOrCopy.sync(src, dest); + } else { + mkdirp.sync(dest); + } + } + }); + } +} diff --git a/lib/serve-ts.js b/lib/serve-ts.js deleted file mode 100644 index aa1cb4f6f..000000000 --- a/lib/serve-ts.js +++ /dev/null @@ -1,125 +0,0 @@ -// @ts-check -/* eslint-env node */ - -const child_process = require('child_process'); -const fs = require('fs'); - -const rimraf = require('rimraf'); - -module.exports = (project, outDir) => { - const Serve = project.require('ember-cli/lib/commands/serve'); - const Builder = project.require('ember-cli/lib/models/builder'); - const Watcher = project.require('ember-cli/lib/models/watcher'); - - /** - * Exclude .ts files from being watched - */ - function filterTS(name) { - if (name.startsWith('.')) { - // these files are filtered by default - return false; - } - - // typescript sources are watched by `tsc --watch` instead - return !name.endsWith('.ts'); - } - - class WatcherNonTS extends Watcher { - buildOptions() { - let options = super.buildOptions(); - options.filter = filterTS; - return options; - } - } - - return Serve.extend({ - name: 'serve-ts', - aliases: ['st'], - works: 'insideProject', - description: - 'Serve the app/addon with the TypeScript compiler in incremental mode. (Much faster!)', - - run(options) { - const config = this.project.config(options.environment); - - this.project._isRunningServeTS = true; - - return new Promise(resolve => { - let started = false; - - this.ui.startProgress('Starting TypeScript compilation...'); - - // TODO: typescript might be installed globally? - // argument sequence here is meaningful; don't apply prettier. - // prettier-ignore - this.tsc = child_process.fork( - 'node_modules/typescript/bin/tsc', - [ - '--watch', - '--outDir', outDir, - '--allowJs', 'false', - '--noEmit', 'false', - ], - { - silent: true, - execArgv: [], - } - ); - - this.tsc.stderr.on('data', data => { - this.ui.writeError(data); - }); - - this.tsc.stdout.on('data', data => { - this.ui.write(data); - - // Wait for the initial compilation to complete before continuing to - // minimize thrashing during startup. - if (data.indexOf('Compilation complete') !== -1 && !started) { - started = true; - this.ui.stopProgress(); - resolve(); - } - }); - }).then(() => { - const builder = new Builder({ - ui: this.ui, - outputPath: options.outputPath, - project: this.project, - environment: options.environment, - }); - - // This will be populated later by the superclass, but is needed by the Watcher now - options.rootURL = config.rootURL || '/'; - - // We're ignoring this because TS doesn't have types for `Watcher`; this is - // fine, though. - // @ts-ignore - const watcher = new WatcherNonTS({ - ui: this.ui, - builder, - analytics: this.analytics, - options, - serving: true, - }); - - options._builder = builder; - options._watcher = watcher; - - return Serve.prototype.run.call(this, options); - }); - }, - - onInterrupt() { - return Serve.prototype.onInterrupt.apply(this, arguments).then(() => { - if (this.tsc) { - this.tsc.kill(); - } - - if (fs.existsSync(outDir)) { - rimraf.sync(outDir); - } - }); - }, - }); -}; diff --git a/lib/typescript-preprocessor.js b/lib/typescript-preprocessor.js deleted file mode 100644 index 26b3e6bff..000000000 --- a/lib/typescript-preprocessor.js +++ /dev/null @@ -1,71 +0,0 @@ -// @ts-check -/* eslint-env node */ - -const fs = require('fs'); -const path = require('path'); - -const funnel = require('broccoli-funnel'); -const mergeTrees = require('broccoli-merge-trees'); -const { typescript } = require('broccoli-typescript-compiler'); - -const BroccoliDebug = require('broccoli-debug'); - -let tag = 0; - -class TypeScriptPreprocessor { - constructor(options) { - this.name = 'ember-cli-typescript'; - this._tag = tag; - this.ext = 'ts'; - this.ui = options.ui; - - // Update the config for how Broccoli handles the file system: no need for - // includes, always emit, and let Broccoli manage any outDir. - const config = fs.readFileSync(path.join(process.cwd(), 'tsconfig.json'), { encoding: 'utf8' }); - this.config = JSON.parse(config); - this.config.compilerOptions.noEmit = false; - this.config.compilerOptions.allowJs = false; - delete this.config.compilerOptions.outDir; - delete this.config.include; - } - - toTree(inputNode /*, inputPath, outputPath*/) { - // increment every time toTree is called so we have some idea what's going on here. - this._tag = tag++; - - const debugTree = BroccoliDebug.buildDebugCallback('ember-cli-typescript'); - - const js = funnel(inputNode, { - exclude: ['**/*.ts'], - annotation: 'JS files', - }); - - const uncompiledTs = debugTree( - funnel(inputNode, { - include: ['**/*.ts'], - annotation: 'uncompiled TS files', - }), - `${this._tag}:uncompiled-ts` - ); - - const tsc = typescript(uncompiledTs, { - throwOnError: this.config.compilerOptions.noEmitOnError, - annotation: 'Compiled TS files', - tsconfig: this.config, - }); - tsc.setDiagnosticWriter(this.ui.writeWarnLine.bind(this.ui)); - - const ts = debugTree(tsc, `${this._tag}:compiled-ts`); - - // Put everything together. - return debugTree( - mergeTrees([js, ts], { - overwrite: true, - annotation: 'merged JS & compiled TS', - }), - `${this._tag}:final` - ); - } -} - -module.exports = TypeScriptPreprocessor; diff --git a/package.json b/package.json index dbe032add..916c086fc 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,17 @@ "name": "ember-cli-typescript", "version": "1.0.6", "description": "Allow ember apps to use typescript files.", - "keywords": ["ember-addon", "typescript"], + "keywords": [ + "ember-addon", + "typescript" + ], "license": "MIT", "author": "Chris Krycho (http://www.chriskrycho.com)", - "contributors": ["Marius Seritan", "David Gardiner", "Philip Bjorge"], + "contributors": [ + "Marius Seritan", + "David Gardiner", + "Philip Bjorge" + ], "directories": { "doc": "doc", "test": "tests" @@ -26,13 +33,14 @@ "broccoli-funnel": "^1.0.6", "broccoli-merge-trees": "^1.1.4", "broccoli-plugin": "^1.2.1", - "broccoli-source": "^1.1.0", "broccoli-stew": "^1.4.0", - "broccoli-typescript-compiler": "^2.1.1", "debug": "^2.2.0", - "ember-cli-babel": "^6.6.0", - "silent-error": "^1.1.0", - "rimraf": "^2.6.2" + "execa": "^0.9.0", + "mkdirp": "^0.5.1", + "resolve": "^1.5.0", + "rimraf": "^2.6.2", + "rsvp": "^4.8.1", + "symlink-or-copy": "^1.1.8" }, "devDependencies": { "@types/ember": "*", @@ -40,6 +48,7 @@ "broccoli-asset-rev": "^2.4.5", "ember-cli": "~2.17.1", "ember-cli-app-version": "^2.0.0", + "ember-cli-babel": "^6.6.0", "ember-cli-blueprint-test-helpers": "^0.18.3", "ember-cli-dependency-checker": "^2.0.0", "ember-cli-eslint": "^4.2.1", @@ -69,7 +78,9 @@ }, "ember-addon": { "configPath": "tests/dummy/config", - "before": ["ember-cli-babel"] + "before": [ + "ember-cli-babel" + ] }, "prettier": { "printWidth": 100, diff --git a/yarn.lock b/yarn.lock index 626f5c568..fd2b891fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -214,10 +214,6 @@ arr-flatten@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" -array-binsearch@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/array-binsearch/-/array-binsearch-1.0.1.tgz#35586dca04ca9ab259c4c4708435acd1babb08ad" - array-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" @@ -1710,20 +1706,6 @@ broccoli-stew@^1.2.0, broccoli-stew@^1.3.3, broccoli-stew@^1.4.0: symlink-or-copy "^1.1.8" walk-sync "^0.3.0" -broccoli-typescript-compiler@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/broccoli-typescript-compiler/-/broccoli-typescript-compiler-2.1.1.tgz#3d40d277c4804305cb8d4cd779137e44a4c16419" - dependencies: - array-binsearch "^1.0.1" - broccoli-funnel "^1.2.0" - broccoli-merge-trees "^2.0.0" - broccoli-plugin "^1.2.1" - fs-tree-diff "^0.5.2" - heimdalljs "0.3.3" - md5-hex "^2.0.0" - typescript "~2.6.1" - walk-sync "^0.3.2" - broccoli-uglify-sourcemap@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/broccoli-uglify-sourcemap/-/broccoli-uglify-sourcemap-2.0.1.tgz#e8f2f6c49e04b6e921f1ecd30e12f06fb75e585f" @@ -3214,6 +3196,18 @@ execa@^0.8.0, execa@~0.8.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +execa@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.9.0.tgz#adb7ce62cf985071f60580deb4a88b9e34712d01" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + exists-stat@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/exists-stat/-/exists-stat-1.0.0.tgz#0660e3525a2e89d9e446129440c272edfa24b529" @@ -3880,12 +3874,6 @@ heimdalljs-logger@^0.1.7: debug "^2.2.0" heimdalljs "^0.2.0" -heimdalljs@0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/heimdalljs/-/heimdalljs-0.3.3.tgz#e92d2c6f77fd46d5bf50b610d28ad31755054d0b" - dependencies: - rsvp "~3.2.1" - heimdalljs@^0.2.0, heimdalljs@^0.2.1, heimdalljs@^0.2.3: version "0.2.5" resolved "https://registry.yarnpkg.com/heimdalljs/-/heimdalljs-0.2.5.tgz#6aa54308eee793b642cff9cf94781445f37730ac" @@ -5606,6 +5594,10 @@ rsvp@^4.6.1, rsvp@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.7.0.tgz#dc1b0b1a536f7dec9d2be45e0a12ad4197c9fd96" +rsvp@^4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.1.tgz#168addb3963222de37ee351b70e3876bdb2ac285" + rsvp@~3.0.6: version "3.0.21" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.0.21.tgz#49c588fe18ef293bcd0ab9f4e6756e6ac433359f" @@ -6260,10 +6252,6 @@ typescript@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.4.2.tgz#f8395f85d459276067c988aa41837a8f82870844" -typescript@~2.6.1: - version "2.6.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.6.2.tgz#3c5b6fd7f6de0914269027f03c0946758f7673a4" - uc.micro@^1.0.1, uc.micro@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192"