The Javascript Ecosystem for the Dazed and Confused

the-javascript-ecosystem-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, and package exports.

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:


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 "https://dev.to/", "./", 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:

               |
                        |

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. But it 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 file extensions

Additionally, I always recommended to add file extensions to your exports entries. Import maps can support 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": "https://dev.to/node_modules/my-library/index.js",
     "my-library/": "https://dev.to/node_modules/my-library/",
   }
 }

This import map would allow the following imports:

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": "https://dev.to/node_modules/my-library/index.js",
     "my-library/": "https://dev.to/node_modules/my-library/",
     "my-library/foo": "https://dev.to/node_modules/my-library/foo.js",
     "my-library/bar": "https://dev.to/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 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",
  }
}
Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post
golang-ile-sifreleme-islemleri-icin-crypto-paketi

GOlang ile şifreleme işlemleri için crypto paketi

Next Post
51-ai-tools-you-should-be-using-for-life,-programming,-content-creation-and-everything-else

51 AI tools you should be using for life, programming, content creation and everything else

Related Posts