loading...

Making Ember Addons Fastboot Compatible

tmns profile image tmns ・6 min read

Intro

Hello to the 30 people out there who still use Ember 👋🐿

Just kidding - I know the number is higher than 30, but in a world dominated by React, Angular, and Vue, it seems like we who develop with Ember (either by choice [really?!?] or by career happenstance), are pretty alone - especially in terms of useful and helpful material.

That's part of the reason why when faced with the task of adding Fastboot (Ember's version of server-side rendering) to a project riddled with jQuery, along with the demand that all the jQuery remain functional, it took me quite a heavy amount of blood, sweat, and tears to get things working.

As such, I'll share here a nice little trick I learned along the way in case any other poor soul finds themself in the dark shadows that is Ember Fastboot development.

What's the issue?

When adding Fastboot to an Ember project that makes heavy use of addons that in turn utilize third-party client-side JS libraries (typically jQuery), you will quickly find out that your project will have a hard time rendering on the server if you don't make some drastic changes. This is simply due to the project being unable to build and render client-side JS within the server (ie node) environment.

This leaves us with a few options. First, we can simply gut all the incompatible client-side JS logic and / or use node-compatible equivalents. A common example of this is using ember-fetch instead of jQuery.ajax. Second, we can hope that the maintainer(s) of the addon in question has taken notice of the Fastboot issue and made their library Fastboot compatible.

Unfortunately, there are inherent problems with both of these options. First, often a node-compatible equivalent simply doesn't exist. Second, often the maintainer of a library's idea of making their library Fastboot compatible looks something like this:

    if (process.env.EMBER_CLI_FASTBOOT) {
        return;
    }

...which, aside from being broken (this test always fails, as EMBER_CLI_FASTBOOT does not exist in process.env as far as I can tell), essentially only does one thing - which is to simply not import the library into the application. This means that when the app finally makes it to the browser, the library will not be there 😑

We want the best of both worlds. We want the offending addon to be loaded into Fastboot but its client-side code not evaluated until it reaches the browser.

What's the solution?

The most streamlined and bulletproof solution I've found so far is acting as if you yourself are the maintainer of the library. In essence, you must become one with the maintainer and realign the inner zen of the library - also known as making some changes to the library's index.js 😁

As noted in the Fastboot Addon Author Guide, if your addon includes third-party code that is incompatible with node / Fastboot, you can add a guard to your index.js that ensures it is only included in the browser build. This is achieved by creating separate build tree specifically for the browser.

Unfortunately, the Fastboot guide falls short in its given example of actually implementing such a guard. So we will give a more thorough and real-world example here.

Being Slick(er)

Let's say we want to use the addon ember-cli-slick, which is essentially an Ember port of the Slick Slider plugin. The addon's index.js looks like this:

    'use strict';

    const path = require('path');

    module.exports = {
      name: require('./package').name,

      blueprintsPath: function() {
        return path.join(__dirname, 'blueprints');
      },

      included: function(app) {
        this._super.included(app);

        app.import('node_modules/slick-carousel/slick/slick.js');
        app.import('node_modules/slick-carousel/slick/slick.css');
        app.import('node_modules/slick-carousel/slick/slick-theme.css');
        app.import('node_modules/slick-carousel/slick/fonts/slick.ttf', { destDir: 'assets/fonts' });
        app.import('node_modules/slick-carousel/slick/fonts/slick.svg', { destDir: 'assets/fonts' });
        app.import('node_modules/slick-carousel/slick/fonts/slick.eot', { destDir: 'assets/fonts' });
        app.import('node_modules/slick-carousel/slick/fonts/slick.woff', { destDir: 'assets/fonts' });
        app.import('node_modules/slick-carousel/slick/ajax-loader.gif', { destDir: 'assets' });
      }
    };

If you look closely, you will see that the first import being made is slick.js. This is awful for Fastboot and will cause it to blow up server-side. So how do we make slick a little more slicker with its imports?

The first step is getting rid of the blueprintsPath and creating a separate import tree for our offending code, which we will term as vendor code. Let's write out the function and import our necessary objects:

    module.exports = {
      name: 'ember-cli-slicker',

      treeForVendor(defaultTree) {        
        const map = require("broccoli-stew").map;
        const Funnel = require("broccoli-funnel");
        const mergeTrees = require('broccoli-merge-trees');
      },

        included: function(app) {
        [...]

Now, let's use the Funnel object to specify the code we want to separate:

    module.exports = {
      name: 'ember-cli-slicker',

      treeForVendor(defaultTree) {        
        const map = require("broccoli-stew").map;
        const Funnel = require("broccoli-funnel");
        const mergeTrees = require('broccoli-merge-trees');

            let browserVendorLib = new Funnel('node_modules/slick-carousel/slick/', {
          destDir: 'slick',
          files: ['slick.js']
        })
      },

        included: function(app) {
        [...]

Next, we define the guard that is mentioned in the Fastboot documentation, which essentially states to only include our code if the FastBoot object is undefined, which is guaranteed to be true when we are in the browser:

    module.exports = {
      name: 'ember-cli-slicker',

      treeForVendor(defaultTree) {        
        const map = require("broccoli-stew").map;
        const Funnel = require("broccoli-funnel");
        const mergeTrees = require('broccoli-merge-trees');

            let browserVendorLib = new Funnel('node_modules/slick-carousel/slick/', {
          destDir: 'slick',
          files: ['slick.js']
        })

            browserVendorLib = map(browserVendorLib, (content) => `if (typeof FastBoot === 'undefined') { ${content} }`);
        },

        included: function(app) {
        [...]

Then, to wrap up the separation, we return a merge of both the defaultTree and our browser / vendor tree:

    module.exports = {
      name: 'ember-cli-slicker',

      treeForVendor(defaultTree) {        
        const map = require("broccoli-stew").map;
        const Funnel = require("broccoli-funnel");
        const mergeTrees = require('broccoli-merge-trees');

            let browserVendorLib = new Funnel('node_modules/slick-carousel/slick/', {
          destDir: 'slick',
          files: ['slick.js']
        })

            browserVendorLib = map(browserVendorLib, (content) => `if (typeof FastBoot === 'undefined') { ${content} }`);

            return new mergeTrees([defaultTree, browserVendorLib]);
        },

        included: function(app) {
        [...]

But wait!! This also has the potential to fail - as it is actually possible for defaulTree to be undefined! So, we must guard against this by only including it if it exists:

    module.exports = {
      name: 'ember-cli-slicker',

      treeForVendor(defaultTree) {        
        const map = require("broccoli-stew").map;
        const Funnel = require("broccoli-funnel");
        const mergeTrees = require('broccoli-merge-trees');

            let browserVendorLib = new Funnel('node_modules/slick-carousel/slick/', {
          destDir: 'slick',
          files: ['slick.js']
        })

            browserVendorLib = map(browserVendorLib, (content) => `if (typeof FastBoot === 'undefined') { ${content} }`);

            let nodes = [browserVendorLib];
            if (defaultTree) {
                nodes.unshift(defaultTree);
            }

        return new mergeTrees(nodes);
        },

        included: function(app) {
        [...]

The next step is correcting the app import statement in included. We want to change the import statement to point at our new vendor/slick/ directory. In our case this looks like:

        [...]
      included: function(app) {
        this._super.included(app);

        app.import("node_modules/slick-carousel/slick/slick.css");
        app.import("node_modules/slick-carousel/slick/slick-theme.css");
        app.import("node_modules/slick-carousel/slick/fonts/slick.ttf", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/fonts/slick.svg", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/fonts/slick.eot", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/fonts/slick.woff", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/ajax-loader.gif", {
          destDir: "assets"
        });

        app.import("vendor/slick/slick.js");
      }
    };

And finally, the obligatory code snippet of everything put together:

    'use strict';

    module.exports = {
      name: 'ember-cli-slicker',

      treeForVendor(defaultTree) {        
        const map = require("broccoli-stew").map;
        const Funnel = require("broccoli-funnel");
        const mergeTrees = require('broccoli-merge-trees');

        let browserVendorLib = new Funnel('node_modules/slick-carousel/slick/', {
          destDir: 'slick',
          files: ['slick.js']
        })

        browserVendorLib = map(browserVendorLib, (content) => `if (typeof FastBoot === 'undefined') { ${content} }`);

            let nodes = [browserVendorLib];
            if (defaultTree) {
                nodes.unshift(defaultTree);
            }

        return new mergeTrees(nodes);
      },

      included: function(app) {
        this._super.included(app);

        app.import("node_modules/slick-carousel/slick/slick.css");
        app.import("node_modules/slick-carousel/slick/slick-theme.css");
        app.import("node_modules/slick-carousel/slick/fonts/slick.ttf", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/fonts/slick.svg", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/fonts/slick.eot", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/fonts/slick.woff", {
          destDir: "assets/fonts"
        });
        app.import("node_modules/slick-carousel/slick/ajax-loader.gif", {
          destDir: "assets"
        });

        app.import("vendor/slick/slick.js");
      }
    };

And that's it! We now can successfully include ember-slick into our server-side rendered Ember project, deferring its evaluation until it reaches the browser and in turn avoiding any fatal errors during the process - which is quite a feat for anyone that's dealt with Ember Fastboot and fancy browser JS addons 🥳

Conclusion

While it's quite a cold, dark world out there for Ember developers nowadays, there are still some glints of light and hope here and there. One such glint is the realization that including client-side JS heavy addons into a Fastboot project is indeed possible and can be achieved by editing the addon's index.js.

I hope this helps the 29 others out there that may be facing similar issues 😉

Posted on by:

tmns profile

tmns

@tmns

pentester -> humanitarian volunteer -> developer

Discussion

pic
Editor guide