Use Rollup to Bundle JavaScript Actions for Apache OpenWhisk

Like other “serverless” platforms, an OpenWhisk JavaScript Action may be a single .js file. As functions grow beyond trivial—and begin to depend on third-party modules—that single, forlorn .js file can no longer shoulder the burden.

The OpenWhisk Documentation suggests simply throwing an entire project—node_modules and all—into a .zip file. Of course, that’s wasteful and silly—especially if most of node_modules is full of development dependencies.

Now, I’m not one to read documentation unless I get stuck, so I didn’t realize that the docs present an alternative: bundle an Action with webpack. Faced with the unpalatable task of zipping up my entire project dir, I knew I wanted to bundle, but I didn’t reach for webpack—I used Rollup.

Why Rollup?

In this article, Rich Harris (the author of Rollup) writes that webpack’s scope is to help bundle single-page applications (SPAs). Rollup, on the other hand:

“Rollup was created for a different reason: to build flat distributables of JavaScript libraries as efficiently as possible, taking advantage of the ingenious design of ES2015 modules.”

—Rich Harris

The key for us in the above quote is “flat distributable.” We want to upload our Action as a single .js file. That is precisely what Rollup provides—with little extra ornamentation.

Rollup doesn’t do stuff like code splitting nor hot module replacement. We don’t need these features anyway, since we’re not bundling SPAs. Heck, we don’t even need ES modules all the way down (though we won’t get all of the benefits of Rollup’s tree-shaking abilities)—its plugin ecosystem has us covered.

Read on for an example configuration.

An Example Action Using Rollup

Here’s the lovely example action which the OpenWhisk docs provide. It’s intended to be uploaded within .zip file which also includes node_modules and everything else in its project folder:

function myAction(args) {
  const leftPad = require("left-pad")
  const lines = args.lines || [];
  return { padded: lines.map(l => leftPad(l, 30, ".")) }
}

exports.main = myAction;

Similarly to OpenWhisk's webpack example, the above needs slight modification to work with Rollup. Let’s write a naïve conversion. Create index.js:

import leftPad from 'left-pad';

function myAction(args) {
  const lines = args.lines || [];
  return { padded: lines.map(l => leftPad(l, 30, ".")) }
}

export const main = myAction;

This may seem a little weird, but note that OpenWhisk executes our .js file as if it were at the top level (no, I’m not sure why). The webpack example in OpenWhisk’s docs make this explicit by assigning the function to global.main; const main = myAction is equivalent.

However, since Rollup aggressively tree-shakes, casually assigning myAction to an unused variable is verboten, and myAction would be trashed. This is also why we can’t just write export {myAction as main}; it doesn’t create the global variable converted to CommonJS module format by Rollup. To address this, just export main; we can use it later when we write our tests!

Even though our dependencies don’t need to use ES modules, our sources do, so we import left-pad at the top. Then, myAction will become the default export. We’ll see how this works in the Rollup config below.

Need a refresher on ES modules? I recommend the modules chapter of Dr. Axel Rauschmayer’s excellent book, Exploring ES6.

If we don’t yet have a package.json, we can create one via npm init -y or copy/paste:

{
  "name": "my-action"
}

Then, install rollup, and left-pad (assuming npm v5.0.0 or newer):

$ npm i rollup@^0.57.1 -D

+ rollup@0.57.1
added 56 packages from 109 contributors in 2.725s

$ npm i left-pad@^1.2.0

+ left-pad@1.2.0
added 1 package from 1 contributor in 1.222s

The versions of rollup and left-pad above, and any subsequent versions, are intended to future-proof this tutorial. We may be able to just use the latest versions of any of these; YMMV.

By default, Rollup looks for a config file in rollup.config.js, so let’s create that now:

// notice: this is an ES module
export default {
  input: 'index.js',
  output: {
    file: 'dist/my-action.js',
    format: 'cjs'
  }
};

This configuration declares we will use CommonJS (Node.js-style; require() and module.exports, exports, etc.).

CommonJS (cjs) format isn’t actually required by OpenWhisk, as an IIFE or UMD bundle would work, but actually to suppress annoying warnings; if we don’t use cjs, it will assume we are bundling for a browser and take exception to what we’re trying to do.

Invoke Rollup now, and see the warning it generates:

$ node_modules/.bin/rollup -c

index.js → dist/my-action.js...
(!) Unresolved dependencies
https://github.com/rollup/rollup/wiki/Troubleshooting#treating-module-as-external-dependency
left-pad (imported by index.js)
created dist/my-action.js in 19ms

In other words, Rollup doesn’t know what to do with left-pad. In fact, it’s just going to assume it can be retrieved via require()! If we dump the contents of dist/my-action.js, we see:

'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

function _interopDefault (ex) { return (ex && (typeof ex === 'object') 
  && 'default' in ex) ? ex['default'] : ex; }

var leftPad = _interopDefault(require('left-pad'));

function myAction(args) {
  const lines = args.lines || [];
  return { padded: lines.map(l => leftPad(l, 30, ".")) }
}

const main = myAction;

exports.main = myAction;

Rollup has converted our ES module to Node-style, “CommonJS” exports, which is good. But…

We wanted to bundle left-pad, yet it didn’t happen; our bundle calls require('left-pad') like it’s in a node_modules/ somewhere. This won’t do; we only want to upload dist/my-action.js, and no part of node_modules/. Where’s the beef?

Other than the fact it broke the internet, left-pad has two problems:

  1. left-pad was installed by npm and lives in node_modules/left-pad.
  2. left-pad uses CommonJS exports.

Rollup makes few assumptions about our environment; indeed, both of the above cases necessitate a plugin. Let’s install them now:

$ npm i rollup-plugin-commonjs@^9.1.0 rollup-plugin-node-resolve@^3.3.0 -D

+ rollup-plugin-commonjs@9.1.0
+ rollup-plugin-node-resolve@3.3.0
added 9 packages from 6 contributors in 2.037s

Modify rollup.config.js as seen here:

import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';

export default {
  input: 'index.js',
  output: {
    file: 'dist/my-action.js',
    format: 'cjs'
  },
  plugins: [resolve(), commonjs()]
};

If we try bundling again, the warning is gone…

$ node_modules/.bin/rollup -c

index.js → dist/my-action.js...
created dist/my-action.js in 48ms

…and if we view dist/my-action.js, we will see that the entirety of left-pad is included. Neat! We can then deploy the action via:

$ wsk action create my-action dist/my-action.js

Or, if we’re using the IBM Cloud CLI (formerly Bluemix CLI):

$ bx wsk action create my-action dist/my-action.js

Next, I’ll discuss a few common recipes I've found useful.

OpenWhisk & Rollup Recipes

various spices in spoons
Photo by Pratiksha Mohanty / Unsplash

Here are some problems (and solutions, thankfully) I’ve encountered while experimenting with Rollup and OpenWhisk.

Using Built-In Node.js Modules

What if we wanted some extra logging? Node.js’ util.inspect() is super handy; we can use it to print only a few items from our lines array:

import leftPad from 'left-pad';
import {inspect} from 'util';

function myAction(args) {
  const lines = args.lines || [];
  console.log(inspect(lines, { maxArrayLength: 10 }));
  return { padded: lines.map(l => leftPad(l, 30, ".")) }
}

export const main = myAction;

Right?

Nope nope nope:

$ node_modules/.bin/rollup -c

index.js → dist/my-action.js...
(!) Unresolved dependencies
https://github.com/rollup/rollup/wiki/Troubleshooting#treating-module-as-external-dependency
util (imported by index.js)
created dist/my-action.js in 44ms

Fortunately, this is straightforward to fix. We add util to the external Array in rollup.config.js:

import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';

export default {
  input: 'index.js',
  output: {
    file: 'dist/my-action.js',
    format: 'cjs'
  },
  plugins: [resolve(), commonjs()],
  external: ['util']
};

If I end up using many builtin modules, I like to save myself some trouble and use rollup-plugin-auto-external.

Consuming JSON

What if we want to read a .json file? We might write this:

import leftPad from 'left-pad';
import {inspect} from 'util';
import {version} from './package.json';

function myAction(args) {
  const lines = args.lines || [];
  console.log(inspect(lines, { maxArrayLength: 10 }));
  return { padded: lines.map(l => leftPad(l, 30, '.')), version };
}

export const main = myAction;

But that would fail!

$ node_modules/.bin/rollup -c

index.js → dist/my-action.js...
[!] Error: Unexpected token
package.json (2:8)
1: {
2:   "name": "openwhisk-rollup",
           ^
3:   "version": "1.0.0",
4:   "description": "",

Shock! Rollup expects JavaScript files?! The solution is to pull in rollup-plugin-json:

$ npm install rollup-plugin-json@^2.3.0

+ rollup-plugin-json@2.3.0
added 1 package in 1.629s
$ node_modules/.bin/rollup -c

index.js → dist/my-action.js...
created dist/my-action.js in 41ms

Instead of just embedding the entire file, it will grab whatever portion of the .json file we actually use, effectively tree-shaking the JSON itself. In dist/my-action.js, we’ll see:

var version = "1.0.0";

Good work, rollup-plugin-json.

Third-Party Modules in The Environment

IBM provides many commonly used modules in its OpenWhisk service, IBM Cloud Functions. The list of these pre-installed modules which Actions can use is found in the documentation.

Practically speaking, this means we don’t need to bundle these modules. They can be ignored, just like a built-in, as shown above. We add any which we're using to the external Array in rollup.config.js.

It's still helpful to npm install any of these which we’re using (for code completion, testing, etc.). They must be added to the external Array, regardless.

Alternatively, use rollup-plugin-auto-external with option {dependencies: false}; then add the modules (as minimatch globs) which we do want to bundle to the Array include property of the commonjs plugin’s configuration object. Here’s an example of a Rollup config where we consume the pre-installed request-promise module, but exclude it from the bundle:

import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';

export default {
  // (imagine there is more configuration here)
  plugins: [
    resolve(),
    commonjs({
      // left-pad is the ONLY dependency which is bundled
      include: ['node_modules/left-pad/**']
    }),
    autoExternal({
      // continue to exclude "util", or any other built-in
      builtins: true,
      // default is true, but we still must bundle left-pad, so this is false.
      dependencies: false
    })
  ],
  // request-promise is a dependency in package.json
  external: ['request-promise']
}

Smooshing The Bundle

a hand squeezing an orange Photo by Sharon Drummond / Flickr

If we follow OpenWhisk's documentation on using webpack to the letter, our resulting bundle will be minified.

In smaller bundles, this ain’t matter. But with larger bundles, we should shave some milliseconds off of startup time.

To minify with Rollup, install rollup-plugin-uglify:

$ npm i rollup-plugin-uglify -D

Then:

import uglify from 'rollup-plugin-uglify';
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';

export default {
  // (imagine there is more configuration here)
  plugins: [
    resolve(),
    commonjs(),
    uglify()
  ]
}

We can squeeze more bytes out of this by providing options to the uglify() function. By default, it doesn’t mangle top-level variable names. This is how we’d do that:

plugins: [
  resolve(),
	commonjs(),
  uglify({
    compress: {
      toplevel: true
    }
  })
]

See the UglifyJS docs for more options.

Obligatory Wrap-Up

Let me remind the reader what the reader read:

  • We learned the difference between Rollup and webpack
  • We learned how to use Rollup to bundle an OpenWhisk Action
  • We learned how to
    • Use built-in Node.js modules
    • Bundle JSON files
    • Consume pre-installed third-party modules
    • Minify our bundle

So far, I’ve found Rollup works just as well as webpack for OpenWhisk Action deployment. I don’t see a clear winner, other than they both beat .zip files. Until I do, I’ll probably continue using Rollup, just ‘cause. What I’d really like to see is a zero-configuration, purpose-built bundler for Node.js OpenWhisk actions. Hmmm…