DEV Community

Pascal Schilp
Pascal Schilp

Posted on

Javascript Questions for the Dazed and Confused

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!');
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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';
                     ^^^
Enter fullscreen mode Exit fullscreen mode

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 "../".
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And the following package.json:

{
  "exports": {
    ".": "./index.js",
    "./foo.js": "./foo.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

over:

{
  "exports": {
    "./foo": "./foo.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
 {
   "imports": {
     "my-library": "/node_modules/my-library/index.js",
     "my-library/": "/node_modules/my-library/",
   }
 }
Enter fullscreen mode Exit fullscreen mode

This import map would allow the following imports:

import 'my-library';
import 'my-library/foo.js'; // βœ…
import 'my-library/bar.js'; // βœ…
Enter fullscreen mode Exit fullscreen mode

But not:

import 'my-library/foo'; // ❌
import 'my-library/bar'; // ❌
Enter fullscreen mode Exit fullscreen mode

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",
   }
 }
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Where dev.js looks like:

export const DEV = true;
export const PROD = false;
Enter fullscreen mode Exit fullscreen mode

and prod.js looks like:

export const DEV = false;
export const PROD = true;
Enter fullscreen mode Exit fullscreen mode

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 requireing 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",
  }
}
Enter fullscreen mode Exit fullscreen mode

Fallback

Technically, we can make package export keys pretty much anything we want. Consider the following example:

my-library/
β”œβ”€ bar.js
β”œβ”€ index.js
Enter fullscreen mode Exit fullscreen mode

And the following package exports:

{
  "exports": {
    "./whatever-name-we-want": "./bar.js",
  }
}
Enter fullscreen mode Exit fullscreen mode

This package exports map will allow the following import:

import 'my-library/whatever-name-we-want';
Enter fullscreen mode Exit fullscreen mode

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",
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
erinposting profile image
Erin Bensinger

Alfredo, the human from Ratatouille, whisking a bowl while wearing a blindfold. On his head is Remy the rat, pulling his hair in order to make his arms move.