Browserify to Rollup hero image

Browserify to Rollup - Unifying our playground in a single bundle

This blog post is a follow up from our post about NodeJS in Browsers and creating a playground for style-dictionary. If you haven't read it, we suggest starting there if you would like more context about the project we are using as the example in this blog.

Browserify is a bundler that is catered towards making NodeJS projects (commonly written in CommonJS format, pun intended).
Since it is just a specialized bundler, it is possible to do the same thing that Browserify does with other, more generic, bundlers.
Rollup is one example of a bundler, and we will use this one in this blogpost.

rollup logo

One big downside of using browserify for browserifying your NodeJS code & libraries is that you probably use a different bundler for bundling your application code.
For example, let's take Rollup.
If you bundle your application code with rollup but you use browserify for the NodeJS code used in your app, you will end up with two bundles and probably lots of code loading twice for the client, because nothing is deduplicated between those two final bundles.

Pretty much all the things browserify does, other bundlers are capable of as well as they work in similar ways: using AST (Abstract Syntax Tree) to do static analysis of your code to then bundle them, optimize, chunk, etc.

If you want to know the differences between bundlers in their capabilities, check out https://bundlers.tooling.report/

So summarizing some of the things browserify does which we need for style-dictionary, which is our NodeJS library that we need to make work in the browser:

  • Remove instances of require and module.exports by bundling into a single chunk
  • Go from CommonJS to something that browser understands
  • Resolve imports to NodeJS exclusives to browser alternatives
  • Allow JSON imports/requires
  • Resolution of bare imports to NPM modules (node_modules folder)
  • Replace global process with NPM module process as a shim

We need to ensure these things are done using Rollup plugins.

Basic config:

export default {
  input: "src/index.js",
  output: {
    format: "es",
    file: "dist/index.js",
    globals: {
      lodash: "_",
    },
  },
  plugins,
}

We pick "ES" as format, because this is the format that modern browsers support and prefer. For older browsers you can opt for iife, systemjs, and others.

Then the plugins are where the magic happens. Let's go one by one.

CommonJS

Rollup comes with a big ecosystem of plugins, some of which are supported officially by rollup itself. A very commonly used plugin is @rollup/plugin-commonjs, it transforms CommonJS code to the format you want.

const plugins = [
  commonjs(),
];

Node Resolution

Bare imports have to be resolved to node_modules, and some meta information should be taken into account for each package from its package.json in order to resolve correctly. For this we use @rollup/plugin-node-resolve. We create the plugin instance first before adding it to the plugin array, because we need a reference to the instance later on if we need to do manual resolution logic for our browser alternatives to NodeJS exclusives.

const nodeResolve = resolve({
  preferBuiltins: false,
  mainFields: ["module", "jsnext:main", "browser"],
});

const plugins = [
  commonjs(),
  nodeResolve,
];

preferBuiltins false is important here, as we do our own logic for using NodeJS builtins. mainFields is important to add the fields you want to consider for resolution references.

NodeJS Exclusives

This is the biggest aspect of browserify and the step where you need most of the knowledge of your bundler and its lifecycle hooks in order to mimic the behavior.

Imports like:

const path = require('path');

Need to be resolved to the package path-browserify instead. There are many others that we need, so let's start by making a map object for that:

const BROWSERIFY_ALIASES = {
  assert: "assert",
  events: "events",
  fs: "memfs",
  module: EMPTY_MODULE_ID,
  path: "path-browserify",
  process: "process",
  util: "util",
};

EMPTY_MODULE_ID here resolves to $empty$ which is an alias that we use later on to resolve to, and then the content we load equals export default {}, this is how we shim module, we just make it load an empty JS object.

Then we create a plugin that uses the resolveId and load rollup build hooks.

const browserify = {
  name: 'browserify',
  resolveId(source, importer) {
    if (source in BROWSERIFY_ALIASES) {
      if (BROWSERIFY_ALIASES[source] === EMPTY_MODULE_ID)
        return EMPTY_MODULE_ID;
      return nodeResolve.resolveId(BROWSERIFY_ALIASES[source], undefined);
    }
    if (source === EMPTY_MODULE_ID) return EMPTY_MODULE_ID;
  },
  load(id) {
    if (id === EMPTY_MODULE_ID) return EMPTY_MODULE;
  },
};

Basically, for every import that goes to something for which we have a browserify alias, we intercept the resolution and redirect it to where want, for example source could be path, and then we resolve to path-browserify instead. Earlier we made an instance of the node-resolve rollup plugin, which we need to use here to make the resolution follow the node-resolve logic.

We also make sure in case of EMPTY_MODULE_ID that it resolves to itself, this is a placeholder we use to then load export default {} in the load hook.

const plugins = [
  commonjs(),
  browserify,
  nodeResolve,
];

Replace global process

Code like this:

process.cwd()

Has to be replaced by

const process = require('process');
process.cwd(); 

You can inject globals by using @rollup/plugin-inject

const plugins = [
  commonjs(),
  browserify,
  inject({ process: 'process' }),
  nodeResolve,
];

JSON requires

Code like:

const json = require('./foo.json');

Has to be supported, not all environments (like browsers) support direct JSON imports.

You can use @rollup/plugin-json for this.

const plugins = [
  commonjs(),
  browserify,
  inject({ process: 'process' }),
  nodeResolve,
  json(),
];

Style-dictionary templates

In the style-dictionary library there's a call to fs.readFileSync to read out the templates that come supported out of the box. When you're bundling style-dictionary, you'll need to ensure that these templates are included in your bundle somehow. A quick fix here is to just inline the contents, as these templates are static:

const inlineFs = {
  name: "inline-fs",
  transform(code, id) {
    return code.replace(
      /fs.readFileSync\(\s*__dirname\s*\+\s*'\/templates\/(.*)'\)/g,
      (match, $1) => {
        const tpl = path.join(
          "./node_modules/browser-style-dictionary/lib/common/templates",
          $1
        );
        return JSON.stringify(fs.readFileSync(tpl, "utf8"));
      }
    );
  },
}

There are probably smarter ways to do this, like statically analyze fs read calls and bundle these files in separate chunks automatically, but this solution will do for now.

const plugins = [
  commonjs(),
  browserify,
  inject({ process: 'process' }),
  nodeResolve,
  json(),
  inlineFs,
];

Circular glob dependency

There's a final small issue with glob causing circular dependencies. There's a line of code in the final bundle as a result that actually causes a fatal error: glob_1.Glob;. Removing this line makes the bundle work, so let's automate that:

const removeGlobWeirdness = {
  name: "remove-glob-weirdness",
  renderChunk(code) {
    return code.replace(/glob_1\.Glob;/g, "");
  }
}
const plugins = [
  commonjs(),
  browserify,
  inject({ process: 'process' }),
  nodeResolve,
  json(),
  inlineFs,
  removeGlobWeirdness,
];

Conclusion

From all this, you can probably tell how cool browserify is for doing all of this bundling and resolution magic out of the box. Configuring it in your own bundler of choice is quite a lot of work actually. So just to recap why we did it:

Your application and your browserified NodeJS code share the same bundle

This is useful for performance, and sometimes necessary for reusing singletons.
Naming one example: if your application uses a fs shim for file-system in the browser, like memfs, and your style-dictionary needs to use the same file-system; they need to access the same instance of memfs.
If they live in separate bundles, they won't.
This is especially important for us in Backlight where style-dictionary related code lives next to the rest of the code in the projects.