Skip to content

Commit

Permalink
feat(cli): convert to shallow, precompiled builds
Browse files Browse the repository at this point in the history
building each addon every time for every app introduced a significant
performance problem - cold boots took upwards of 10s with a minimal app.
Furthermore, debugging was much more difficult thanks to the mirrored
compiled addon structure in dist/node_modules.

This changes denali to only build the app/addon you run the command
inside, but adds the ability to mark a linked addon dependency as "under
"development", which causes denali to also watch and rebuild that linked
dependency's source.

This introduces additional burden on addon developers (they must publish
their `dist/` folder, **not** the project root). But the benefits for
all users outweigh the cost to addon developers, and those caveats can
be clearly documented.
  • Loading branch information
davewasmer committed Dec 5, 2016
1 parent 5d2def6 commit 9e5e20d
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 165 deletions.
3 changes: 3 additions & 0 deletions blueprints/addon/files/__name__/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
"ava": "^0.16.0",
"babel-eslint": "^6.0.4"
},
"scripts": {
"prepublish": "echo \"Use 'denali publish' instead.\" && exit 1"
},
"ava": {
"plugins": [],
"presets": []
Expand Down
5 changes: 5 additions & 0 deletions commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import path from 'path';
import ui from '../lib/cli/ui';
import findKey from 'lodash/findKey';
import discoverAddons from '../lib/utils/discover-addons';
import createDebug from 'debug';

const debug = createDebug('denali:commands');

export default function run(localDenaliPath) {
debug('discovering commands from addons');
let commands = {};

// Find addon commands
Expand Down Expand Up @@ -44,6 +48,7 @@ export default function run(localDenaliPath) {
let command = new commands[fullCommandName]();

// Invoke the command
debug('running command');
command._run(argTokens, commands).catch((err) => {
ui.error(err.stack);
});
Expand Down
20 changes: 19 additions & 1 deletion commands/server.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import fs from 'fs';
import path from 'path';
import dedent from 'dedent-js';
import { spawn } from 'child_process';
import ui from '../lib/cli/ui';
import Command from '../lib/cli/command';
import Project from '../lib/cli/project';
import assign from 'lodash/assign';
import createDebug from 'debug';

const debug = createDebug('denali:commands:server');

export default class ServerCommand extends Command {

Expand Down Expand Up @@ -70,6 +75,7 @@ export default class ServerCommand extends Command {
}

run({ flags }) {
debug('running server command');
this.watch = flags.watch || flags.environment === 'development';
this.port = flags.port;
this.debug = flags.debug;
Expand All @@ -87,17 +93,20 @@ export default class ServerCommand extends Command {
process.on('SIGTERM', this.cleanExit.bind(this));

if (flags.watch || flags.environment === 'development') {
debug('starting watcher');
this.project.watch({
outputDir: flags.output,
onBuild: () => {
if (this.server) {
debug('killing existing server');
this.server.removeAllListeners('exit');
this.server.kill();
}
this.startServer(flags.output);
}
});
} else {
debug('building project');
this.project.build(flags.output)
.then(() => {
this.startServer(flags.output);
Expand All @@ -121,11 +130,20 @@ export default class ServerCommand extends Command {
if (this.debug) {
args.unshift('--inspect', '--debug-brk');
}
this.server = spawn('node', args, {
if (!fs.existsSync(path.join('app', 'index.js'))) {
ui.error('Unable to start your application: missing app/index.js file');
return;
}
debug(`starting server process: ${ process.execPath } ${ args.join(' ') }`);
this.server = spawn(process.execPath, args, {
cwd: dir,
stdio: [ 'pipe', process.stdout, process.stderr ],
env: assign({}, defaultEnvs, process.env)
});
this.server.on('error', (error) => {
ui.error('Unable to start your application:');
ui.error(error.stack);
});
this.server.on('exit', (code) => {
let result = code === 0 ? 'exited' : 'crashed';
ui.error(`Server ${ result }. waiting for changes to restart ...`);
Expand Down
8 changes: 8 additions & 0 deletions denali-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import LintTree from './lib/cli/lint-tree';

export default class DenaliBuilder extends Builder {

isDevelopingAddon = true;

unbuiltDirs = [
'bin',
'blueprints',
'commands'
];

processSelf(tree, dir) {
tree = this.lintTree(tree, dir);
tree = this.transpileTree(tree, dir);
Expand Down
119 changes: 45 additions & 74 deletions lib/cli/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,53 @@ import Funnel from 'broccoli-funnel';
import MergeTree from 'broccoli-merge-trees';
import PackageTree from './package-tree';
import discoverAddons from '../utils/discover-addons';
import tryRequire from '../utils/try-require';
import createDebug from 'debug';

const debug = createDebug('denali:builder');


export default class Builder {

static createFor(dir, project) {
debug(`creating builder for ${ dir }`);
// Use the local denali-build.js if present
let denaliBuildPath = path.join(dir, 'denali-build');
if (fs.existsSync(`${ denaliBuildPath }.js`)) {
let LocalBuilder = require(denaliBuildPath);
LocalBuilder = LocalBuilder.default || LocalBuilder;
return new LocalBuilder(dir, project);
}
return new this(dir, project);
}

ignoreVulnerabilities = [
[ '[email protected]' ]
];

constructor(dir, project, preseededAddons) {
// Use the local denali-build.js if present
let LocalBuilder = tryRequire(path.join(dir, 'denali-build'));
if (LocalBuilder && this.constructor === Builder) {
return new LocalBuilder(dir, project, preseededAddons);
}
packageFiles = [
'package.json',
'README.md',
'CHANGELOG.md',
'LICENSE',
'denali-build.js'
];

unbuiltDirs = [
'blueprints',
'commands'
];

constructor(dir, project) {
this.dir = dir;
this.pkg = require(path.join(this.dir, 'package.json'));
this.project = project;
this.preseededAddons = preseededAddons;
this.dest = path.relative(project.dir, this.dir);
if (this.isAddon && this.isProjectRoot) {
this.dest = path.join('node_modules', this.pkg.name);
} else if (this.dest === path.join('test', 'dummy')) {
this.dest = '.';
}
this.lint = this.isProjectRoot ? project.lint : false;
// Inherit the environment from the project, *except* when this builder is
// representing an addon dependency and the environment is test. Basically,
// when we run tests, we don't want addon dependencies building for test.
this.environment = !this.isProjectRoot && project.environment === 'test'
? 'development'
: project.environment;

// Register with the project; this must happen before child builders are
// created to ensure that any child addons that share this dependency will
// use this builder, rather than attempting to create their own because none
// existed in the project builders map.
this.project.builders.set(dir, this);

// Find child addons
let addons = discoverAddons(this.dir, { preseededAddons: this.preseededAddons });
// Build a list of child addons for the processParent hook later
this.childBuilders = addons.map((addonDir) => this.project.builderFor(addonDir));
}

get isAddon() {
return this.pkg.keywords && this.pkg.keywords.includes('denali-addon');
}

get isProjectRoot() {
return this.project.dir === this.dir;
this.isAddon = this.pkg.keywords && this.pkg.keywords.includes('denali-addon');
this.addons = discoverAddons(this.dir);
}

sourceDirs() {
let dirs = [ 'app', 'config', 'lib' ];
if (this.environment === 'test') {
if (this.project.environment === 'test') {
dirs.push('test');
}
return dirs;
Expand All @@ -87,9 +77,9 @@ export default class Builder {
return false;
}).filter(Boolean);

// Copy the package.json file into our build output (this special tree is
// Copy top level files into our build output (this special tree is
// necessary because broccoli can't pick a file from the root dir).
sourceTrees.push(new PackageTree(this.pkg));
sourceTrees.push(new PackageTree(this, { files: this.packageFiles }));

// Combine everything into our unified source tree, ready for building
return new MergeTree(sourceTrees, { overwrite: true });
Expand All @@ -98,51 +88,32 @@ export default class Builder {
toTree() {
let tree = this._prepareSelf();

// Find child addons
this.childBuilders = this.addons.map((addonDir) => Builder.createFor(addonDir, this.project));

// Run processParent hooks
this.childBuilders.forEach((builder) => {
if (builder.processParent) {
tree = builder.processParent(tree, this.dir);
}
});

// Run processEach hooks
this.project.builders.forEach((builder) => {
if (builder.processEach) {
tree = builder.processEach(tree, this.dir);
}
});

// Run processApp hooks
if (this.isProjectRoot) {
this.project.builders.forEach((builder) => {
if (builder.processApp) {
tree = builder.processApp(tree, this.dir);
}
});
}

// Run processSelf hooks
if (this.processSelf) {
tree = this.processSelf(tree, this.dir);
}

// If this is an addon, the project root, and we are building
// for "test", we want to move the tests from the addon up to the dummy
// app so they are actually run, but move everything else into
// node_modules like normal.
if (this.isAddon && this.isProjectRoot && this.environment === 'test') {
let addonTests = new Funnel(tree, {
include: [ 'test/**/*' ],
exclude: [ 'test/dummy/**/*' ]
});
let addonWithoutTests = new Funnel(tree, {
exclude: [ 'test/**/*' ],
destDir: this.dest
});
return new MergeTree([ addonWithoutTests, addonTests ]);
let unbuiltTrees = [];
this.unbuiltDirs.forEach((dir) => {
if (fs.existsSync(path.join(this.dir, dir))) {
unbuiltTrees.push(new Funnel(path.join(this.dir, dir), { destDir: dir }));
}
});
if (unbuiltTrees.length > 0) {
tree = new MergeTree(unbuiltTrees.concat(tree), { overwrite: true });
}

return new Funnel(tree, { destDir: this.dest });
return tree;
}

}
31 changes: 28 additions & 3 deletions lib/cli/package-tree.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
import path from 'path';
import fs from 'fs';
import Plugin from 'broccoli-plugin';
import cloneDeep from 'lodash/cloneDeep';

export default class PackageTree extends Plugin {

constructor(pkg, options) {
constructor(builder, options) {
super([], options);
this.pkg = pkg;
this.builder = builder;
this.dir = builder.dir;
this.isAddon = builder.pkg.keywords && builder.pkg.keywords.includes('denali-addon');
this.files = options.files;
}

build() {
fs.writeFileSync(path.join(this.outputPath, 'package.json'), JSON.stringify(this.pkg, null, 2));
// Copy over any top level files specified
this.files.forEach((file) => {
let src = path.join(this.dir, file);
let dest = path.join(this.outputPath, file);
if (fs.existsSync(src)) {
fs.writeFileSync(dest, fs.readFileSync(src));
}
});

// Addons should publish their dist directories, not the root project directory.
// To enforce this, the addon blueprint ships with a prepublish script that
// fails immediately, telling the user to run `denali publish` instead (which
// tests the addon, builds it, then runs npm publish from the dist folder).
// However, Denali itself would get blocked by our prepublish blocker too,
// so when we build an addon, we remove that blocker. But if the user has
// changed the prepublish script, then we leave it alone.
let scripts = this.builder.pkg.scripts;
if (scripts && scripts.prepublish === 'echo "Use \'denali publish\' instead." && exit 1') {
let pkg = cloneDeep(this.builder.pkg);
delete pkg.scripts.prepublish;
fs.writeFileSync(path.join(this.outputPath, 'package.json'), JSON.stringify(pkg, null, 2));
}
}

}
Loading

0 comments on commit 9e5e20d

Please sign in to comment.