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.
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 globalrequire
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:
[@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.__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.
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);}}
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.
When making isomorphic JS packages:
__non_webpack_require__
.Thanks for reading!
Who doesn’t like Morph?!?