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.
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
andmodule.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 moduleprocess
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.