diff --git a/bin/g2dtk b/bin/g2dtk new file mode 100755 index 0000000..1de95b3 --- /dev/null +++ b/bin/g2dtk @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../lib/cli/index').cli(); diff --git a/lib/cli/builder.js b/lib/cli/builder.js new file mode 100644 index 0000000..5b5ccb4 --- /dev/null +++ b/lib/cli/builder.js @@ -0,0 +1,548 @@ +'use strict' + +var path = require('path') +var fs = require('fs') +var RSVP = require('rsvp') +var tmp = require('tmp') +var rimraf = require('rimraf') +var underscoreString = require('underscore.string') +var WatchedDir = require('broccoli-source').WatchedDir +var broccoliNodeInfo = require('broccoli-node-info') + +// Clean up left-over temporary directories on uncaught exception. +tmp.setGracefulCleanup() + + +// For an explanation and reference of the API that we use to communicate with +// nodes (__broccoliFeatures__ and __broccoliGetInfo__), see +// https://github.com/broccolijs/broccoli/blob/master/docs/node-api.md + + +// Build a graph of nodes, referenced by its final output node. Example: +// +// var builder = new Builder(outputNode) +// builder.build() +// .then(function() { +// // Build output has been written to builder.outputPath +// }) +// // To rebuild, call builder.build() repeatedly +// .finally(function() { +// // Delete temporary directories +// builder.cleanup() +// }) +// +// Note that the API of this Builder may change between minor Broccoli +// versions. Backwards compatibility is only guaranteed for plugins, so any +// plugin that works with Broccoli 1.0 will work with 1.x. + +module.exports = Builder +function Builder(outputNode, options) { + if (options == null) options = {} + + this.outputNode = outputNode + this.tmpdir = options.tmpdir // can be null + + this.unwatchedPaths = [] + this.watchedPaths = [] + + // nodeWrappers store additional bookkeeping information, such as paths. + // This array contains them in topological (build) order. + this.nodeWrappers = [] + // This populates this.nodeWrappers as a side effect + this.outputNodeWrapper = this.makeNodeWrapper(this.outputNode) + + // Catching missing directories here helps prevent later errors when we set + // up the watcher. + this.checkInputPathsExist() + + this.setupTmpDirs() + + // Now that temporary directories are set up, we need to run the rest of the + // constructor in a try/catch block to clean them up if necessary. + try { + + this.setupNodes() + this.outputPath = this.outputNodeWrapper.outputPath + this.buildId = 0 + + } catch (e) { + this.cleanup() + throw e + } +} + +RSVP.EventTarget.mixin(Builder.prototype) + +// Trigger a (re)build. +// +// Returns a promise that resolves when the build has finished. If there is a +// build error, the promise is rejected with a Builder.BuildError instance. +// This method will never throw, and it will never be rejected with anything +// other than a BuildError. +Builder.prototype.build = function() { + var self = this + this.buildId++ + var promise = RSVP.resolve() + this.nodeWrappers.forEach(function(nw) { + // We use `.forEach` instead of `for` to close nested functions over `nw` + + // Wipe all buildState objects at the beginning of the build + nw.buildState = {} + + promise = promise + .then(function() { + // We use a nested .then/.catch so that the .catch can only catch errors + // from this node, but not from previous nodes. + return RSVP.resolve() + .then(function() { + self.trigger('beginNode', nw) + }) + .then(function() { + return nw.build() + }) + .finally(function() { + self.trigger('endNode', nw) + }) + .catch(function(err) { + throw new BuildError(err, nw) + }) + }) + }) + return promise +} + +// Destructor-like method. Cleanup is synchronous at the moment, but in the +// future we might change it to return a promise. +Builder.prototype.cleanup = function() { + this.builderTmpDirCleanup() +} + +// This method recursively traverses the node graph and returns a nodeWrapper. +// The nodeWrapper graph parallels the node graph 1:1. +Builder.prototype.makeNodeWrapper = function(node, _stack) { + if (_stack == null) _stack = [] + var self = this + + // Dedupe nodes reachable through multiple paths + for (var i = 0; i < this.nodeWrappers.length; i++) { + if (this.nodeWrappers[i].originalNode === node) { + return this.nodeWrappers[i] + } + } + + // Turn string nodes into WatchedDir nodes + var originalNode = node // keep original (possibly string) node around for deduping + if (typeof node === 'string') { + node = new WatchedDir(node, { annotation: 'string node' }) + } + + // Call node.__broccoliGetInfo__() + var nodeInfo + try { + nodeInfo = broccoliNodeInfo.getNodeInfo(node) + } catch (e) { + if (!(e instanceof broccoliNodeInfo.InvalidNodeError)) throw e + // We don't have the instantiation stack of an invalid node, so to aid + // debugging, we instead report its parent node + var messageSuffix = (_stack.length > 0) ? + '\nused as input node to ' + _stack[_stack.length-1].label + + _stack[_stack.length-1].formatInstantiationStackForTerminal() + : '\nused as output node' + throw new broccoliNodeInfo.InvalidNodeError(e.message + messageSuffix) + } + + // Compute label, like "Funnel (test suite)" + var label = nodeInfo.name + var labelExtras = [] + if (nodeInfo.nodeType === 'source') labelExtras.push(nodeInfo.sourceDirectory) + if (nodeInfo.annotation != null) labelExtras.push(nodeInfo.annotation) + if (labelExtras.length > 0) label += ' (' + labelExtras.join('; ') + ')' + + // We start constructing the nodeWrapper here because we'll need the partial + // nodeWrapper for the _stack. Later we'll add more properties. + var nodeWrapper = nodeInfo.nodeType === 'transform' ? + new TransformNodeWrapper : new SourceNodeWrapper + nodeWrapper.nodeInfo = nodeInfo + nodeWrapper.originalNode = originalNode + nodeWrapper.node = node + nodeWrapper.label = label + + // Detect cycles + for (i = 0; i < _stack.length; i++) { + if (_stack[i].node === originalNode) { + var cycleMessage = 'Cycle in node graph: ' + for (var j = i; j < _stack.length; j++) { + cycleMessage += _stack[j].label + ' -> ' + } + cycleMessage += nodeWrapper.label + throw new BuilderError(cycleMessage) + } + } + + // For 'transform' nodes, recurse into the input nodes; for 'source' nodes, + // record paths. + var inputNodeWrappers = [] + if (nodeInfo.nodeType === 'transform') { + var newStack = _stack.concat([nodeWrapper]) + inputNodeWrappers = nodeInfo.inputNodes.map(function(inputNode) { + return self.makeNodeWrapper(inputNode, newStack) + }) + } else { // nodeType === 'source' + if (nodeInfo.watched) { + this.watchedPaths.push(nodeInfo.sourceDirectory) + } else { + this.unwatchedPaths.push(nodeInfo.sourceDirectory) + } + } + + // For convenience, all nodeWrappers get an `inputNodeWrappers` array; for + // 'source' nodes it's empty. + nodeWrapper.inputNodeWrappers = inputNodeWrappers + + nodeWrapper.id = this.nodeWrappers.length + + // this.nodeWrappers will contain all the node wrappers in topological + // order, i.e. each node comes after all its input nodes. + // + // It's unfortunate that we're mutating this.nodeWrappers as a side effect, + // but since we work backwards from the output node to discover all the + // input nodes, it's harder to do a side-effect-free topological sort. + this.nodeWrappers.push(nodeWrapper) + + return nodeWrapper +} + +Builder.prototype.features = broccoliNodeInfo.features + +Builder.prototype.checkInputPathsExist = function() { + // We might consider checking this.unwatchedPaths as well. + for (var i = 0; i < this.watchedPaths.length; i++) { + var isDirectory + try { + isDirectory = fs.statSync(this.watchedPaths[i]).isDirectory() + } catch (err) { + throw new Builder.BuilderError('Directory not found: ' + this.watchedPaths[i]) + } + if (!isDirectory) { + throw new Builder.BuilderError('Not a directory: ' + this.watchedPaths[i]) + } + } +}; + +Builder.prototype.setupTmpDirs = function() { + // Create temporary directories for each node: + // + // out-01-someplugin/ + // out-02-otherplugin/ + // cache-01-someplugin/ + // cache-02-otherplugin/ + // + // Here's an alternative directory structure we might consider (it's not + // clear which structure makes debugging easier): + // + // 01-someplugin/ + // out/ + // cache/ + // in-1 -> ... // symlink for convenience + // in-2 -> ... + // 02-otherplugin/ + // ... + var tmpobj = tmp.dirSync({ prefix: 'dtk-', unsafeCleanup: true, dir: this.tmpdir }) + this.builderTmpDir = tmpobj.name + this.builderTmpDirCleanup = tmpobj.removeCallback + for (var i = 0; i < this.nodeWrappers.length; i++) { + var nodeWrapper = this.nodeWrappers[i] + if (nodeWrapper.nodeInfo.nodeType === 'transform') { + nodeWrapper.inputPaths = nodeWrapper.inputNodeWrappers.map(function(nw) { + return nw.outputPath + }) + nodeWrapper.outputPath = this.mkTmpDir(nodeWrapper, 'out') + + if (nodeWrapper.nodeInfo.needsCache) { + nodeWrapper.cachePath = this.mkTmpDir(nodeWrapper, 'cache') + } + } else { // nodeType === 'source' + // We could name this .sourcePath, but with .outputPath the code is simpler. + nodeWrapper.outputPath = nodeWrapper.nodeInfo.sourceDirectory + } + } +} + + +// Create temporary directory, like +// /tmp/dtk-9rLfJh/out-067-merge_trees_vendor_packages +// type is 'out' or 'cache' +Builder.prototype.mkTmpDir = function(nodeWrapper, type) { + var nameAndAnnotation = nodeWrapper.nodeInfo.name + ' ' + (nodeWrapper.nodeInfo.annotation || '') + // slugify turns fooBar into foobar, so we call underscored first to + // preserve word boundaries + var suffix = underscoreString.underscored(nameAndAnnotation.substr(0, 60)) + suffix = underscoreString.slugify(suffix).replace(/-/g, '_') + // 1 .. 147 -> '001' .. '147' + var paddedId = underscoreString.pad('' + nodeWrapper.id, ('' + this.nodeWrappers.length).length, '0') + var dirname = type + '-' + paddedId + '-' + suffix + var tmpDir = path.join(this.builderTmpDir, dirname) + fs.mkdirSync(tmpDir) + return tmpDir +} + +Builder.prototype.setupNodes = function() { + for (var i = 0; i < this.nodeWrappers.length; i++) { + var nw = this.nodeWrappers[i] + try { + nw.setup(this.features) + } catch (err) { + throw new NodeSetupError(err, nw) + } + } +} + + +// Base class for builder errors +Builder.BuilderError = BuilderError +BuilderError.prototype = Object.create(Error.prototype) +BuilderError.prototype.constructor = BuilderError +function BuilderError(message) { + // Subclassing Error in ES5 is non-trivial because reasons, so we need this + // extra constructor logic from http://stackoverflow.com/a/17891099/525872. + // Note that ES5 subclasses of BuilderError don't in turn need any special + // code. + var temp = Error.apply(this, arguments) + // Need to assign temp.name for correct error class in .stack and .message + temp.name = this.name = this.constructor.name + this.stack = temp.stack + this.message = temp.message +} + +Builder.InvalidNodeError = broccoliNodeInfo.InvalidNodeError + +Builder.NodeSetupError = NodeSetupError +NodeSetupError.prototype = Object.create(BuilderError.prototype) +NodeSetupError.prototype.constructor = NodeSetupError +function NodeSetupError(originalError, nodeWrapper) { + if (nodeWrapper == null) { // Chai calls new NodeSetupError() :( + BuilderError.call(this) + return + } + originalError = wrapPrimitiveErrors(originalError) + var message = originalError.message + + '\nat ' + nodeWrapper.label + + nodeWrapper.formatInstantiationStackForTerminal() + BuilderError.call(this, message) + // The stack will have the original exception name, but that's OK + this.stack = originalError.stack +} + +Builder.BuildError = BuildError +BuildError.prototype = Object.create(BuilderError.prototype) +BuildError.prototype.constructor = BuildError +function BuildError(originalError, nodeWrapper) { + if (nodeWrapper == null) { // for Chai + BuilderError.call(this) + return + } + + originalError = wrapPrimitiveErrors(originalError) + + // Create heavily augmented message for easy printing to the terminal. Web + // interfaces should refer to broccoliPayload.originalError.message instead. + var filePart = '' + if (originalError.file != null) { + filePart = originalError.file + if (originalError.line != null) { + filePart += ':' + originalError.line + if (originalError.column != null) { + // .column is zero-indexed + filePart += ':' + (originalError.column + 1) + } + } + filePart += ': ' + } + var instantiationStack = '' + if (originalError.file == null) { + // We want to report the instantiation stack only for "unexpected" errors + // (bugs, internal errors), but not for compiler errors and such. For now, + // the presence of `.file` serves as a heuristic to distinguish between + // those cases. + instantiationStack = nodeWrapper.formatInstantiationStackForTerminal() + } + var message = filePart + originalError.message + + (originalError.treeDir ? '\n in ' + originalError.treeDir : '') + + '\n at ' + nodeWrapper.label + + instantiationStack + + BuilderError.call(this, message) + this.stack = originalError.stack + + // This error API can change between minor Broccoli version bumps + this.broccoliPayload = { + originalError: originalError, // guaranteed to be error object, not primitive + originalMessage: originalError.message, + // node info + nodeId: nodeWrapper.id, + nodeLabel: nodeWrapper.label, + nodeName: nodeWrapper.nodeInfo.name, + nodeAnnotation: nodeWrapper.nodeInfo.annotation, + instantiationStack: nodeWrapper.nodeInfo.instantiationStack, + // error location (if any) + location: { + file: originalError.file, + treeDir: originalError.treeDir, + line: originalError.line, + column: originalError.column + } + } +} + + +Builder.NodeWrapper = NodeWrapper +function NodeWrapper() { + this.buildState = {} +} + +Builder.TransformNodeWrapper = TransformNodeWrapper +TransformNodeWrapper.prototype = Object.create(NodeWrapper.prototype) +TransformNodeWrapper.prototype.constructor = TransformNodeWrapper +function TransformNodeWrapper() { + NodeWrapper.apply(this, arguments) +} + +Builder.SourceNodeWrapper = SourceNodeWrapper +SourceNodeWrapper.prototype = Object.create(NodeWrapper.prototype) +SourceNodeWrapper.prototype.constructor = SourceNodeWrapper +function SourceNodeWrapper() { + NodeWrapper.apply(this, arguments) +} + +TransformNodeWrapper.prototype.setup = function(features) { + this.nodeInfo.setup(features, { + inputPaths: this.inputPaths, + outputPath: this.outputPath, + cachePath: this.cachePath + }) + this.callbackObject = this.nodeInfo.getCallbackObject() +} + +SourceNodeWrapper.prototype.setup = function(features) { +} + +// Call node.build(), plus bookkeeping +TransformNodeWrapper.prototype.build = function() { + var self = this + + var startTime = process.hrtime() + + if (!this.nodeInfo.persistentOutput) { + rimraf.sync(this.outputPath) + fs.mkdirSync(this.outputPath) + } + + return RSVP.resolve(self.callbackObject.build()) + + .then(function() { + var now = process.hrtime() + // Build time in milliseconds + self.buildState.selfTime = 1000 * ((now[0] - startTime[0]) + (now[1] - startTime[1]) / 1e9) + self.buildState.totalTime = self.buildState.selfTime + for (var i = 0; i < self.inputNodeWrappers.length; i++) { + self.buildState.totalTime += self.inputNodeWrappers[i].buildState.totalTime + } + }) +} + +SourceNodeWrapper.prototype.build = function() { + // We only check here that the sourceDirectory exists and is a directory + try { + if (!fs.statSync(this.nodeInfo.sourceDirectory).isDirectory()) { + throw new Error('Not a directory') + } + } catch (err) { // stat might throw, or we might throw + err.file = this.nodeInfo.sourceDirectory + // fs.stat augments error message with file name, but that's redundant + // with our err.file, so we strip it + err.message = err.message.replace(/, stat '[^'\n]*'$/m, '') + throw err + } + + this.buildState.selfTime = 0 + this.buildState.totalTime = 0 +} + +TransformNodeWrapper.prototype.toString = function() { + var hint = this.label + hint = this.label + if (this.inputNodeWrappers) { // a bit defensive to deal with partially-constructed node wrappers + hint += ' inputNodeWrappers:[' + this.inputNodeWrappers.map(function(nw) { return nw.id }) + ']' + } + hint += ' at ' + this.outputPath + if (this.buildState.selfTime != null) { + hint += ' (' + Math.round(this.buildState.selfTime) + ' ms)' + } + return '[NodeWrapper:' + this.id + ' ' + hint + ']' +} + +SourceNodeWrapper.prototype.toString = function() { + var hint = this.nodeInfo.sourceDirectory + + (this.nodeInfo.watched ? '' : ' (unwatched)') + return '[NodeWrapper:' + this.id + ' ' + hint + ']' +} + +NodeWrapper.prototype.toJSON = function() { + return undefinedToNull({ + id: this.id, + nodeInfo: this.nodeInfoToJSON(), + buildState: this.buildState, + label: this.label, + inputNodeWrappers: this.inputNodeWrappers.map(function(nw) { return nw.id }), + cachePath: this.cachePath, + outputPath: this.outputPath + // leave out node, originalNode, inputPaths (redundant), build + }) +} + +TransformNodeWrapper.prototype.nodeInfoToJSON = function() { + return undefinedToNull({ + nodeType: 'transform', + name: this.nodeInfo.name, + annotation: this.nodeInfo.annotation, + persistentOutput: this.nodeInfo.persistentOutput, + needsCache: this.nodeInfo.needsCache + // leave out instantiationStack (too long), inputNodes, and callbacks + }) +} + +SourceNodeWrapper.prototype.nodeInfoToJSON = function() { + return undefinedToNull({ + nodeType: 'source', + sourceDirectory: this.nodeInfo.sourceDirectory, + watched: this.nodeInfo.watched, + name: this.nodeInfo.name, + annotation: this.nodeInfo.annotation + // leave out instantiationStack + }) +} + +NodeWrapper.prototype.formatInstantiationStackForTerminal = function() { + return '\n-~- created here: -~-\n' + this.nodeInfo.instantiationStack + '\n-~- (end) -~-' +} + + +// Replace all `undefined` values with `null`, so that they show up in JSON output +function undefinedToNull(obj) { + for (var key in obj) { + if (obj.hasOwnProperty(key) && obj[key] === undefined) { + obj[key] = null + } + } + return obj +} + +function wrapPrimitiveErrors(err) { + if (err !== null && typeof err === 'object') { + return err + } else { + // We could augment the message with " [string exception]" to indicate + // that the stack trace is not useful, or even set the .stack to null. + return new Error(err + '') + } +} diff --git a/lib/cli/cli.js b/lib/cli/cli.js new file mode 100644 index 0000000..8b807c5 --- /dev/null +++ b/lib/cli/cli.js @@ -0,0 +1,107 @@ +var fs = require('fs-extra'); +var path = require('path'); +var program = require('commander'); +var copyDereferenceSync = require('copy-dereference').sync; +var rimraf = require('rimraf'); +var shell = require('shelljs'); +var RSVP = require('rsvp'); + +var dtk = require('./index'); +var Watcher = require('./watcher'); + +function usesGit(baseDir) { + return shell.which('git') && fs.existsSync(baseDir + '/.git'); +} + +module.exports = genkgoDTK; + +function genkgoDTK () { + var actionPerformed = false; + + program + .version(JSON.parse(fs.readFileSync(__dirname + '/../package.json', 'utf8')).version) + .usage('[options] []'); + + program.command('serve') + .description('start a dtk server') + .option('--live-reload-port ', 'the port to start LiveReload on [35729]', 35729) + .action(function(options) { + var outputDir = 'public/debug'; + + actionPerformed = true; + if (fs.existsSync(outputDir)) { + rimraf.sync(outputDir); + } + + var buildOptions = { + 'environment': 'development', + 'outputDir': outputDir + }; + + var builder = getBuilder(buildOptions); + outputDir = buildOptions.outputDir; + fs.ensureDirSync(outputDir); + + dtk.serve.serve(new Watcher(builder), outputDir, options); + }); + + program.command('build') + .description('output files to build directory') + .action(function() { + var outputDir = 'public/build'; + + actionPerformed = true; + + var buildOptions = { + 'environment': 'production', + 'outputDir': outputDir + }; + + var builder = getBuilder(buildOptions); + outputDir = buildOptions.outputDir; + + if (fs.existsSync(outputDir)) { + rimraf.sync(outputDir); + } + + fs.ensureDirSync(path.dirname(outputDir)); + + builder.build() + .then(function() { + copyDereferenceSync(builder.outputPath, outputDir); + }) + .finally(function () { + return builder.cleanup(); + }) + .then(function () { + if (usesGit(process.cwd())) { + shell.exec('git add ' + outputDir); + } + + return RSVP.Promise.resolve(); + }) + .then(function () { + process.exit(0) + }) + .catch(function (err) { + // Should show file and line/col if present + if (err.file) { + console.error('File: ' + err.file); + } + console.error(err.stack); + console.error('\nBuild failed'); + process.exit(1); + }) + }); + + program.parse(process.argv); + if(!actionPerformed) { + program.outputHelp(); + process.exit(1); + } +} + +function getBuilder (options) { + var dtkApp = dtk.loadDtkFile(); + return new dtk.Builder(dtkApp.build(options)); +} diff --git a/lib/cli/index.js b/lib/cli/index.js new file mode 100644 index 0000000..7a91128 --- /dev/null +++ b/lib/cli/index.js @@ -0,0 +1,6 @@ +exports.Builder = require('./builder'); +exports.loadDtkFile = require('./load_dtk_file'); +exports.Watcher = require('./watcher'); +exports.WatcherAdapter = require('./watcher_adapter'); +exports.cli = require('./cli'); +exports.serve = require('./serve'); diff --git a/lib/cli/load_dtk_file.js b/lib/cli/load_dtk_file.js new file mode 100644 index 0000000..dd82e0a --- /dev/null +++ b/lib/cli/load_dtk_file.js @@ -0,0 +1,20 @@ +var path = require('path'); +var findup = require('findup-sync'); + +module.exports = loadDtkFile; + +function loadDtkFile () { + var dtkFile = findup('g2dtk.js', { + nocase: true + }); + + if (dtkFile == null) throw new Error('g2dtk.js not found'); + + var baseDir = path.dirname(dtkFile); + + // The chdir should perhaps live somewhere else and not be a side effect of + // this function, or go away entirely + process.chdir(baseDir); + + return require(dtkFile); +} diff --git a/lib/cli/serve.js b/lib/cli/serve.js new file mode 100644 index 0000000..7e8d09e --- /dev/null +++ b/lib/cli/serve.js @@ -0,0 +1,73 @@ +var copyDereferenceSync = require('copy-dereference').sync; +var rimraf = require('rimraf'); +var printSlowNodes = require('broccoli-slow-trees'); +var tinylr = require('tiny-lr'); + +exports.serve = serve; + +function serve (watcher, outputDir, options) { + var server = {}; + + server.watcher = watcher; + server.builder = server.watcher.builder; + + var livereloadServer = new tinylr.Server; + livereloadServer.listen(options.liveReloadPort, function (err) { + if(err) { + throw err; + } + }); + + var liveReload = function() { + // Chrome LiveReload doesn't seem to care about the specific files as long + // as we pass something. + livereloadServer.changed({body: {files: ['livereload_dummy']}}); + }; + + function cleanupAndExit() { + return server.watcher.quit() + } + + process.on('SIGINT', cleanupAndExit); + process.on('SIGTERM', cleanupAndExit); + + server.watcher.on('buildSuccess', function() { + rimraf.sync(outputDir); + copyDereferenceSync(server.builder.outputPath, outputDir); + + try { + liveReload(); + } catch (e) { + } + + printSlowNodes(server.builder.outputNodeWrapper); + console.log('Built - ' + Math.round(server.builder.outputNodeWrapper.buildState.totalTime) + ' ms @ ' + new Date().toString()) + }); + + server.watcher.on('buildFailure', function(err) { + console.log('Built with error:'); + console.log(err.message); + if (!err.broccoliPayload || !err.broccoliPayload.location.file) { + console.log(''); + console.log(err.stack) + } + console.log('') + }); + + server.watcher.start() + .catch(function(err) { + console.log(err && err.stack || err) + }) + .finally(function() { + server.builder.cleanup(); + }) + .catch(function(err) { + console.log('Cleanup error:'); + console.log(err && err.stack || err); + }) + .finally(function() { + process.exit(1) + }); + + return server; +} \ No newline at end of file diff --git a/lib/cli/watcher.js b/lib/cli/watcher.js new file mode 100644 index 0000000..3eec6cd --- /dev/null +++ b/lib/cli/watcher.js @@ -0,0 +1,134 @@ +'use strict' + +var RSVP = require('rsvp') +var WatcherAdapter = require('./watcher_adapter') +var logger = require('heimdalljs-logger')('dtk:watcher') + +// This Watcher handles all the Broccoli logic, such as debouncing. The +// WatcherAdapter handles I/O via the sane package, and could be pluggable in +// principle. + +module.exports = Watcher +function Watcher(builder, options) { + this.options = options || {} + if (this.options.debounce == null) this.options.debounce = 100 + this.builder = builder + this.watcherAdapter = new WatcherAdapter(this.options.saneOptions) + this.currentBuild = null + this._rebuildScheduled = false + this._ready = false + this._quitting = false + this._lifetimeDeferred = null +} + +RSVP.EventTarget.mixin(Watcher.prototype) + +Watcher.prototype.start = function() { + var self = this + + if (this._lifetimeDeferred != null) throw new Error('Watcher.prototype.start() must not be called more than once') + this._lifetimeDeferred = RSVP.defer() + + this.watcherAdapter.on('change', this._change.bind(this)) + this.watcherAdapter.on('error', this._error.bind(this)) + RSVP.resolve().then(function() { + return self.watcherAdapter.watch(self.builder.watchedPaths) + }).then(function() { + logger.debug('ready') + self._ready = true + self.currentBuild = self._build() + }).catch(function(err) { + self._error(err) + }) + + return this._lifetimeDeferred.promise +} + +Watcher.prototype._change = function() { + var self = this + + if (!this._ready) { + logger.debug('change', 'ignored: before ready') + return + } + if (this._rebuildScheduled) { + logger.debug('change', 'ignored: rebuild scheduled already') + return + } + logger.debug('change') + this._rebuildScheduled = true + // Wait for current build, and ignore build failure + RSVP.resolve(this.currentBuild).catch(function() { }).then(function() { + if (self._quitting) return + var buildPromise = new RSVP.Promise(function(resolve, reject) { + logger.debug('debounce') + self.trigger('debounce') + setTimeout(resolve, self.options.debounce) + }).then(function() { + // Only set _rebuildScheduled to false *after* the setTimeout so that + // change events during the setTimeout don't trigger a second rebuild + self._rebuildScheduled = false + return self._build() + }) + self.currentBuild = buildPromise + }) +} + +Watcher.prototype._build = function() { + var self = this + + logger.debug('buildStart') + this.trigger('buildStart') + var buildPromise = self.builder.build() + // Trigger change/error events. Importantly, if somebody else chains to + // currentBuild, their callback will come after our events have + // triggered, because we registered our callback first. + buildPromise.then(function() { + logger.debug('buildSuccess') + self.trigger('buildSuccess') + }, function(err) { + logger.debug('buildFailure') + self.trigger('buildFailure', err) + }) + return buildPromise +} + +Watcher.prototype._error = function(err) { + var self = this + + logger.debug('error', err) + if (this._quitting) return + this._quit().catch(function() { }).then(function() { + self._lifetimeDeferred.reject(err) + }) +} + +Watcher.prototype.quit = function() { + var self = this + + if (this._quitting) { + logger.debug('quit', 'ignored: already quitting') + return + } + this._quit().then(function() { + self._lifetimeDeferred.resolve() + }, function(err) { + self._lifetimeDeferred.reject(err) + }) +} + +Watcher.prototype._quit = function(err) { + var self = this + + this._quitting = true + logger.debug('quitStart') + + return RSVP.resolve().then(function() { + return self.watcherAdapter.quit() + }).finally(function() { + // Wait for current build, and ignore build failure + return RSVP.resolve(self.currentBuild).catch(function() { }) + }).finally(function() { + logger.debug('quitEnd') + }) +} diff --git a/lib/cli/watcher_adapter.js b/lib/cli/watcher_adapter.js new file mode 100644 index 0000000..cda9d36 --- /dev/null +++ b/lib/cli/watcher_adapter.js @@ -0,0 +1,53 @@ +var sane = require('sane') +var RSVP = require('rsvp') +var logger = require('heimdalljs-logger')('dtk:watcherAdapter') + + +function defaultFilterFunction(name) { + return /^[^\.]/.test(name) +} + +module.exports = WatcherAdapter +RSVP.EventTarget.mixin(WatcherAdapter.prototype) +function WatcherAdapter(options) { + this.options = options || {} + this.options.filter = this.options.filter || defaultFilterFunction +} + +WatcherAdapter.prototype.watch = function(watchedPaths) { + var self = this + + this.watchers = [] + this.readyPromises = [] + watchedPaths.forEach(function(watchedPath) { + var watcher = new sane(watchedPath, self.options) + function bindFileEvent(event) { + watcher.on(event, function(filepath, root, stat) { + logger.debug(event, root + '/' + filepath) + self.trigger('change') + }) + } + bindFileEvent('change') + bindFileEvent('add') + bindFileEvent('delete') + watcher.on('error', function(err) { + logger.debug('error', err) + self.trigger('error', err) + }) + var readyPromise = new RSVP.Promise(function(resolve, reject) { + watcher.on('ready', function() { + logger.debug('ready', watchedPath) + resolve() + }) + }) + self.watchers.push(watcher) + self.readyPromises.push(readyPromise) + }) + return RSVP.Promise.all(this.readyPromises) +} + +WatcherAdapter.prototype.quit = function () { + for (var i = 0; i < this.watchers.length; i++) { + this.watchers[i].close() + } +} diff --git a/package.json b/package.json index ce4b71f..33be520 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,9 @@ { "name": "genkgo-dtk", "main": "lib/dtk-app.js", + "bin": { + "g2dtk": "bin/g2dtk" + }, "dependencies": { "@babel/eslint-parser": "^7.5.4", "@babel/plugin-transform-dynamic-import": "^7.18.6", @@ -35,7 +38,17 @@ "modernizr": "^3.11.8", "node-sass": "^7.0.1 || ^8.0", "node-sass-package-importer": "^5.3.2", - "postcss-discard-unused": "^4.0.1" + "postcss-discard-unused": "^4.0.1", + "broccoli-node-info": "1.1.0", + "commander": "^2.5.0", + "copy-dereference": "^1.0.0", + "heimdalljs-logger": "^0.1.7", + "rimraf": "^2.4.3", + "sane": "^4.1.0", + "shelljs": "^0.8.4", + "tiny-lr": "^1.0.3", + "tmp": "0.0.28", + "underscore.string": "^3.2.2" }, "devDependencies": { "broccoli-test-helper": "^2.0.0",