Skip to content

Instantly share code, notes, and snippets.

@timmyomahony
Last active November 23, 2018 17:49
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save timmyomahony/03c2de3f93096c90f61559ddab6a8ca9 to your computer and use it in GitHub Desktop.
Save timmyomahony/03c2de3f93096c90f61559ddab6a8ca9 to your computer and use it in GitHub Desktop.
Making Ember addons FastBoot 1.0 compatible

Converting addons to Fastboot v1.0

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.

How dependencies are manager for Ember projects (not addons)

TODO: basic package.json, bower.json, ember install and ember-cli-build.js/app.import

How dependencies are installed and imported in an Ember CLI addon

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.

Installing addon dependencies

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 to package.json)

Importing dependencies

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');
  }
};

More about index.js

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.

What is the EmberApp?

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.

What are trees?

"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.

What are funnels?

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;

The EmberApp in detail (and how your app is built)

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.

app.import()

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.

app.toTrees()

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.

ember-cli-build.js

We can now see exactly what is happening when we ember build ...:

  1. A new instance of EmberApp is created (as seen in the ember-cli-build.js file)
  2. 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 calls initializeAddons which in turn calls the project.initialiseAddons() method which creates the addon instances, then it calls the included hook on the AddOn instance which allows the addon to app.import it's own files into the application.
  3. When the toTrees() function is called in ember-cli-build.js, the EmberApp instance creates all of the trees from the internal objects
  4. 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 the treeFor hooks (such as treeForVendor) on the AddOn instance.
  5. These resulting trees are merged into a single tree
  6. This single tree is what is passed into Broccoli (just like in a normal Brocfile.js) for compilation, with the results being outputed to the dist folder.

AddOn tree hooks in detail

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.

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.


Regarding FastBoot

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?

The included hook

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.

The app.import

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.

The treeForVendor hook

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.


Resources


Example PRs:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment