2

Custom ESM loaders: Who, what, when, where, why, how

 1 year ago
source link: https://dev.to/jakobjingleheimer/custom-esm-loaders-who-what-when-where-why-how-4i1o
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Jacob Smith

Posted on Jul 12

Custom ESM loaders: Who, what, when, where, why, how

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

Custom 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:

$> 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:

$> node --loader redirect.mjs app.mjs
// 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:

$> node \
   --loader typescript-loader \
   --loader css-loader \
   --loader network-loader \
   app.tsx
// 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 in a loader you write yourself, or an off-the-shelf loader like ts-node/esm 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, 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. 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:

.Container {
  border: 1px solid black;
}

.SomeInnerPiece {
  background-color: blue;
}
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.

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.

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:

$> 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 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.

// 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.

For something long-lived (ex a dev server for local development), something like esbuild's 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 (who published an explanatory article on it here).

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK