Fastboot is currently transitioning to version 1.0 which introduces some breaking changes. The most pressing issue is that the approach previously suggested for handling dependencies in addons has changed.
This post has a bit of background of how Ember CLI and addons work followed by some details on how to migrate addons to the new Fastboot v1.0 structure.
TODO: basic package.json
, bower.json
, ember install
and ember-cli-build.js
/app.import
The first thing to understand is how an addon can handle external dependencies
It's very important to note that installing is not the same is importing. An addon can install the dependency without specifying that it should be imported during its build.
All Ember CLI addons have a default "blueprint" in their directory at the location blueprints/<name-of-addon>/index.js
.
This blueprint is executued when an addon is installed in a project and gives the addon author the opportunity to carry out installation hooks, for example, installing dependencies:
module.exports = {
normalizeEntityName: function() {},
afterInstall: function() {
return this.addBowerPackageToProject('mousetrap', '~1.5.3');
}
};
This example instructs Ember CLI to install an external depedancy mousetrap
from bower and add it to the project's bower.json
(note that it gets added to the project's bower.json
, not the addon's bower.json
)
As well as installing Bower dependencies with the addBowerPackageToProject
function like in the example above, you can also:
- install other Ember addons using
this.addAddonsToProject
- pull in other Node dependencies using
this.addPackagesToProject
(this adds package topackage.json
)
To actually import a dependency, an addon uses the included()
hook in it's index.js
file.
Ember CLI has good documentation on this process
... the included hook on your addon’s main entry point is run during the build process. This is where you want to add import statements to actually bring in the dependency files for inclusion. Note that this is a separate step from adding the actual dependency itself—done in the default blueprint—which merely makes the dependency available for inclusion.
as well as an example:
// index.js
module.exports = {
name: 'ember-cli-x-button',
included: function(app) {
this._super.included.apply(this, arguments);
app.import(app.bowerDirectory + '/x-button/dist/js/x-button.js');
app.import(app.bowerDirectory + '/x-button/dist/css/x-button.css');
}
};
The index.js
file is Ember CLI's entry point into an addon when building a project. It exports a simple object and:
(from the docs) "the exported object extends the
Addon
class. So any hooks that exist on the Addon class may be overridden by addon author."
As mentioned above, the hook we are most interested in for importing dependencies is the included
hook but it's worth noting that this included
hook is called by the EmberApp
, not the Addon
, and the app
instance it passed into it.
From the documentation, EmberApp
is:
... the main class Ember CLI uses to manage the Broccoli trees for your application. It is very tightly integrated with Broccoli and has a toTree() method you can use to get the entire tree for your application.
Ember CLI uses Broccoli.js
under the hood to build your Ember applications. Broccoli is build tool for compiling frontend projects, it essentially works by creating a pipeline of directories and processing them through plugins, outputing a compiled project.
If you wanted to build a non-Ember project using Broccoli, you would use a configuration file known as a Brocfile.js
to do so and you would then build the project with the broccoli-cli
command broccoli build dist
.
In Ember, instead of a Brocfile.js
we have a ember-cli-build.js
file which does the same thing. When you look inside it, you'll see:
/* eslint-env node */
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
module.exports = function(defaults) {
var app = new EmberApp(defaults, {
// Add options here
});
//...
return app.toTree();
};
This ember-cli-build.js
file contains the "compilation configuration" (aka build specification) used by Broccoli when building your project. It's essentially the Brocfile.js for your project (in fact, it used to be called a Brocfile.js
in earlier Ember versions).
You'll see that in instance of EmberApp
is created here and a tree
is returned.
"Trees" (referred to as "nodes" in the latest version of Broccoli) are the fundamental building block of Broccoli. A tree is simply a representation of a file system directory and that directory's files and subdirectories. This tree can be passed to plugins to operate on the trees contents (for example, compile a folder of SASS files to css).
Put another way:
Broccoli’s unit of abstraction to describe sources and build products is not a file, but rather a tree – that is, a directory with files and subdirectories. So it’s not file-goes-in-file-goes-out, it’s tree-goes-in-tree-goes-out.
this is taken from a great blog post on Broccoli that has plenty more informaation on its architecture and internals.
The final piece we need to understand are Broccoli funnels. These are provided via the broccoli-funnel
plugin. A funnel allows us to basically filter a tree:
Given an input node [tree], the Broccoli Funnel plugin returns a new node with only a subset of the files from the input node. The files can be moved to different paths. You can use regular expressions to select which files to include or exclude.
So if we had the example directory:
.
├── Brocfile.js
└── src/
├── css/
│ ├── reset.css
│ └── todos.css
├── icons/
│ ├── check-mark.png
│ └── logo.jpg
└── javascript/
├── app.js
└── todo.js
we could use a Funnel
to target just the css folder:
var Funnel = require('broccoli-funnel');
var cssFiles = new Funnel('src/css');
/*
cssFiles contains the following files:
├── reset.css
└── todos.css
*/
// export the node for Broccoli to begin processing
module.exports = cssFiles;
With all the above in mind, we can now understand what the EmberApp
is responsible for and how it works.
First, if we look at the source, we see that when it is initialised, it creates a number of registry-like objects such as:
_styleOutputFiles
_scriptOutputFiles
vendorFiles
The purpose of these objects is to hold the paths to the files we need to build our app. These internal objects are later converted into actual Broccoli trees/nodes that are furthermore merged into a single asset tree that can be compiled by Broccoli into a dist
folder.
The import
method is a public method that allows us to add dependencies to our application by placing the included path on one these internal objects - mentioned above - depending on the extension (i.e. type) of the path. So if it's a Javascript file it gets placed on the _styleOutputFiles
, if it's a CSS file it gets placed on the _scriptOutputFiles
etc.
This function merges all of the trees. When it is called, it in turn calls the toArray()
function which builds all of the various trees separately:
let sourceTrees = [
this.index(),
this.javascript(),
this.styles(),
this.otherAssets(),
this.publicTree(),
];
and then merges them with the Broccoli command mergeTrees
to return a single tree.
We can now see exactly what is happening when we ember build ...
:
- A new instance of
EmberApp
is created (as seen in theember-cli-build.js
file) - This
EmberApp
iniatialises. There are some extra steps in here not mentioned above:- A
Project
instance is created. This is where addon discovery happens (but not initialisation). - All of Ember's own internal vendor files are placed on an
vendorFiles
object, later to be added to the tree. - All of the addons are initialised via
_notifyAddonIncluded
. This first callsinitializeAddons
which in turn calls theproject.initialiseAddons()
method which creates the addon instances, then it calls theincluded
hook on theAddOn
instance which allows the addon toapp.import
it's own files into the application.
- A
- When the
toTrees()
function is called inember-cli-build.js
, theEmberApp
instance creates all of the trees from the internal objects - During this process, each addon is asked for all of it's own trees (via
addOnTrees
) to be merged into the application trees. This is done via thetreeFor
hooks (such astreeForVendor
) on theAddOn
instance. - These resulting trees are merged into a single tree
- This single tree is what is passed into Broccoli (just like in a normal
Brocfile.js
) for compilation, with the results being outputed to thedist
folder.
There are a number of tree-related hooks available to the addon developer in the index.js
file on the Addon
class:
treeForApp
treeForStyles
treeForTemplates
treeForAddonTemplates
treeForAddon
treeForVendor
treeForTestSupport
treeForAddOnTestSupport
treeForPublic
So how/when are they called? Well, these hooks are called by the EmberApp
when toTrees()
is called. Notably this means that at this point, our included
hook has been called, all of our app.import
calls have already been made and all our paths should be saved in the EmberApp
instance. It's only now that we are requesting the trees to be built that these hooks are called.
So for our treeForVendor
hook specifically, let's see how it gets called.
- The
javascript()
function is called fromtoArray()
. This is tasked with creating a tree with all the javascript assets for the entire application included - This calls the
appAndDependencies()
function which, like above, "returns the tree for the app and its dependencies" - This calls
_processedExternalTree
- This calls
_processedVendorTree
- This calls
this.addonTreesFor('vendor')
- this function iterates through all addons, calling theirtreeFor(type)
function, which is described as "returning a given type of tree (if present), merged with the application tree" - This (finally!) calls the
treeForVendor
hook we are interested in.
Note the addonTreesFor(...)
function:
/**
Returns a list of trees for a given type, returned by all addons.
@private
@method addonTreesFor
@param {String} type Type of tree
@return {Array} List of trees
*/
addonTreesFor(type) {
return this.project.addons.reduce((sum, addon) => {
if (addon.treeFor) {
let val = addon.treeFor(type);
if (val) { sum.push(val); }
}
return sum;
}, []);
}
It's important to note that this returns a list of merged trees (that are later merged together), where each tree in the list comes from a single addon for that particular tree-type (vendor, script, style, etc.). In other words, it iterates through all addons, asking them to return a tree for that tree type.
Let's now look at the underlying _treeFor
method on the AddOn
class;
_treeFor: function _treeFor(name) {
let treePath = path.resolve(this.root, this.treePaths[name]);
let treeForMethod = this.treeForMethods[name];
let tree;
if (existsSync(treePath)) {
tree = this.treeGenerator(treePath);
}
if (this[treeForMethod]) {
tree = this[treeForMethod](tree);
}
return tree;
},
Note that although no tree was passed to it from the EmberApp
, it generates its own tree. This default tree simply corresponds to the directory (treePath
) of the addon plus the type of tree being requested:
let treePath = path.resolve(this.root, this.treePaths[name]);
where treePaths
is:
this.treePaths = {
app: 'app',
styles: 'app/styles',
templates: 'app/templates',
addon: 'addon',
'addon-styles': 'addon/styles',
'addon-templates': 'addon/templates',
vendor: 'vendor',
'test-support': 'test-support',
'addon-test-support': 'addon-test-support',
public: 'public',
};
so you'll see that, for example, in the case of the treeForVendor(defaultTree)
hook, if there is a vendor
folder in the addon, the defaultTree
passed to the hook will include one tree node:
/path/to/application/node_modules/addon-name/vendor
If you delete the vendor
folder, you will notice that the defaultTree
is now undefined
. Similarly, the defaultTree
in the treeForStyles(defaultTree)
would be:
/path/to/application/node_modules/addon-name/styles
So you can use any of these hooks to augment the tree and include any other resources you require.
Now that we have a bit of background study done, we can fully understand how to upgrade addons to version 1.0.
Fastboot previously had two build targets: one for the browser (dist
folder) and another for node/FastBoot (fastboot-dist
folder). That meant it was easy to control an addon's dependencies: you could use the process.env.FastBoot
variable during the application's build time to include/exclude dependencies depending on whether the build was for the browser or node:
included() {
if (!process.env.EMBER_CLI_FASTBOOT) {
// only when it is not fastboot build
app.import('vendor/<browserLibName>.js');
}
}
Now, in FastBoot 1.0 there is only one build for both the browser and node. This means that there is no process.env.EMBER_CLI_FASTBOOT
available and so have to include all of the dependencies at build time. This is obviously a problem as, when we run the application in FastBoot, any incompatible browser-based dependencies will throw errors. So to get around this, at build time we wrap the dependencies code in a conditional statement that means that at run time the dependency will be included/excluded depending on whether it's being run by node or the browser (by checking the FastBoot
variable).
To deal with this change, you need to update your index.js
to the following:
treeForVendor(defaultTree) {
var browserVendorLib = new Funnel(...);
return new mergeTrees([defaultTree, browserVendorLib]);
}
included() {
app.import('vendor/<browserLibName>.js');
}
So what is happening here?
We now know that the included
hook is run by the EmberApp
on initialization, after it has added all of its own (Ember) vendor files to the internal objects but before any Broccoli trees have been created. Within this function the addon can therefore add any paths to the internal objects that will later become trees.
Note that this is referencing the conditional import with the vendor/...
path and not a bower_components/...
or node_modules/...
path? This is because we are actually referencing the vendor file that we are about to manually add to the tree via the treeForVendor
hook. In other words, the app.import('vendor/...')
call is essentially a placeholder import that the treeForVendor
hook is going to "fill-in" later on.
As we noted above, these treeFor
give addons the opportunity to add trees (i.e. folders of assets) to the Ember application that is using the addon. The defaultTree
in this case is simply the vendor/
folder within the addon folder (if it exists). We create our own tree using the Funnel
command and include the asset we are conditionally importing. We then wrap the asset's code with a conditional statement to guard it on FastBoot builds (you can actually see the result of this if you open the assets/vendor.js
in your dist
folder once everything is built). We then merge the defaultTree
with our new tree for the Ember application to process.
- Official migration guide: https://gist.github.com/kratiahuja/d22de0fb1660cf0ef58f07a6bcbf1a1c
- Relevant Github issue: ember-fastboot/ember-cli-fastboot#413
- Relevant Github issue: ember-fastboot/ember-cli-fastboot#387
- Relevant Github issue: ember-fastboot/ember-cli-fastboot#396
- Relevant Github issue: ember-fastboot/ember-cli-fastboot#369