DEV Community

Cover image for Mastering NPM Library Creation: Bundling with Vite
Patrick Organ
Patrick Organ

Posted on

Mastering NPM Library Creation: Bundling with Vite

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

npm install vite -D
Enter fullscreen mode Exit fullscreen mode

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

Imports: not much to say here.

import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, build as viteBuild } from 'vite';
Enter fullscreen mode Exit fullscreen mode

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

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

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:

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

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:

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

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

main();
Enter fullscreen mode Exit fullscreen mode

By leveraging the asynchronous functionality of NodeJS, our build script concurrently processes all six configurations, streamlining the build process:

Image description

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.

Top comments (0)