Resources
- My Node / NPM Cheatsheets
- https://blog.codecentric.de/en/2014/02/cross-platform-javascript/
- https://www.intricatecloud.io/2020/02/creating-a-simple-npm-library-to-use-in-and-out-of-the-browser/
- Browserify:
- Webpack: "Authoring Libraries" Guide
- 2ality: "Setting Up Multi-Platform NPM Packages"
- Great guide (well-researched) on the role
package.json
fields can play in cross-env NPM package development
- Great guide (well-researched) on the role
- @terabaud: My First TypeScript Library
- Really excellent walk-through
- Concise (👍), despite covering many topics; TS as source, targeting cross-environment distribution (Node + Browser), Rollup as a build tool, distribution, and more.
- Nolan Lawson: JavaScript Package for both Node and the Browser
- I like the advanced usage of rollup for string replacement in the build step
- Ali Kamalizade: Publishing to NPM with Rollup & TypeScript
Creating Cross-Environment NPM Packages
Splitting your code into separate packages is always an option, but what if you want to bundle and publish a single JS package that can be used for both Node.JS (backend) and the browser (frontend)? This is possible, but a little complicated and there are a few important things to note:
Cross-Environment NPM Packages - Environment Detection
One way to make sure that your code only uses what is available within its executing environment is to physically separate the code into logical files and bundle them separately for distribution - e.g. src/helpers_node.js
& src/helpers_web.js
.
If this is not possible, or you have files that you want shared across environments, you need to build in environment detection code, so you don't try to do something like access document.location
inside Node.JS, which would result in a fatal error.
Cross-Environment NPM Packages - How to Detect NodeJS vs Browser
You could roll your own method to detect if your code is executing inside Node vs the browser, or even grab something off Stack Overflow, but the easiest solution is to just use something like the process
package - this exposes the process
global to browser code, and sets process.browser
to true
in the browser, but undefined
in Node.
It does this by hooking into the browser
field in the package.json
file, so in reality, the use of this package is usually not left to you as a package developer, but to the developers of the bundler you are using:
- If you are using
Browserify
orWebpack
, both of these will automatically setprocess.browser
based on environment Parcel
also supports this to some degree, although documented to a lesser extent; looks like it replaces specific keys with the value itself, so depending onprocess.browser
works, but only when interacted with normally- Doing something like
console.log(process)
will fail for the browser export, and so willeval('process.browser')
- Normal usage, such as
if (process.browser) { ... }
works just fine though
- Doing something like
- If you are using
Rollup
, you need to do some manual work to getprocess.browser
working- Using
@rollup/plugin-node-resolve
, withbrowser: true
, androllup-plugin-node-builtins
+rollup-plugin-node-globals
, should accomplish the task of exposingprocess
as a cross-env global, and settingprocess.browser
appropriately - You could also (or alternatively), use
rollup-plugin-replace
to replace the string literal'process.browser'
with a boolean value based on a config (see this guide for details).
- Using
Cross-Environment NPM Packages - Distribution Files
The easiest way (for both you and your users) is to distribute both browser JS and Node.JS code in the same package is to automatically bundle the browser code together into a distributable file(s), and upload it to the NPM registry along with your regular NodeJS files.
Pre-compiling the final browser-ready and node-ready code just takes a little time and processing power on your end, but can save a bunch of headache for your end-users.
Using this strategy usually involves at least a few of the following steps:
- Create an automated build process step that creates the JS files that can be consumed by the browser
- Use a logical module pattern (probably UMD)
- Unless your code was written as 100% vanilla JS, with no modules, you need to convert the code to a module pattern that the browser can understand. UMD is a great fit for this case, as it works for both Node.JS and the browser.
- ES6 Modules (aka ES Modules) are gaining browser support, but still leave out ~10% of users (2020).
- You could technically use an IIFE and global scope for cross-compatibility, but UMD uses that anyways as a last-resort fallback
- This can be via a standard script entry calling a standard bundler like Webpack (example - Vuetify), or even something more advanced, like calling and executing a Makefile (example - chaijs)
- Ideally, if this is a true distribution file, it should be minified / optimized
- You might want to include source-maps
- You can trigger the build step automatically (⚡) before NPM publishing by using the
prepublish
script entry inpackage.json
!
- Use a logical module pattern (probably UMD)
- Make sure the distributable files are including in the NPM release, but optionally (although highly recommended), exclude the compiled files from being tracked in version control (e.g. Git)
- The easiest way to include
dist
files in a NPM release, but exclude fromgit
, is by adding thedist
files to your.gitignore
, and then using thefiles
entry inpackage.json
to include them in the NPM release. See the publishing section for more details. - Please don't require your end users to transpile code themselves...
- The easiest way to include
- Provide instructions to your users on how to pull the bundled browser-ready JS into a webpage
- You might give multiple options, such as pulling directly from
./node_modules/{...}
folder in a<script>
tag, to pulling from a file distributed via a CDN, such asjsdelivr
- You might give multiple options, such as pulling directly from
Targeting UMD Output From Bundlers
- Browserify
- Use the
-s {myVarName}
option- aka standalone
- Use the
- Webpack
- Use the
libraryTarget
option
- Use the
- Rollup
- Use the
--format umd --name "{myVarName}"
option
- Use the
- Parcel
- Use the
--global {myVarName}
option
- Use the
Cross-Environment NPM Packages - Real World Examples
To understand how to write, package, and distribute a cross-environment NPM Package, it might help to see real example repositories. Here are some popular projects as examples:
- Axios: Promise-Based HTTP Request Library (for both Node and Browser)
- Bundler: Webpack
- Target: UMD
- Package.json: here
- Detection Code:
- Mainly implemented in the adapter, which is kind of the core base that is passed around.
- Notes:
- They track the minified code in VC (don't gitignore). I believe this is to support hotlinking / direct caching of Github assets via CDN layer. See
/dist
folder. - They include source maps with minified JS
- They track the minified code in VC (don't gitignore). I believe this is to support hotlinking / direct caching of Github assets via CDN layer. See
- superagent: HTTP Request Library (similar to Axios)
- Bundler: browserify (called directly via CLI, no config file) ( + babel)
- Target: UMD (using
-s {global_name}
(standalone) with browserify triggers UMD) - Package.json: here
- Detection Code:
- Notes:
- They do not track distribution files in VC, but do upload them via NPM. A little unusual is that they do not include source files in NPM upload, ONLY the babel'ified files.
/src
is transformed (via babel) into/lib
, inbuild:lib
task.
- They do not track distribution files in VC, but do upload them via NPM. A little unusual is that they do not include source files in NPM upload, ONLY the babel'ified files.
- flitbit/diff: Deep object comparison
- Mocha: Testing Framework
- Bundler: Rollup
- They recently switched from
browserify
torollup + babel
- you can see how they did so in the PR
- They recently switched from
- Target: IIFE
- IIFE is totally acceptable here instead of UMD, since the output is a single browser-only
js
file, not to be shared across both NodeJS and Browser.
- IIFE is totally acceptable here instead of UMD, since the output is a single browser-only
- Package.json: here
- Detection Code:
- The overall separation is maintained by having a different entry-point; they use
browser-entry.js
as a browser-only wrapper / initializer of Mocha. - They also have various sections of code that use reusable detection method and then take different paths; example.
- The overall separation is maintained by having a different entry-point; they use
- Bundler: Rollup
- faker.js: Generate fake / placeholder data
- Bundler: gulp (for tasks) and browserify (for actual transpiling)
- Target: UMD
- Package.json: here
- Detection Code: Not really used / necessary
- Notes:
- Transpiled browser code (both full and minified) is not excluded from VC. These files are also explicitly included in NPM upload.
- chromatism: Color Utilities
- isomorphic-git
- Bundler: Rollup (and Webpack)
- Target: Pretty much all of them (UMD, ES, CommonJS)
- Package.json: Here
- Detection code: It mostly doesn't have much; it requires you bring your own
fs
andhttp
lib with you
- Rich-Harris/yootils
- Bundler: Rollup
- Target: UMD and ES
- Package.json: Here
- Detection Code: NA
- Notes: Uses TypeScript, and extra build step to move declaration files to right location
Examples of PRs / Commits that add various cross-browser stuff
sindresorhus/ky
: PR #81 - Add UMD Build
For finding more real-world examples, try searching "node browser" on Github and sorting by star count.