This is an issue that I’ve seen a lot of confusion over, and even seasoned JavaScript developers might have missed some of its subtleties. So I thought it was worth a short tutorial.
Let’s say you have a JavaScript module that you want to publish to npm, available both for Node and for the browser. But there’s a catch! This particular module has a slightly different implementation for the Node version compared to the browser version.
This situation comes up fairly frequently, since there are lots of tiny environment differences between Node and the browser. And it can be tricky to implement correctly, especially if you’re trying to optimize for the smallest possible browser bundle.
Let’s build a JS package
So let’s write a mini JavaScript package, called base64-encode-string
. All it does is take a string as input, and it outputs the base64-encoded version.
For the browser, this is easy; we can just use the built-in btoa
function:
module.exports = function (string) { return btoa(string); };
In Node, though, there is no btoa
function. So we’ll have to create a Buffer
instead, and then call buffer.toString() on it:
module.exports = function (string) { return Buffer.from(string, 'binary').toString('base64'); };
Both of these should provide the correct base64-encoded version of a string. For instance:
var b64encode = require('base64-encode-string'); b64encode('foo'); // Zm9v b64encode('foobar'); // Zm9vYmFy
Now we’ll just need some way to detect whether we’re running in the browser or in Node, so we can be sure to use the right version. Both Browserify and Webpack define a process.browser
field which returns true
, whereas in Node it’s falsy. So we can simply do:
if (process.browser) { module.exports = function (string) { return btoa(string); }; } else { module.exports = function (string) { return Buffer.from(string, 'binary').toString('base64'); }; }
Now we just name our file index.js
, type npm publish
, and we’re done, right? Well, this works, but unfortunately there’s a big performance problem with this implementation.
Since our index.js
file contains references to the Node built-in process
and Buffer
modules, both Browserify and Webpack will automatically include the polyfills for those entire modules in the bundle.
From this simple 9-line module, I calculated that Browserify and Webpack will create a bundle weighing 24.7KB minified (7.6KB min+gz). That’s a lot of bytes for something that, in the browser, only needs to be expressed with btoa
!
“browser” field, how I love thee
If you search through the Browserify or Webpack documentation for tips on how to solve this problem, you may eventually discover node-browser-resolve. This is a specification for a "browser"
field inside of package.json
, which can be used to define modules that should be swapped out when building for the browser.
Using this technique, we can add the following to our package.json
:
{ /* ... */ "browser": { "./index.js": "./browser.js" } }
And then separate the functions into two different files, index.js
and browser.js
:
// index.js module.exports = function (string) { return Buffer.from(string, 'binary').toString('base64'); };
// browser.js module.exports = function (string) { return btoa(string); };
After this fix, Browserify and Webpack provide much more reasonable bundles: Browserify’s is 511 bytes minified (315 min+gz), and Webpack’s is 550 bytes minified (297 min+gz).
When we publish our package to npm, anyone running require('base64-encode-string')
in Node will get the Node version, and anyone doing the same thing with Browserify or Webpack will get the browser version. Success!
For Rollup, it’s a bit more complicated, but not too much extra work. Rollup users will need to use rollup-plugin-node-resolve and set browser
to true
in the options.
For jspm there is unfortunately no support for the “browser” field, but jspm users can get around it in this case by doing require('base64-encode-string/browser')
or jspm install npm:base64-encode-string -o "{main:'browser.js'}"
. Alternatively, the package author can specify a “jspm” field in their package.json
.
Advanced techniques
The direct "browser"
method works well, but for larger projects I find that it introduces an awkward coupling between package.json
and the codebase. For instance, our package.json
could quickly end up looking like this:
{ /* ... */ "browser": { "./index.js": "./browser.js", "./widget.js": "./widget-browser.js", "./doodad.js": "./doodad-browser.js", /* etc. */ } }
So every time you want a browser-specific module, you’d have to create two separate files, and then remember to add an extra line to the "browser"
field linking them together. And be careful not to misspell anything!
Also, you may find yourself extracting individual bits of code into separate modules, merely because you wanted to avoid an if (process.browser) {}
check. When these *-browser.js
files accumulate, they can start to make the codebase a lot harder to navigate.
If this situation gets too unwieldy, there are a few different solutions. My personal favorite is to use Rollup as a build tool, to automatically split a single codebase into separate index.js
and browser.js
files. This has the added benefit of de-modularizing the code you ship to consumers, saving bytes and time.
To do so, install rollup
and rollup-plugin-replace
, then define a rollup.config.js
file:
import replace from 'rollup-plugin-replace'; export default { entry: 'src/index.js', format: 'cjs', plugins: [ replace({ 'process.browser': !!process.env.BROWSER }) ] };
(We’ll use that process.env.BROWSER
as a handy way to switch between browser builds and Node builds.)
Next, we can create a src/index.js
file with a single function using a normal process.browser
condition:
export default function base64Encode(string) { if (process.browser) { return btoa(string); } else { return Buffer.from(string, 'binary').toString('base64'); } }
Then add a prepublish
step to package.json
to generate the files:
{ /* ... */ "scripts": { "prepublish": "rollup -c > index.js && BROWSER=true rollup -c > browser.js" } }
The generated files are fairly straightforward and readable:
// index.js 'use strict'; function base64Encode(string) { { return Buffer.from(string, 'binary').toString('base64'); } } module.exports = base64Encode;
// browser.js 'use strict'; function base64Encode(string) { { return btoa(string); } } module.exports = base64Encode;
You’ll notice that Rollup automatically converts process.browser
to true
or false
as necessary, then shakes out the unused code. So no references to process
or Buffer
will end up in the browser bundle.
Using this technique, you can have any number of process.browser
switches in your codebase, but the published result is two small, focused index.js
and browser.js
files, with only the Node-related code for Node, and only the browser-related code for the browser.
As an added bonus, you can configure Rollup to also generate ES module builds, IIFE builds, or UMD builds. For an example of a simple library with multiple Rollup build targets, you can check out my project marky.
The actual project described in this post (base64-encode-string
) has also been published to npm so that you can inspect it and see how it ticks. The source code is available on GitHub.
Posted by Wellington Soares on January 9, 2017 at 9:22 AM
The most complete post about write javascript package for node and browser. Thanks ;)
Sharing on 3..2..1.
Posted by 【これで解決!】Nodeとbrowserの両方に対応したJavaScriptのパッケージの書き方|SeleQt【セレキュト】 on January 11, 2017 at 1:46 AM
[…] ※本稿は「How to write a JavaScript package for both Node and the browser」を翻訳・再編集したものです。 […]
Posted by MaxArt on January 15, 2017 at 11:08 AM
Thanks Nolan, nice writeup.
Just a note, npm v4 has deprecated the “prepublish” script in favor of “prepare”.
Posted by Sergei on January 20, 2017 at 12:25 AM
I use this solution:
;(function () {
const self = this;
self.foo = function () {};
}).call(this);
In browser it create a global function foo.
Posted by Anatoly on July 8, 2017 at 1:33 AM
Thank you!
Posted by Sipho on January 31, 2018 at 8:36 AM
Thought you might be interested to know that most of your article was essentially copy-pasted without attribution to https://fossbytes.com/npm-module-cross-environment/
Posted by Mr Karouni on December 3, 2018 at 8:33 AM
Hi, how to browserify some npm package with browser field? How to use your sample package in browser?
Posted by jedatu on January 9, 2020 at 6:07 PM
This is very helpful. Do you have thoughts on how NPM dependencies are handled? This example doesn’t have any “3rd party” dependencies that may or may not support running in a browser. Will node-browser-resolve also allow shimming require statements in package.json dependencies that are not built with the browser in mind?
In other words, would something like this in the package json of the primary module also shim inside 3rd party dependencies?
{
/* … */
“browser”: {
“crypto”: “browser-crypto”
}
}
Posted by Nolan Lawson on November 24, 2020 at 3:56 PM
Per the specification for node-browser-resolve, yes, that should work.