Skip to content

Commit

Permalink
Rework watcher, and pull broccoli-sane-watcher functionality into core
Browse files Browse the repository at this point in the history
  • Loading branch information
joliss committed Dec 1, 2016
1 parent 44284f0 commit 7cc793f
Show file tree
Hide file tree
Showing 10 changed files with 204 additions and 417 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# master

* Rework watcher
* Pull broccoli-sane-watcher functionality into core
* Update findup-sync dependency

# 1.0.0-beta.7
Expand Down
4 changes: 3 additions & 1 deletion lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ var program = require('commander')
var copyDereferenceSync = require('copy-dereference').sync

var broccoli = require('./index')
var Watcher = require('./watcher')


module.exports = broccoliCLI
function broccoliCLI () {
Expand All @@ -17,7 +19,7 @@ function broccoliCLI () {
.option('--host <host>', 'the host to bind to [localhost]', 'localhost')
.action(function(options) {
actionPerformed = true
broccoli.server.serve(getBuilder(), options)
broccoli.server.serve(new Watcher(getBuilder()), options.host, options.port)
})

program.command('build <target>')
Expand Down
1 change: 1 addition & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ exports.loadBrocfile = require('./load_brocfile')
exports.server = require('./server')
exports.getMiddleware = require('./middleware')
exports.Watcher = require('./watcher')
exports.WatcherAdapter = require('./watcher_adapter')
exports.cli = require('./cli')
5 changes: 4 additions & 1 deletion lib/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ var mime = require('mime')
var errorTemplate = handlebars.compile(fs.readFileSync(path.resolve(__dirname, 'templates/error.html')).toString())
var dirTemplate = handlebars.compile(fs.readFileSync(path.resolve(__dirname, 'templates/dir.html')).toString())

// You must call watcher.watch() before you call `getMiddleware`
// You must call watcher.start() before you call `getMiddleware`
//
// This middleware is for development use only. It hasn't been reviewed
// carefully enough to run on a production server.
Expand All @@ -23,6 +23,9 @@ module.exports = function getMiddleware(watcher, options) {
var outputPath = watcher.builder.outputPath

return function broccoliMiddleware(request, response, next) {
if (watcher.currentBuild == null) {
throw new Error('Waiting for initial build to start')
}
watcher.currentBuild.then(function() {
var urlObj = url.parse(request.url)
var filename = path.join(outputPath, decodeURIComponent(urlObj.pathname))
Expand Down
62 changes: 37 additions & 25 deletions lib/server.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,44 @@
var Watcher = require('./watcher')
var middleware = require('./middleware')
var http = require('http')
var connect = require('connect')
var printSlowNodes = require('broccoli-slow-trees')

exports.serve = serve
function serve (builder, options) {
options = options || {}
function serve (watcher, host, port) {
if (watcher.constructor.name !== 'Watcher') throw new Error('Expected Watcher instance')
if (typeof host !== 'string') throw new Error('Expected host to bind to (e.g. "localhost")')
if (typeof port !== 'number') throw new Error('Expected port to bind to (e.g. 4200)')

var server = {}

console.log('Serving on http://' + options.host + ':' + options.port + '\n')
console.log('Serving on http://' + host + ':' + port + '\n')

server.watcher = options.watcher || new Watcher(builder)
server.watcher = watcher
server.builder = server.watcher.builder

server.app = connect().use(middleware(server.watcher))

server.http = http.createServer(server.app)

server.watcher.watch()
.catch(function(err) {
console.log(err && err.stack || err)
})
.finally(function() {
builder.cleanup()
server.http.close()
})
.catch(function(err) {
console.log('Cleanup error:')
console.log(err && err.stack || err)
})
.finally(function() {
process.exit(1)
})


// We register these so the 'exit' handler removing temp dirs is called
function cleanupAndExit() {
// TODO who is responsible for calling builder.cleanup?
return server.watcher.quit()
}

process.on('SIGINT', cleanupAndExit)
process.on('SIGTERM', cleanupAndExit)

server.watcher.on('change', function() {
printSlowNodes(builder.outputNodeWrapper)
console.log('Built - ' + Math.round(builder.outputNodeWrapper.buildState.totalTime) + ' ms @ ' + new Date().toString())


server.watcher.on('buildSuccess', function() {
printSlowNodes(server.builder.outputNodeWrapper)
console.log('Built - ' + Math.round(server.builder.outputNodeWrapper.buildState.totalTime) + ' ms @ ' + new Date().toString())
})

server.watcher.on('error', function(err) {
server.watcher.on('buildFailure', function(err) {
console.log('Built with error:')
console.log(err.message)
if (!err.broccoliPayload || !err.broccoliPayload.location.file) {
Expand All @@ -56,6 +48,26 @@ function serve (builder, options) {
console.log('')
})

server.http.listen(parseInt(options.port, 10), options.host)


server.watcher.start()
.catch(function(err) {
console.log(err && err.stack || err)
})
.finally(function() {
server.builder.cleanup() // TODO
server.http.close()
})
.catch(function(err) {
console.log('Cleanup error:')
console.log(err && err.stack || err)
})
.finally(function() {
process.exit(1)
})



server.http.listen(parseInt(port, 10), host)
return server
}
181 changes: 102 additions & 79 deletions lib/watcher.js
Original file line number Diff line number Diff line change
@@ -1,111 +1,134 @@
'use strict'

var RSVP = require('rsvp')
var WatcherAdapter = require('./watcher_adapter')
var logger = require('heimdalljs-logger')('broccoli:watcher')

var helpers = require('broccoli-kitchen-sink-helpers')

// 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.builder = builder
this.options = options || {}
this.treeHashes = []
this.quitting = false
this.initialBuildStarted = false
this.watchDeferred = null
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.watch = function() {
Watcher.prototype.start = function() {
var self = this

if (this.watchDeferred != null) throw new Error('watcher.watch() must only be called once')
this.watchDeferred = RSVP.defer()
self.check()
return this.watchDeferred.promise
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.detectChanges = function () {
var changedPaths = []
Watcher.prototype._change = function() {
var self = this

for (var i = 0; i < this.builder.watchedPaths.length; i++) {
var hash = helpers.hashTree(this.builder.watchedPaths[i])
if (hash !== this.treeHashes[i]) {
changedPaths.push(this.builder.watchedPaths[i])
this.treeHashes[i] = hash
}
if (!this._ready) {
logger.debug('change', 'ignored: before ready')
return
}

return changedPaths
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.check = function() {
Watcher.prototype._build = function() {
var self = this

this.timeoutID = null
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
}

// .check can be scheduled via setTimeout or via .then, so we cannot
// just rely on clearTimeout for quitting
if (this.quitting) {
return
}
Watcher.prototype._error = function(err) {
var self = this

try {
var changedPaths = this.detectChanges()

if (changedPaths.length > 0 || !this.initialBuildStarted) {
this.initialBuildStarted = true
this.trigger('build')
this.currentBuild = this.builder.build()
this.currentBuild
// Trigger change/error events. If somebody else chains to
// currentBuild, their callback will come after our events have
// triggered, because we registered our callback first. This is subtle
// but important.
.then(function() {
self.trigger('change')
}, function(err) {
self.trigger('error', err)
// Do not rethrow
})
.then(function() {
// Resume watching
self.check()
}, function(err) {
// A 'change' or 'error' event handler threw an error
self.watchDeferred.reject(err)
})
} else {
// Schedule next check in 100 milliseconds
var interval = this.options.interval || 100
this.timeoutID = setTimeout(this.check.bind(this), interval)
}
} catch (err) {
// An error occurred in this.detectChanges(); this is usually because one
// of the watched source directories is missing
this.watchDeferred.reject(err)
}
logger.debug('error', err)
if (this._quitting) return
this._quit().catch(function() { }).then(function() {
self._lifetimeDeferred.reject(err)
})
}

// You typically want to call watcher.quit().then()
Watcher.prototype.quit = function() {
var self = this

this.quitting = true
if (this.timeoutID) {
clearTimeout(this.timeoutID)
this.timeoutID = null
if (this._quitting) {
logger.debug('quit', 'ignored: already quitting')
return
}
this._quit().then(function() {
self._lifetimeDeferred.resolve()
}, function(err) {
self._lifetimeDeferred.reject(err)
})
}

return RSVP.resolve(this.currentBuild)
.catch(function(err) {
// Ignore build errors to stop them from being propagated to
// RSVP.on('error')
})
.finally(function() {
// It might have been rejected in the meantime, in which case this has
// no effect
self.watchDeferred.resolve()
})
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')
})
}
Loading

0 comments on commit 7cc793f

Please sign in to comment.