DEV Community

Raul Melo
Raul Melo

Posted on • Originally published at raulmelo.dev

Build a JavaScript library with multiple entry points using Vite3

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", 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:

import { foo } from 'package'
Enter fullscreen mode Exit fullscreen mode

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

import { bar } from 'package/another-module'
Enter fullscreen mode Exit fullscreen mode

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:

import Link from 'next/link';
import Image from 'next/image';
import type { GetServerSideProps } from 'next';
Enter fullscreen mode Exit fullscreen mode

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 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:

console.log(process.env.MY_ENV_VAR);
Enter fullscreen mode Exit fullscreen mode

Then, when I do this:

MY_ENV_VAR=random-value node index.js
Enter fullscreen mode Exit fullscreen mode

It consoles the value I've given to MY_ENV_VAR:

random-value
Enter fullscreen mode Exit fullscreen mode

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

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

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'],
    },
  }
})
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

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

Summarizing the actions:

  1. We get the config based on the environment variable we'll specify while running this command;
  2. We add validation to help us identify if we misspelled the environment variable and try to build a lib that is not mapped;
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

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

{
  "scripts": {
    "build:math": "LIB_NAME=math vite build",
    "build:logger": "LIB_NAME=logger vite build",
    "build": "npm run build:math && npm run build:logger"
  }
}
Enter fullscreen mode Exit fullscreen mode

Importing build from Vite in a custom script

In the same discussions, another user suggested a different strategy for solving this problem: using build from Vite.

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

import { build } from 'vite';
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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:

{
  "type": "module",
  "exports": {
    "./math": {
      "import": "./dist/math.js",
      "require": "./dist/math.cjs"
    },
    "./logger": {
      "import": "./dist/logger.js",
      "require": "./dist/logger.cjs"
    }
  },
  "files": [
    "dist/*",
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

And that's it.

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

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

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

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

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:

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

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 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:

export * from "./dist/logger";
Enter fullscreen mode Exit fullscreen mode
export * from "./dist/math";
Enter fullscreen mode Exit fullscreen mode

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

/// <reference path="./logger.d.ts" />
/// <reference path="./math.d.ts" />
Enter fullscreen mode Exit fullscreen mode

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:

{
  "types": "./index.d.ts",
  "files": [
    "dist/*",
    "index.d.ts",
    "logger.d.ts",
    "math.d.ts"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)