Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed addon/.gitkeep
Empty file.
Empty file removed app/.gitkeep
Empty file.
159 changes: 23 additions & 136 deletions index.js
Original file line number Diff line number Diff line change
@@ -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);
}
},
}
};
174 changes: 174 additions & 0 deletions lib/incremental-typescript-compiler.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
}
}
Loading