From f9efb42fc7969a7c3ee23b8e18d614b0bd18ee6c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 14 May 2015 18:25:51 -0400 Subject: [PATCH] initial commit --- .babelrc | 18 +++ .gitignore | 4 + README.md | 119 ++++++++++++++++++ gobblefile.js | 9 ++ package.json | 42 +++++++ src/Bundle/index.js | 76 +++++++++++ src/Module/index.js | 85 +++++++++++++ src/ast/Scope.js | 31 +++++ src/ast/analyse.js | 98 +++++++++++++++ src/ast/walk.js | 57 +++++++++ src/rollup.js | 16 +++ src/utils/map-helpers.js | 3 + src/utils/object.js | 1 + src/utils/promise.js | 22 ++++ .../samples/import-default-binding/_config.js | 3 + test/samples/import-default-binding/foo.js | 3 + test/samples/import-default-binding/main.js | 2 + test/samples/no-imports/_config.js | 3 + test/samples/no-imports/main.js | 1 + test/test.js | 39 ++++++ 20 files changed, 632 insertions(+) create mode 100644 .babelrc create mode 100644 .gitignore create mode 100644 README.md create mode 100644 gobblefile.js create mode 100644 package.json create mode 100644 src/Bundle/index.js create mode 100644 src/Module/index.js create mode 100644 src/ast/Scope.js create mode 100644 src/ast/analyse.js create mode 100644 src/ast/walk.js create mode 100644 src/rollup.js create mode 100644 src/utils/map-helpers.js create mode 100644 src/utils/object.js create mode 100644 src/utils/promise.js create mode 100644 test/samples/import-default-binding/_config.js create mode 100644 test/samples/import-default-binding/foo.js create mode 100644 test/samples/import-default-binding/main.js create mode 100644 test/samples/no-imports/_config.js create mode 100644 test/samples/no-imports/main.js create mode 100644 test/test.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..f6216cb --- /dev/null +++ b/.babelrc @@ -0,0 +1,18 @@ +{ + "whitelist": [ + "es6.arrowFunctions", + "es6.blockScoping", + "es6.classes", + "es6.constants", + "es6.destructuring", + "es6.parameters.default", + "es6.parameters.rest", + "es6.properties.shorthand", + "es6.spread", + "es6.templateLiterals" + ], + "loose": [ + "es6.classes", + "es6.destructuring" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f38aa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules +.gobble* +dist \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f339bb --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# rollup + +*I roll up, I roll up, I roll up, Shawty I roll up* +*I roll up, I roll up, I roll up* + +-- [Wiz Khalifa](https://www.youtube.com/watch?v=UhQz-0QVmQ0) + +This is an experimental project. You definitely shouldn't try and use it, yet. + + +## A next-generation ES6 module bundler + +Right now, you have a few different options if you want to create a bundle out of your ES6 modules: + +* The best option, in terms of performance, size of the resulting bundle, and accurate representation of ES6 module semantics, is to use [esperanto](esperantojs.org). It's used by [ractive.js](ractivejs.org), [moment.js](http://momentjs.com/), Facebook's [immutable.js](https://github.com/facebook/immutable-js), the jQuery Foundation's [pointer events polyfill](https://github.com/jquery/PEP), [Ember CLI](http://www.ember-cli.com/) and a bunch of other libraries and apps +* You could use [jspm](http://jspm.io/), which combines a module bundler with a loader and a package manager +* Or you could use [browserify](http://browserify.org/) or [webpack](http://webpack.github.io/), transpiling your modules into CommonJS along the way + +But there's a flaw in how these systems work. Pretend it's the future, and lodash is available as an ES6 module, and you want to use a single helper function from it: + +```js +// app.js +import { pluck } from 'lodash'; +``` + +With that single import statement, you've just caused the whole of [lodash](https://lodash.com/) to be included in your bundle, even though you only need to use a tiny fraction of the code therein. + +If you're using esperanto, that's not totally disastrous, because a sufficiently good minifier will be able to determine through [static analysis](http://en.wikipedia.org/wiki/Static_program_analysis) that most of the code will never run, and so remove it. But there are lots of situations where static analysis fails, and unused code will continue to clutter up your bundle. + +If you're using any of the other tools, static analysis won't be able to even begin to determine which of lodash's exports aren't used by your app (AFAIK! please correct me if I'm wrong). + + +### The current solution is not future-proof + +I picked lodash because it does offer one solution to this problem: modular builds. Today, in your CommonJS modules, you can do this: + +```js +var pluck = require( 'lodash/collection/pluck' ); +``` + +**This is not the answer.** Using a folder structure to define an interface is a bad idea - it makes it harder to guarantee backwards compatibility, it imposes awkward constraints on library authors, and it allows developers to do this sort of thing: + +```js +var cheekyHack = require( 'undocumented/private/module' ); +``` + +Sure enough, you [won't be able to do this with ES6 modules](https://github.com/esperantojs/esperanto/issues/68#issuecomment-73302346). + + +### A better approach? + +This project is an attempt to prove a different idea: ES6 modules should define their interface through a single file (which, by convention, is currently exposed as the `jsnext:main` field in your package.json file), like so... + +```js +// snippet from future-lodash.js +export { partition } from './src/collection/partition'; +export { pluck } from './src/collection/pluck'; +export { reduce } from './src/collection/reduce'; +/* ...and so on... */ +``` + +...and ES6 *bundlers* should handle importing in a much more granular fashion. Rather than importing an entire module, an intelligent bundler should be able to reason like so: + +* I need to import `pluck` from `future-lodash.js` +* According to `future-lodash.js`, the definition of `pluck` can be found in `lodash/src/collection/pluck.js` +* It seems that `pluck` depends on `map` and `property`, which are in... *these* files +* ... +* Right, I've found all the function definitions I need. I can just include those in my bundle and disregard the rest + +In other words, the 'tree-shaking' approach of throwing everything in then removing the bits you don't need is all wrong - instead, we should be selective about what we include in the first place. + +This is not a trivial task. There are almost certainly a great many complex edge cases. Perhaps it's not possible. But I intend to find out. + + +## Goals + +* Maximally efficient bundling +* Ease of use +* Flexible output - CommonJS, AMD, UMD, globals, ES6, System etc +* Speed +* Character-accurate sourcemaps +* Eventually, port the functionality to esperanto + +### Secondary goals + +* Support for legacy module formats +* Respect for original formatting and code comments + + +### API + +The example below is aspirational. It isn't yet implemented - it exists in the name of [README driven development](http://tom.preston-werner.com/2010/08/23/readme-driven-development.html). + +```js +rollup.rollup( 'app.js', { + /* options */ +}).then( function ( bundle ) { + // generate code and a sourcemap + const { code, map } = bundle.generate({ + format: 'amd' + }); + + fs.writeFileSync( 'bundle.js', code + '\n//# sourceMappingURL=bundle.js.map' ); + fs.writeFileSync( 'bundle.js.map', map.toString() ); + + // possible convenience method + bundle.write({ + dest: 'bundle.js', // also writes sourcemap + format: 'amd' + }); +}); +``` + +The duplication (`rollup.rollup`) is intentional. You have to say it like you're in the circus, otherwise it won't work. + + +## License + +Not that there's any code here at the time of writing, but this project is released under the MIT license. \ No newline at end of file diff --git a/gobblefile.js b/gobblefile.js new file mode 100644 index 0000000..d5bac91 --- /dev/null +++ b/gobblefile.js @@ -0,0 +1,9 @@ +var gobble = require( 'gobble' ); + +module.exports = gobble( 'src' ) + .transform( 'babel' ) + .transform( 'esperanto-bundle', { + entry: 'rollup', + type: 'cjs', + strict: true + }); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..3bbf64d --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "rollup", + "version": "0.1.0", + "description": "Next-generation ES6 module bundler", + "main": "dist/index.js", + "jsnext:main": "src/index.js", + "scripts": { + "test": "mocha", + "pretest": "npm run build", + "build": "gobble build -f dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/rich-harris/rollup" + }, + "keywords": [ + "modules", + "bundler", + "bundling", + "es6", + "optimizer" + ], + "author": "Rich Harris", + "license": "MIT", + "bugs": { + "url": "https://github.com/rich-harris/rollup/issues" + }, + "homepage": "https://github.com/rich-harris/rollup", + "devDependencies": { + "gobble": "^0.10.1", + "gobble-babel": "^5.1.0", + "gobble-cli": "^0.4.2", + "gobble-esperanto-bundle": "^0.2.0", + "mocha": "^2.2.4", + "source-map-support": "^0.2.10" + }, + "dependencies": { + "acorn": "^1.1.0", + "escodegen": "^1.6.1", + "sander": "^0.3.3" + } +} diff --git a/src/Bundle/index.js b/src/Bundle/index.js new file mode 100644 index 0000000..df06276 --- /dev/null +++ b/src/Bundle/index.js @@ -0,0 +1,76 @@ +import { resolve } from 'path'; +import { readFile } from 'sander'; +import { generate } from 'escodegen'; +import { hasOwnProp } from '../utils/object'; +import { sequence } from '../utils/promise'; +import Module from '../Module/index'; + +export default class Bundle { + constructor ( options ) { + this.entryPath = resolve( options.entry ); + this.entryModule = null; + + this.modulePromises = {}; + this.modules = {}; + + // this will store the top-level AST nodes we import + this.body = []; + } + + collect () { + return this.build() + .then( () => { + return this; + }); + } + + fetchModule ( path ) { + if ( !hasOwnProp.call( this.modulePromises, path ) ) { + this.modulePromises[ path ] = readFile( path, { encoding: 'utf-8' }) + .then( code => { + const module = new Module({ + path, + code, + bundle: this + }); + + this.modules[ path ] = module; + return module; + }); + } + + return this.modulePromises[ path ]; + } + + build () { + // bring in top-level AST nodes from the entry module + return this.fetchModule( this.entryPath ) + .then( entryModule => { + this.entryModule = entryModule; + + // pull in imports + return sequence( Object.keys( entryModule.imports ), name => { + return entryModule.define( name ) + .then( nodes => this.body.push.apply( this.body, nodes ) ); + }) + .then( () => { + entryModule.ast.body.forEach( node => { + // exclude imports and exports, include everything else + if ( !/^(?:Im|Ex)port/.test( node.type ) ) { + this.body.push( node ); + } + }); + }); + }); + } + + generate () { + return { + code: generate({ + type: 'Program', + body: this.body + }), + map: null // TODO... + }; + } +} \ No newline at end of file diff --git a/src/Module/index.js b/src/Module/index.js new file mode 100644 index 0000000..b92dab0 --- /dev/null +++ b/src/Module/index.js @@ -0,0 +1,85 @@ +import { dirname, resolve } from 'path'; +import { parse } from 'acorn'; +import analyse from '../ast/analyse'; +import { hasOwnProp } from '../utils/object'; +import { sequence } from '../utils/promise'; + +export default class Module { + constructor ({ path, code, bundle }) { + this.path = path; + this.code = code; + this.bundle = bundle; + + this.ast = parse( code, { + ecmaVersion: 6, + sourceType: 'module' + }); + + analyse( this.ast ); + + this.definitions = {}; + this.modifications = {}; + + this.ast._topLevelStatements.forEach( statement => { + Object.keys( statement._defines ).forEach( name => { + this.definitions[ name ] = statement; + }); + + Object.keys( statement._modifies ).forEach( name => { + this.modifications[ name ] = statement; + }); + }); + + this.imports = {}; + this.exports = {}; + + this.ast.body.forEach( node => { + if ( node.type === 'ImportDeclaration' ) { + const source = node.source.value; + + node.specifiers.forEach( specifier => { + const name = specifier.local.name; + + this.imports[ name ] = { + source, + name, // TODO import { foo as bar } etc + isDefault: specifier.type === 'ImportDefaultSpecifier', + module: null + }; + }); + } + + else if ( node.type === 'ExportDeclaration' ) { + // TODO + } + }); + } + + define ( name ) { + if ( hasOwnProp.call( this.imports, name ) ) { + const declaration = this.imports[ name ]; + const path = resolve( dirname( this.path ), declaration.source ) + '.js'; + return this.bundle.fetchModule( path ) + .then( module => module.define( name ) ); + } + + else { + const statement = this.definitions[ name ]; + + if ( statement ) { + const nodes = []; + + return sequence( Object.keys( statement._dependsOn ), name => { + return this.define( name ); + }) + .then( definitions => { + nodes.push.apply( nodes, definitions ); + }) + .then( () => { + nodes.push( statement ); + }) + .then( () => nodes ); + } + } + } +} \ No newline at end of file diff --git a/src/ast/Scope.js b/src/ast/Scope.js new file mode 100644 index 0000000..4b31dc6 --- /dev/null +++ b/src/ast/Scope.js @@ -0,0 +1,31 @@ +export default class Scope { + constructor ( options ) { + options = options || {}; + + this.parent = options.parent; + this.names = options.params || []; + this.isBlockScope = !!options.block; + } + + add ( name, isBlockDeclaration ) { + if ( !isBlockDeclaration && this.isBlockScope ) { + // it's a `var` or function declaration, and this + // is a block scope, so we need to go up + this.parent.add( name, isBlockDeclaration ); + } else { + this.names.push( name ); + } + } + + contains ( name ) { + if ( ~this.names.indexOf( name ) ) { + return true; + } + + if ( this.parent ) { + return this.parent.contains( name ); + } + + return false; + } +} \ No newline at end of file diff --git a/src/ast/analyse.js b/src/ast/analyse.js new file mode 100644 index 0000000..e161111 --- /dev/null +++ b/src/ast/analyse.js @@ -0,0 +1,98 @@ +import walk from './walk'; +import Scope from './Scope'; +import { getName } from '../utils/map-helpers'; + +function isStatement ( node ) { + return node.type === 'ExpressionStatement' || + node.type === 'FunctionDeclaration'; // TODO or any of the other various statement-ish things it could be +} + +export default function analyse ( ast ) { + let scope = new Scope(); + let topLevelStatements = []; + let currentTopLevelStatement; + + function addToScope ( declarator ) { + var name = declarator.id.name; + scope.add( name, false ); + + if ( !scope.parent ) { + currentTopLevelStatement._defines[ name ] = true; + } + } + + function addToBlockScope ( declarator ) { + var name = declarator.id.name; + scope.add( name, true ); + + if ( !scope.parent ) { + currentTopLevelStatement._defines[ name ] = true; + } + } + + walk( ast, { + enter ( node, parent ) { + if ( !currentTopLevelStatement && isStatement( node ) ) { + node._defines = {}; + node._modifies = {}; + node._dependsOn = {}; + + currentTopLevelStatement = node; + topLevelStatements.push( node ); + } + + switch ( node.type ) { + case 'FunctionExpression': + case 'FunctionDeclaration': + case 'ArrowFunctionExpression': + if ( node.id ) { + addToScope( node ); + } + + let names = node.params.map( getName ); + + scope = node._scope = new Scope({ + parent: scope, + params: names, // TODO rest params? + block: false + }); + + break; + + case 'BlockStatement': + scope = node._scope = new Scope({ + parent: scope, + block: true + }); + + break; + + case 'VariableDeclaration': + node.declarations.forEach( node.kind === 'let' ? addToBlockScope : addToScope ); // TODO const? + break; + + case 'ClassExpression': + case 'ClassDeclaration': + addToScope( node ); + break; + } + }, + leave ( node ) { + if ( node === currentTopLevelStatement ) { + currentTopLevelStatement = null; + } + + switch ( node.type ) { + case 'FunctionExpression': + case 'FunctionDeclaration': + case 'ArrowFunctionExpression': + case 'BlockStatement': + scope = scope.parent; + break; + } + } + }); + + ast._scope = scope; + ast._topLevelStatements = topLevelStatements; +} \ No newline at end of file diff --git a/src/ast/walk.js b/src/ast/walk.js new file mode 100644 index 0000000..a6202a1 --- /dev/null +++ b/src/ast/walk.js @@ -0,0 +1,57 @@ +let shouldSkip; +let shouldAbort; + +export default function walk ( ast, { enter, leave }) { + shouldAbort = false; + visit( ast, null, enter, leave ); +} + +let context = { + skip: () => shouldSkip = true, + abort: () => shouldAbort = true +}; + +let childKeys = {}; + +let toString = Object.prototype.toString; + +function isArray ( thing ) { + return toString.call( thing ) === '[object Array]'; +} + +function visit ( node, parent, enter, leave ) { + if ( !node || shouldAbort ) return; + + if ( enter ) { + shouldSkip = false; + enter.call( context, node, parent ); + if ( shouldSkip || shouldAbort ) return; + } + + let keys = childKeys[ node.type ] || ( + childKeys[ node.type ] = Object.keys( node ).filter( key => typeof node[ key ] === 'object' ) + ); + + let key, value, i, j; + + i = keys.length; + while ( i-- ) { + key = keys[i]; + value = node[ key ]; + + if ( isArray( value ) ) { + j = value.length; + while ( j-- ) { + visit( value[j], node, enter, leave ); + } + } + + else if ( value && value.type ) { + visit( value, node, enter, leave ); + } + } + + if ( leave && !shouldAbort ) { + leave( node, parent ); + } +} \ No newline at end of file diff --git a/src/rollup.js b/src/rollup.js new file mode 100644 index 0000000..3309215 --- /dev/null +++ b/src/rollup.js @@ -0,0 +1,16 @@ +import Bundle from './Bundle'; + +export function rollup ( entry, options = {} ) { + const bundle = new Bundle({ + entry + }); + + return bundle.collect().then( () => { + return { + generate: options => bundle.generate( options ), + write: () => { + throw new Error( 'TODO' ); + } + }; + }); +} \ No newline at end of file diff --git a/src/utils/map-helpers.js b/src/utils/map-helpers.js new file mode 100644 index 0000000..7e58215 --- /dev/null +++ b/src/utils/map-helpers.js @@ -0,0 +1,3 @@ +export function getName ( x ) { + return x.name; +} \ No newline at end of file diff --git a/src/utils/object.js b/src/utils/object.js new file mode 100644 index 0000000..aa26612 --- /dev/null +++ b/src/utils/object.js @@ -0,0 +1 @@ +export const hasOwnProp = Object.prototype.hasOwnProperty; \ No newline at end of file diff --git a/src/utils/promise.js b/src/utils/promise.js new file mode 100644 index 0000000..5f60489 --- /dev/null +++ b/src/utils/promise.js @@ -0,0 +1,22 @@ +import { Promise } from 'sander'; + +export function sequence ( arr, callback ) { + const len = arr.length; + let results = new Array( len ); + + let promise = Promise.resolve(); + + function next ( i ) { + return promise + .then( () => callback( arr[i], i ) ) + .then( result => results[i] = result ); + } + + let i; + + for ( i = 0; i < len; i += 1 ) { + promise = next( i ); + } + + return promise.then( () => results ); +} \ No newline at end of file diff --git a/test/samples/import-default-binding/_config.js b/test/samples/import-default-binding/_config.js new file mode 100644 index 0000000..ed1eb68 --- /dev/null +++ b/test/samples/import-default-binding/_config.js @@ -0,0 +1,3 @@ +module.exports = { + description: 'imports a default binding' +}; \ No newline at end of file diff --git a/test/samples/import-default-binding/foo.js b/test/samples/import-default-binding/foo.js new file mode 100644 index 0000000..27e9d21 --- /dev/null +++ b/test/samples/import-default-binding/foo.js @@ -0,0 +1,3 @@ +export default function foo () { + return 42; +} \ No newline at end of file diff --git a/test/samples/import-default-binding/main.js b/test/samples/import-default-binding/main.js new file mode 100644 index 0000000..bd938fa --- /dev/null +++ b/test/samples/import-default-binding/main.js @@ -0,0 +1,2 @@ +import foo from './foo'; +assert.equal( foo(), 42 ); \ No newline at end of file diff --git a/test/samples/no-imports/_config.js b/test/samples/no-imports/_config.js new file mode 100644 index 0000000..47ae3de --- /dev/null +++ b/test/samples/no-imports/_config.js @@ -0,0 +1,3 @@ +module.exports = { + description: 'creates a bundle from a module with no imports' +}; \ No newline at end of file diff --git a/test/samples/no-imports/main.js b/test/samples/no-imports/main.js new file mode 100644 index 0000000..af841a4 --- /dev/null +++ b/test/samples/no-imports/main.js @@ -0,0 +1 @@ +assert.ok( true ); \ No newline at end of file diff --git a/test/test.js b/test/test.js new file mode 100644 index 0000000..ff88174 --- /dev/null +++ b/test/test.js @@ -0,0 +1,39 @@ +require( 'source-map-support' ).install(); + +var path = require( 'path' ); +var sander = require( 'sander' ); +var assert = require( 'assert' ); +var rollup = require( '../dist/rollup' ); + +var SAMPLES = path.resolve( __dirname, 'samples' ); + +describe( 'rollup', function () { + it( 'exists', function () { + assert.ok( !!rollup ); + }); + + it( 'has a rollup method', function () { + assert.equal( typeof rollup.rollup, 'function' );; + }); + + sander.readdirSync( SAMPLES ).forEach( function ( dir ) { + var config = require( SAMPLES + '/' + dir + '/_config' ); + + it( config.description, function () { + return rollup.rollup( SAMPLES + '/' + dir + '/main.js' ) + .then( function ( bundle ) { + var result = bundle.generate({ + format: 'cjs' + }); + + try { + var fn = new Function( 'assert', result.code ); + fn( assert ); + } catch ( err ) { + console.log( result.code ); + throw err; + } + }); + }); + }); +}); \ No newline at end of file