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.
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'
And from the same library, also give us a sub-module:
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:
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 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);
Then, when I do this:
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:
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
:
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:
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:
- We get the config based on the environment variable we'll specify while running this command;
- We add validation to help us identify if we misspelled the environment variable and try to build a lib that is not mapped;
- 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:
{
"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 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';
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,
},
});
});
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:
{
"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
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')
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
:
{
"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 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";
export * from "./dist/math";
Now, we create an index.d.ts
file, referencing both type definitions:
/// <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:
{
"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
Top comments (0)