Differential Serving of Assets in Ember

· 5 min read

Update: I recently spoke about this in Chennai EmberJS.



The market share of users with evergreen browsers is rising fast. Inspite of this, we ship transpiled assets, often caused by the need to support legacy browsers like IE, to everyone. This is because dropping browser support isn't an easy affair, at least in enterprises. As a result, everyone, including those having the latest versions of browsers are forced to consume a bigger and slower bundle.

While there are established ways in React(through Webpack), Meteor and Vue to achieve differential bundling to serve assets based on the browser, there is currently no official way to do it in Ember. There is a pre-RFC that is being discussed upon and I felt like writing out about a strategy that we have found to be feasible.

This strategy tries to address engines as well. With the usual type="module" and nomodule approach, you cannot handle engines as they rely on the asset manifest provided by a meta tag with name as app/config/asset-manifest. There is no way to toggle meta tags in the frontend as of now and until Ember Engines support the storeConfigInMeta option, you will either have to do something similar to this solution or not do differential serving of engine assets. PS: There is a long-pending PR to implement the storeConfigInMeta option in Ember Engines.

This solution requires you to serve different bundles by manipulating the HTML at runtime in the server based on a cookie, or if possible, parsing the UA.

Steps

  • In your index.html, add data-for="ember" attribute to script tags that are generated by Ember. For example,
      <script data-for="ember" src="{{rootURL}}assets/vendor.js"></script>
      <script data-for="ember" src="{{rootURL}}assets/app.js"></script>
    
  • In your application code, run a small script to set a cookie to determine if the user is currently using a modern or legacy browser.
      // Serve modern build if Promise and async/await are present
      let testCode = 'async() => { let p = new Promise(); await p(); }';
      try {
        // this needs to be a new Function/eval because otherwise,
        // you will get Syntax Errors in the code
        (new Function(testCode))();
        // Set a never-expiring cookie
      } catch(err) {
        // Set a cookie with a short validity(say a month)
        // If the user updates their browser, it will be reflected
        // once the cookie expires
      }
    
  • In your targets.js file, use the process.env.LEGACY flag to toggle support for legacy browsers.
      'use strict';
    
      let browsers = [
        'last 1 Chrome versions',
        'last 1 Firefox versions',
        'last 1 Safari versions'
      ];
    
      const isCI = !!process.env.CI;
      const isProduction = process.env.EMBER_ENV === 'production';
      const isLegacyBuild = !!process.env.LEGACY;
    
      if (isCI || isProduction || isLegacyBuild) {
        browsers = [
          'Chrome >= 42',
          'Firefox >= 39',
          'Edge >= 14',
          'Safari >= 10'
          'ie 11'
        ];
      }
    
      module.exports = { browsers };
    
  • Run parallel builds with
      ember build --environment=production --output-path=modern &&
      LEGACY=true ember build --environment=production --output-path=legacy
    
    Note: You can also run these builds in parallel. When doing so, you might often get build failures with the error Unexpected end of file ... due to an issue in broccoli-persistent-filter. To avoid this, set process.env.BROCCOLI_PERSISTENT_FILTER_CACHE_ROOT to a unique value for each build(for example, the complete path of the workspace or the current timestamp).
  • Run the index.js provided in this repo. It will generate a dist folder with an index.html containing combined script tags and meta tags wrapped in <MODERN> and <LEGACY> tags.
  • In your server, detect the presence of the cookie that was previously set (or use the UA) and replace the tags accordingly.
      // if modern browser
      replaceAll('<LEGACY>.*</LEGACY>', '');
      replaceAll('<MODERN>(.*)</MODERN>', '$1');
      // if legacy browser
      replaceAll('<MODERN>.*</MODERN>', '');
      replaceAll('<LEGACY>(.*)</LEGACY>', '$1');
    

Feel free to share your ideas or comments on this approach on GitHub or on Twitter.


RSS FeedTwitterGitHubEmailToggle Dark Mode OnToggle Dark Mode Off