This post is a little bit of a ratatouille on things that I often see people be confused about in the javascript ecosystem, and will hopefully serve as a reference to point to whenever I see people ask questions about some of the following topics: process.env
, bare module specifiers, import maps, package exports, and non-standard imports.
process.env
The bane of my existence, and the curse that will haunt frontend for the next few years or so. Unfortunately, many frontend libraries (even modern ESM libraries, looking at you floating-ui
) use process.env
to distinguish between development-time and build-time, for example to enable development-time only logging. You'll often see code that looks something like this:
if (process.env.NODE_ENV === 'development') {
console.log('Some dev logging!');
}
The problem with code like this is that the process
global doesn't actually exist in the browser; its a Node.js global. So whenever you import a library that uses process.env
in the browser, it'll cause a pesky Uncaught ReferenceError: process is not defined
error. The browser is not Node.js.
When library authors include these kind of process.env
checks, they make the assumption that their user uses some kind of tooling (like a bundler) to take care of handling the process
global. However, this is assumption is often wrong, which leads to many people running into runtime errors caused by process.env
.
So how can we deal with process.env
in frontend code? Well, there's two things you can do, and they're equally bad. Your first option is to simply define the process
global on the window
object in your index.html
:
index.html
:
<script>
window.process = {
env: {
NODE_ENV: 'development' // or 'production'
}
}
</script>
The second option is to use a buildtool to take care of this for you. Some buildtools will take care of process.env
by default, but some buildtools do not. For example, if you're using Rollup as your buildtool of choice, you'll have to use something like @rollup/plugin-replace
to replace any instance of process.env
, or something like rollup-plugin-dotenv
.
The reality is, that in the year 2023, there is no good reason for a browser-only library to contain process.env
, period. So whenever you encounter the Uncaught ReferenceError: process is not defined
error caused by a library using process.env
, I urge you create a github issue on their repository.
Thankfully, there are other, more browser-friendly ways for library authors to distinguish between development and build-time, like for example the esm-env
package by Ben McCann, which makes clever use of package exports, which I'll talk more about later in this blog post.
Bare module specifiers
Another thing that often throws developers off are bare module specifiers. In Node.js, you can import a library like so:
import { foo } from 'foo';
^^^
And Node's resolution logic will try to resolve the 'foo'
package. If you're using Node.js, its likely that the 'foo'
package is a third-party package installed via NPM; because if it was a local file, the import specifier would be relative, and start with a '/'
, './'
or '../'
. So Node's resolution logic will try to locate the file on the filesystem, and resolve it to wherever it's installed.
At some point in time, bare module specifiers started making their way into frontend code, because NPM turned out to be a convenient way of publishing and installing libraries, and modularizing code. However, bare module specifiers by themself won't work in the browser; browsers dont have the same resolution logic thats built-in to Node.js, and they sure as hell don't have access to your filesystem by default. This means that if you use a bare module specifier in the browser, you'll get the following error:
Uncaught TypeError: Failed to resolve module specifier "foo". Relative references must start with either "/", "./", or "../".
This means that whenever you're using bare module specifiers, you'll have to somehow resolve those specifiers. This is usually done by applying Node.js's resolution logic to the bare module specifiers via tooling. For example, if you're using a development server, the development server may take a look at the imports in your code, and resolve them following Node.js's resolution logic. If you're using a bundler, it may take care of this behavior for you out of the box, or you may have to enable it specifically, like for example using @rollup/plugin-node-resolve
.
If you've installed the foo
package via NPM's npm install foo
command, the foo
package will be on your disk at my-project/node_modules/foo
. So whenever you import bare module specifier 'foo'
, tools can resolve that bare module specifier to point to my-project/node_modules/foo
. But by default, bare module specifiers will not work in the browser; they need to be handled somehow. The browser is not Node.js.
Import maps
Another way to handle bare module specifiers is by using a relatively new standard known as Import Maps. In your index.html
you can define an import map via a script
with type="importmap"
, and tell the browser how to resolve specific imports. Consider the following example:
<script type="importmap">
{
"imports": {
"foo": "./node_modules/foo/index.js",
// or
"foo": "https://some-cdn.com/foo/index.js",
} ^
} |
</script> |
|
<script type="module"> |
import { foo } from 'foo';
</script>
The browser will now resolve any import on the page being made to 'foo'
to whatever we assigned to it in the import map; 'https://some-cdn.com/foo/index.js'
. Now we can use bare module specifiers in the browser 🙂
Package exports
Another good thing to be aware of are a relatively new concept called package exports. Package exports modify the way Node's resolution logic resolves imports for your package. You can define package exports in your package.json
. Consider the following project structure:
my-package/
├─ src/
│ ├─ bar.js
├─ index.js
├─ foo.js
├─ package.json
├─ README.md
And the following package.json
:
{
"exports": {
".": "./index.js",
"./foo.js": "./foo.js"
}
}
This will cause any import for 'my-package'
to resolve to my-package/index.js
, and any import to 'my-package/foo.js'
to 'my-package/foo.js'
. However, this will also PREVENT any import for 'my-package/src/bar.js'
; it's not specified in the package exports, so there's no way for us to import that file. This can be nice for package authors, because it means they can control which code is public facing, and which code is intended for internal use only.
However, package exports can also be painful; sometimes packages add package exports to their project on minor or patch semver versions, not fully realizing how it will affect their users use of their code, and lead to unexpected breaking changes. As a rule of thumb, adding package exports to your project is always a breaking change!
On extensionless imports
I generally recommend package export keys to contain file extensions, e.g. prefer:
{
"exports": {
"./foo.js": "./foo.js"
}
}
over:
{
"exports": {
"./foo": "./foo.js"
}
}
The reason for this is how this translates to import maps. With import maps, we can support both extensionful and extensionless specifiers, but it'll lead to bloating your import map. Consider the following library:
my-library/
├─ bar.js
├─ foo.js
├─ index.js
{
"imports": {
"my-library": "/node_modules/my-library/index.js",
"my-library/": "/node_modules/my-library/",
}
}
This import map would allow the following imports:
import 'my-library';
import 'my-library/foo.js'; // ✅
import 'my-library/bar.js'; // ✅
But not:
import 'my-library/foo'; // ❌
import 'my-library/bar'; // ❌
While technically we can support the extensionless imports, it would mean adding lots of extra entries for every file that we want to support having extensionless imports for to our import map:
{
"imports": {
"my-library": "/node_modules/my-library/index.js",
"my-library/": "/node_modules/my-library/",
"my-library/foo": "/node_modules/my-library/foo.js",
"my-library/bar": "/node_modules/my-library/bar.js",
}
}
This results in the import map becoming more complicated and convoluted than it needs to be. As a rule of thumb; just always use file extensions in your package exports keys, and you'll keep your users import maps smaller and simpler.
Export conditions
You can also add conditions to your exports. Here's an example of what export conditions can look like:
{
"exports": {
".": {
"import": "./index.js", // when your package is loaded via `import` or `import()`
"require": "./index.cjs", // when your package is loaded via `require`
"default": "./index.js" // should always be last
}
}
}
By default, Node.js supports the following export conditions: "node-addons"
, "node"
, "import"
, "require"
and "default"
. When resolving imports based on package exports, Node will look for keys in the package export to figure out which file to use.
Tools however can use custom keys here as well, like "types"
, "browser"
, "development"
, or "production"
. This is nice, because it means tools can easily distinguish between environments without having to rely on process.env
; this is how esm-env
cleverly utilizes package exports to distinguish between development and production environments;
{
"exports": {
".": {
"development": "./dev.js",
"default": "./prod.js"
},
},
}
Where dev.js
looks like:
export const DEV = true;
export const PROD = false;
and prod.js
looks like:
export const DEV = false;
export const PROD = true;
There are many quirks related to this however, for example if you use "types"
it should always be the first entry in your exports, and if you use "default"
it should always be the last entry in your exports. Package exports are easy to mess up, and get wrong. Thankfully, there's a really nice project called publint
that helps you with things like these.
Package.json
Another quirk of package exports is that, if not specified, it also prevents tooling from import
or require
ing your package.json
🙃 This means that it can sometimes be useful to add your package.json
to your package exports as well.
{
"exports": {
"./package.json": "./package.json",
}
}
Fallback
Technically, we can make package export keys pretty much anything we want. Consider the following example:
my-library/
├─ bar.js
├─ index.js
And the following package exports:
{
"exports": {
"./whatever-name-we-want": "./bar.js",
}
}
This package exports map will allow the following import:
import 'my-library/whatever-name-we-want';
However, tools that don't support package exports yet, or CDN's will not be able to resolve that import. This is why generally it's good practice to keep a 1-on-1 mapping between your package export keys and the project structure on the filesystem. In this case, the following package exports would have been better:
{
"exports": {
"./bar.js": "./bar.js",
}
}
Non standard imports
Another nice example of non-standard behavior I see often is non standard imports. Here's a common example:
import icon from './my-icon.svg';
This, also, will not run in the browser. You need a buildtool to enable this behavior. Some buildtools, like Vite (which under the hood uses an opinionated Rollup build), enable this behavior by default. Philosophies and opinions on this differ, but I would consider this a bad default. The problem with enabling imports like these in tools by default, while convenient, is that it's non-standard behavior, and developers learn the wrong basics.
Alternatively, you can use import.meta
to reference non-javascript assets. import.meta
is a special object provided by the runtime that provides some metadata about the current module.
For example, given the following project:
my-project/
├─ index.html
├─ src/
│ ├─ bar.js
│ ├─ icon.svg
Where bar.js
looks like:
const iconUrl = new URL('./icon.svg', import.meta.url);
const image = document.createElement('img');
image.src = iconUrl.href;
document.body.appendChild(image);
When opening the index.html
in the browser, import.meta.url
will point to http://localhost:8000/src/bar.js
; the full URL to the module. This means that we can reference assets relative to our current module by creating a new URL that combines'./icon.svg'
and import.meta.url
, the iconUrl.href
will correctly point to http://localhost:8000/src/icon.svg
.
During local development, this should all work nicely and without any build magic. However, imagine we want to bundle our project for production. We give our bundler an entrypoint javascript file, and from there it bundles any other javascript thats used in the project. After running our build, our project directory may look something like this:
my-project/
├─ dist/
│ ├─ as7d547asd45.js
├─ index.html
├─ src/
│ ├─ bar.js
│ ├─ icon.svg
In our build output, which is now a bundled (and probably minified) javascript file (as7d547asd45.js
), import.meta.url
does not point to the correct location of './icon.svg'
anymore! There's several things we can do about this; we can simply copy icon.svg
to our dist/
folder, or we can use a plugin in our buildtool to automatically take care of this for us. If you're using rollup, @web/rollup-plugin-import-meta-assets
takes care of this for you.
Top comments (1)