FuseBox - TypeScript-centric code bundler introduction

With JavaScript capabilities and complexity of written code on-growing, a great switch was made towards more modular architecture. CommonJS, AMD, and finally standardized ES6 modules (ESM) clearly show that. But, with such trends, it's still much better for today's browsers to provide a singular code bundle, rather than multiple smaller modules. And that's why code bundling πŸ“¦ became a really popular and common task.

There are a lot of bundling tools available in the JS ecosystem. The main ones being, arguably, Webpack, Rollup and Parcel. All of which serve the same purpose, but with certain goals of its own. E.g. Webpack is used mostly to bundle web apps, Rollup for libraries and Parcel for prototyping or getting stuff done fast. But, one thing that many of such tools have in common, is their extendability. The amount of available plugins, loaders and etc. is just stunning! On the other hand, some might enjoy tools like Parcel, that doesn't require much configuration. Including me. Personally, I prefer to use tools like Microbundle, Bili or Poi - based on highly-configurable and stable options, but with much less configuration needed. Some of them prefer a minimalistic approach, some just include as many functionalities out-of-the-box as possible. Still - I don't care as it just improves my experience and ultimately doesn't affect the output bundle size (hopefully). πŸ™ƒ

Anyway, today I'd like to share with you a bundler that I only recently learned about - FuseBox. Maybe you know about it already? But, for me, this is really something new. And, from a while of usage, I can really say that it's a worth-considering option for my future projects...

What's FuseBox?

Just like I said before, it's a module bundler. But a special one (every tool is special in its own way). It's not really a that young project, it's pretty solid and arguably quite popular ~ 3700 stars on GitHub (if you use this kind of measure πŸ˜…). So much so, that it even has a special, icon in my VS Code icon pack of choice! Anyway, let's make a quick overview of its features.

First, some that many other bundlers have. Customization, incremental builds and caching - standard stuff. There's also support for ESM dynamic imports and nice plugin system.

From special stuff, FuseBox comes with automatic CSS splitting, code optimizations, HMR, and... first-class TypeScript support! That's right! There's no further configuration needed - just swap your .js to .ts extensions (I'm talking about changing entry file) and that's all!

Now, such built-in features may remind you of Parcel (if you've used it before). Similarly, it comes with TS support, HMR, caching and even more stuff! But, and correct me if I'm wrong, Parcel feels to be targeted towards web app development (not libraries) and even then it's relatively good for prototyping mostly. It's still an awesome tool tho, it's just that I think FuseBox, with its a bit more complex config and stability, is just a much better choice. πŸ‘

What I love and what I hate

Right now, I know I seem completely biased about FuseBox. It just provided me with some really good first impressions. But, with that said, FuseBox isn't perfect - no tool is. There's still a lot of room to improve. So, to give you a bigger picture of this tool, let me talk about some pros and cons.

Pros

There's definitely a lot to love about FuseBox, that makes it stand out from the rest of bundlers. TypeScript support super fast builds, and very easy configuration are my top picks. FuseBox uses CommonJS as main resolve method under-the-hood to allow your builds to be blazing-fast. ⚑ I mean like milliseconds fast! Oh, and you can still use ESM the way you want. As for the config - it's really, really intuitive! Instead of standard configuration object most tools usually accept, FuseBox is run with Node.js itself, by executing its fuse.js "config" file. It's more of a Node.js program. Inside such file, you can use FuseBox chainable, very much pleasing API, supported by TypeScript autocompletion. Also, there's even built-in task runner called Sparky for additional functionalities!

But, yeah, there are some downsides too. For me, the main one is that FuseBox cannot generate TypeScript declaration files on its own. In fact, FuseBox often ignores tsconfig.json to some high extents. Sure you can use TSC separately or Sparky for such things, but with many bundlers having this option built-in, it's just something that I'd like to have.

Cons

Next, there's its documentation. It's nice, easy-to-navigate and understandable, but, when it comes to more complex things, I have a feeling that it doesn't cover it all. These are my main picks. There's also the fact that you have to manually install tools that FuseBox will utilize (why not all-in-one? - it's a development tool, right?). And lastly, there's the plugins collection. It's surely not as big as one of Webpack or Rollup. There are only some third-party plugins and the rest is provided with the FuseBox package. Those have a fair amount of documentation of the FuseBox official site and are pretty comfortable to use. But, there's one plugin that's arguably the most important - QuantumPlugin. It's just kind-of all-in-one plugin for creating production-ready builds. It's still nice to use, but it's quite bloated and really complex. So much so, that it's still big documentation doesn't explain all things clearly. And, to makes matters worse, QuantumPlugin doesn't work in all cases (more on that later). πŸ˜•

Anyway, FuseBox v4 is on its way, featuring improvements to tsconfig.json support, HMR, and other stuff, so I hope that this tool will only get better. Now, after all, these complaints of mine, it's time to finally get our hands dirty and do some FuseBox setup on our own!

FuseBox usage

Setup

Basics

Let's start with an already prepared package, by installing FuseBox itself with TypeScript and Terser for future use.

yarn add --dev fuse-box typescript terser

Now, it's time to plan our project's structure. We'll use TypeScript and place our entry file (index.ts) in the src folder. For development, we would like to have hot reloading and incremental builds enabled. As for the building process, we'll output our bundle file to build folder, and TS declarations and processed files to typings and lib folders respectively. The somewhat standard structure for a library. πŸ“–

Config

First, create the fuse.js file and analyze its basic boilerplate.

// fuse.js
const { FuseBox } = require("fuse-box");
const fuse = FuseBox.init({
  homeDir: "src",
  target: "browser@es5",
  output: "build/$name.js",
});
fuse
  .bundle("app")
  .instructions(" > index.ts")
fuse.run();

Here, we're initiating FuseBox with FuseBox.init() method. There, we pass our basic configuration object with some required properties. homeDir indicates the main directory of our input files, target, in form of "[env]@[lang]" where env can be "browser", "server" or "electron" and lang having a form of language level string (e.g. "es6" or "esnext"). Finally, we specify the output location of our bundle with output property and a useful $name macro, matching our bundle's name.

Then, we make use of FuseBox chainable API and name our bundle with .bundle() method, and provide proper running instruction (input file) with .instructions() method. Finishing the job with simple .run() call.

Usage

You can execute such a prepared file, just like any other Node.js program - with node fuse.js command. So convenient! πŸ˜‰

node fuse.js

Now, to be clear, with the above config we should already have nice TS support included and... pretty big bundle. By default, FuseBox uses CommonJS under-the-hood (that's why it's so fast). It just wraps different modules inside these CJS wrappers that can quickly be bundled. But, this additional code (and some metadata) results in your final bundle gaining additional 5 KB (unminified) in size. Ouch!

Task runner

Leaving the bundle size aside for a moment, we also wanted to have an automatic generation of TS declaration files and output (for modular environments) within our FuseBox pipeline. And, as mentioned before, there's no built-in option for that. So, we'll have to use Sparky instead.

Context

const { task, context, tsc } = require("fuse-box/sparky");
// ...

context({
    getConfig() {
      return FuseBox.init({
          homeDir: "src",
          target: "browser@es5",
          output: "build/$name.js",
      });
    }
});
// ...

First, we'll have to change the basic structure of our fuse.js file. As the official documentation suggests, before using Sparky, we should first set up a so-called context, which later will be accessible by all our tasks. And, although it indeed brings some clarity to our config, we sadly lose TS autocompletion along the way. πŸ˜”

Build

After our context is set up, it's only a matter of creating our tasks. In our case, we'll use only two. The first for building process and second for development. Let's see how it's done...

// ...
task("build", async ctx => {
    const fuse = ctx.getConfig();
    fuse
        .bundle("app")
        .instructions(" > index.ts");
    await fuse.run();
    await tsc("src", {
        target: "esnext",
        outDir: "lib/",
        declaration: true,
        declarationDir: "typings/"
    });
});

Within the above "build" task, aside from its declaration ( task() function with the provided name and asynchronous function), we pretty much follow the same pattern as in our first version of the file. It's only after that that we use new tsc() function that Sparky kindly provided to us among other methods of its API. As the name indicates, this functions allows you to run TSC with provided configuration, and local tsconfig.json file. Sadly, it requires TypeScript to be installed globally in order to run. Now that's some serious disadvantage! 😠 Here, we're providing it with a minimal setup to just output our built, modular files and TS declaration files.

Oh, and about this tsconfig.json of ours...

{
  "include": ["src/**/*"],
  "exclude": ["node_modules"],
  "compilerOptions": {
    "target": "es5"
  }
}

If tsconfig.json is not present, FuseBox will generate one with its default configuration. And, aside from that, FuseBox ignores compilarOptions.module property anyway, and sets it to its own CommonJS format, so it doesn't make sense to even set that option. Just remember that, in your code, you should use ESM only.

Watch

As for our "watch" task, we're doing pretty much the same thing here. Only this time instead of just running FuseBox once, we use its chainable API and make FuseBox automatically enable HMR and files watching for us. That's the convenience I was talking about!

// ...
task("watch", async ctx => {
    const fuse = ctx.getConfig();
    fuse
        .bundle("app")
        .instructions(" > index.ts")
        .hmr()
        .watch();
    fuse.run();
});

Of course, we won't be running TSC here. I'd only slow down our sweet, almost-instantaneous rebuild times.

And, to run any of your tasks, just supply its name after the main command, like this:

node fuse.js build

When you run FuseBox without any task name, and there are some Sparky tasks defined, the "default" task is run. You can use this fact to create your own custom default task with available, vast API of Sparky. If no such task is provided, FuseBox execution won't do anything.

Production

Paths

It's time for some final production tips! ✨ When you e.g. want to use one config for multiple directories (like in the monorepo setup from the previous post) you have to know that FuseBox has some different resolve system. That's why simple ./ won't do the job. With FuseBox taking into account homeDir, tsconfig.json baseUrl, or config file's location, it's easy to get lost (at least IMHO). That's why, if you want to make sure that you're referencing the directory currently worked-on, just use process.cwd() and path module. That's just the magic of using a full-fledged Node.js program!

// ...
const fuse = FuseBox.init({
    homeDir: process.cwd(),
    // ...
});
// ...

Globals

Next - globals. Especially when creating libraries for browser environments, creating builds that expose certain properties on global objects (window in browser and exports in Node.js) is often really useful. For this purpose, FuseBox provides special globals field in its config object (or a .globals() chainable method). With this property, you can expose multiple packages (refer to docs) or, more often, just the ones exported from your entry file. In this case, just provide your globals object with the chosen name and assign it to default property.

// ...
const fuse = FuseBox.init({
    globals: {
        default: "NameToExposeToWindow"
    }
});

Minification

Lastly, we have our bundle size. It's here where things start to get a little... complicated. In theory, we should just be able to drop the TerserPlugin, QuantumPlugin, in a way just like any other and call it a day. πŸ™Œ

//...
const { FuseBox, TerserPlugin, QuantumPlugin } = require("fuse-box");

const isProduction = process.env.NODE_ENV === "production";

const fuse = FuseBox.init({
    // ...
    plugins: [
        isProduction && QuantumPlugin({
            bakeApiIntoBundle: true
            treeshake: true
        }),
        isProduction && TerserPlugin(),
    ]
    // ...
});

Here, we use a straightforward method of applying plugins in FuseBox. First, we apply the QuantumPlugin only if we're in production mode. It's just a simple check to save some time during development (then set with NODE_ENV=production). Then we initiated our plugin with a simple call and configuration - just like any other. QuantumPlugin uses a different API from standard CJS wrappers mentioned earlier. It's significantly smaller, but not yet fully compatible with the original one - that's why it's not used by default. It also applies several optimizations, like e.g. tree-shaking. Our configuration object basically enables mentioned tree-shaking feature and puts this API into our bundle (by default it's located in a separate file).

Then, we drop in the TerserPlugin to minify our bundle. As a note, Terser, in contrast to standard Uglify, supports modern ES-Next syntax minification out-of-the-box.

The configuration above should significantly shrink the size of our output. There still will be some boilerplate there, but we're talking only 300~400 bytes. It seems perfect! So, what's the problem? πŸ€”

Well, I previously said that FuseBox has good support for web apps and libraries bundling... yeah, that's how far the second goes. The QuantumPlugin doesn't work well with all features available in standard resolution method... and support for globals is one of them. And, while it's not a big deal for web apps of any kind, libraries development takes a big hit. The only solution, for now, is just not to use QuantumPlugin in certain scenarios and stay with TerserPlugin or UglifyJSPlugin only. This, however, leaves us still with some KB of CommonJS wrappers... even if minified. I just hope that this will be fixed with the v4 release. It seems to be a big one.

Thoughts?

So, as you can clearly see, FuseBox is a nice bundler... and surely one that deserves to be known. With some additional tweaks, I really have big hopes for this tool. Feature-set is mostly complete, the configuration process is extremely compelling... There are just some things that need to be worked on. And, after it's done, who knows? Maybe it'll even rival the position of both Webpack and Rollup? πŸ€”

Anyway, thank you for reading this post, and, as always, let me know what do you think of it and FuseBox down in the comments! Also, drop a reaction here and a star there to show your support! For more up-to-date content from this blog, consider following me on Twitter, on my Facebook page and signing up for the weekly newsletter. Again, thank you for reading and I'll see you in the next one! πŸ”₯✌