From 4d1bbe41d73b4006878f7b2a9f9ea0d45bbe8e62 Mon Sep 17 00:00:00 2001 From: Rich-Harris Date: Sat, 4 Apr 2015 13:37:15 -0400 Subject: [PATCH 1/2] implement .observe() --- CHANGELOG.md | 4 ++ package.json | 2 +- src/nodes/Node.js | 49 +++++++++++++- src/nodes/Observer.js | 138 +++++++++++++++++++++++++++++++++++++ src/nodes/Transformer.js | 32 --------- src/nodes/build/index.js | 76 +++++++++++---------- src/nodes/index.js | 3 +- test/scenarios.js | 143 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 377 insertions(+), 70 deletions(-) create mode 100644 src/nodes/Observer.js diff --git a/CHANGELOG.md b/CHANGELOG.md index dbb13d4..ac1e4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.8.0 + + + ## 0.7.11 * More robust invalidation ([#42](https://github.com/gobblejs/gobble/issues/42)) diff --git a/package.json b/package.json index 746116e..060b000 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gobble", "description": "The last build tool you'll ever need", - "version": "0.7.11", + "version": "0.8.0-edge", "author": "Rich Harris", "license": "MIT", "repository": "https://github.com/gobblejs/gobble", diff --git a/src/nodes/Node.js b/src/nodes/Node.js index 1ff3846..58a417e 100644 --- a/src/nodes/Node.js +++ b/src/nodes/Node.js @@ -1,9 +1,10 @@ import { EventEmitter2 } from 'eventemitter2'; -import { rimraf } from 'sander'; +import * as crc32 from 'buffer-crc32'; +import { lsrSync, readFileSync, rimraf } from 'sander'; import { join, resolve } from 'path'; import * as requireRelative from 'require-relative'; import { grab, include, map as mapTransform, move } from '../builtins'; -import { Transformer } from './index'; +import { Observer, Transformer } from './index'; import config from '../config'; import GobbleError from '../utils/GobbleError'; import assign from '../utils/assign'; @@ -108,6 +109,38 @@ export default class Node extends EventEmitter2 { return new Transformer( this, include, { patterns, exclude: true }); } + getChanges ( inputdir ) { + const files = lsrSync( inputdir ); + + if ( !this._files ) { + this._files = files; + this._checksums = {}; + + files.forEach( file => { + this._checksums[ file ] = crc32( readFileSync( inputdir, file ) ); + }); + + return files.map( file => ({ file, added: true }) ); + } + + const added = files.filter( file => !~this._files.indexOf( file ) ).map( file => ({ file, added: true }) ); + const removed = this._files.filter( file => !~files.indexOf( file ) ).map( file => ({ file, removed: true }) ); + + const maybeChanged = files.filter( file => ~this._files.indexOf( file ) ); + + let changed = []; + + maybeChanged.forEach( file => { + let checksum = crc32( readFileSync( inputdir, file ) ); + if ( checksum !== this._checksums[ file ] ) { + changed.push({ file, changed: true }); + this._checksums[ file ] = checksum; + } + }); + + return added.concat( removed ).concat( changed ); + } + grab () { const src = join.apply( null, arguments ); return new Transformer( this, grab, { src }); @@ -140,6 +173,18 @@ export default class Node extends EventEmitter2 { return new Transformer( this, move, { dest }); } + observe ( fn, userOptions ) { + if ( userOptions && '__condition' in userOptions && !userOptions.__condition ) { + return this; + } + + if ( typeof fn === 'string' ) { + fn = tryToLoad( fn ); + } + + return new Observer( this, fn, userOptions ); + } + serve ( options ) { return serve( this, options ); } diff --git a/src/nodes/Observer.js b/src/nodes/Observer.js new file mode 100644 index 0000000..a192451 --- /dev/null +++ b/src/nodes/Observer.js @@ -0,0 +1,138 @@ +import * as sander from 'sander'; +import Node from './Node'; +import queue from '../queue'; +import GobbleError from '../utils/GobbleError'; +import assign from '../utils/assign'; +import uid from '../utils/uid'; +import makeLog from '../utils/makeLog'; +import config from '../config'; +import extractLocationInfo from '../utils/extractLocationInfo'; +import { ABORTED } from '../utils/signals'; + +export default class Observer extends Node { + constructor ( input, fn, options, id ) { + super(); + + this.input = input; + + this.fn = fn; + this.options = assign( {}, options ); + + this.name = id || fn.id || fn.name || 'unknown'; + this.id = uid( this.name ); + } + + ready () { + let observation; + + if ( !this._ready ) { + observation = { + node: this, + log: makeLog( this ), + env: config.env, + sander: sander + }; + + this._abort = () => { + this._ready = null; + observation.aborted = true; + }; + + this._ready = this.input.ready().then( inputdir => { + return queue.add( ( fulfil, reject ) => { + this.emit( 'info', { + code: 'TRANSFORM_START', // TODO + progressIndicator: true, + id: this.id + }); + + const start = Date.now(); + let called = false; + + const callback = err => { + if ( called ) return; + called = true; + + if ( observation.aborted ) { + reject( ABORTED ); + } + + else if ( err ) { + let stack = err.stack || new Error().stack; + let { file, line, column } = extractLocationInfo( err ); + + let gobbleError = new GobbleError({ + inputdir, + stack, file, line, column, + message: 'observation failed', + id: this.id, + code: 'TRANSFORMATION_FAILED', // TODO + original: err + }); + + reject( gobbleError ); + } + + else { + this.emit( 'info', { + code: 'TRANSFORM_COMPLETE', // TODO + id: this.id, + duration: Date.now() - start + }); + + fulfil( inputdir ); + } + }; + + try { + observation.changes = this.input.changes || this.getChanges( inputdir ); + + const promise = this.fn.call( observation, inputdir, assign({}, this.options ), callback ); + const promiseIsPromise = promise && typeof promise.then === 'function'; + + if ( !promiseIsPromise && this.fn.length < 3 ) { + throw new Error( `Observer ${this.id} did not return a promise and did not accept callback` ); + } + + if ( promiseIsPromise ) { + promise.then( () => callback(), callback ); + } + } catch ( err ) { + callback( err ); + } + }); + }); + } + + return this._ready; + } + + start () { + if ( this._active ) { + return; + } + + this._active = true; + + // Propagate invalidation events and information + this._oninvalidate = changes => { + this._abort(); + this.emit( 'invalidate', changes ); + }; + + this._oninfo = details => this.emit( 'info', details ); + + this.input.on( 'invalidate', this._oninvalidate ); + this.input.on( 'info', this._oninfo ); + + return this.input.start(); + } + + stop () { + this.input.off( 'invalidate', this._oninvalidate ); + this.input.off( 'info', this._oninfo ); + + this.input.stop(); + this._active = false; + } +} diff --git a/src/nodes/Transformer.js b/src/nodes/Transformer.js index 953af81..853973f 100644 --- a/src/nodes/Transformer.js +++ b/src/nodes/Transformer.js @@ -152,38 +152,6 @@ export default class Transformer extends Node { this._active = false; } - getChanges ( inputdir ) { - const files = lsrSync( inputdir ); - - if ( !this._files ) { - this._files = files; - this._checksums = {}; - - files.forEach( file => { - this._checksums[ file ] = crc32( readFileSync( inputdir, file ) ); - }); - - return files.map( file => ({ file, added: true }) ); - } - - const added = files.filter( file => !~this._files.indexOf( file ) ).map( file => ({ file, added: true }) ); - const removed = this._files.filter( file => !~files.indexOf( file ) ).map( file => ({ file, removed: true }) ); - - const maybeChanged = files.filter( file => ~this._files.indexOf( file ) ); - - let changed = []; - - maybeChanged.forEach( file => { - let checksum = crc32( readFileSync( inputdir, file ) ); - if ( checksum !== this._checksums[ file ] ) { - changed.push({ file, changed: true }); - this._checksums[ file ] = checksum; - } - }); - - return added.concat( removed ).concat( changed ); - } - _cleanup ( latest ) { const dir = join( session.config.gobbledir, this.id ); diff --git a/src/nodes/build/index.js b/src/nodes/build/index.js index f42d1d6..3e34237 100644 --- a/src/nodes/build/index.js +++ b/src/nodes/build/index.js @@ -20,14 +20,30 @@ export default function ( node, options ) { let promise; let previousDetails; - // that does double duty as a promise - task.then = function () { - return promise.then.apply( promise, arguments ); - }; + function build () { + task.emit( 'info', { + code: 'BUILD_START' + }); - task.catch = function () { - return promise.catch.apply( promise, arguments ); - }; + node.on( 'info', details => { + if ( details === previousDetails ) return; + previousDetails = details; + task.emit( 'info', details ); + }); + + node.start(); // TODO this starts a file watcher! need to start without watching + + return node.ready().then( + inputdir => { + node.stop(); + return copydir( inputdir ).to( dest ); + }, + err => { + node.stop(); + throw err; + } + ); + } promise = cleanup( gobbledir ) .then( () => { @@ -43,34 +59,26 @@ export default function ( node, options ) { return cleanup( dest ).then( build ); }, build ); }) - .then( () => { - task.emit( 'complete' ); - session.destroy(); - }) - .catch( err => { - task.emit( 'error', err ); - session.destroy(); - throw err; - }); + .then( + () => { + task.emit( 'complete' ); + session.destroy(); + }, + err => { + session.destroy(); + task.emit( 'error', err ); + throw err; + } + ); - return task; - - function build () { - task.emit( 'info', { - code: 'BUILD_START' - }); - - node.on( 'info', details => { - if ( details === previousDetails ) return; - previousDetails = details; - task.emit( 'info', details ); - }); + // that does double duty as a promise + task.then = function () { + return promise.then.apply( promise, arguments ); + }; - node.start(); // TODO this starts a file watcher! need to start without watching + task.catch = function () { + return promise.catch.apply( promise, arguments ); + }; - return node.ready().then( inputdir => { - node.stop(); - return copydir( inputdir ).to( dest ); - }); - } + return task; } diff --git a/src/nodes/index.js b/src/nodes/index.js index 8182292..7489cf0 100644 --- a/src/nodes/index.js +++ b/src/nodes/index.js @@ -1,5 +1,6 @@ import Source from './Source'; import Merger from './Merger'; +import Observer from './Observer'; import Transformer from './Transformer'; -export { Source, Merger, Transformer }; +export { Source, Merger, Observer, Transformer }; diff --git a/test/scenarios.js b/test/scenarios.js index 2b17d3e..555dfc2 100644 --- a/test/scenarios.js +++ b/test/scenarios.js @@ -5,6 +5,8 @@ var gobble = require( '../tmp' ).default; var sander = require( 'sander' ); var simulateChange = require( './utils/simulateChange' ); +var Promise = sander.Promise; + gobble.cwd( __dirname ); module.exports = function () { @@ -510,6 +512,147 @@ module.exports = function () { task.on( 'error', done ); }); + + it( 'calls observers on initial build', function () { + var observed = 0; + + var source = gobble( 'tmp/foo' ).observe( function ( inputdir, options, done ) { + observed += 1; + done(); + }); + + task = source.build({ + dest: 'tmp/output' + }); + + return task.then( function () { + assert.equal( observed, 1 ); + }); + }); + + it( 'triggers observers on file changes', function ( done ) { + var observed = 0; + + var source = gobble( 'tmp/foo' ); + + task = source.observe( function ( inputdir, options, done ) { + observed += 1; + done(); + }).watch({ + dest: 'tmp/output' + }); + + task.once( 'built', function () { + assert.equal( observed, 1 ); + + task.once( 'built', function () { + assert.equal( observed, 2 ); + done(); + }); + + simulateChange( source, { + type: 'change', + path: 'tmp/foo/foo.md' + }); + }); + }); + + it( 'prevents build completing if observers error', function () { + var source = gobble( 'tmp/foo' ); + var error, threw; + + var node = source + .observe( function () { + error = new Error( 'oh noes!' ); + throw error; + }); + + return node.build({ + dest: 'tmp/output' + }) + .catch( function ( err ) { + if ( err.original == error ) { + threw = true; + } else { + throw err; + } + }) + .then( function () { + assert.ok( threw ); + }); + }); + + it( 'prevents build completing if observers fail asynchronously via callback', function () { + var source = gobble( 'tmp/foo' ); + var error, threw; + + var node = source + .observe( function ( inputdir, options, done ) { + error = new Error( 'oh noes!' ); + setTimeout( function () { + done( error ); + }); + }); + + return node.build({ + dest: 'tmp/output' + }) + .catch( function ( err ) { + if ( err.original == error ) { + threw = true; + } else { + throw err; + } + }) + .then( function () { + assert.ok( threw ); + }); + }); + + it( 'prevents build completing if observers fail asynchronously via promise', function () { + var source = gobble( 'tmp/foo' ); + var error, threw; + + var node = source + .observe( function () { + error = new Error( 'oh noes!' ); + return Promise.reject( error ); + }); + + return node.build({ + dest: 'tmp/output' + }) + .catch( function ( err ) { + if ( err.original == error ) { + threw = true; + } else { + throw err; + } + }) + .then( function () { + assert.ok( threw ); + }); + }); + + it( 'skips an observer if __condition is false', function () { + var observed = 0; + + function incrementObservedCount () { + observed += 1; + } + + var source = gobble( 'tmp/foo' ).observe( incrementObservedCount, { + __condition: false + }); + + task = source.build({ + dest: 'tmp/output' + }); + + return task.then( function () { + assert.equal( observed, 0 ); + }); + }); }); From 7fe7093702a0d9f19f3728ca39868517ed6d7fc7 Mon Sep 17 00:00:00 2001 From: Rich-Harris Date: Thu, 9 Apr 2015 11:27:39 -0400 Subject: [PATCH 2/2] implement observeIf and transformIf --- src/nodes/Node.js | 12 ++++++++---- test/scenarios.js | 24 ++++++++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/nodes/Node.js b/src/nodes/Node.js index 58a417e..7a5087a 100644 --- a/src/nodes/Node.js +++ b/src/nodes/Node.js @@ -174,10 +174,6 @@ export default class Node extends EventEmitter2 { } observe ( fn, userOptions ) { - if ( userOptions && '__condition' in userOptions && !userOptions.__condition ) { - return this; - } - if ( typeof fn === 'string' ) { fn = tryToLoad( fn ); } @@ -185,6 +181,10 @@ export default class Node extends EventEmitter2 { return new Observer( this, fn, userOptions ); } + observeIf ( condition, fn, userOptions ) { + return condition ? this.observe( fn, userOptions ) : this; + } + serve ( options ) { return serve( this, options ); } @@ -213,6 +213,10 @@ export default class Node extends EventEmitter2 { return new Transformer( this, fn, userOptions ); } + transformIf ( condition, fn, userOptions ) { + return condition ? this.transform( fn, userOptions ) : this; + } + watch ( options ) { return watch( this, options ); } diff --git a/test/scenarios.js b/test/scenarios.js index 555dfc2..1449466 100644 --- a/test/scenarios.js +++ b/test/scenarios.js @@ -634,16 +634,14 @@ module.exports = function () { }); }); - it( 'skips an observer if __condition is false', function () { + it( 'skips an observer if condition is false', function () { var observed = 0; function incrementObservedCount () { observed += 1; } - var source = gobble( 'tmp/foo' ).observe( incrementObservedCount, { - __condition: false - }); + var source = gobble( 'tmp/foo' ).observeIf( false, incrementObservedCount ); task = source.build({ dest: 'tmp/output' @@ -653,6 +651,24 @@ module.exports = function () { assert.equal( observed, 0 ); }); }); + + it( 'skips a transformer if condition is false', function () { + var source = gobble( 'tmp/foo' ); + + return source + .transformIf( false, function ( input ) { + return input.toUpperCase(); + }) + .build({ + dest: 'tmp/output' + }) + .then( function () { + assert.equal( + sander.readFileSync( 'tmp/foo/foo.md' ).toString(), + sander.readFileSync( 'tmp/output/foo.md' ).toString() + ); + }) + }); });