Blog

We write about web engineering, Ember.js, Ruby on Rails as well as Elixir and Phoenix.

Using npm libraries in Ember CLI

tl;dr Use npm instead of Bower whenever you can!

With Ember 2.11 we are now using the ember-source module and no longer the ember Bower package. In the upcoming Ember CLI 2.12 release, Bower will also no longer be installed by default and only install lazily when an addon requests it. All this indicates that from now on we should try to use npm packages instead of Bower whenever possible. This blog post will explain how we can do that and what options are available to us.

Status Quo

The way to import Bower libraries into your app consists of three major steps. First we will have to install the library. We will use the popular Moment.js library as an example here:

bower install --save moment

Secondly we will import it into the Ember CLI build pipeline by adding the following line to our ember-cli-build.js file:

app.import('bower_components/moment/moment.js');

This import() call tells Ember CLI to add the moment.js file to the generated vendor.js file to make the moment global available to the app.

As we prefer to use ES6 imports instead of globals we will generate a so-called “vendor shim” which essentially just wraps the global and provides us with a way to import it as an ES6 module:

ember generate vendor-shim moment

Running this command will generate a vendor/shims/moment.js file that looks like this:

(function() {
  function vendorModule() {
    'use strict';

    return { 'default': self['moment'] };
  }

  define('moment', [], vendorModule);
})();

This looks a little cryptic at first, but once you understand the individual parts it starts to make sense.

The files that the Ember CLI build pipeline generates use the Asynchronous Module Definition (or shorter: AMD). The define() call in the code above defines a new AMD module with the name moment and the return value of the vendorModule function describes what the module exports, or what we can import from that module in our own code. In this case we export an object with a default property that contains the moment global. You can find more information on “default exports” and ES6 modules in general in the Exploring ES6 ebook.

To use this vendor shim we will have to app.import() it like we did with the library itself:

app.import('vendor/shims/moment.js');

We are now able to use import moment from 'moment'; in our Ember code.

App vs. Addon

The above instructions work great for apps, but what about addons? The ember-cli-build.js file for addons is only relevant for building the dummy app of the addon, but not for any other app using the addon. That means we can’t just call app.import() in the ember-cli-build.js file like we did above.

The solution to that is using the included() in the index.js file of the addon:

included() {
  this._super.included.apply(this, arguments);
  this.import('bower_components/moment/moment.js');
  this.import('vendor/shims/moment.js');
}

Note that the this.import() method is only available starting with Ember CLI 2.7. You can easily polyfill it though if you want to support Ember CLI releases below that. Have a look at the ember-simple-auth code to find out how to do it.

This seems to work fine now if we are just looking at the dummy app, but in reality it will not work yet for any other apps. The reason for this is that the bower_components folder above refers to the bower_components folder of the “host app”, not of the addon itself. That means we will have to bower install --save moment into the host app itself and there are two ways to do it:

  • the lazy way: tell the users in the README file to install it like that
  • the comfortable way: install it automatically for them using an Ember CLI blueprint

Since we want to make it as easy for our users as possible we will prefer the second solution, which is not actually that hard to implement either. First we will have to generate a blueprint that matches the package name of our addon. So if our addon was called ember-moment we would run:

ember generate blueprint ember-moment

This will generate a blueprints/ember-moment/index.js file in which we will have to implement two hooks:

module.exports = {
  normalizeEntityName() {},

  afterInstall() {
    return this.addBowerPackageToProject('moment');
  },
};

The normalizeEntityName hook is usually used to e.g. read the name of the route you want to generate with ember generate route <routename>. Since this is the default blueprint which will be executed automatically when the our addon is installed through ember install ember-moment we don’t need this method and have to overwrite it to make sure it does not complain about a missing name.

The afterInstall hook is the important part. The addBowerPackageToProject() method installs the moment Bower package into the application by adding it to the host app bower.json file. That way the Moment.js files will be present in the top level bower_components folder of the application instead of being available only inside of the addon. Since the addBowerPackageToProject() method returns a Promise we have to return it from the afterInstall hook to make sure that Ember CLI waits for the installation to finish.

npm vs. Bower

In an effort to simplify the situation the Ember team decided to focus on npm in the future. Bower support will still be available to support older addons for now, but might be deprecated at some point. That means that we should modify our ember-moment addon above to install Moment.js via npm instead of Bower.

Fortunately for us Moment.js is also distributed on npm so we can just npm install --save moment instead of using Bower and we’re done, right? Well, not quite. Unfortunately for the time being things are a little more complicated, but the Ember CLI team has plans to make it easier in the future.

Let’s start with the simple part: Installing via npm instead of Bower. Instead of using addBowerPackageToProject() to install Moment.js we can use the addPackageToProject() method instead:

afterInstall() {
  return this.addPackageToProject('moment');
}

That was easy! So where is the problem now?

Remember how we called this.import() to import the moment.js file into the build pipeline and the vendor.js file? The import() method currently only works for files inside the bower_components and vendor folders, but not the node_modules folder. This was done for reasons of build performance but might change at some point in the future once other issues are resolved. As we cannot simply import moment from the node_modules folder we have to find another way for loading the newly installed dependency into the app.

At this point we could just stop and give up, but instead we will use a workaround that “moves” the file into our vendor folder and import it from there. Instead of actually moving it as part of the installation process though we tell Ember CLI to move it automatically as part of the build process.

To implement this we will need the fundamental broccoli-funnel and broccoli-merge-trees npm packages:

npm install --save broccoli-funnel broccoli-merge-trees

Next we will implement the treeForVendor() hook in our index.js file and adjust the moment.js import in the included() hook to point to vendor/moment.js instead:

var path = require('path');
var Funnel = require('broccoli-funnel');
var MergeTrees = require('broccoli-merge-trees');

module.exports = {
  name: 'ember-moment',

  included() {
    this._super.included.apply(this, arguments);
    this.import('vendor/moment.js');
    this.import('vendor/shims/moment.js');
  },

  treeForVendor(vendorTree) {
    var momentTree = new Funnel(path.join(this.project.root, 'node_modules', 'moment'), {
      files: ['moment.js'],
    });

    return new MergeTrees([vendorTree, momentTree]);
  },
};

Let me explain what we did here. The vendorTree argument holds the actual content of our vendor folder as we can see it in our addon. Next we create a new Funnel tree, which is a fancy way of saying: import files into the build pipeline. Essentially we just lookup the moment.js file inside the node_modules/moment folder of the host app and wrap that in a Broccoli tree. The last step merges the vendorTree and the momentTree into a single tree which now contains the moment.js file and our vendor shim for the vendor folder.

If you want to learn more about Broccoli and how the build pipeline works I recommend watching Estelle DeBlois explain it in her fantastic EmberConf talk: Dissecting an Ember CLI Build.

Now that we’ve imported the moment.js file into our vendor tree we can finally import() it in the included() hook and everything works again.

npm dependencies

As we are using npm now we can also take advantage of the way that npm resolves dependencies. That means instead of using a blueprint to install the npm package into the host app we declare moment as a dependency of the addon instead in our package.json file:

{
  "dependencies": {
    "moment": "^2.17.1"
  }
}

Since npm deduplicates packages during installation we can not be certain about the path where moment is actually installed though. We need to use a resolver algorithm to find the file inside the node_modules folder. Fortunately Node.js has that algorithm built-in and we can just use it through the require.resolve() function. All we have to do now is modify the momentTree code like this:

var momentTree = new Funnel(path.dirname(require.resolve('moment/moment.js')), {
  files: ['moment.js'],
});

Note that we are passing the path to the moment folder, not to the moment.js file itself, to the Funnel constructor.

Everything should work fine now and we can remove the ember-moment blueprint and the moment dependency from the host app again since that is now a subdependency via ember-moment.

If you want to see these things applied to a real addon visit the code of the ember-cli-moment-shim addon and have a look at their index.js file. They do support fastboot too though which makes the code a little more complicated compared to our simplified example here.

App vs. Addon again

Now that we have converted our ember-moment addon to use npm instead of Bower, how could we do the same if we wanted to use Moment.js in our app directly without an additional addon?

Unfortunately there is currently no perfect solution for this and the best way is using an in-repo-addon for that. You could for example generate a ember-moment in-repo-addon:

ember generate in-repo-addon ember-moment

and use the same code as above inside the lib/ember-moment/index.js file.

Next: CommonJS and ES6 modules

Things get a little more complicated when you want to use npm packages that are not distributed in a prebuilt form like Moment.js. If they instead export only CommonJS modules or ES6 modules we will need to add a few more plugins to the build pipeline to make this work and we will explain how to do that in a future blog post.

TOBIAS BIENIEK

Do you have a project in mind?