Mastering NPM Library Creation: Bundling with Vite
Tags: javascript, tutorial, node, tooling
Published: 2024-03-03 00:21:53

If you're writing a library to publish on NPM, there are several options for bundling (building) the library files. The number of options can be confusing, so this article will cover a popular choice for advanced library bundling, Vite.

I won't be covering use of the CLI for bundling, instead we'll cover more complex build configurations using a NodeJS build script that leverages the API provided by Vite.

We'll be bundling a new version of one of my NPM libraries, [node-ray](https://github.com/permafrost-dev/node-ray).  It's written in TypeScript, has two entry points (one for NodeJS and one for browser use), and builds six library files: ESM and CommonJS format for the two entry point files, as well as a "standalone" version that bundles all of the dependencies to allow importing it into a webpage via a CDN (the standard builds exclude all dependencies in the final bundles).

## Building with Vite

Vite is a modern build tool that provides a great development experience with features like ultra-fast build times (thanks to ESBuild) and Hot Module Reloading (HMR). While HMR might not be a necessity for library development, it's a boon for projects such as React component libraries, enhancing the development flow.

First, install the `vite` package into your project's development dependencies:

```bash
npm install vite -D
```

Let's go through each section of the build script individually.

Imports: not much to say here.

```js
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, build as viteBuild } from 'vite';
```

Following the imports, we introduce a global configuration object, a central location for all configurable aspects of the build environment:

```js
const globalConfig = {
    libraryName: 'Ray',
    outDir: resolve(dirname(fileURLToPath(import.meta.url)), '../dist'),
    basePath: resolve(dirname(fileURLToPath(import.meta.url)), '..'),
    builds: [
        {
            entry: 'src/Ray.ts',
            outfile: 'web.cjs',
            target: 'browser',
        },
        {
            entry: 'src/Ray.ts',
            outfile: 'web.js',
            target: 'browser',
        },
        {
            entry: 'src/RayNode.ts',
            outfile: 'index.cjs',
            target: 'node',
        },
        {
            entry: 'src/RayNode.ts',
            outfile: 'index.js',
            target: 'node',
        },
        {
            entry: 'src/Ray.ts',
            outfile: 'standalone.js',
            target: 'browser',
        },
        {
            entry: 'src/Ray.ts',
            outfile: 'standalone.min.js',
            target: 'browser',
        },
    ],
    /** @type Record<string, any>|null */
    pkg: null, // assigned in init()
    getDependencies(standalone = false) {
        if (standalone) return [];

        return Object.keys(this.pkg.dependencies).concat([
            'node:fs',
            'node:fs/promises',
            'node:os',
            'node:path',
            'node:process',
        ]);
    },
    async init() {
        this.pkg = JSON.parse(await readFile(resolve(this.basePath, 'package.json')));

        this.builds = this.builds.map(config => {
            config.entry = resolve(globalConfig.basePath, config.entry);
            config.minify = config.outfile.includes('.min.');
            config.standalone = config.outfile.includes('standalone');
            config.format = config.outfile.endsWith('.js') ? 'es' : 'cjs';
            if (config.standalone) {
                config.format = 'iife';
            }

            return config;
        });
    },
};
```

This object encompasses everything from the library name to output directories and build configurations. The `libraryName` sets the identity for the library being built. The `outDir` and `basePath` properties, derived from the current file _(the build script, `build.js`)_, establish the path used for the output and project root directories. 

The `builds` array is a collection of build configurations, each tailored to a specific target, such as the browser or NodeJS. The `outfile` extension, `.js` or `.cjs`, dictates whether the output format is ESM or CommonJS, respectively.

The `pkg` property, once initialized, holds the parsed contents of `package.json`, providing access to project dependencies.  This is necessary so we can tell Vite to not include them in the final bundles, with the exception of the standalone build. The standard bundles will be installed via an NPM package, so their dependencies will automatically be installed, making them unnecessary to include in the bundles.

The `getDependencies` and `init` methods are where the global configuration object shines. `getDependencies` dynamically determines external dependencies, essential for configuring our bundling process, since we're also building a "standalone" bundle ("iife" format) in addition to the ESM and CJS builds. 

The `init` method initializes the global configuration by reading and parsing `package.json`, then refining the build configurations for use later by Vite's build API.

Now on to the build function itself, which uses the Vite API to perform a build asynchronously:

```js
async function buildWithVite(config) {
    await viteBuild(defineConfig({
        build: {
            lib: {
                entry: config.entry,
                name: globalConfig.libraryName,
                formats: [config.format],
                fileName: () => config.outfile,
            },
            emptyOutDir: false,
            outDir: globalConfig.outDir,
            minify: config.minify || false,
            sourcemap: true,
            rollupOptions: {
                external: globalConfig.getDependencies(config.standalone),
                // don't eliminate any "unused" code, since it's a library:
                treeshake: false,
            },
            target: config.target === 'browser' ? 'chrome70' : 'node18',
        },
        resolve: {
            extensions: ['.ts', '.js'],
            alias: {
                '@': `${globalConfig.basePath}/src`,
            },
        },
    }));
}
```

This function is a testament to the power and flexibility of Vite. It leverages `viteBuild` and `defineConfig` to orchestrate the build process, tailoring it to each configuration defined in our global setup. Through asynchronous execution, it processes each build configuration, taking into account settings such as entry points, output formats, and targets.

Lastly, we define and call a `main()` function that initializes the global configuration, then calls the `buildWithVite()` function above for each build configuration:

```js
async function main() {
    await globalConfig.init();
    await Promise.all(globalConfig.builds.map(config => buildWithVite(config)));

    console.log('All builds completed');
}

main();
```

By leveraging the asynchronous functionality of NodeJS, our build script concurrently processes all six configurations, streamlining the build process:
  
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hpecrojd9dzvivru0ptr.png)

There you have it — a comprehensive, ~100 line, asynchronous build script for multiple library outputs - all built in 1 second - thanks to the power of Vite. This approach not only simplifies the build process but also eliminates the need for multiple configuration files and CLI runs, offering an efficient solution for library development.