bigpipe
Version:
Bigpipe a radical new web framework for Node.js that's inspired by Facebook's bigpipe concept.
681 lines (581 loc) • 18.9 kB
JavaScript
'use strict';
var debug = require('diagnostics')('bigpipe:compiler')
, Collection = require('./collection')
, browserify = require('browserify')
, preprocess = require('smithy')
, mkdirp = require('mkdirp')
, crypto = require('crypto')
, stream = require('stream')
, async = require('async')
, File = require('./file')
, path = require('path')
, fs = require('fs');
/**
* Small extension of a Readable Stream to push content into the browserify build.
*
* @Constructor
* @param {Mixed} str file content
* @api private
*/
function Content(str) {
stream.Readable.call(this);
this.push(Array.isArray(str) ? str.join('') : str);
this.push(null);
}
//
// Inherit from Readable Stream and provide a _read stub.
//
require('util').inherits(Content, stream.Readable);
Content.prototype._read = function noop () {};
/**
* Asset compiler and management.
*
* @constructor
* @param {String} directory The directory where we save our static files.
* @param {Pipe} pipe The configured Pipe instance.
* @param {Object} options Configuration.
* @api private
*/
function Compiler(directory, pipe, options) {
options = options || {};
this.pipe = pipe;
// The namespace where we can download files.
this.pathname = options.pathname || '/';
// Directory to save the compiled files.
this.dir = directory;
// List of pre-compiled or previous compiled files.
this.list = [];
// Contains template engines that are used to render.
this.core = [];
this.buffer = Object.create(null); // Precompiled asset cache
this.alias = Object.create(null); // Path aliases.
//
// Create the provided directory, will short circuit if present.
//
mkdirp.sync(directory);
}
Compiler.prototype.__proto__ = require('eventemitter3').prototype;
Compiler.prototype.asyncemit = require('asyncemit');
/**
* Create the BigPipe base front-end framework that's required for the handling
* of the real-time connections and the initialization of the arriving pagelets.
*
* @param {Function} done Completion callback.
* @api private
*/
Compiler.prototype.bigPipe = function bigPipe(done) {
var framework = this.pipe._framework
, library
, plugin
, name
, file;
debug('Creating the bigpipe.js front-end library');
library = browserify();
framework.get('library').forEach(function each(file) {
library.require(file.path, { expose: file.expose });
});
if (this.core.length) library.require(new Content(this.core));
for (name in this.pipe._plugins) {
plugin = this.pipe._plugins[name];
if (plugin.library) {
library.require(plugin.library.path, { expose: plugin.library.name });
}
if (!plugin.client || !plugin.path) continue;
debug('Adding the client code of the %s plugin to the client file', name);
library.require(new Content(framework.get('plugin', {
client: plugin.client.toString(),
name: name
})), { file: plugin.path, entry: true });
}
library.bundle(done);
};
/**
* Merge in objects.
*
* @param {Object} target The object that receives the props
* @param {Object} additional Extra object that needs to be merged in the target
* @api private
*/
Compiler.prototype.merge = function merge(target, additional) {
var result = target
, compiler = this;
if (Array.isArray(target)) {
compiler.forEach(additional, function arrayForEach(index) {
if (JSON.stringify(target).indexOf(JSON.stringify(additional[index])) === -1) {
result.push(additional[index]);
}
});
} else if ('object' === typeof target) {
compiler.forEach(additional, function objectForEach(key, value) {
if (target[key] === void 0) {
result[key] = value;
} else {
result[key] = compiler.merge(target[key], additional[key]);
}
});
} else {
result = additional;
}
return result;
};
/**
* Iterate over a collection. When you return false, it will stop the iteration.
*
* @param {Mixed} collection Either an Array or Object.
* @param {Function} iterator Function to be called for each item
* @api private
*/
Compiler.prototype.forEach = function forEach(collection, iterator, context) {
if (arguments.length === 1) {
iterator = collection;
collection = this;
}
var isArray = Array.isArray(collection || this)
, length = collection.length
, i = 0
, value;
if (context) {
if (isArray) {
for (; i < length; i++) {
value = iterator.apply(collection[i], context);
if (value === false) break;
}
} else {
for (i in collection) {
value = iterator.apply(collection[i], context);
if (value === false) break;
}
}
} else {
if (isArray) {
for (; i < length; i++) {
value = iterator.call(collection[i], i, collection[i]);
if (value === false) break;
}
} else {
for (i in collection) {
value = iterator.call(collection[i], i, collection[i]);
if (value === false) break;
}
}
}
return this;
};
/**
* Get the processed extension for a certain file.
*
* @param {String} filepath full path to file
* @api public
*/
Compiler.prototype.type = function type(filepath) {
var processor = this.processor(filepath);
return processor ? '.' + processor.export : path.extname(filepath);
};
/**
* Get preprocessor.
*
* @param {String} filepath
* @returns {Function}
* @api public
*/
Compiler.prototype.processor = function processor(filepath) {
return preprocess[path.extname(filepath).substr(1)];
};
/**
* Upsert new file in compiler cache.
*
* @param {String} filepath full path to file
* @api private
*/
Compiler.prototype.put = function put(filepath) {
var compiler = this;
compiler.process(filepath, function processed(error, code) {
if (error) return compiler.emit('error', error);
compiler.emit('preprocessed', filepath);
compiler.register(new File(filepath, {
extname: compiler.type(filepath),
code: code
}));
});
};
/**
* Read the file from disk and preprocess it depending on extension.
*
* @param {String} filepath full path to file
* @param {Function} fn callback
* @api private
*/
Compiler.prototype.process = function process(filepath, fn) {
var processor = this.processor(filepath)
, paths = [ path.dirname(filepath) ];
fs.readFile(filepath, 'utf-8', function read(error, code) {
if (error || !processor) return fn(error, code);
//
// Only preprocess the file if required.
//
processor(code, { location: filepath, paths: paths }, fn);
});
};
/**
* Prefix selectors of CSS with [data-pagelet='name'] to contain CSS to
* specific pagelets. File can have the following properties.
*
* @param {File} file instance of File
* @param {Function} fn completion callback.
* @api public
*/
Compiler.prototype.namespace = function prefix(file, fn) {
//
// Only prefix if the code is CSS content and not a page dependency.
//
if (!file.is('css') || file.dependency) return fn(null, file);
debug('namespacing %s to pagelets %s', file.hash, file.pagelets);
var processor = preprocess.css
, options = {}
, pagelets;
//
// Transform the pagelets names to data selectors.
//
pagelets = file.pagelets.map(function prepare(pagelet) {
return '[data-pagelet="'+ pagelet +'"]';
});
options.plugins = [ processor.plugins.namespace(pagelets) ];
processor(file.code, options, function done(error, code) {
if (error) return fn(error);
fn(null, file.set(code, true));
});
};
/**
* Register a new library with the compiler. The following traits can be
* provided to register a specific file.
*
* @param {File} file instance of File.
* @param {Boolean} origin Flag to store file with original filepath as reference.
* @api private
*/
Compiler.prototype.register = function register(file, origin) {
if (!file.length) return debug('Skipped registering empty file %j', file.aliases);
var compiler = this;
//
// Add file to the buffer collection.
//
this.buffer[origin ? file.origin : file.location] = file;
//
// Add file references to alias.
//
file.aliases.forEach(function add(alias) {
if (!alias) return;
this.alias[alias] = file.location;
}, this);
this.asyncemit('register', file, function (error) {
if (error) return compiler.emit('error', error);
compiler.save(file);
});
};
/**
* Catalog the pages. As we're not caching the file look ups, this method can be
* called when a file changes so we will generate new.
*
* @param {Array} pages The array of pages.
* @param {Function} done callback
* @api private
*/
Compiler.prototype.catalog = function catalog(pages, done) {
var framework = this.pipe._framework
, temper = this.pipe._temper
, core = this.core
, compiler = this
, list = {};
/**
* Process the dependencies.
*
* @param {Object} assemble generated collection of file properties.
* @param {String} filepath The location of a file.
* @param {Function} next completion callback.
* @api private
*/
function prefab(assemble, filepath, next) {
if (compiler.http(filepath)) return next(null, assemble);
compiler.process(filepath, function store(error, code) {
if (error) return next(error);
var file = new File(filepath, {
extname: compiler.type(filepath),
dependency: list[filepath].dependency,
code: code
});
file = file.hash in assemble ? assemble[file.hash] : file;
file.pagelets = (file.pagelets || []).concat(list[filepath].pagelets);
file.alias(filepath);
assemble[file.hash] = file;
debug('finished pre-processing %s to hash %s', path.basename(filepath), file.hash);
next(null, assemble);
});
}
/**
* Register the files in the assembly, prefix CSS first.
*
* @param {Object} assemble generated collection of file properties.
* @param {Function} next completion callback.
* @api private
*/
function register(assemble, next) {
async.each(Object.keys(assemble), function prefix(hash, fn) {
compiler.asyncemit('assembly', assemble[hash], function (err) {
if (err) return fn(err);
compiler.register(assemble[hash]);
fn();
});
}, next);
}
//
// Check all pages for dependencies and files to add to the list.
//
pages.forEach(function each(Page) {
var page = Page.prototype
, dependencies = Array.isArray(page.dependencies) ? page.dependencies : [];
/**
* Add files to the process list.
*
* @param {String} name Pagelet name.
* @param {String|Array} files Path to files.
* @param {Boolean} dependency Push this file to global dependencies.
* @api private
*/
function add(name, files, dependency) {
//
// Check if files is an object and return, this Pagelet has already
// been cataloged and the dependencies overwritten.
//
if ('object' === typeof files && !Array.isArray(files)) return;
files = Array.isArray(files) ? files : [ files ];
files.forEach(function loopFiles(file) {
if (dependency && !~dependencies.indexOf(file)) dependencies.push(file);
//
// Use stored file or create a new one based on the filepath.
//
file = list[file] = list[file] || { dependency: false, pagelets: [] };
if (name && !~file.pagelets.indexOf(name)) file.pagelets.push(name);
if (dependency) file.dependency = true;
});
}
/**
* Register a new view.
*
* @param {String} path Location of the template file
* @param {String} error
* @api private
*/
function view(page, type) {
var path = page[type]
, data;
debug('Attempting to compile the view %s', path);
data = temper.fetch(path);
//
// The views can be rendered on the client, but some of them require
// a library, this library should be cached in the core library.
//
if (data.library && !~core.indexOf(data.library)) {
core.push(data.library);
}
if (!data.hash) data.hash = {
client: crypto.createHash('md5').update(data.client).digest('hex')
};
compiler.register(new File(path, {
extname: '.js',
code: framework.get('template', {
name: data.hash.client,
client: data.client
})
}));
}
//
// Note: quick fix, now that routed pages have become pagelets
// we should also resolve the Page assets.
//
page._children.concat(Page).forEach(function each(Pagelet) {
if (Array.isArray(Pagelet)) return Pagelet.forEach(each);
var pagelet = Pagelet.prototype;
if (pagelet.js) add(pagelet.name, pagelet.js);
if (pagelet.css) add(pagelet.name, pagelet.css);
add(pagelet.name, pagelet.dependencies, true);
if (pagelet.view) view(pagelet, 'view');
if (pagelet.error) view(pagelet, 'error');
});
//
// Store the page level dependencies per file extension in the page.
// If the file extension cannot be determined, the dependency will be tagged
// as foreign, so other functions like `html` and `page` can do additional
// checks to include the file.
//
page._dependencies = dependencies.concat(framework.get('name')).reduce(function reduce(memo, dependency) {
var extname = path.extname(dependency) || 'foreign';
memo[extname] = memo[extname] || [];
memo[extname].push(dependency);
return memo;
}, Object.create(null));
});
//
// Process and register the CSS/JS of all the pagelets.
//
async.waterfall([
async.apply(async.reduce, Object.keys(list), {}, prefab),
register
], function completed(err, data) {
if (err) return done(err);
compiler.bigPipe(function browserified(err, buffer) {
if (err) return done(err);
debug('Finished creating browserify build');
var file = new File(framework.get('name'), {
dependency: true,
extname: '.js',
code: buffer
});
//
// Also register the file under the name of the fittings so
// it can also be loaded _without_ knowing the md5.
//
compiler.register(file);
compiler.register(file, true);
done(err, data);
});
});
};
/**
* Find all required dependencies for given page constructor.
*
* @param {Page} page The initialized page.
* @returns {Object}
* @api private
*/
Compiler.prototype.page = function find(page) {
var compiler = this
, assets = new Collection({ toString: this.html });
//
// The page is rendered in `sync` mode, so add all the required CSS files from
// the pagelet to the head of the page.
//
if (!('.css' in page._dependencies)) page._dependencies['.css'] = [];
if ('sync' === page.mode) page._enabled.forEach(function enabled(pagelet) {
Array.prototype.push.apply(page._dependencies['.css'], compiler.pagelet(pagelet).css);
});
//
// Push dependencies into the page. JS is pushed as extension after CSS,
// still adheres to the CSS before JS pattern, although it is less important
// in newer browser. See http://stackoverflow.com/questions/9271276/ for more
// details. Foreign extensions are added last to allow unidentified files to
// be included if possible.
//
preprocess.extensions.concat('.js', 'foreign').forEach(function map(type) {
if (!(type in page._dependencies)) return;
page._dependencies[type].forEach(function each(dependency) {
if (compiler.http(dependency)) {
return assets.push(new File(dependency, {
extname: compiler.type(dependency),
dependency: true,
external: true
}));
}
dependency = compiler.resolve(dependency);
if (!dependency) return;
assets.push(compiler.buffer[dependency]);
});
});
return assets;
};
/**
* Check if the path is a http(s) url.
*
* @param {String} filepath Url to file.
* @return {Boolean}
* @api private
*/
Compiler.prototype.http = function http(filepath) {
return /^(http:|https:)?\/\//.test(filepath);
};
/**
* Generate HTML.
*
* @param {String} file The filename that needs to be added to a DOM.
* @returns {String} A correctly wrapped HTML tag.
* @api private
*/
Compiler.prototype.html = function html(file) {
switch (file.extname) {
case '.css': return '<link rel=stylesheet href="'+ file.location +'" />';
case '.js': return '<script src="'+ file.location +'"></script>';
default: return '';
}
};
/**
* Resolve all dependencies to their hashed versions.
*
* @param {String} original The original file path.
* @returns {String} The hashed version.
* @api private
*/
Compiler.prototype.resolve = function resolve(original) {
return this.alias[original] || false;
};
/**
* A list of resources that need to be loaded for the given pagelet.
*
* @param {Pagelet} pagelet The initialized pagelet.
* @returns {Object}
* @api private
*/
Compiler.prototype.pagelet = function find(pagelet) {
var error = this.resolve(pagelet.error)
, view = this.resolve(pagelet.view)
, frag = {}
, css = []
, js = [];
debug('Compiling data from pagelet %s/%s', pagelet.name, pagelet.id);
if (pagelet.js.length) js = js.concat(pagelet.js.map(this.resolve, this));
if (pagelet.css.length) css = css.concat(pagelet.css.map(this.resolve, this));
if (view) js.push(view);
if (error) js.push(error);
frag.css = css; // Add the compiled css.
frag.js = js; // Add the required js.
return frag;
};
/**
* Store the compiled files to disk. This a vital part of the compiler as we're
* changing the file names every single time there is a change. But these files
* can still be cached on the client and it would result in 404's and or broken
* functionality.
*
* @param {File} file The file instance.
* @api private
*/
Compiler.prototype.save = function save(file) {
var directory = path.resolve(this.dir)
, pathname = this.pathname;
fs.writeFileSync(path.join(directory, file.location), file.buffer);
this.list = fs.readdirSync(directory).reduce(function reduce(memo, file) {
if (path.extname(file)) {
memo[pathname + file] = path.resolve(directory, file);
}
return memo;
}, {});
return this;
};
/**
* Serve the file.
*
* @param {Request} req Incoming HTTP request.
* @param {Response} res Outgoing HTTP response.
* @returns {Boolean} The request is handled by the compiler.
* @api private
*/
Compiler.prototype.serve = function serve(req, res) {
var file = (this._compiler || this).buffer[req.uri.pathname];
if (!file) return undefined;
res.setHeader('Content-Type', file.type);
res.setHeader('Content-Length', file.length);
res.end(file.buffer);
return true;
};
//
// Expose the module.
//
module.exports = Compiler;