DEV Community

Arnaud
Arnaud

Posted on

Authoring a JavaScript library that works everywhere using Rollup

In this article, we aim to create and publish a library that can be used, without any changes in the code, in both client side and server side applications.

We need to fulfill the following use cases:

  1. The library is written in ES6+, using import and export keywords
  2. The library can be used with a <script> tag
  3. The library can be used in a web application that uses a modern bundler.
  4. The library can be used in a node application.

Technically, this means the library needs to work in the following contexts:

  • Using a <script> tag:
<html>
  <head>
    <script src="scripts/my-library.min.js"></script>
  </head>
  <body>
    <div id="app" />
    <script>
      myLibrary.helloWorld();
    </script>
  </body>
</html>
  • Using RequireJS:
define(["my-library"], function (myLibrary) {});
// or
define(function (require) {
  var myLibrary = require("my-library");
});
  • In a web application using a bundler such as webpack:
import { helloWorld } from "my-library";
helloWorld();
  • In a node application:
const myLibrary = require("my-library");
myLibrary.helloWorld();
// or
const { helloWorld } = require("my-library");
helloWorld();

Note: In the web application using a bundler, there is no way to import the whole library and call individual functions on it (import lib from 'library'; lib.sayHello();), and that’s completely intentional. We want consumers to call just the bits they use so tree-shaking can do its work and dead code is eliminated when bundling the final application. Remember, in the case of an application using a modern bundler, the consuming web application will also produce a bundle for deployment, and we want that to be as small as possible, so we’ll spare our consumers from having to include code that is not used in their application.

To achieve all this, we’re going to use rollup.js. The main reasons is that Rollup is very fast (although not the fastest), requires minimal configuration, and supports everything we need through its convenient plugin system.

Once our library is written, we will use Rollup to export the code in the following three formats:

  1. UMD (Universal Module Definition): this will support the use of a script tag, and RequireJS. As the consuming app will not be transpiling or bundling the code themselves, we need to provide a version of our library that is minified and transpiled for wide browser support.

  2. ESM (ES2015 Module): this will allow bundlers to import our application, eliminate dead code and transpile it down to the level they choose. We’re still compiling the code, but just providing it in a format that’s convenient for consumers and letting them decide what to do next. This will allow the import keyword to work.

  3. CJS (CommonJS): the format of choice for Node.js. No tree-shaking needed here as code size doesn’t matter as much, this format allows the use of the require keyword in a node application.

For each of these format, we will also provide a source map so consumers can debug the library should they need to.

The first step is to create a project:

$ mkdir my-library
$ cd my-library
$ npm init -y

Next we need to add some dependencies.
Obviously , we need rollup.

$ npm install rollup --save-dev

We know that we need to transpile the code for the UMD format, so let’s install babel:

$ npm install @babel/core @babel/preset-env --save-dev

We also need rollup to use babel and to minify the code, so let’s install the necessary plugins to use babel and terser:

$ npm install @rollup/plugin-babel rollup-plugin-terser --save-dev

And finally, we want to be able to use the import/export syntax in our library in the style of node: this allows us to write import fn from './fn' instead of import fn from './fn/index.js' and of course to use modules from the node_modules directory (which we are not doing here).

$ npm install @rollup/plugin-node-resolve --save-dev

The final list of dependencies of our library should look something like this:

"dependencies": {},
"devDependencies": {
  "@babel/core": "^7.11.6",
  "@babel/preset-env": "^7.11.5",
  "@rollup/plugin-babel": "^5.2.1",
  "@rollup/plugin-node-resolve": "^9.0.0",
  "rollup": "^2.28.2",
  "rollup-plugin-terser": "^7.0.2"
},

We also need a directory for our source code, a configuration file for babel, and a configuration file for rollup:

$ mkdir src
$ touch .babelrc.json
$ touch rollup.config.js

The babel configuration is going to be very simple, we just need to tell babel that we want to use the most recent version of JavaScript:

{
  "presets": [["@babel/env", { "modules": false }]]
}

For Rollup, we need to import the necessary plugins:

import { nodeResolve } from "@rollup/plugin-node-resolve";
import { terser } from "rollup-plugin-terser";
import babel from "@rollup/plugin-babel";

And we’re also going to import the package.json, so we can use the name field when exporting the UMD bundle:

import pkg from "./package.json";

Our rollup.config.js is going to do two things:

For UMD: take the code, process it and run it through babel (transpile) and terser (minify), and export it as a UMD consumable file.

{
  // UMD
  input: "src/index.js",
  plugins: [
    nodeResolve(),
    babel({
      babelHelpers: "bundled",
    }),
    terser(),
  ],
  output: {
    file: `dist/${pkg.name}.min.js`,
    format: "umd",
    name: "myLibrary",
    esModule: false,
    exports: "named",
    sourcemap: true,
  },
},

For CJS/ESM: take the code, process it, and export it as an ESM module, and as a CJS module. Remember, in this case, we don’t need to transpile or minify. Node doesn’t need it, and for ESM, the consumer will do that.

{
  input: ["src/index.js"],
  plugins: [nodeResolve()],
  output: [
    {
      dir: "dist/esm",
      format: "esm",
      exports: "named",
      sourcemap: true,
    },
    {
      dir: "dist/cjs",
      format: "cjs",
      exports: "named",
      sourcemap: true,
    },
  ],
},

In all instances however, we generate a sourcemap.

Note the exports: "named" option in all configs, there’s a longer explanation in rollup’s documentation, essentially this tells rollup that we’re using named exports over default exports. Long story short, this allows the widest possible compatibility, and makes tree shaking happen. If you use a linter, make sure to configure it to favor named exports over default exports (this doesn’t apply to applications, only to libraries, it’s completely fine to use default exports and even mix default/named exports for applications).

The complete rollup file looks like this. And because the name is taken from the package.json, you can actually use this file almost as is, as long as the entry point is src/index.js and the name is set accordingly in the output of the UMD module.

import { nodeResolve } from "@rollup/plugin-node-resolve";
import { terser } from "rollup-plugin-terser";
import babel from "@rollup/plugin-babel";
import pkg from "./package.json";
const input = ["src/index.js"];
export default [
  {
    // UMD
    input,
    plugins: [
      nodeResolve(),
      babel({
        babelHelpers: "bundled",
      }),
      terser(),
    ],
    output: {
      file: `dist/${pkg.name}.min.js`,
      format: "umd",
      name: "myLibrary", // this is the name of the global object
      esModule: false,
      exports: "named",
      sourcemap: true,
    },
  },
  // ESM and CJS
  {
    input,
    plugins: [nodeResolve()],
    output: [
      {
        dir: "dist/esm",
        format: "esm",
        exports: "named",
        sourcemap: true,
      },
      {
        dir: "dist/cjs",
        format: "cjs",
        exports: "named",
        sourcemap: true,
      },
    ],
  },
];

Now that we have our dependencies, configured babel and rollup, time to write the code.
We’re going to layout our files like so:

src
├── goodbye
│   ├── goodbye.js
│   └── index.js
├── hello
│   ├── hello.js
│   └── index.js
└── index.js

And the code is going to be extremely simple:

// src/index.js
export { default as hello } from "./hello";
export { default as goodbye } from "./goodbye";
// src/hello/index.js
export { default } from "./hello";
// src/hello/hello.js
export default function hello() {
  console.log("hello");
}
// src/goodbye/index.js
export { default } from "./goodbye";
// src/goodbye/goodbye.js
export default function goodbye() {
  console.log("goodbye");
}

Next we need to call rollup and tell it to do its job. For convenience’s sake, we’re going to create two npm scripts, one for building the library, and a dev task which will recompile the code every time a change is made:

“scripts”: {
 “build”: “rollup -c”,
 “dev”: “rollup -c -w”
},

Finally we need to describe how the application is exported, both for npmjs to make it available, and for consumers to use.

We’re going to define three values in package.json:

The files option that tells npm what to pack (this can be tested using npm pack ), the main option that point to a CJS module, and the module option, which while not standard, has become the norm for ESM modules.

// package.json
...
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
...
files: [
  "dist"
]

And that’s it!
To build the library, just run npm run build and while developing, you can use npm run dev . The export can be tested using npm pack.

Testing the library

Using the script tag, simply create an HTML file, and open it in a browser. You will see the word “hello” printed in the console.

<html>
  <head>
    <script src="dist/my-library.min.js"></script>
  </head>
  <body>
    <script>
      myLibrary.hello();
    </script>
  </body>
</html>

Using Requires.JS, create a small webapp and serve it using serve :

www
├── index.html
└── scripts
├── app.js
├── my-library.min.js
└── require.js
index.html
<html>
  <head>
    <script data-main="scripts/app.js" src="scripts/require.js"></script>
  </head>
  <body></body>
</html>
// app.js
requirejs.config({
  baseUrl: "scripts",
});
requirejs(["my-library.min"], function (myLibrary) {
  myLibrary.hello();
});

The word ‘hello’ will be printed in the console. Once the module is published, the file my-library.min.js will be available from https://unpkg.com/.

From node, outside of the library directory, create a js file and require the module by pointing to the my-library directory (not the dist folder!):

const myLibrary = require("../my-library");
myLibrary.hello(); // hello
myLibrary.goodbye(); // goodbye

If you take it a step further and debug the application, the source map will kick in too!

From a web application using webpack, like a React application:

$ npx create-react-app my-library-cra
$ cd my-library-cra

In the dependencies section of the package.json, simply add this line:

"my-library": "../my-library/"

And run yarn install
In src/App.js, import and invoke the hello function only:

import { hello } from "my-library";
hello();

Run the React application using yarn start and open the JavaScript console, and you should see the “hello” word printed.

Now to make sure tree shaking works, run yarn build. The React application will be bundled and put into the build directory. If you do a search in files for the hello keyword, you will see that it is in a js file with a long complicated name, but the keyword goodbye cannot be found. This shows that webpack only pulled in the necessary code. And since we are using a named export in our library, consumers of our library cannot write import myLibrary from 'my-library'; and mistakenly import the entire package while only using a fraction of it.

Hope this helps, let me know if you have any questions in the comments!

Top comments (2)

Collapse
 
petitkriket profile image
Sam

Thank you ! clear explanation on different formats, use cases and rollup configuration.
Quick question: why are you generating source maps too ? If I got it right, for debbuging purposes ?

Collapse
 
advename profile image
Lars

Exactly, when bundling files, rollup generates some weird output. Source maps basically "map" the weird output between our original code, and when the weird output throws an error at position XYZ, then the source map will help the browser to display where in your original code the error happened.