Ever wondered how to build a TypeScript module that’s compatible with both Node.js and the browser platform?
Always wanted to do something like this?
// Project X: Depending on my-lib in a Node.js environment
import lib from 'my-lib/dist/node'
// Another project: A React app, depending on the exact same library
import lib from 'my-lib/dist/browser'
Well, it’s actually trickier than I thought before, because I had the requirement of having different dependencies, depending on where the library was used. The thing was that there is a native DOMImplementation in browsers, but not in Node.js and my library was dependent on DOMImplementation, but I wanted to support both Node.js and browser environments. That lead me to the idea of building two separate modules, but with one codebase.
Of course, this guide also applies if you intend to write a cross compatible JavaScript SDK that should be cross-compatible. In this case, you don’t even have to use TSDX, but can rather depend on a simple rollup config 🙂
tl;dr
Here’s the final outcome, if you are super busy and don’t want to read the whole article: https://github.com/gebsl/cross-platform-ts-lib
Other examples that were created according to this idea:
- ng112-js: A cross platform library for NG112 compatible communication through text messages (ETSI standards ETSI TS 103 479 and ETSI TS 103 698).
- pidf-lo: Cross platform PIDF-LO document generation and parsing according to IETF RFC 5491.
Let’s get started
I started by using TSDX for creating my base project. TSDX is super handy when it comes to setting up a TypeScript environment for building libraries. It covers basic TypeScript settings, a nice bundling config, minification, linting and much more.
# create the project
npx tsdx create cross-platform-ts-lib
I chose the basic
template to start with.
Code for node
Well that’s some bad linguistic joke, forget it…
Now that Node.js does not have a DOM implementation, we’ll use a substitute on Node environments. The npm package xmldom
covers all our needs and is completely compatible with the browser’s DOM implementation. Let’s install it:
npm install xmldom
Ok, let’s write some code for use in a node environment. Open src/index.ts
and remove what’s in there. As an example, we’ll use some basic XML manipulation. For simplicity, in our example, we use xmldom
to create a valid HTML5 document. That’s not tricky itself, therefore it’s a good fit for example purposes.
The outcome of our library’s only function should be something like:
Here comes the code for creating our valid HTML5 document:
export interface Markup {
node: Node,
stringified: string,
}
const {
DOMImplementation,
XMLSerializer,
} = require('xmldom');
const xmlToString = (xml: Element): string => {
return new XMLSerializer().serializeToString(xml);
}
export const createHtml5Markup = (): Markup => {
const doc = document.implementation.createDocument('', '', null);
const html = doc.createElement('html');
doc.appendChild(html);
const head = doc.createElement('head');
html.appendChild(head);
const title = doc.createElement('title');
head.appendChild(title);
title.textContent = 'I am a valid HTML5 document';
const body = doc.createElement('body');
html.appendChild(body);
return {
node: html,
stringified: `<!DOCTYPE html>${xmlToString(html)}`,
};
}
Quickly writing a small test to confirm our implementation:
Now, if we build the project using npm run build
we’ll get the library delivered into folder dist
with both a CommonJs and an ECMA module version of the library. Our final aim is to have the CommonJs version being compatible with Node.js, the ECMA module being compatible with the browser platform.
Modifying the build process
Fortunately TSDX allows customization of the build process. Additionally we’ll need a custom build script. But before, we’ll create some new script entries in our package.json
so we can distinguish between browser and node builds. Add this to your package.json
:
"build": "node ./scripts/build.js",
"build:browser": "tsdx build --target web --format esm --name index",
"build:node": "tsdx build --target node --format cjs --name index",
Here we tell TSDX to create our libraries slightly differently. For browser environments, we’ll use esm
module format. For Node.js we’ll stick to CommonJs
. The generic build
entry is already linked to our custom build script. Let’s continue with creating this one.
Create a folder scripts
and place a file build.js
in there. What the file basically should do:
- Building the browser version of the library. TSDX places all built files in directory
dist
. - Temporarily moving the browser build from folder
dist
todist-browser
, so TSDX does not overwrite the directory with our subsequent Node.js build (overwriting the build directory is TSDX’s default behaviour). - Running the Node.js build.
- Separating javascript build files from all TypeScript typings.
Why? For each build, TSDX creates separate TypeScript typings. But as they are essentially the same, we separate them from each build and put them in a separate directory. That way we can also build cross-compatible libraries on top of this cross-compatible library (stack overflow!!!), but this will be handeled in another post 😉
In our case, we just usebrowser's
typings, but we could do the same with Node.js build’s typings. The typings of our Node.js build are removed entirely.
For reference, this is the build file:
Last step is to create a template for linking our types together. As all typings are now in a separate folder, we can not use TypeScript support if we directly do something like this:
// no types here :-(
import { createBasicHtml5Markup } from 'cross-platform-ts-lib/dist/browser';
// this would be the workaround, however, it's not very nice
import type { Markup } from 'cross-platform-ts-lib/dist/types'; // meh :-(
But the solution to this problem is easy. We just place a simple typings file in our build folders to link all the typings correctly. For this reason create folder templates
in your root directory and create a file named index.d.ts
with the following content:
export * from '../types';
You might have already seen that the build script uses this template file at the very end of the script. It just copies it into our build directories, linking together our common types in the types
folder. Easy!
Finally, we can start the build:
npm run build
After the build has finished, you’ll have the following folder structure:
project root
└── dist
├── node # Node.js build
├── browser # browser build
└── types # Common typings
Platform specific implemantation
Now we have separated builds for browser and Node.js environments, but we still do not distinguish within our code. Best practices tell us we’d never decide which implemenation to use just by predefining the environment the code will run in. Usually we’d always go with this:
if ('document' in globalThis && 'implementation' in globalThis.document) {
// that's definetely the browser and it has got DOMImplemetation
}
else {
// that's Node.js, we have to import our dependency
const xmldom = require('xmldom');
}
In this example, we detect features available on each platform, which is the normal way-to-go (e.g. for feature detection in browser apps).
BUT, that comes with one serious pitfall! Each built library (be it Node.js or browser) will include this if-else
statement, thus bloating our code and leading to module bundlers bundling code that is not even needed. How do we solve this?
Static distinction between Node.js and browser
We’ve to work against best practices, but this way, we can potentially save a lot of resources, because some modules might not be needed on certain environments. We achieve this static distinction through a rollup plugin called rollup-plugin-replace
. Let’s install it:
# install as dev dependency
npm install rollup-plugin-replace --save-dev
Then we have to customize our TSDX build a little bit. We can achieve this by adding a file tsdx.config.js
to our root directory, containing the following:
Let me explain what rollup-plugin-replace
does: The plugin searches for the string process.envType
in our source code and replaces every occurrence with the content that we specify in environment variable ENV_TYPE
. Assuming ENV_TYPE
is set to browser
, every process.envType
will be converted to 'browser'
.
That leads us to the following nice thing:
if (process.envType === 'browser') {
// do something browser specific
}
// will be transformed to
if ('browser' === 'browser') {
// do something browser specific
}
This might look supid. Actually, it is stupid. Why would you compare a string with its exact representation?
It’s supid and it’s good, because also rollup knows this is stupid and it will just remove the if
, as if it was not event there, because 'browser' === 'browser'
will always return true, so no need for an if
.
The best thing is yet to come! What if ENV_TYPE
is node
? The outcome will be:
if (process.envType === 'browser') {
// do something browser specific
}
// will be transformed to
if ('node' === 'browser') {
// do something browser specific
}
Again, super stupid. Also rollup knows this. 'node' === 'browser'
will always return false
, therefore rollup just strips this part of the code away! That’s exactly what we want to achieve for node environments, because this code anyway will not run in node environments. Excellent!
Make our implementation compatible with browsers
Now let’s dive into real compatibility with Node.js and browser. All we now have to do is distinguishing between the two environments and (only for Node.js) requiring the additional module from our node_modules, which will then look like this:
Now, TypeScript will complain, because it does not recognize the property envType
on Node.js‘ process
object. But that’s easy to resolve. Just create a custom typing types.d.ts
in your src
directory and fill it with following content:
This will tell TypeScript that the process object also contains a property envType
of type string
.
The really last step is to specify the correct environment variable for each build. If you remember, we’ve add some additional build commands to our package.json
. No we’ll slightly modify them, giving each build environment its corresponding environment variable:
"build": "node ./scripts/build.js",
"build:browser": "ENV_TYPE=browser tsdx build --target web --format esm --name index",
"build:node": "ENV_TYPE=node tsdx build --target node --format cjs --name index",
The first line didn’t change. However, build:browser
and build:node
got their ENV_TYPE
specified. As formerly stated, this environment variable will then be consumed by our modified rollup script in tsdx.config.js
.
Running our build again, this is the output we get. Note how our if
statement was gracefully stripped away for each environment by rollup, thus revealing the correct implementation for each environment without any additional code.
Browser-Env
var domImplementation;
var xmlSerializer;
{
domImplementation = document.implementation;
xmlSerializer = /*#__PURE__*/new XMLSerializer();
}
var xmlToString = function xmlToString(xml) {
return xmlSerializer.serializeToString(xml);
};
// ... rest of the code
Node.js-Env
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
let domImplementation;
let xmlSerializer;
{
const {
DOMImplementation,
XMLSerializer
} = /*#__PURE__*/require('xmldom');
domImplementation = /*#__PURE__*/new DOMImplementation();
xmlSerializer = /*#__PURE__*/new XMLSerializer();
}
const xmlToString = xml => {
return xmlSerializer.serializeToString(xml);
};
// ... rest of the code
Now you should be able to publish the project to npm. Other projects can then import this library by either importing:
import { createHtml5Markup } from "cross-platform-ts-lib/dist/browser";
or
import { createHtml5Markup } from "cross-platform-ts-lib/dist/node";
without having to worry about compatibility or bloating their project because of unnecessary dependencies.
Resources that helped me a lot!
This article is heavily inspired by gdad-s-river (https://fossbytes.com/npm-module-cross-environment) and Nolan Lawson (https://nolanlawson.com/2017/01/09/how-to-write-a-javascript-package-for-both-node-and-the-browser)