DEV Community

Bart Louwers
Bart Louwers

Posted on

Bundling without a bundler with esm.sh

In my previous post I shared some tips how you can avoid complicated developer tooling in modern web projects. I shared how you can import packages directly from the browser with esm.sh.

As you accumulate dependencies, and especially as you take on dependencies which themselves have dependencies (which are called called transitive dependencies), you may find that your initial load time suffers. Sure, once the page loaded everything is neatly cached. But your browser needs to load a lot of different files (as your network tab in the developer tools will tell you) and once it's loaded those files, it needs to load yet another bunch of files.

Of course, this is the whole reason why bundlers exist! So the conclusion is that at some point, you need a bundler. Well, maybe. But you don't need to run that bundler yourself. esm.sh has an experimental feature that will actually create a bundle for you with whatever packages you specify. Here is how I use it.

Say we need the following packages for an editor we are building.

import ts from typescript;
import { tsSync, tsFacet, tsLinter, tsAutocomplete } from "@valtown/codemirror-ts";
import { basicSetup, EditorView } from "codemirror";
import { javascript } from "@codemirror/lang-javascript";
import { acceptCompletion, autocompletion } from "@codemirror/autocomplete";
import { Compartment, StateEffect } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark";
import { indentWithTab } from "@codemirror/commands";
import { keymap, ViewPlugin } from "@codemirror/view";
import {
  createDefaultMapFromCDN,
  createSystem,
  createVirtualTypeScriptEnvironment,
} from "@typescript/vfs";
Enter fullscreen mode Exit fullscreen mode

Using an importmap

Now you could just add these to your importmap in index.html.

    <script type="importmap">
      {
        "imports": {
          "typescript": "https://esm.sh/typescript",
          "@valtown/codemirror-ts": "https://esm.sh/*@valtown/codemirror-ts",
          "style-mod": "https://esm.sh/style-mod",
          "w3c-keyname": "https://esm.sh/w3c-keyname",
          "crelt": "https://esm.sh/crelt",
          "@marijn/find-cluster-break": "https://esm.sh/@marijn/find-cluster-break",
          "@lezer/": "https://esm.sh/*@lezer/",
          "@codemirror/": "https://esm.sh/*@codemirror/",
          "codemirror": "https://esm.sh/*codemirror"
        }
      }
    </script>
Enter fullscreen mode Exit fullscreen mode

The * marks all dependencies as external, which is another feature of esm.sh. I also had to add all dependencies of codemirror manually. I found out that this is required, because different codemirror packages have slightly different versions of translative dependencies, and it will import different versions of those transitive dependencies causing conflicts.

This approach works, but as described in the introduction, initial load time will suffer because the browser needs to download a lot of files and it doesn't know ahead of time which files it should download.

Letting esm.sh compile a bundle

You can use this approach to let esm.sh create a bundle without using a bundler yourself. I will also explain how you can get types to work.

First of all I created a file /deps/editor.deps.js:

import build from "https://esm.sh/build";

const ret = await build({
  dependencies: {
    "codemirror": "^6.0.1",
    "@valtown/codemirror-ts": "^2.3.1",
    "@codemirror/lang-javascript": "^6.2.2",
    "@codemirror/autocomplete": "^6.18.4",
    "@codemirror/state": "^6.5.0",
    "@codemirror/theme-one-dark": "^6.1.2",
    "@codemirror/commands": "^6.7.1"
  },
  source: `
  import ts from "typescript";
  import { tsSync, tsFacet, tsLinter, tsAutocomplete } from "@valtown/codemirror-ts";
  import { basicSetup, EditorView } from "codemirror";
  import { javascript } from "@codemirror/lang-javascript";
  import { acceptCompletion, autocompletion } from "@codemirror/autocomplete";
  import { Compartment, StateEffect } from "@codemirror/state";
  import { oneDark } from "@codemirror/theme-one-dark";
  import { indentWithTab } from "@codemirror/commands";
  import { keymap, ViewPlugin } from "@codemirror/view";
  import {
    createDefaultMapFromCDN,
    createSystem,
    createVirtualTypeScriptEnvironment,
  } from "@typescript/vfs";

  export {
    ts,
    tsSync,
    tsFacet,
    tsLinter,
    tsAutocomplete,
    basicSetup,
    EditorView,
    javascript,
    acceptCompletion,
    autocompletion,
    Compartment,
    StateEffect,
    oneDark,
    indentWithTab,
    keymap,
    ViewPlugin,
    createDefaultMapFromCDN,
    createSystem,
    createVirtualTypeScriptEnvironment,
  };`
});

const {
  ts,
  tsSync,
  tsFacet,
  tsLinter,
  tsAutocomplete,
  basicSetup,
  EditorView,
  javascript,
  acceptCompletion,
  autocompletion,
  Compartment,
  StateEffect,
  oneDark,
  indentWithTab,
  keymap,
  ViewPlugin,
  createDefaultMapFromCDN,
  createSystem,
  createVirtualTypeScriptEnvironment,
} = await import(ret.bundleUrl);

console.log({ret});

export {
  ts,
  tsSync,
  tsFacet,
  tsLinter,
  tsAutocomplete,
  basicSetup,
  EditorView,
  javascript,
  acceptCompletion,
  autocompletion,
  Compartment,
  StateEffect,
  oneDark,
  indentWithTab,
  keymap,
  ViewPlugin,
  createDefaultMapFromCDN,
  createSystem,
  createVirtualTypeScriptEnvironment,
};
Enter fullscreen mode Exit fullscreen mode

There is quite a bit of repetition here. And you'll need to update the imports in multiple places if you want to change them. This is admittedly quite a hassle. I let you decide if it's worth it.

In any case, you will see that when you import this file ret will be printed to the console. Here is what is printed:

{
  ret: {
    bundleUrl: "https://esm.sh/~e4d1ab3ba39fc16e6de014e6f19bd819605fdd95?bundle",
    id: "e4d1ab3ba39fc16e6de014e6f19bd819605fdd95",
    url: "https://esm.sh/~e4d1ab3ba39fc16e6de014e6f19bd819605fdd95"
  }
}
Enter fullscreen mode Exit fullscreen mode

The bundleUrl is the URL which contains the bundle esm.sh created for us! We import it with a dynamic import() and then re-export it.

So you can simply import everything from /deps/editor.deps.js.

import { ts } from "/deps/editor.deps.js";
Enter fullscreen mode Exit fullscreen mode

And you are done!

If you want imports like

import { basicSetup, EditorView } from "codemirror";
Enter fullscreen mode Exit fullscreen mode

to work we can update our importmap as follows:

    <script type="importmap">
      {
        "imports": {
          "codemirror": "/deps/editor.deps.js"
        }
      }
    </script>
Enter fullscreen mode Exit fullscreen mode

This doesn't work for default exports (like with the typescript package). For this we can create a deps/editor.deps.d.ts file to get types to work:

import ts from "typescript";

export {
  ts,
};
Enter fullscreen mode Exit fullscreen mode

And there you have it! Bundling without a bundler. We may call
it bundlerless, since just like with serverless there's still a server/bundler involved, just not one you need to deal with.

Top comments (0)