Web dev and stuff GitHub Twitter

ESM dynamic import secrets

Import calls A.K.A. import() has been a part of the ECMASpec for almost a decade. Besides allowing to dynamically import javascript modules it has a couple of gotchas and more tricks up its sleeve. Let's dig in.

Thing you can import

All modern run times i.e. browser/node/deno/bun/etc support the most common cases

Some run times may extend the supported cases and add other supported protocols. Example: nodejs supports node:* protocol to import node's builtin modules.

Before NodeJS added support for require'ing ES modules, dynamic import was the only way to load ES modules from a CommonJS module.

Secret 1: windows paths

Absolute paths are tricky. The following code will work on MacOS and Linux but will fail on Windows.

// file.cjs

import path from 'node:path';

import(path.join(__dirname, 'module.js'));

On unixy systems this resolves to

import('/Users/username/project/module.js');

On Windows this resolves to

import('c:\\Users\\username\\project\\module.js');

And results in an error:

Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'

This throws an error as c: is not a supported protocol. In order to fix it you need to signal that the following is a file path. This can be done by using a supported file URL protocol

import('file://c:\\Users\\username\\project\\module.js');

Now this will work as expected. But how can we do this? This brings us to the next secret.

Secret 2: import.meta

import.meta is a special object that is available on import keyword. Among other things it contains an utility method that can help us import.meta.resolve(). It resolves a module specifier to a URL

import.meta.resolve() is a built-in function defined on the import.meta object of a JavaScript module that resolves a module specifier to a URL using the current module's URL as base.

source: MDN

Sounds exactly what we need.

// file.cjs
import(import.meta.resolve('module.js'));

This works in ES modules. But since our file is a CommonJS module it throws an error as import.meta can only be using from within ES modules. For this reason in CommonJS files we have to fallback to node's builtin utility

// file.cjs
import url from 'node:url';

import(url.pathToFileURL(path.join(__dirname, 'module.js')));

Hooray, it works on all platforms.

Eval JavaScript

As you could notice, besides importing modules from URLs you can also import from data URLs. This can be used to eval JavaScript code.

const mod = await import('data:text/javascript,export default 42');

console.log(mod.default); // 42

At first glance this may look like a good old eval. But it is not. The code is executed in a separate context and does not have access to the current scope. This is good not only for security reasons but also for correctly attributing errors.

Secret 3: Keep source maps for generated code

const mod = await import('data:text/javascript,export default 42\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjoiMS4wLjAifQ==');

Now if during the execution of our new "virtual" module an error occurs, the error will be correctly attributed to the original source file.