ember-cli-fastboot
Version:
Server-side rendering for Ember.js apps
396 lines (325 loc) • 12.3 kB
JavaScript
/* eslint-disable no-prototype-builtins, prettier/prettier */
/* eslint-env node */
'use strict';
const path = require('path');
const fs = require('fs');
const MergeTrees = require('broccoli-merge-trees');
const FastBootExpressMiddleware = require('fastboot-express-middleware');
const FastBoot = require('fastboot');
const chalk = require('chalk');
const fastbootAppBoot = require('./lib/utilities/fastboot-app-boot');
const FastBootConfig = require('./lib/broccoli/fastboot-config');
const fastbootAppFactoryModule = require('./lib/utilities/fastboot-app-factory-module');
const migrateInitializers = require('./lib/build-utilities/migrate-initializers');
const SilentError = require('silent-error');
const Concat = require('broccoli-concat');
const Funnel = require('broccoli-funnel');
const p = require('ember-cli-preprocess-registry/preprocessors');
const fastbootTransform = require('fastboot-transform');
const existsSync = fs.existsSync;
let checker;
function getVersionChecker(context) {
if (!checker) {
const VersionChecker = require('ember-cli-version-checker');
checker = new VersionChecker(context);
}
return checker;
}
/*
* Main entrypoint for the Ember CLI addon.
*/
module.exports = {
name: require('./package').name,
init() {
this._super.init && this._super.init.apply(this, arguments);
this._existsCache = new Map();
},
existsSync(path) {
if (this._existsCache.has(path)) {
return this._existsCache.get(path);
}
const exists = existsSync(path);
this._existsCache.set(path, exists);
return exists;
},
/**
* Called at the start of the build process to let the addon know it will be
* used. Sets the auto run on app to be false so that we create and route app
* automatically only in browser.
*
* See: https://ember-cli.com/user-guide/#integration
*/
included(app) {
let assetRev = this.project.addons.find(addon => addon.name === 'broccoli-asset-rev');
if (assetRev && !assetRev.supportsFastboot) {
throw new SilentError(
'This version of ember-cli-fastboot requires a newer version of broccoli-asset-rev'
);
}
// set autoRun to false since we will conditionally include creating app when app files
// is eval'd in app-boot
app.options.autoRun = false;
app.import('vendor/experimental-render-mode-rehydrate.js');
// get the app registry object and app name so that we can build the fastboot
// tree
this._appRegistry = app.registry;
this._name = app.name;
this.fastbootOptions = this._fastbootOptionsFor(app.env, app.project);
migrateInitializers(this.project);
},
/**
* Registers the fastboot shim that allows apps and addons to wrap non-compatible
* libraries in Node with a FastBoot check using `app.import`.
*
*/
importTransforms() {
return {
fastbootShim: fastbootTransform,
};
},
/**
* Inserts placeholders into index.html that are used by the FastBoot server
* to insert the rendered content into the right spot. Also injects a module
* for FastBoot application boot.
*/
contentFor(type, config, contents) {
if (type === 'body') {
return '<!-- EMBER_CLI_FASTBOOT_BODY -->';
}
if (type === 'head') {
return '<!-- EMBER_CLI_FASTBOOT_TITLE --><!-- EMBER_CLI_FASTBOOT_HEAD -->';
}
if (type === 'app-boot') {
return fastbootAppBoot(config.modulePrefix, JSON.stringify(config.APP || {}));
}
// if the fastboot addon is installed, we overwrite the config-module so that the config can be read
// from meta tag/directly for browser build and from Fastboot config for fastboot target.
if (type === 'config-module') {
const originalContents = contents.join('');
const appConfigModule = `${config.modulePrefix}`;
contents.splice(0, contents.length);
contents.push(
"if (typeof FastBoot !== 'undefined') {",
"return FastBoot.config('" + appConfigModule + "');",
'} else {',
originalContents,
'}'
);
return;
}
},
treeForFastBoot(tree) {
let fastbootHtmlBarsTree;
// check the ember version and conditionally patch the DOM api
if (this._getEmberVersion().lt('2.10.0-alpha.1')) {
fastbootHtmlBarsTree = this.treeGenerator(path.resolve(__dirname, 'fastboot-app-lt-2-9'));
return tree ? new MergeTrees([tree, fastbootHtmlBarsTree]) : fastbootHtmlBarsTree;
}
return tree;
},
_processAddons(addons, fastbootTrees) {
addons.forEach(addon => {
this._processAddon(addon, fastbootTrees);
});
},
_processAddon(addon, fastbootTrees) {
// walk through each addon and grab its fastboot tree
const currentAddonFastbootPath = path.join(addon.root, 'fastboot');
let fastbootTree;
if (this.existsSync(currentAddonFastbootPath)) {
fastbootTree = this.treeGenerator(currentAddonFastbootPath);
}
// invoke addToFastBootTree for every addon
if (addon.treeForFastBoot) {
let additionalFastBootTree = addon.treeForFastBoot(fastbootTree);
if (additionalFastBootTree) {
fastbootTrees.push(additionalFastBootTree);
}
} else if (fastbootTree !== undefined) {
fastbootTrees.push(fastbootTree);
}
this._processAddons(addon.addons, fastbootTrees);
},
/**
* Function that builds the fastboot tree from all fastboot complaint addons
* and project and transpiles it into appname-fastboot.js
*/
_getFastbootTree() {
const appName = this._name;
let fastbootTrees = [];
this._processAddons(this.project.addons, fastbootTrees);
// check the parent containing the fastboot directory
const projectFastbootPath = path.join(this.project.root, 'fastboot');
// ignore the project's fastboot folder if we are an addon, as that is already handled above
if (!this.project.isEmberCLIAddon() && this.existsSync(projectFastbootPath)) {
let fastbootTree = this.treeGenerator(projectFastbootPath);
fastbootTrees.push(fastbootTree);
}
// transpile the fastboot JS tree
let mergedFastBootTree = new MergeTrees(fastbootTrees, {
overwrite: true,
});
let funneledFastbootTrees = new Funnel(mergedFastBootTree, {
destDir: appName,
});
const processExtraTree = p.preprocessJs(funneledFastbootTrees, '/', this._name, {
registry: this._appRegistry,
});
// FastBoot app factory module
const writeFile = require('broccoli-file-creator');
let appFactoryModuleTree = writeFile('app-factory.js', fastbootAppFactoryModule(appName));
let newProcessExtraTree = new MergeTrees([processExtraTree, appFactoryModuleTree], {
overwrite: true,
});
function stripLeadingSlash(filePath) {
return filePath.replace(/^\//, '');
}
let appFilePath = stripLeadingSlash(this.app.options.outputPaths.app.js);
let finalFastbootTree = new Concat(newProcessExtraTree, {
outputFile: appFilePath.replace(/\.js$/, '-fastboot.js'),
});
return finalFastbootTree;
},
treeForPublic(tree) {
let fastbootTree = this._getFastbootTree();
let trees = [];
if (tree) {
trees.push(tree);
}
trees.push(fastbootTree);
let newTree = new MergeTrees(trees);
let fastbootConfigTree = this._buildFastbootConfigTree(newTree);
// Merge the package.json with the existing tree
return new MergeTrees([newTree, fastbootConfigTree], { overwrite: true });
},
/**
* Need to handroll our own clone algorithm since JSON.stringy changes regex
* to empty objects which breaks hostWhiteList property of fastboot.
*
* @param {Object} config
*/
_cloneConfigObject(config) {
if (config === null || typeof config !== 'object') {
return config;
}
if (config instanceof Array) {
let copy = [];
for (let i = 0; i < config.length; i++) {
copy[i] = this._cloneConfigObject(config[i]);
}
return copy;
}
if (config instanceof RegExp) {
// converting explicitly to string since we create a new regex object
// in fastboot: https://github.com/ember-fastboot/fastboot/blob/master/src/fastboot-request.js#L28
return config.toString();
}
if (config instanceof Object) {
let copy = {};
for (let attr in config) {
if (config.hasOwnProperty(attr)) {
copy[attr] = this._cloneConfigObject(config[attr]);
}
}
return copy;
}
throw new Error('App config cannot be cloned for FastBoot.');
},
_getHostAppConfig() {
let env = this.app.env;
// clone the config object
let appConfig = this._cloneConfigObject(this.project.config(env));
// do not boot the app automatically in fastboot. The instance is booted and
// lives for the lifetime of the request.
let APP = appConfig.APP;
if (APP) {
APP.autoboot = false;
} else {
appConfig.APP = { autoboot: false };
}
return appConfig;
},
_buildFastbootConfigTree(tree) {
let appConfig = this._getHostAppConfig();
let fastbootAppConfig = appConfig.fastboot;
return new FastBootConfig(tree, {
project: this.project,
name: this.app.name,
outputPaths: this.app.options.outputPaths,
ui: this.ui,
fastbootAppConfig: fastbootAppConfig,
appConfig: appConfig,
});
},
serverMiddleware(options) {
let emberCliVersion = this._getEmberCliVersion();
let app = options.app;
if (emberCliVersion.gte('2.12.0-beta.1')) {
// only run the middleware when ember-cli version for app is above 2.12.0-beta.1 since
// that version contains API to hook fastboot into ember-cli
app.use((req, resp, next) => {
const fastbootQueryParam =
req.query.hasOwnProperty('fastboot') && req.query.fastboot === 'false' ? false : true;
const enableFastBootServe = !process.env.FASTBOOT_DISABLED && fastbootQueryParam;
if (req.serveUrl && enableFastBootServe) {
// if it is a base page request, then have fastboot serve the base page
if (!this.fastboot) {
const broccoliHeader = req.headers['x-broccoli'];
const outputPath = broccoliHeader['outputPath'];
const fastbootOptions = Object.assign({}, this.fastbootOptions, {
distPath: outputPath,
});
this.ui.writeLine(chalk.green('App is being served by FastBoot'));
this.fastboot = new FastBoot(fastbootOptions);
}
let fastbootMiddleware = FastBootExpressMiddleware({
fastboot: this.fastboot,
});
fastbootMiddleware(req, resp, next);
} else {
// forward the request to the next middleware (example other assets, proxy etc)
next();
}
});
}
},
postBuild(result) {
if (this.fastboot) {
// should we reload fastboot if there are only css changes? Seems it maynot be needed.
// TODO(future): we can do a smarter reload here by running fs-tree-diff on files loaded
// in sandbox.
this.ui.writeLine(chalk.blue('Reloading FastBoot...'));
this.fastboot.reload({
distPath: result.directory,
});
}
},
_getEmberCliVersion() {
const checker = getVersionChecker(this);
return checker.for('ember-cli', 'npm');
},
_getEmberVersion() {
const checker = getVersionChecker(this);
const emberVersionChecker = checker.for('ember-source', 'npm');
if (emberVersionChecker.version) {
return emberVersionChecker;
}
return checker.for('ember', 'bower');
},
/**
* Reads FastBoot configuration from application's `config/fastboot.js` file if present,
* otherwise returns empty object.
*
* The configuration file is expected to export a function with `environment` as an argument,
* which is same as a how `config/environment.js` works.
*
* TODO Allow add-ons to provide own options and merge them with the application's options.
*/
_fastbootOptionsFor(environment, project) {
const configPath = path.join(path.dirname(project.configPath()), 'fastboot.js');
if (fs.existsSync(configPath)) {
return require(configPath)(environment);
}
return {};
},
};