DEV Community

Cover image for How to easily create JS libraries with bundled dependencies compatible with ES/AMD/UMD/CJS module systems using Nx
Iulian Preda
Iulian Preda

Posted on

How to easily create JS libraries with bundled dependencies compatible with ES/AMD/UMD/CJS module systems using Nx

General aspects

If you haven't yet checked out my last post it's high time to do so as we will need it for this article.

Getting that out of the way, let's suppose we already have the workspace set up, we can build the libraries and publish them, but something is not exactly right, what if we want to ship a version of the library with all the dependencies already bundled so our users could use it directly from a CDN.

In this article, I will show you not only how to set up such a feature with the least possible setup, but I will also show you how to minimize the bundles to their possible best.

Concepts used this time around

  • @nrwl/web:webpack
  • webpack config

Getting to action - Creating bundles

  • npm install --save-dev @babel/preset-typescript
  • Adjust the babel.config.json file created last time to contain
{ "presets": ["@babel/preset-typescript", "minify"] }
Enter fullscreen mode Exit fullscreen mode
  • Create a webpack.config.js in the root folder that should contain
// https://webpack.js.org/configuration/output/#outputlibrarytype
// possible libraryTargets in webpack 5: 'var', 'module', 'assign', 'assign-properties', 'this', 'window', 'self', 'global', 'commonjs', 'commonjs2', 'commonjs-module', 'amd', 'amd-require', 'umd', 'umd2', 'jsonp' and 'system'

// type:name collection used in file names
const libraryTypesWithNames = {
    var: 'var',
    module: 'esm',
    assign: 'assign',
    'assign-properties': 'assign-properties',
    this: 'this',
    window: 'window',
    self: 'self',
    global: 'global',
    commonjs: 'commonjs',
    commonjs2: 'commonjs2',
    'commonjs-module': 'commonjs-module',
    amd: 'amd',
    'amd-require': 'amd-require',
    umd: 'umd',
    umd2: 'umd2',
    jsonp: 'jsonp',
    system: 'system',
};

const getLibraryName = (type) => libraryTypesWithNames[type];

const getLibrary = (type, name) => {
    const unsetNameLibraries = ['module', 'amd-require']; // these libraries cannot have a name
    if (unsetNameLibraries.includes(type)) name = undefined;
    return { name, type, umdNamedDefine: true };
};

const modifyEntries = (config, libraryName, libraryTarget) => {
    const mainEntryPath = config.entry.main;
    try {
        delete config.entry.main;
    } catch (error) {
        console.warn(`Could not delete entry.main: ${error}`);
    }

    if (libraryTarget.includes('module')) {
        // https://webpack.js.org/configuration/output/#librarytarget-module
        // for esm library name must be unset and config.experiments.outputModule = true - This is experimental and might result in empty umd output
       config.experiments.outputModule = true
        config.experiments = {
            ...config.experiments,
            outputModule: true,
        };
    }

    libraryTarget.forEach((type) => {
        config.entry[`${libraryName}.${getLibraryName(type)}`] = {
            import: mainEntryPath,
            library: getLibrary(type, libraryName),
        };
    });

    // @nrwl/web:webpack runs webpack 2 times with es5 and esm configurations
    const outputFilename = config.output.filename.includes('es5') ? config.output.filename : '[name].js';
    config.output = {
        ...config.output,
        filename: outputFilename,
    };
};

module.exports = (config, { options }) => {
    const libraryTargets = options.libraryTargets ?? ['global', 'commonjs', 'amd', 'umd'];
    const libraryName = options.libraryName;

    config.optimization.runtimeChunk = false;
    modifyEntries(config, libraryName, libraryTargets);

    return config;
};

Enter fullscreen mode Exit fullscreen mode
  • go to packages/LibraryName/project.json and add this json property under the package property.
 "bundle": {
            "executor": "@nrwl/web:webpack",
            "outputs": ["{options.outputPath}"],
            "options": {
                "libraryName": "LibraryName",
                "libraryTargets": ['global', 'commonjs', 'amd', 'umd'],
                "index": "",
                "tsConfig": "packages/LibraryName/tsconfig.lib.json",
                "main": "packages/LibraryName/src/index.ts",
                "outputPath": "dist/packages/LibraryName/bundles",
                "compiler": "babel",
                "optimization": true,
                "extractLicenses": true,
                "runtimeChunk": false,
                "vendorChunk": false,
                "generateIndexHtml": false,
                "commonChunk": false,
                "namedChunks": false,
                "webpackConfig": "webpack.config.js"
            }
        },
Enter fullscreen mode Exit fullscreen mode
  • Run nx bundle:LibraryName - this should create a dist/packages/LibraryName/bundles folder containing the .umd and .umd.es5 bundled files.

Inputs and configurations

In packages/LibraryName/project.json

These variables are custom, as they are not internally used by Nx, and are just passed to the webpack.config.js.

  • libraryName - String - This affects how webpack will export your library. For example "libraryName": "LibraryName" in UMD will export your library to an object called "LibraryName".
  • libraryTargets - Array of any available webpack 5 library type (the keys of libraryTypesWithNames form the webpack.config.js)

Webpack.config.js

  • You can manually change the values of libraryTypesWithNames to alter the bundle suffix. E.g. changing var:'var' to 'var:'web' will generate a bundle file ending in .web.js and .web.es5.js.
  • You can manually change the default array for libraryTargets.

Some code explanation for the configurable variables

const libraryTypesWithNames = {
    var: 'var',
    module: 'esm',
    assign: 'assign',
    'assign-properties': 'assign-properties',
    this: 'this',
    window: 'window',
    self: 'self',
    global: 'global',
    commonjs: 'commonjs',
    commonjs2: 'commonjs2',
    'commonjs-module': 'commonjs-module',
    amd: 'amd',
    'amd-require': 'amd-require',
    umd: 'umd',
    umd2: 'umd2',
    jsonp: 'jsonp',
    system: 'system',
};
Enter fullscreen mode Exit fullscreen mode

This contains all the available libraries in webpack 5 as stated on their website.
We use the key in it to retrieve the name we want to use in our bundled filename. Feel free to change them as you would like. We also do not use them all at once as you will notice later on.

const libraryTargets = options.libraryTargets ?? ['global', 'commonjs', 'amd', 'umd'];
Enter fullscreen mode Exit fullscreen mode

This line contains as default an array of options that can be used for most libraries, you can customize it directly or you can provide an array of library types to the libraryTargets property in packages/LibraryName/project.json using the keys of libraryTypesWithNames.
E.g. if you want to use all the available options you can simply change the variable to

const libraryTargets = Object.keys(libraryTypesWithNames);
Enter fullscreen mode Exit fullscreen mode

Minimizing the bundle size using gzip

  • npm install compression-webpack-plugin --save-dev
  • Depending on what you want to achieve change in webpack.config.js the following - This will help with AWS and some CDNs as the gzipped files have .js extension and can be embedded directly.
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = (config, { options }) => {
    const libraryTargets = options.libraryTargets ?? ['global', 'commonjs', 'amd', 'umd'];
    const libraryName = options.libraryName;

    config.optimization.runtimeChunk = false;
    const terser = config.optimization.minimizer.find((minimizer) => minimizer.constructor.name === 'TerserPlugin');
    if (terser) {
        terser.options.exclude = /\.gz\.js$/;
    }
    config.plugins = [
        ...config.plugins,
        new CompressionPlugin({
            filename: `[name].gz[ext]`,
        }),
    ];
    modifyEntries(config, libraryName, libraryTargets);

    return config;
};

Enter fullscreen mode Exit fullscreen mode

Otherwise, you might consider the simpler version that outputs .gz files.


const CompressionPlugin = require('compression-webpack-plugin');

module.exports = (config, { options }) => {
    const libraryTargets = options.libraryTargets ?? ['global', 'commonjs', 'amd', 'umd'];
    const libraryName = options.libraryName;

    config.optimization.runtimeChunk = false;
    config.plugins = [
        ...config.plugins,
        new CompressionPlugin(),
    ];
    modifyEntries(config, libraryName, libraryTargets);

    return config;
};
Enter fullscreen mode Exit fullscreen mode

Final touches

If last time you created an executor to automate publishing, we can now automate even more. We will set up automatically running bundling and packaging before publishing whenever we run nx publish:LibraryName.
All you need to do is:

  • Go to packages/LibraryName/project.json and change the bundle property to:

        "bundle": {
            "executor": "@nrwl/web:webpack",
            "outputs": ["{options.outputPath}"],
            "dependsOn": [
                {
                    "target": "package",
                    "projects": "dependencies"
                }
            ],
            "options": {
                "libraryName": "LibraryName",
                "libraryTargets": ["global", "commonjs", "amd", "umd"],
                "index": "",
                "tsConfig": "packages/LibraryName/tsconfig.lib.json",
                "main": "packages/LibraryName/src/index.ts",
                "outputPath": "dist/packages/LibraryName/bundles",
                "compiler": "babel",
                "optimization": true,
                "extractLicenses": true,
                "runtimeChunk": false,
                "vendorChunk": false,
                "generateIndexHtml": false,
                "commonChunk": false,
                "namedChunks": false,
                "webpackConfig": "webpack.config.js"
            }
        },
Enter fullscreen mode Exit fullscreen mode
  • Then go to nx.json and add in targetDependencies another option with
 "publish": [
            {
                "target": "package",
                "projects": "self"
            }
        ]
Enter fullscreen mode Exit fullscreen mode

Alternatively you can just add to targetDependencies these 2 options to affect all the future projects.

    "bundle": [
            {
                "target": "package",
                "projects": "self"
            }
        ],
        "publish": [
            {
                "target": "bundle",
                "projects": "self"
            }
        ]
Enter fullscreen mode Exit fullscreen mode

Good to know

Top comments (11)

Collapse
 
hugoazevedosoares profile image
hugoazevedosoares

Thank you for this post, it saved me a lot of time!

Collapse
 
ipreda profile image
Iulian Preda

You are welcome! I am glad i could help someone.
Whenever I have time I'll continue this series demonstrating how to

  • automatically publish to multiple package managers
  • automatically increase the version number
  • automatically link dependant internal projects in your package.json.
Collapse
 
hugoazevedosoares profile image
hugoazevedosoares

That would be awesome. Also, a nice thing we have done here at my team is also bundling a css for a library, so we can also publish it to a cdn on the same build pipeline.

By the way, we went back to use rollup, I though the configuration overall it's simpler.

Thread Thread
 
andyclausen profile image
Andreas Clausen

How did you get d.ts files into your build folder with rollup? I had to make another command that calls tsc "manually" (i.e. not with @nrwl/js:tsc) after the rollup. Did you find a better way?

Thread Thread
 
ipreda profile image
Iulian Preda

D.ts files should be created automatically. Please check your ts-vonfig files to have them emitted. That should be done using the "declaration":true flag

Thread Thread
 
andyclausen profile image
Andreas Clausen

I have declarations on, and they are being generated when I call tsc with the same tsconfig. We are talking about the @nrwl/web:rollup executor, right?
Maybe this is only an issue when using swc, but I'm pretty sure it didn't work for me with babel either.

Thread Thread
 
ipreda profile image
Iulian Preda

Swc is nice, but until now I didn't have the best experience with it, maybe it's just my luck of experience, at least with babel with the version stated in the article I can guarantee that the declarations are emitted using the @nrwl/web:rollup executor

Thread Thread
 
samhecquet profile image
Samuel Hecquet

I use @nrwl/web:rollup to build my library and it's very powerful. However, I'm having an issue now because I want to add font files in it and I can't find a way to compile it because I need webpack.
Have you already faced this situation?

Thread Thread
 
ipreda profile image
Iulian Preda

I think that is outside it's scope. Personally I would use @nrwl/web:webpack with a custom webpack config as presented in the article, you would need some more code to handle font import. Another solution would be to use some custom scripts that would be run after everything is bundled.

Thread Thread
 
samhecquet profile image
Samuel Hecquet • Edited

you're right!
For people being in the same situation that I was and ending up in this article, I fixed it by pointing to a customized rollup config file and using "@rollup/plugin-url" for the fonts:

const url = require('@rollup/plugin-url');
const nrwlConfig = require('@nrwl/react/plugins/bundle-rollup');

module.exports = (config) => {
  const originalConfig = nrwlConfig(config);
  return {
    ...originalConfig,
    plugins: [
      ...originalConfig.plugins,
      url({
        limit: 100000,
        include: ['**/*.woff', '**/*.woff2'],
      }),
    ],
  };
};
Enter fullscreen mode Exit fullscreen mode

I think it also works with jpg

Thread Thread
 
ipreda profile image
Iulian Preda

Thank you for your contribution!