Native ESM and import maps

Load published modules in a browser with native ES modules: the release shell with its inline import map and preloaded eager modules, or an import map you build from a resolution document.

  • Availability: Experimental
  • Evidence: Recorded run
  • How-to guide

Starting state

An application chooses one loader mode, esm or system. esm is the default: the browser loads modules natively and an import map tells it where each public specifier lives. This page covers that mode. For the other, see SystemJS, and for every environment side by side, Strategies per environment.

You need a release whose frontend target was prepared with target=browser and format=esm, and the resolution document of that release.

Why an import map

Compiled modules keep their imports of other public modules as bare references:

JavaScript
import { route } from '@example/app/core/router';

A browser cannot fetch a bare specifier. An import map gives it the URL, so application code never contains a version or an origin, and two releases of the same application differ only in their map.

Build the map from the resolution

The values of a beyond-resolution/1 document are origin-relative. A browser resolves the relative addresses of an import map against the page, which is rarely the origin that serves the modules, so write absolute URLs by prepending your base origin:

HTMLimportmap.html
<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<title>Native ESM with an import map</title>
		<script>
			// The only value that changes between a development server and the CDN.
			// Pass ?origin=http://localhost:<port> to load the same page against another origin.
			const origin = new URLSearchParams(location.search).get('origin') ?? 'https://cdn.example';

			// Origin-relative values, exactly as a beyond-resolution/1 document lists them
			const query = 'target=browser&format=esm&env=production&min=true&sourcemap=external&types=false&css=false';
			const imports = {
				'@example/shared/text': `/m/@example/shared@1.0.0/modules/text?${query}`
			};

			// An import map must be in the document before the first module loads, and a browser
			// resolves its relative addresses against the page, so write absolute URLs.
			const map = document.createElement('script');
			map.type = 'importmap';
			map.textContent = JSON.stringify({
				imports: Object.fromEntries(Object.entries(imports).map(([specifier, path]) => [specifier, origin + path]))
			});
			document.currentScript.after(map);
		</script>
	</head>
	<body>
		<pre id="output">Loading…</pre>
		<script type="module">
			// The application imports the public specifier. It never writes a version or an origin.
			const module = await import('@example/shared/text');
			document.getElementById('output').textContent = Object.keys(module).join('\n');
		</script>
	</body>
</html>

On the host of a released application you do not write this page yourself. The release carries a generated HTML shell:

  1. the eager stylesheets, linked in the head;
  2. the import map, inline, built from the frozen resolution with the release prefix as base origin (Resolution.importmap('/_r/<release number>')), so deep links work;
  3. <link rel="modulepreload"> for every eager module, right after the map, so the browser requests them in parallel instead of discovering them one import at a time;
  4. a module bootstrap that imports the entry by its public specifier and links the lazy stylesheets once the entry has loaded.

Shell, import map and bootstrap are generated once per release and target and stored as artifacts; nothing is generated on a request. See the release prefix.

With the shared codec the same step is one call:

JavaScript
import { Resolution } from '@beyond-js/artifact-api';

const resolution = Resolution.parse(text);
const map = resolution.importmap(origin); // { imports, scopes } with absolute URLs

scopes is how two versions of one package coexist: the modules under a scope prefix resolve a specifier differently from everyone else. Keep the scopes when you build the map.

An origin also answers the map itself at <base>/importmap.json?target=browser&format=esm, with addresses relative to that URL. A browser does not load an external import map (<script type="importmap" src> is not supported by browsers), so a page either inlines absolute addresses, as above, or is the release shell.

Expected outcome

The browser requests each module from the base origin with the canonical query, and every response is application/javascript with a strong ETag. Lazy modules are requested when the application imports them, from the same release.

Limits

If it fails

Symptom Likely cause
Failed to resolve module specifier The specifier is not in the map, or the map was added after a module had already loaded
404 OUTPUT_NOT_AVAILABLE The release was not prepared with this target, format or option set
401 ACCESS_REQUIRED The application is private; see Private access
A module loads an unexpected version of a dependency The scopes of the resolution were dropped when building the map