Building isomorphic JavaScript packages

Written by meeshkan | Published 2019/01/22
Tech Story Tags: javascript | typescript | nodejs | isomorphic-javascript | universal-javascript

TLDRvia the TL;DR App

I had to look up isomorphic in a dictionary the first time I came across this word in web development. Wikipedia reports that Isomorphic JavaScript, also known as Universal JavaScript, describes JavaScript applications which run both on the client and the server.

Building a package that works out of the box in both the server (ie Node) and the client (ie a browser) is hard. As soon as you start mixing in dependencies, if even one of them makes a call to a node-only package (think [fs](https://nodejs.org/api/fs.html)) or browser-only object (think [window](https://www.w3schools.com/jsref/obj_window.asp)) , it will tank your whole build. Worse yet, you may not even be able to reasonably infer this problem exists, as they may be buried in sub-sub-sub-dependencies that have no bearing on the code you are writing.

This document describes a couple easy conventions for developing isomorphic node packages and ends with one opinionated missive about how they should be packaged.

Ignore requires in webpack

Even if you use a package like [detect-node](https://www.npmjs.com/package/detect-node) to create conditional node vs browser imports, webpack does not care about conditionals because it has no clue how the runtime will resolve them. So, in the following scenario:

const foo = isNode ?require("fs") : require("fs-web");

it will happily try to package all of the code you require, meaning that in this case, it will try to package fs. It will then fail with the following error message.

Module not found: Error: Can't resolve 'fs' in [insert file here]

In response to my question on how to get around this conundrum, Alex Rokabilis writes:

[check out] the not very well known __non_webpack_require__ function. This is a webpack specific function that will instruct the parser to avoid bundling this module that is being requested and assume that a global require function is available.

While [__non_webpack_require__](https://webpack.js.org/api/module-variables/) is a documented part of the API, it is buried deep within the documentation, thus its obscurity. However, it works like a charm with one minor tweak.

If you try to write:

const foo = isNode ?[__non_webpack_require__](https://webpack.js.org/api/module-variables/)("fs") : require("fs-web");

Node will barf with the following error message:

__non_webpack_require__ is not defined

Typescript will also not recognize __non_webpack_require__.

This can be overcome in the following ways:

  1. For typescript users, require [@types/webpack-env](https://www.npmjs.com/package/@types/webpack-env). Do not use [@types/webpack](https://www.npmjs.com/package/@types/webpack) for this, it will not work.
  2. In your code, somewhere before you use __non_webpack_require__, write the following hack:

if (isNode) {(global as any).__non_webpack_require__ = require;}

There are a few different ways to accomplish this, most of which will not raise an error in an IDE and will compile with babel and typescript but will fail in your node environment. So use this one — it’s been extensively tested by our team and works.

Use dependency injection and TS interfaces

Dependency injection is a popular pattern that is famously evangelized by Uncle Bob in this article that I’ve mentioned several time on this blog. The idea is that dependencies should always import inwards, meaning that “core” code should never know about dependencies, but should make a contract with the calling code via interfaces, and the calling code implements classes that make good on this contract by, amongst other things, pulling in relevant dependencies.

In unmock-js, we use dependency injection for the logging and persistence mechanisms, both of which are defined in an options object that is injected into the main unmock function at runtime. If no option is given, sensible defaults are chosen based on the detect-node package, which is the most popular and reliable way to detect if an environment is node or not. Let’s see how that works.

In our dependency injection, options parameter is defined to contain, amongst other things, the following interfaces:

export interface IUnmockOptions {// ... some stuff, then ...logger?: ILogger;persistence?: IPersistence;// ... more stuff}

ILogger and IPersistence themselves contain various methods, such as ILogger::log and IPersistence::saveHeaders, that call functions specific to node or the browser. For example, here is the difference between node and jsdom code for one method in the IPersistence interface:

// fs-persistence.ts

export default class FSPersistence implements IPersistence {public saveHeaders(hash: string, headers: {[key: string]: string}){fs.writeFileSync(`${this.outdir(hash)}/response-header.json`, JSON.stringify(headers, null, 2));}}

// local-storage-persistence.tsexport default class LocalStoragePersistence implements IPersistence {public saveHeaders(hash: string, headers: {[key: string]: string}){window.localStorage[`${this.outdir(hash)}/response-header.json`] = JSON.stringify(headers, null, 2);}}

Don’t use separate packages

I’ve flipped back and forth on this issue quite a lot. On one hand, using separate packages for different environment solves the “1980s Christmas lights” phenomenon where an error in one environment tanks all of the others. This can, of course, be toxic for package and project management. On the other hand, separate packages can lead to a usability problem if the developer is trying to develop an isomorphic package and now needs to include and manage multiple packages. This is currently the case, for example, with [@sentry/node](https://www.npmjs.com/package/@sentry/node) versus [@sentry/browser](https://www.npmjs.com/package/@sentry/browser). If you are developing a Next.js package, for example, and are trying to reuse server and client code that relies on Sentry, this can lead to a huge mess of if / then clauses. I’m sure that Sentry will fix this, but the general problem should be avoided if possible.

As a result of this philosophy, [unmock-js](https://www.npmjs.com/package/unmock) is a single package that works flawlessly across Node and browser environments, even in complicated scenarios that mix code from the two.

The goal is to ease developer experience so that they have a batteries-included way to start working with mock data as they build out their web app.

tl;dr

When making isomorphic JS packages:

  1. Use __non_webpack_require__.
  2. Use dependency injection.

Thanks for reading!

Who doesn’t like Morph?!?


Published by HackerNoon on 2019/01/22