Build a JavaScript library with multiple entry points using Vite3
Tags: vite, javascript, typescript, tutorial
Published: 2022-08-05 06:25:00

> Original post: https://www.raulmelo.dev/blog/build-javascript-library-with-multiple-entry-points-using-vite-3

## Intro

This post probably will get outdated soon.

That's because while I'm writing, Vite 3 (v3.0.4) still does not support an out-of-the-box solution for using it in Library mode with multiple entry points.

Despite proposing two strategies, I'd also point to a problem I encountered while trying to solve this: **_How to have multiple type definitions?_**

I wish everything was more straightforward than it actually is, but I'll walk you step-by-step through the problems and solutions.

## About Vite

Vite is an opinionated build tool that aims to provide a faster and learner development experience for modern web projects.

> https://vitejs.dev/guide/#overview

It's focused on ES Modules structure (although we can use it with Common JS), and it gives us a development server (like when you run `npm run dev`) with a blazing fast Hot Module Replacement (HMR).

We can define HMR for the situation when you're running a dev server, change a file, this file is processed again (to be supported by the browser), and the server gets refreshed.

Under the hood, Vite combines esbuild for a particular file optimization (due to its outstanding performance) and Rollup (for actually outputting the files), having an excellent performance in general.

If you're new to Vite, I strongly recommend you walk through their docs and try to create a simple app using one of their templates to see how amazing it's with your eyes.

---


## Building a JS library

If you want to build a JS library, you will likely pick up Rollup.

That's because it's a mature tool, isn't as complex as Webpack, and is not that hard to configure.

Even though the configuration isn't too hard, you still have to install a bunch of plugins, care about TypeScript parsing (in case you write your lib in TS), care about transpiling CommonJS code, etc.

Luckily Vite has something called ["Library Mode"](https://vitejs.dev/guide/build.html#library-mode), which allows us to point to an entry file (like a `index.ts` file that exports everything the package contains) and get it compiled using all the Vite ecosystem.

The documentation around that is great, and I believe it's enough so you can have a lib ready to be published or consumed by an application itself.

The only problem with that is: **_"What if instead of having a single entry point, I want to have it multiple?"_**

You might have noticed already, that some libraries allow you to import a few things from the main import:

```js
import { foo } from 'package'
```

And from the same library, also give us a sub-module:

```js
import { bar } from 'package/another-module'
```

An example of it is Next.JS. When we install next, we can import a few things from the main package and other things from sub-modules:

```ts
import Link from 'next/link';
import Image from 'next/image';
import type { GetServerSideProps } from 'next';
```

We can't simply point to an `index.ts` for those cases and have multiple outputs. We need to point to other files.

In the same example used before (next), they most likely would point to multiple files like `src/image.tsx`, `src/link.tsx` are compiled to files like `dist/image.js`, and `dist/link.js`.

> Side note, they don't use Vite for that. Looking at their code base, the ecosystem and building are more complex than we need and for that, they have a different approach and tooling.

Ok, but if Vite doesn't support multiple entries, how do we achieve this?

---

## Strategies

It might have be a lot of ways to solve that, but here, I want to mention two strategies I found very simple.

### Single and configurable vite.config

I saw [this comment](https://github.com/vitejs/vite/discussions/1736#discussioncomment-2833413) on Vite's thread regarding this topic, and because I'm into scripting, I wanted to enhance that.

The thing is, because we're in a Node environment and we have access to environment variables, we can them inside a JavaScript file for example:

```js
console.log(process.env.MY_ENV_VAR);
```

Then, when I do this:

```bash
MY_ENV_VAR=random-value node index.js
```

It consoles the value I've given to `MY_ENV_VAR`:

```
random-value
```

Ok. Now, let's imagine I want to build a lib that exports two modules: `logger` and `math` (just for the sake of the example):

```
.
├── src
│ ├── lib.ts
│ └── math.ts
└── package.json
```

In our `vite.config.js` for a single entry, we would have something like this:

```js
import { resolve } from 'path'
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'lib/index.ts'),
      fileName: 'my-lib',
      formats: ['cjs', 'es'],
    },
  }
})
```

On Vite 3, a vite.config file must export default the result of `defineConfig`.

To have a more dynamic build, we could have an object in our config file that holds the different information between `math` and `logger`:

```js
const config = {
  math: {
    entry: resolve(__dirname, "./src/math.ts"),
    fileName: "math.js",
  },
  logger: {
    entry: resolve(__dirname, "./src/logger.ts"),
    fileName: "logger.js",
  },
};
```

The `entry` and `fileName` are the only difference between these two files.

From there, we can use an environment variable to determine which configuration it should be used and which file should be output:

```js
import { resolve } from "path";
import { defineConfig } from "vite";

const config = {
  math: {
    entry: resolve(__dirname, "./src/math.ts"),
    fileName: "math.js",
  },
  logger: {
    entry: resolve(__dirname, "./src/logger.ts"),
    fileName: "logger.js",
  },
};

const currentConfig = config[process.env.LIB_NAME];

if (currentConfig === undefined) {
  throw new Error('LIB_NAME is not defined or is not valid');
}

export default defineConfig({
  build: {
    outDir: "./dist",
    lib: {
      ...currentConfig,
      formats: ["cjs", "es"],
    },
    emptyOutDir: false,
  },
});
```

Summarizing the actions:

1. We get the config based on the environment variable we'll specify while running this command;
1. We add validation to help us identify if we misspelled the environment variable and try to build a lib that is not mapped;
1. We call defineConfig with the common config and spread the current config

Now, we can run the following command:

```
$ LIB_NAME=math npx vite build

vite v3.0.4 building for production...
✓ 1 modules transformed.
dist/math.js.cjs 0.15 KiB / gzip: 0.14 KiB
dist/math.js.js 0.06 KiB / gzip: 0.08 KiB
```
> The `npx` command call the binary of Vite (the CLI itself)

Now, we can also use the same command for the logger lib:

```
$ LIB_NAME=logger npx vite build

vite v3.0.4 building for production...
✓ 1 modules transformed.
dist/logger.js.cjs 0.16 KiB / gzip: 0.16 KiB
dist/logger.js.js 0.09 KiB / gzip: 0.09 KiB
```

To make our life easier, we could have these two commands as npm script and a single command that calls both scripts:

```json
{
  "scripts": {
    "build:math": "LIB_NAME=math vite build",
    "build:logger": "LIB_NAME=logger vite build",
    "build": "npm run build:math && npm run build:logger"
  }
}
```

### Importing build from Vite in a custom script

In the same discussions, [another user suggested a different strategy](https://github.com/vitejs/vite/discussions/1736#discussioncomment-3229793) for solving this problem: using `build` from Vite.

In case you don't know, Vite exposes build method so we can do it programmatically:

```js
import { build } from 'vite';
```

With that in mind, all we need to do is create an array of configurations and iterate over this array calling build:


```js
import { build } from "vite";
import path from "path";
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

const libraries = [
  {
    entry: path.resolve(__dirname, "../src/logger.ts"),
    fileName: "logger",
  },
  {
    entry: path.resolve(__dirname, "../src/math.ts"),
    fileName: "math",
  },
];

libraries.forEach(async (lib) => {
  await build({
    build: {
      outDir: "./dist",
      lib: {
        ...lib,
        formats: ["es", "cjs"],
      },
      emptyOutDir: false,
    },
  });
});
```

And that would generate the exact same output as the previous strategy.

The difference is, instead of calling `vite build`, all we need to do is call our script with node:

```
$ node scripts/build.mjs

vite v3.0.4 building for production...
vite v3.0.4 building for production... (x2)
✓ 1 modules transformed.
✓ 1 modules transformed. (x2)
dist/math.js 0.06 KiB / gzip: 0.08 KiB
dist/logger.js 0.09 KiB / gzip: 0.09 KiB
dist/math.cjs 0.15 KiB / gzip: 0.14 KiB
dist/logger.cjs 0.16 KiB / gzip: 0.16 KiB
```

> Because I've defined this file to be a .mjs extension, I had to do the workaround to have `__dirname` and I need to use Node 16 or higher.

---

## Configuring the package.json

One of the most crucial steps for having a javascript lib is defining in our package.json how node needs to resolve the files.

On Vite docs, they have a recommendation of how to do that, which is almost what we want:

```
{
  "name": "my-lib",
  "type": "module",
  "files": ["dist"],
  "main": "./dist/my-lib.umd.cjs",
  "module": "./dist/my-lib.js",
  "exports": {
    ".": {
      "import": "./dist/my-lib.js",
      "require": "./dist/my-lib.umd.cjs"
    }
  }
}
```

Explaining each field:

- `files` is an array of what files we're going to publish (so you can pack to your lib only the necessary code);
- `main` is the entry point for the common.js (require) statements;
- `module` is the entry for ES Modules (import ? from) statements;
- `exports` is a modern alternative to allow us to have multiple entry files (exactly what we need)
  - `"."` in this case stands for the main entry point (require('my-lib') and import 'my-lib')
    - `import` is actually for ES Modules;
    - `require` is for CommonJS.

`main`, `module`, and `export` has the same goal. The difference is that `exports` has a more flexible way of mapping the files we want to expose, while `main` and `module` is more for a single entry point.

> Keep in mind that that `exports` requires at least Node v12 to work, which I don't think it's a problem nowadays.
Alright, now let's create our own config.

Because the lib we're creating doesn't have a default entry point, we can get rid of `main` and `module` and only use `exports`.

In our `exports` field, we can now define both `math` and `logger` sub-modules and point to the files that the build command will output:


```json
{
  "type": "module",
  "exports": {
    "./math": {
      "import": "./dist/math.js",
      "require": "./dist/math.cjs"
    },
    "./logger": {
      "import": "./dist/logger.js",
      "require": "./dist/logger.cjs"
    }
  },
  "files": [
    "dist/*",
  ]
}
```

> Be aware that the file we're pointing should exist, and it'll when we run npm run build
>
> Another observation, we're defining "type": "module" to define how node should run the file (using .mjs extension or not). Vite will use this information to generate the file with either .mjs or .cjs extension. [Read here](https://vitejs.dev/guide/build.html#library-mode:~:text=%7D%0A%20%20%7D%0A%7D-,Note,become%20.mjs%20and%20.cjs%20will%20become%20.js.,-Environment%20Variables)

And that's it.

Now, we'll be able to use import/require this package by:

```js
// ES Modules environment
import { sum } from 'my-package/math'
import { logger } from 'my-package/logger'

// CommonJS (node)
const { sum } = require('my-package/math')
const { logger } = require('my-package/logger')
```

---

## Generating type definitions

TypeScript is getting more popular every year.

Because of that, it's a good practice for library maintainers also to provide the type definitions from the library, so our IDEs or Text Editors IntelliSense can give us hints about the function signature, etc.

Because the code of this package is in TypeScript, we can use the tsc compiler to generate it automatically for us.

It's a good practice to have a `tsconfig.json` config in case you want to compile our code with `tsc` or even use the compiler as a "linter".

However, for this case, because we only want generate the types, we can skip the config part and straight use the compiler:

```
tsc src/*.ts --declaration --emitDeclarationOnly --declarationDir dist/
```

Here we're saying to the compiler to only emit declarations for all `.ts` files present on `src` and put them on the `dist` folder.

When we build our files with Vite and run this command to generate the files, we'll have a dist folder like this:

```
.
└── dist
    ├── logger.cjs
    ├── logger.d.ts
    ├── logger.js
    ├── math.cjs
    ├── math.d.ts
    └── math.js
```

Cool.

Now we have to declare our types using the package.json attribute `types`.

`types` isn't a standard field from Node but something TypeScript introduced, and Node and the JS Ecosystem have embraced it.

The biggest problem here is that types only accept a string, not an array of strings. How can we actually point to multiple definitions then?

### Problem

This is a problem I found very intriguing.

I read some people saying that once you have the type definition (d.ts) in the same folder as we have the built file (e.g. `math.cjs`), TypeScript would be able to infer that automatically the types.

I tried that, and didn't work out as I expected.

Maybe because in this lib dist folder, I not only have `math.cjs` but also `math.js`, and they would be imported differently. Maybe TypeScript gets confused trying to guess if that definition is from the file I'm actually importing.

To be fair, I don't know for sure.

It would be great if we could define the types inside the `exports`:


```json
{
  "exports": {
    "./math": {
      "import": "./dist/math.js",
      "require": "./dist/math.cjs",
      "type": "./dist/math.d.ts"
    },
    "./logger": {
      "import": "./dist/logger.js",
      "require": "./dist/logger.cjs",
      "type": "./dist/logger.d.ts"
    }
  }
}
```

But that won't work.

As I said, `exports` is something official from node while `types` is just something TypeScript come up.

### Solution

I'm not sure the best way to solve that, but I couldn't find any better.

Using Next.js as example again, they do a great job of providing such separate packages and types, so I went there to see how they do.

I realize that despite the source code, they have in the root of the main [next](https://github.com/vercel/next.js/tree/canary/packages/next) package type definition for each package.

In every single `.d.ts`, they only `export * from './dist/<package-name>.js` and, the cherry of the cake, they have one file called `index.d.ts` that uses `<reference />` directive from TypeScript and "import" all those types.

The logic behind that is that every single submodule we're going to export will point to its type definition generated by the compiler on the dist folder.

Then, we use the `<reference>` directive from TypeScript to inform the compiler that those referenced files need to be included in the compilation process.

With this instruction, TypeScript compiler will be able to infer that what's the type definition for that sub-module.

Again, I'm not sure if that's the best way of solving that, but in my tests, that worked as a charm so let's implement this.

### Hands-on

First thing, we're going to create in our root folder two `.d.ts` files, one for the `logger` module and another for the `math` module.

In both, we'll only export everything that the `dist/*.js` has:

```ts
export * from "./dist/logger";
```

```ts
export * from "./dist/math";
```

Now, we create an `index.d.ts` file, referencing both type definitions:


```ts
/// <reference path="./logger.d.ts" />
/// <reference path="./math.d.ts" />
```

Cool.

You might have noticed that now we have a single entry for our types (`index.d.ts`).

The last step is to point this entry file to `types` and listing these 3 new type definition `files` to our files field:


```json
{
  "types": "./index.d.ts",
  "files": [
    "dist/*",
    "index.d.ts",
    "logger.d.ts",
    "math.d.ts"
  ]
}
```

And that would do the job.

---

## Demo

To show you this works really well, I've created a demo app so you can see and use it as a reference for building your own library.

The only difference is that in the post I use `npm` as example, but in the demo, I used `pnpm` just for having a workspace where I could maintain both build strategies and a vanilla TS app that consumes the packages.

https://github.com/raulfdm/vite-3-lib-multiple-entrypoints

---

## Conclusion

Again, I think this problem might be solved in a future version of Vite but because there's a struggle around that, I thought it worth a post with some explanation.

I hope I could help you somehow.

Peace!

---

## References

- https://github.com/raulfdm/vite-3-lib-multiple-entrypoints
- https://github.com/vitejs/vite/discussions/1736#discussioncomment-3310054
- https://nodejs.org/api/packages.html#package-entry-points
- https://webpack.js.org/guides/package-exports/
- https://nodejs.org/api/packages.html#type
- https://www.typescriptlang.org/docs/handbook/declaration-files/publishing.html
- https://nodejs.org/api/packages.html#community-conditions-definitions
- https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html
- https://github.com/raulfdm/vite-3-lib-multiple-entrypoints
- https://github.com/rollup/rollup