Custom ESM loaders: Who, what, when, where, why, how
Tags: esm, javascript, node, webdev
Published: 2022-07-12 22:31:50

Most people probably won't write their own custom ESM loaders, but using them could drastically simply your workflow.

[Custom loaders](https://nodejs.org/api/esm.html#loaders) are a powerful mechanism for controlling an application, providing extensive control over loading modules—be that data, files, what-have-you. This article lays out real-world use-cases. End users will likely consume these via packages, but it could still be useful to know, and doing a small and simple one-off is very easy and could save you a lot of hassle with very little effort (most of the loaders I've seen/written are about 20 lines of code, many fewer).

For prime-time usage, multiple loaders work in tandem in a process called "chaining"; it works like a promise chain (because it literally is a promise chain). Loaders are added via command-line in reverse order, following the pattern of its forebearer, [`--require`](https://nodejs.org/api/cli.html#-r---require-module):

```console
$> node --loader third.mjs --loader second.mjs --loader first.mjs app.mjs
```

`node` internally processes those loaders and then starts to load the app (`app.mjs`). Whilst loading the app, `node` invokes the loaders: `first.mjs`, then `second.mjs`, then `third.mjs`. Those loaders can completely change basically everything within that process, from redirect to an entirely different file (even on a different device across a network) or quietly provide modified or entirely different contents of those file(s).

In a contrived example:

```console
$> node --loader redirect.mjs app.mjs
```

```js
// redirect.mjs

export function resolve(specifier, context, nextResolve) {
  let redirect = 'app.prod.mjs';

  switch(process.env.NODE_ENV) {
    case 'development':
      redirect = 'app.dev.mjs';
      break;
    case 'test':
      redirect = 'app.test.mjs';
      break;
  }

  return nextResolve(redirect);
}
```

This will cause `node` to dynamically load `app.dev.mjs`, `app.test.mjs`, or `app.prod.mjs` based on the environment (instead of `app.mjs`).

However, the following provides a more robust and practical use-case:

```console
$> node \
   --loader typescript-loader \
   --loader css-loader \
   --loader network-loader \
   app.tsx
```

```js
// app.tsx

import ReactDOM from 'react-dom/client';
import {
  BrowserRouter,
  useRoutes,
} from 'react-router-dom';

import AppHeader from './AppHeader.tsx';
import AppFooter from './AppFooter.tsx';

import routes from 'https://example.com/routes.json' assert { type: 'json' };

import './global.css' assert { type: 'css' };

const root = ReactDOM.createRoot(document.getElementById('root'));

root.render(
  <BrowserRouter>
    <AppHeader />
    <main>{useRoutes(routes)}</main>
    <AppFooter />
  </BrowserRouter>
);
```

The above presents quite a few items to address. Before loaders, one might reach for Webpack, which sits on top of Node.js. However, now, one can tap into `node` directly to handle all of these on the fly.

## The TypeScript

First up is `app.tsx`, a TypeScript file: `node` doesn't understand TypeScript. TypeScript brings a number of challenges, the first being the most simple and common: transpiling to javascript. The second is an obnoxious problem: TypeScript demands that import specifiers lie, pointing to files that don't exist. `node` of course cannot load non-existent files, so you'd need to tell `node` how to detect the lies and find the truth.

You have a couple options:

* Don't lie. Use the `.ts` etc extensions and use something like [esbuild](https://esbuild.github.io/) in a loader you write yourself, or an off-the-shelf loader like [ts-node/esm](https://github.com/TypeStrong/ts-node#node-flags-and-other-tools) to transpile the output. On top of being correct, this is also _significantly_ more performant. This is Node.js’s recommended approach.

  Note: `tsc` appears soon to support `.ts` file extensions during type-checking: [TypeScript#37582](https://github.com/microsoft/TypeScript/issues/37582), so you'll hopefully be able to have your cake and eat it too.

* Use the wrong file extensions and guess (this will lead to decreased performance and possibly bugs).

Due to design decisions in TypeScript, there are unfortunately drawbacks in both options.

If you want to write your own TypeScript loader, the Node.js Loaders team have put together a simple example: [nodejs/loaders-test/typescript-loader](https://github.com/nodejs/loaders-test/tree/HEAD/typescript-loader). `ts-node/esm` would probably suit you better though.

## The CSS

`node` also does not understand CSS, so it needs a loader (`css-loader` above) to parse it into some JSON-like structure. I use this most commonly when running tests, where styles themselves often don't matter (just the CSS classnames). So the loader I use for that merely exposes the classnames as simple, matching key-value pairs. I've found this to be sufficient as long as the UI is not actually drawn:

```css
.Container {
  border: 1px solid black;
}

.SomeInnerPiece {
  background-color: blue;
}
```

```jsx
import styles from './MyComponent.module.css' assert { type: 'css' };
// { Container: 'Container', SomeInnerPiece: 'SomeInnerPiece' }

const MyComponent () => (<div className={styles.Container} />);
```

A quick-n-dirty example of `css-loader` is available here: [JakobJingleheimer/demo-css-loader](https://github.com/JakobJingleheimer/demo-css-loader).

A Jest-like snapshot or similar consuming the classnames works perfectly fine and reflects real-world output. If you're manipulating the styles within your JavaScript, you'll need a more robust solution (which is still very feasible); however, this is maybe not the best choice. Depending on what you're doing, CSS Variables are likely better (and do not involve manipulating the styles at all).

## The remote data (file)

`node` does not yet fully support loading modules over a network (there is experimental support that is intentionally very restricted). It’s possible to instead facilitate this with a loader (`network-loader` above). The Node.js Loaders team have put together a rudimentary example of this: [nodejs/loaders-test/https-loader](https://github.com/nodejs/loaders-test/tree/HEAD/https-loader).

## All together now

If you have a "one-off" task to complete, like compiling your app to run tests against, this is all you need:

```console
$> NODE_ENV=test \
   NODE_OPTIONS='--loader typescript-loader --loader css-loader --loader network-loader' \
   mocha \
   --extension '.spec.js' \
   './src'
```

As of this week, the team at [Orbiit.ai](https://orbiit.ai) are using this as part of their development process, to a near 800% speed improvement to test runs. Their new setup isn't quite finished enough to share before & after metrics and some fancy screenshots, but I'll update this article as soon as they are.

```json
// package.json

{
  "scripts": {
    "test": "concurrently --kill-others-on-fail npm:test:*",
    "test:types": "tsc --noEmit",
    "test:unit": "NODE_ENV=test NODE_OPTIONS='…' mocha --extension '…' './src'",
    "test:…": "…"
  }
}
```

You can see a similar working example in an open-source project here: [JakobJingleheimer/react-form5](https://github.com/JakobJingleheimer/react-form5).

For something long-lived (ex a dev server for local development), something like `esbuild`'s [`serve`](https://esbuild.github.io/api/#serve) may better suit the need. If you're keen do it with custom loaders, you'll need a couple more pieces:

* A simple http server (JavaScript modules require it) using a dynamic import on the requested module.
* A cache-busting custom loader (for when the source code changes), such as [quibble](https://www.npmjs.com/package/quibble) (who published an explanatory article on it [here](https://dev.to/giltayar/mock-all-you-want-supporting-es-modules-in-the-testdouble-js-mocking-library-3gh1)).

---

All in all, custom loaders are pretty neat. Try them out with today's v18.6.0 release of Node.js!