Cross-Compatible Typescript Libraries (browser and Node.js)

~ 7 min.

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:

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:

  1. Building the browser version of the library. TSDX places all built files in directory dist.
  2. Temporarily moving the browser build from folder dist to dist-browser, so TSDX does not overwrite the directory with our subsequent Node.js build (overwriting the build directory is TSDX’s default behaviour).
  3. Running the Node.js build.
  4. 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 use browser'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)

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert