A Custom ESM Registry

Andrea Giammarchi
4 min readJan 22, 2021
Photo by vaun0815 on Unsplash

There is a “slightly annoying” difference, or better, a limitation, in native ESM import, compared to CommonJS require: it’s not possible to deal with the module system internal cache, hence it’s not possible to invalidate such cache, or tell NodeJS that module-x is actually referring to file://x.js, unless importmap lands officially in NodeJS core too.

… but I’m sure there is a module for that …” and you’re obviously right, although we don’t always control how NodeJS is bootstrapped, as example via serverless services, or server side workers, so I’ve decided to explore a different approach.

A Home Made Registry

The technique I am going to show is not suitable for all scenarios, it’s not a substitute of regular import modules, and most importantly, it cannot be bundled the way we usually bundle all files together.

export default new Map;

As simple as it looks, the registry.js file should expose a Map that either client or server side ESM can consume and share, thanks to ESM’s internal cache.

This registry is in charge of setting arbitrary module names, so that libraries that could work with both client or server modules, can use such registry instead of importing the libraries themselves.

Check this µland example out:

import registry from './registry.js';const {Component, html, useState} = registry.get('@uland');export default Component(initialState => {
const [count, setCount] = useState(initialState);
return html`
<button onclick=${() => setCount(count + 1)}>
Count: ${count}
</button>`;
});

The Counter component exported by above counter.js file, retrieve its utilities via the previously mentioned registry.js, which once again, should not be bundled within the rest of the code.

The Client Side

So, how can we define that @uland entry in the registry?

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module">
// bootstrap registry and libraries
import registry from './registry.js';
import * as uland from '
https://cdn.skypack.dev/uland';
registry.set('@uland', uland);
</script>
<!-- it is important this is a separate script/file -->
<script type="module">
import {render, html} from 'https://cdn.skypack.dev/uland';
import Counter from './counter.js';
render(document.body, () => html`
<div>
A bounce of counters.<hr>
${Counter(0)} ${Counter(1)}
</div>
`);
</script>
</head>
</html>

The reason we need to separate scripts, is that ESM is solved all at once, so that importing the counter within the same script, where the registry defines libraries, will parse such counter before any code is executed, hence once it’s executed, the registry won’t have the library in yet, as that happens after.

It’s also worth mentioning that this technique coincidentally allows bundling all 3rd party libraries dependencies eventually at once, as long as these are then set as registry entry, so that the bundle could be split in libraries, and the rest of the code could land on demand, via dynamic import, or as separate bundle, still being sure that the registry is not bundled within the process.

The Server Side

As we don’t have to deal with <script> tags on the server, the only way to grant that the registry works as expected, is to define right away all libraries and dependencies ASAP, and import modules that are based on such registry asynchronously.

// bootstrap registry and libraries
import registry from './registry.js';
import * as uland from 'uland-ssr';
registry.set('@uland', uland);
const {render, html} = uland;// it is important anything using the `registry`
// is dynamically, asynchronously, imported
const {default: Counter} = await import('./counter.js');
render(console.log, () => html`
<div>
A bounce of counters.<hr>
${Counter(0)} ${Counter(1)}
</div>
`);

In this example, the @ulandmodule is provided instead by µland-ssr, so that our Counter component doesn’t need a single line change between client and server.

… but why …

I have different modules that work seamlessly in both client and server, and being able to reuse exact same code across these two different environments, and libraries targets, is something I’ve been doing, in a way or another, pretty much forever, but the ability to “not care about the env at the module level” is something still very hard to do, but I’d love to experiment this approach more.

µhtml and µhtml-ssr are just yet another example where this technique could make development pretty sweet, but the list of client/server modules that expose the same API, but produce dedicated results per environment, is bigger than what’s in my repositories, so I hope this technique will help other developers that, like me, would like to blur the line between client and server, as opposite of writing thing.client.js and thing.server.js, especially when thing.js would be identical for both scenarios 👋

P.S. I can’t wait for importmap to land in NodeJS and make this technique less useful, or needed at all, in the long term!

--

--

Andrea Giammarchi

Web, Mobile, IoT, and all JS things since 00's. Formerly JS engineer at @nokia, @facebook, @twitter.