UNPKG

15.6 kBJavaScriptView Raw
1var fs = require('fs');
2var crypto = require('crypto');
3var async = require('async');
4var EventEmitter = require('events').EventEmitter;
5var browserify = require('browserify');
6var UglifyJS = require('uglify-js');
7var cssmin = require('cssmin');
8var path = require('path');
9var mdeps = require('module-deps');
10var meta = require('bundle-metadata');
11
12
13function Moonboots(opts) {
14 var self = this;
15 // we'll calculate this to know whether to change the filename
16 var jssha = crypto.createHash('sha1');
17 var csssha = crypto.createHash('sha1');
18 var item;
19
20 // inherit
21 EventEmitter.call(this);
22
23 if (!opts.main) {
24 throw new Error("You must supply at minimum a `main` file for your moonboots app: {main: 'myApp.js'}");
25 }
26
27 this.config = {
28 server: '',
29 developmentMode: false,
30 libraries: [],
31 stylesheets: [],
32 templateFile: '',
33 jsFileName: 'app',
34 cssFileName: 'styles',
35 cachePeriod: 86400000 * 360, // one year,
36 browserify: {}, // overridable browerify options
37 modulesDir: '',
38 beforeBuildJS: function (cb) { cb(); },
39 beforeBuildCSS: function (cb) { cb(); },
40 sourceMaps: false,
41 resourcePrefix: '/',
42 minify: true,
43 };
44
45 // Were we'll store generated
46 // source code, etc.
47 this.result = {
48 js: {
49 fileName: '',
50 minFileName: '',
51 source: '',
52 min: '',
53 checkSum: '',
54 bundleHash: {}
55 },
56 css: {
57 fileName: '',
58 minFileName: '',
59 source: '',
60 min: '',
61 checkSum: ''
62 },
63 error: '',
64 html: '',
65 libs: ''
66 };
67
68 if (typeof opts === 'object') {
69 for (item in opts) {
70 this.config[item] = opts[item];
71 }
72 }
73
74 // make sourcemaps a simple top-level option that only works
75 // in development mode.
76 if (this.config.sourceMaps && this.config.developmentMode) {
77 this.config.browserify.debug = true;
78 }
79
80 // register handler for serving JS
81 if (opts.server) {
82 opts.server.get('/' + encodeURIComponent(this.config.jsFileName) + '*.js', this.js());
83 opts.server.get('/' + encodeURIComponent(this.config.cssFileName) + '*.css', this.css());
84 }
85
86 this._concatExternalLibraries();
87
88 async.parallel([
89 // CSS
90 function (cb) {
91 async.series([
92 function (_cb) {
93 self.prepareCSSBundle(_cb);
94 },
95 function (_cb) {
96 var cssCheckSum;
97 // create our hash and build filenames accordingly
98 csssha.update(self.result.css.source);
99 cssCheckSum = self.result.css.checkSum = csssha.digest('hex').slice(0, 8);
100
101 // store filenames
102 self.result.css.fileName = self.config.cssFileName + '.' + cssCheckSum + '.css';
103 self.result.css.minFileName = self.config.cssFileName + '.' + cssCheckSum + '.min.css';
104
105 if (self._shouldMinify()) {
106 self.result.css.min = cssmin(self.result.css.source);
107 }
108
109 _cb();
110 }
111 ], function (err) {
112 if (err) self._bundleError(err);
113 cb();
114 });
115 },
116 // JS
117 function (cb) {
118 async.series([
119 function (_cb) {
120 self.prepareBundle(_cb);
121 },
122 function (_cb) {
123 var jsCheckSum;
124 // create our hash and build filenames accordingly
125 jssha.update(self.result.libs + self.result.js.bundleHash);
126 jsCheckSum = self.result.js.checkSum = jssha.digest('hex').slice(0, 8);
127
128 // store filenames
129 self.result.js.fileName = self.config.jsFileName + '.' + jsCheckSum + '.js';
130 self.result.js.minFileName = self.config.jsFileName + '.' + jsCheckSum + '.min.js';
131
132 if (self._shouldMinify()) {
133 self.result.js.min = UglifyJS.minify(self.result.js.source, {fromString: true}).code;
134 }
135
136 _cb();
137 }
138 ], function (err) {
139 if (err) self._bundleError(err);
140 cb();
141 });
142 }
143 ], function () {
144 self.result.html = self.getTemplate();
145 self.ready = true;
146 self.emit('ready');
147 });
148}
149
150// Inherit from event emitter
151Moonboots.prototype = Object.create(EventEmitter.prototype, {
152 constructor: {
153 value: Moonboots
154 }
155});
156
157// Shows stack in browser instead of just blowing up on the server
158Moonboots.prototype._bundleError = function (err) {
159 if (!this.config.developmentMode) throw err;
160 var trace;
161 if (err.stack) {
162 trace = err.stack;
163 } else if (typeof err === 'string') {
164 trace = err;
165 } else {
166 trace = JSON.stringify(err);
167 }
168 console.error(trace);
169 this.result.error = 'document.write("<pre style=\'background:#ECFOF2; color:#444; padding: 20px\' >' + trace.split('\n').join('<br>').replace(/"/g, '&quot;') + '</pre>");';
170};
171
172// Returns contactenated external libraries
173Moonboots.prototype._concatExternalLibraries = function () {
174 var cache = this.result;
175 return cache.libs || (cache.libs = concatFiles(this.config.libraries));
176};
177
178// Helper for preparing either JS or CSS bundle
179Moonboots.prototype._prepare = function (type, cb) {
180 // Aliasing beforeBuild to beforeBuildJS
181 var beforeBuildJSName = this.config.beforeBuild ? 'beforeBuild' : 'beforeBuildJS',
182 beforeName = type === 'css' ? 'beforeBuildCSS' : beforeBuildJSName,
183 before = this.config[beforeName];
184
185 // if they pass a callback, wait for it
186 if (before.length) {
187 before(cb);
188 } else {
189 before();
190 cb();
191 }
192};
193
194// Actually generate the JS bundle
195Moonboots.prototype.prepareBundle = function (cb) {
196 var self = this;
197 this._prepare('js', function (err) {
198 if (err) return cb(err);
199
200 // Create two bundles:
201 // bundle is to get the actual js source from a browserify bundle
202 // hashBundle is to create a copy of our other bundle (with the same requires and transforms)
203 // so we can use its resolve fn to get a predictable hash from module-deps
204 self.bundle = browserify();
205 self.hashBundle = browserify();
206
207 // handle module folder that you want to be able to require
208 // without relative paths.
209 if (self.config.modulesDir) {
210 var modules = fs.readdirSync(self.config.modulesDir);
211 modules.forEach(function (moduleFileName) {
212 if (path.extname(moduleFileName) === '.js') {
213 var args = [
214 self.config.modulesDir + '/' + moduleFileName,
215 {expose: path.basename(moduleFileName, '.js')}
216 ];
217 self.bundle.require.apply(self.bundle, args);
218 self.hashBundle.require.apply(self.hashBundle, args);
219 }
220 });
221 }
222
223 // handle browserify transforms if passed
224 if (self.config.browserify.transforms) {
225 self.config.browserify.transforms.forEach(function (tr) {
226 self.bundle.transform(tr);
227 self.hashBundle.transform(tr);
228 });
229 }
230
231 // add main import
232 self.bundle.add(self.config.main);
233
234 async.parallel([
235 function (_cb) {
236 // Get a predictable hash for the bundle
237 mdeps(self.config.main, {
238 resolve: self.hashBundle._resolve.bind(self.hashBundle)
239 })
240 .pipe(meta().on('hash', function (hash) {
241 self.result.js.bundleHash = hash;
242 _cb();
243 }));
244 },
245 function (_cb) {
246 // run main bundle function
247 self.bundle.bundle(self.config.browserify, function (err, js) {
248 if (err) return _cb(err);
249 self.result.js.source = self.result.libs + js;
250 _cb();
251 });
252 }
253 ], function (err) {
254 if (err) return cb(err);
255 if (cb) cb(null, self.result.js.source);
256 });
257 });
258};
259
260// Actually prepare CSS bundle
261Moonboots.prototype.prepareCSSBundle = function (cb) {
262 var self = this;
263 this._prepare('css', function (err) {
264 if (err) return cb(err);
265
266 var css = concatFiles(self.config.stylesheets);
267 self.result.css.source = css;
268 cb(null, self.result.css.source);
269 });
270};
271
272// util for making sure files are built before trying to
273// serve them
274Moonboots.prototype._ensureReady = function (cb) {
275 if (this.ready) {
276 cb();
277 } else {
278 this.on('ready', cb);
279 }
280};
281
282// Helper to determine if minification should happen
283Moonboots.prototype._shouldMinify = function () {
284 return this.config.minify && !this.config.developmentMode;
285};
286
287// Returns request handler to serve html
288Moonboots.prototype.html = function () {
289 var self = this;
290 return function (req, res) {
291 self._ensureReady(function () {
292 res.set('Content-Type', 'text/html; charset=utf-8').send(self.result.html);
293 });
294 };
295};
296
297// Returns request handler for serving JS file
298// minified, if appropriate.
299Moonboots.prototype.js = function () {
300 return this._responseHandler('js');
301};
302
303// returns request handler for serving CSS file
304// minified, if appropriate.
305Moonboots.prototype.css = function () {
306 return this._responseHandler('css');
307};
308
309Moonboots.prototype._responseHandler = function (type) {
310 var self = this;
311 return function (req, res) {
312 self.result.error = ''; // Reset errors on file requests
313 self._ensureReady(function () {
314 self._sendSource(type, function (err, source) {
315 if (self.result.error && type === 'js') {
316 // If we have an error (from CSS or JS)
317 // and this is our JS handler then return with only our error
318 // so we can display it in the browser
319 source = self.result.error;
320 }
321 var contentType = 'text/' + (type === 'css' ? type : 'javascript') + '; charset=utf-8';
322 res.set('Content-Type', contentType);
323 // set our far-future cache headers if not in dev mode
324 if (!self.config.developmentMode) {
325 res.set('Cache-Control', 'public, max-age=' + self.config.cachePeriod);
326 }
327 res.send(source);
328 });
329 });
330 };
331};
332
333// Returns with source code for CSS or JS
334// minified, if appropriate
335Moonboots.prototype._sendSource = function (type, cb) {
336 var self = this,
337 result = self.result[type],
338 prepare = type === 'css' ? self.prepareCSSBundle : self.prepareBundle,
339 config = self.config;
340
341 if (config.developmentMode) {
342 prepare.call(self, function (err, source) {
343 // If we have an error, then make it into a JS string
344 if (err) self._bundleError(err);
345 cb(err, source);
346 });
347 } else if (config.minify) {
348 cb(null, result.min);
349 } else {
350 cb(null, result.source);
351 }
352};
353
354//Legacy method to get JS sourcecode
355Moonboots.prototype.sourceCode = function (cb) {
356 this.jsSource(cb);
357};
358
359// Returns with the JS sourcecode
360// minified, if appropriate
361Moonboots.prototype.jsSource = function (cb) {
362 this._sendSource('js', cb);
363};
364
365// Returns with the CSS sourcecode
366// minified, if appropriate
367Moonboots.prototype.cssSource = function (cb) {
368 this._sendSource('css', cb);
369};
370
371// returns the filename of the currently built CSS or JS file based on
372// development and minification settings.
373Moonboots.prototype._filename = function (type) {
374 var result = this.result[type],
375 configFileName = type === 'css' ? this.config.cssFileName : this.config.jsFileName;
376 if (this.config.developmentMode) {
377 return configFileName + '.' + type;
378 } else {
379 return this.config.minify ? result.minFileName : result.fileName;
380 }
381};
382
383// returns the filename of the currently built JS file based on
384// development and minification settings.
385Moonboots.prototype.jsFileName = function () {
386 return this._filename('js');
387};
388
389// returns the filename of the currently built CSS file based on
390// development and minification settings.
391Moonboots.prototype.cssFileName = function () {
392 return this._filename('css');
393};
394
395// Main template fetcher. Will look for passed file and settings
396// or build default template.
397Moonboots.prototype.getTemplate = function () {
398 var templateString = '';
399 var prefix = this.config.resourcePrefix;
400 if (this.config.templateFile) {
401 templateString = fs.readFileSync(this.config.templateFile, 'utf-8');
402 templateString = templateString
403 .replace('#{jsFileName}', prefix + this.jsFileName())
404 .replace('#{cssFileName}', prefix + this.cssFileName());
405 } else {
406 templateString = this._defaultTemplate();
407 }
408 return templateString;
409};
410
411// If no custom template is specified use a standard one.
412Moonboots.prototype._defaultTemplate = function () {
413 var string = '<!DOCTYPE html>\n';
414 var prefix = this.config.resourcePrefix;
415 if (this.result.css.source) {
416 string += linkTag(prefix + this.cssFileName());
417 }
418 string += scriptTag(prefix + this.jsFileName());
419 return this.result.html = string;
420};
421
422// Build kicks out your app HTML, JS, and CSS into a folder you specify.
423Moonboots.prototype.build = function (folder, callback) {
424 var self = this;
425 self._ensureReady(function () {
426 async.parallel([
427 function (cb) {
428 self.sourceCode(function (err, source) {
429 if (err) return cb(err);
430 fs.writeFile(path.join(folder, self.jsFileName()), source, cb);
431 });
432 },
433 function (cb) {
434 self.cssSource(function (err, source) {
435 if (err) return cb(err);
436 fs.writeFile(path.join(folder, self.cssFileName()), source, cb);
437 });
438 },
439 function (cb) {
440 fs.writeFile(path.join(folder, 'index.html'), self.getTemplate(), cb);
441 }
442 ], callback);
443 });
444};
445
446// Non-express servers need config items like cachePeriod
447Moonboots.prototype.getConfig = function (key) {
448 var self = this;
449 if (typeof key === 'string') {
450 return this.config[key];
451 }
452 return this.config;
453};
454
455Moonboots.prototype.getResult = function(key, cb) {
456 var self = this;
457 self._ensureReady(function () {
458 if (typeof key === 'string') {
459 return cb(null, self.result[key]);
460 }
461 return cb(null, self.result);
462 });
463};
464
465// Main export
466module.exports = Moonboots;
467
468
469// a few helpers
470function concatFiles(arrayOfFiles) {
471 return (arrayOfFiles || []).reduce(function (result, fileName) {
472 return result + fs.readFileSync(fileName) + '\n';
473 }, '');
474}
475
476function linkTag(filename) {
477 return '<link href="' + filename + '" rel="stylesheet" type="text/css">\n';
478}
479
480function scriptTag(filename) {
481 return '<script src="' + filename + '"></script>';
482}