From 5f85971d11135315767d65297f48362dba65bb86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 3 Jun 2014 14:08:34 +0200 Subject: [PATCH] Implement compileToBrowserify and bootBrowserApp Hide `compile` and `execute` and provide a better API for browserified applications: - `boot.compileToBrowserify(options, bundler)` calls `compile` under the hood and adds all instructions and scripts to the bundler. - `bootBrowserApp(app)` is exported by loopback-boot when the module is loaded in a browser, the function loads the instructions as bundled by `compileToBrowserify`. This new API hides all implementation details from the user and makes it easy to add loopback-boot to any build script. --- browser.js | 24 ++++++ docs.json | 5 +- docs/browserify.md | 74 +++++++++++++++++ docs/instructions.md | 33 -------- index.js | 15 ++++ lib/bundler.js | 59 +++++++++++++ package.json | 7 +- test/browser.test.js | 91 +++++++++++++++++++++ test/fixtures/browser-app/app.js | 5 ++ test/fixtures/browser-app/boot/configure.js | 3 + 10 files changed, 278 insertions(+), 38 deletions(-) create mode 100644 browser.js create mode 100644 docs/browserify.md delete mode 100644 docs/instructions.md create mode 100644 lib/bundler.js create mode 100644 test/browser.test.js create mode 100644 test/fixtures/browser-app/app.js create mode 100644 test/fixtures/browser-app/boot/configure.js diff --git a/browser.js b/browser.js new file mode 100644 index 0000000..ac49bc2 --- /dev/null +++ b/browser.js @@ -0,0 +1,24 @@ +var execute = require('./lib/executor'); + +/** + * The browser version of `bootLoopBackApp`. + * + * When loopback-boot is loaded in browser, the module exports this + * function instead of `bootLoopBackApp`. + * + * The function expects the boot instructions to be included in + * the browser bundle, see `boot.compileToBrowserify`. + * + * @param {Object} app The loopback app to boot, as returned by `loopback()`. + * + * @header bootBrowserApp(app) + */ + +exports = module.exports = function bootBrowserApp(app) { + // The name of the module containing instructions + // is hard-coded in lib/bundler + var instructions = require('loopback-boot#instructions'); + execute(app, instructions); +}; + +exports.execute = execute; diff --git a/docs.json b/docs.json index c87c562..05f6402 100644 --- a/docs.json +++ b/docs.json @@ -5,9 +5,8 @@ "depth": 2 }, "index.js", - "lib/compiler.js", - "lib/executor.js", + "browser.js", "docs/configuration.md", - "docs/instructions.md" + "docs/browserify.md" ] } diff --git a/docs/browserify.md b/docs/browserify.md new file mode 100644 index 0000000..9873026 --- /dev/null +++ b/docs/browserify.md @@ -0,0 +1,74 @@ +## Running in a browser + +The bootstrap process is implemented in two steps that can be called +independently. + +### Build + +The first step loads all configuration files, merges values from additional +config files like `app.local.js` and produces a set of instructions +that can be used to boot the application. + +These instructions must be included in the browser bundle together +with all configuration scripts from `models/` and `boot/`. + +Don't worry, you don't have to understand these details. +Just call `boot.compileToBrowserify`, it will take care of everything for you. + +```js +/*-- build file --*/ +var browserify = require('browserify'); +var boot = require('loopback-boot'); + +var b = browserify({ + basedir: appDir, +}); + +// add the main application file +b.require('./app.js', { expose: 'loopback-app' }); + +// add boot instructions +boot.compileToBrowserify(appDir, b); + +// create the bundle +var out = fs.createWriteStream('app.bundle.js'); +b.bundle().pipe(out); +// handle out.on('error') and out.on('close') +``` + +### Run + +In the browser, the main application file should call loopback-boot +to setup the loopback application by executing the instructions +contained in the browser bundle: + +```js +/*-- app.js --*/ +var loopback = require('loopback'); +var boot = require('loopback-boot'); + +var app = module.exports = loopback(); +boot(app); +``` + +The app object created above can be accessed via `require('loopback-app')`, +where `loopback-app` is the identifier used for the main app file in +the browserify build shown above. + +Here is a simple example demonstrating the concept: + +```xml + + +``` diff --git a/docs/instructions.md b/docs/instructions.md deleted file mode 100644 index 8acfc8c..0000000 --- a/docs/instructions.md +++ /dev/null @@ -1,33 +0,0 @@ -## Two-step boot - -The methods `compile` and `execute` can be used to split the bootstrap -process into two steps, the first one run by a build script before calling -`browserify`, the second one run in the browser by the browserified app. - -The first method - `compile` - loads all configuration files, applies any -values specified in environmental variable and produces one JSON object -containing all instructions needed by `execute` to bootstrap the application. - -```js -{ - app: { - /* application config from app.json & friends */ - }, - models: { - /* model configuration from models.json */ - }, - dataSources: { - /* datasources configuration from datasources.json & friends*/ - }, - files: { - models: [ - '/project/models/customer.js', - /* ... */ - ], - boot: [ - '/project/boot/authentication.js', - /* ... */ - ] - } -} -``` diff --git a/index.js b/index.js index 9c25987..fc0e2ef 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ var ConfigLoader = require('./lib/config-loader'); var compile = require('./lib/compiler'); var execute = require('./lib/executor'); +var addInstructionsToBrowserify = require('./lib/bundler'); /** * Initialize an application from an options object or @@ -67,6 +68,20 @@ exports = module.exports = function bootLoopBackApp(app, options) { execute(app, instructions); }; +/** + * Compile boot instructions and add them to a browserify bundler. + * @param {Object|String} options as described in `bootLoopBackApp` above. + * @param {Object} bundler A browserify bundler created by `browserify()`. + * + * @header boot.compileToBrowserify(options, bundler) + */ +exports.compileToBrowserify = function(options, bundler) { + addInstructionsToBrowserify(compile(options), bundler); +}; + +//-- undocumented low-level API --// + exports.ConfigLoader = ConfigLoader; exports.compile = compile; exports.execute = execute; +exports.addInstructionsToBrowserify = addInstructionsToBrowserify; diff --git a/lib/bundler.js b/lib/bundler.js new file mode 100644 index 0000000..d26b7fe --- /dev/null +++ b/lib/bundler.js @@ -0,0 +1,59 @@ +var fs = require('fs'); +var path = require('path'); +var commondir = require('commondir'); + +/** + * Add boot instructions to a browserify bundler. + * @param {Object} instructions Boot instructions. + * @param {Object} bundler A browserify object created by `browserify()`. + */ + +module.exports = function addInstructionsToBrowserify(instructions, bundler) { + bundleScripts(instructions.files, bundler); + bundleInstructions(instructions, bundler); +}; + +function bundleScripts(files, bundler) { + for (var key in files) { + var list = files[key]; + if (!list.length) continue; + + var root = commondir(files[key].map(path.dirname)); + + for (var ix in list) { + var filepath = list[ix]; + + // Build a short unique id that does not expose too much + // information about the file system, but still preserves + // useful information about where is the file coming from. + var fileid = 'loopback-boot#' + key + '#' + path.relative(root, filepath); + + // Add the file to the bundle. + bundler.require(filepath, { expose: fileid }); + + // Rewrite the instructions entry with the new id that will be + // used to load the file via `require(fileid)`. + list[ix] = fileid; + } + } +} + +function bundleInstructions(instructions, bundler) { + var instructionsString = JSON.stringify(instructions, null, 2); + + /* The following code does not work due to a bug in browserify + * https://github.com/substack/node-browserify/issues/771 + var instructionsStream = require('resumer')() + .queue(instructionsString); + instructionsStream.path = 'boot-instructions'; + b.require(instructionsStream, { expose: 'loopback-boot#instructions' }); + */ + + // Write the instructions to a file in our node_modules folder. + // The location should not really matter as long as it is .gitignore-ed + var instructionsFile = path.resolve(__dirname, + '..', 'node_modules', 'instructions.json'); + + fs.writeFileSync(instructionsFile, instructionsString, 'utf-8'); + bundler.require(instructionsFile, { expose: 'loopback-boot#instructions' }); +} diff --git a/package.json b/package.json index dcd90a8..f0b3cf9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "url": "https://github.com/loobpack/loopback-boot" }, "main": "index.js", + "browser": "browser.js", "scripts": { "pretest": "jshint .", "test": "mocha" @@ -23,14 +24,16 @@ }, "dependencies": { "underscore": "^1.6.0", - "debug": "^0.8.1" + "debug": "^0.8.1", + "commondir": "0.0.1" }, "devDependencies": { "loopback": "^1.5.0", "mocha": "^1.19.0", "must": "^0.11.0", "supertest": "^0.13.0", - "fs-extra": "^0.9.1" + "fs-extra": "^0.9.1", + "browserify": "^4.1.8" }, "peerDependencies": { "loopback": "1.x || 2.x" diff --git a/test/browser.test.js b/test/browser.test.js new file mode 100644 index 0000000..31ddf94 --- /dev/null +++ b/test/browser.test.js @@ -0,0 +1,91 @@ +var boot = require('../'); +var fs = require('fs'); +var path = require('path'); +var expect = require('must'); +var browserify = require('browserify'); +var sandbox = require('./helpers/sandbox'); +var vm = require('vm'); + +describe('browser support', function() { + it('has API for bundling and executing boot instructions', function(done) { + var appDir = path.resolve(__dirname, './fixtures/browser-app'); + + browserifyTestApp(appDir, function(err, bundlePath) { + if (err) return done(err); + + var app = executeBundledApp(bundlePath); + + // configured in fixtures/browser-app/boot/configure.js + expect(app.settings).to.have.property('custom-key', 'custom-value'); + + done(); + }); + }); +}); + +function browserifyTestApp(appDir, next) { + var b = browserify({ + basedir: appDir, + }); + b.require('./app.js', { expose: 'browser-app' }); + + boot.compileToBrowserify(appDir, b); + + var bundlePath = sandbox.resolve('browser-app-bundle.js'); + var out = fs.createWriteStream(bundlePath); + b.bundle({ debug: true }).pipe(out); + + out.on('error', function(err) { return next(err); }); + out.on('close', function() { + next(null, bundlePath); + }); +} + +function executeBundledApp(bundlePath) { + var code = fs.readFileSync(bundlePath); + var context = createBrowserLikeContext(); + vm.runInContext(code, context, bundlePath); + var app = vm.runInContext('require("browser-app")', context); + + printContextLogs(context); + + return app; +} + +function createBrowserLikeContext() { + return vm.createContext({ + // required by browserify + XMLHttpRequest: function() { throw new Error('not implemented'); }, + + // used by loopback to detect browser runtime + window: {}, + + // allow the browserified code to log messages + // call `printContextLogs(context)` to print the accumulated messages + console: { + log: function() { + this._logs.log.push(Array.prototype.slice.call(arguments)); + }, + warn: function() { + this._logs.warn.push(Array.prototype.slice.call(arguments)); + }, + error: function() { + this._logs.error.push(Array.prototype.slice.call(arguments)); + }, + _logs: { + log: [], + warn: [], + error: [] + }, + } + }); +} + +function printContextLogs(context) { + for (var k in context.console._logs) { + var items = context.console._logs[k]; + for (var ix in items) { + console[k].apply(console, items[ix]); + } + } +} diff --git a/test/fixtures/browser-app/app.js b/test/fixtures/browser-app/app.js new file mode 100644 index 0000000..6a9b9cb --- /dev/null +++ b/test/fixtures/browser-app/app.js @@ -0,0 +1,5 @@ +var loopback = require('loopback'); +var boot = require('../../../'); + +var app = module.exports = loopback(); +boot(app); diff --git a/test/fixtures/browser-app/boot/configure.js b/test/fixtures/browser-app/boot/configure.js new file mode 100644 index 0000000..b159ba7 --- /dev/null +++ b/test/fixtures/browser-app/boot/configure.js @@ -0,0 +1,3 @@ +module.exports = function(app) { + app.set('custom-key', 'custom-value'); +};