DEV Community

Cover image for The Mythical One-Fits-All Build Tool Plugin ๐Ÿฆ„ (It Actually Exists)
Pascal Thormeier
Pascal Thormeier

Posted on

The Mythical One-Fits-All Build Tool Plugin ๐Ÿฆ„ (It Actually Exists)

Do you know that feeling when you're building a complex web app and you need some functionality that actually exists, but not for the framework you're using? Or, let's say you're building a library that needs to hook into the build process of your project, you'd like to open source it, and you just so happen to use Vite, but some poor soul out there would need this exact library, but for Webpack instead? Or they use Snowpack? Or Brunch? Or... Gulp?


Ok, perhaps it's not that bad anymore. The wildest times of the JS world are definitely over. You know, the times when build tools and bundlers and frameworks and component libraries sprouted like mushrooms. A classic XKCD comic about competing standards fits pretty well:

Situation: There are 14 competing standards.

You can even read about my own adventures with the niche build tools Brunch and Snowpack](https://dev.to/thormeier/i-m-going-to-give-snowpack-a-try-now-3ohm) in some previous articles I wrote. Both of these tools havenโ€™t received a commit in 4 to 5 years now, so support is minimal at best.

Nowadays, there are still about half a dozen, give or take a few, build tools/bundlers left that are still highly maintained, broadly used, and that are generally accepted as "standard": Webpack, esbuild, Vite, rspack, Rollup, Rolldown, Bun, and some others based on these.

The problem I described initially persists, though: Most of these work in wildly different ways. A Webpack plugin usually doesn't "just work" in Vite and vice versa. And let's not forget esbuild and all the others!

Luckily, there's movement. Not only are people using things resembling "standard tools" by now, but ever more of these are emerging.

Introducing the UnJS ecosystem

One particular group is building off-the-shelf, framework-agnostic packages that work on their own with few to no dependencies: UnJS.

The UnJS website
(The UnJS website)

If youโ€™ve built anything with Nuxt, Vue, Vite or thelike, youโ€™ve likely already used some of their tools without even realising. There are some instant classics like:

  • h3 - a portable and lightweight http server)
  • citty - a CLI builder
  • changelogen - a tool for generating changelogs
  • ofetch - a highly portable fetch replacement
  • nitro - the very thing that powers most of Nuxt's server-side capabilities

These tools are everywhere. Staying with Nuxt here for a second, it sometimes feels like Nuxt is simply Vue plus a bunch of UnJS packages and some glue code. Excellent work by these people, if you ask me.

These libraries work agnostic of your build tool/bundler, but they don't directly integrate with them. That's where the, at least in my humble opinion, magnum opus of the UnJS team comes in: unplugins.

I know what a plugin is - but what's an unplugin?

Great question!

I could imagine that the UnJS people had a look at some popular build tools and thought, "Most, if not all, of them use some hook system for plugins. Often, these hooks are named similarly. Why not unify them into a single plugin system?"

And that's precisely what unplugin is: A unified system to hook into build tools. Authors of any unplugin only need to define the actual business logic (i.e., what does the plugin actually do), and the unplugin system takes over the rest. It essentially defines a plugin for each supported build system, all of which contain the same logic the author has implemented. Let's compare the "legagcy plugin architecture" to the unplugin approach:

Without unplugin With unplugin
One codebase per plugin One logic factory
Tool-specific APIs, lots of reading up on them Unified hooks, all behaving the same way
Higher maintenance Lower maintenance

An example using a starter template

So, let's say we want to create a small plugin that replaces one word with another in the user's main.ts file.

To get started, it's advised to use a template. Luckily, the folks over at UnJS have created a starter template for us that we can use by executing these commands:

npx degit unplugin/unplugin-starter my-unplugin
cd my-unplugin
npm i
Enter fullscreen mode Exit fullscreen mode

This will clone the starter repository into a folder called my-unplugin. It creates everything we need for a working unplugin.

And lo and behold, it even includes our basic unplugin already! When we open src/index.ts, we see the following code:

import type { UnpluginFactory } from 'unplugin'
import type { Options } from './types'
import { createUnplugin } from 'unplugin'

export const unpluginFactory: UnpluginFactory<Options | undefined> = options => ({
  name: 'unplugin-starter',
  transformInclude(id) {
    return id.endsWith('main.ts')
  },
  transform(code) {
    return code.replace('__UNPLUGIN__', `Hello Unplugin! ${options}`)
  },
})

export const unplugin = /* #__PURE__ */ createUnplugin(unpluginFactory)

export default unplugin
Enter fullscreen mode Exit fullscreen mode

Now, there's a ton to unpack here. Unplugins are written by creating a factory function that takes a bunch of options. The function returns the unplugin's definition. Using some generic hooks (in this case, transformInclude and transform, we can do all sorts of things. In these hooks, we specify what will be executed when the user's build tool runs them.

transformInclude checks if a given file name (that's what id is) should be transformed in the first place. If true, the transform function then receives the contents of that file and returns a transformed version. In our case, we replace __UNPLUGIN__ with Hello Unplugin!.

So if the user's project's main.ts would look like this:

console.log('__UNPLUGIN__')
Enter fullscreen mode Exit fullscreen mode

The built main.js would look like this:

console.log('Hello Unplugin!')
Enter fullscreen mode Exit fullscreen mode

And how does it now create the build tool specific stuff?

Again, great question! We notice a bunch of other files in the src/ directory. They're named after the build tool they're for, for example, vite.ts, astro.ts, webpack.ts and so on.

Let's have a look at vite.ts:

import { createVitePlugin } from 'unplugin'
import { unpluginFactory } from '.'

export default createVitePlugin(unpluginFactory)
Enter fullscreen mode Exit fullscreen mode

Is it really that simple? Let's look at webpack.ts:

import { createWebpackPlugin } from 'unplugin'
import { unpluginFactory } from '.'

export default createWebpackPlugin(unpluginFactory)
Enter fullscreen mode Exit fullscreen mode

Yup, seems like!

What the unplugin library does here is take the factory and create a plugin from it, with specialised functions. Ideally, we donโ€™t even need to touch these files, ever.

A diagram showing the workflow/architecture of unplugin

Sounds good - but what can we actually do with this?

Well, the possibilities are endless. Here's a list of all supported hooks:

Hook Rollup Vite webpack esbuild Rspack Farm Rolldown Bun
enforce โŒ โœ… โœ… โŒ โœ… โœ… โœ… โŒ
buildStart โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ…
resolveId โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ…
loadInclude โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ…
load โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ…
transformInclude โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ…
transform โœ… โœ… โœ… โœ… โœ… โœ… โœ… โœ…
watchChange โœ… โœ… โœ… โŒ โœ… โœ… โœ… โŒ
buildEnd โœ… โœ… โœ… โœ… โœ… โœ… โœ… โŒ
writeBundle โœ… โœ… โœ… โœ… โœ… โœ… โœ… โŒ

(Source: official unplugin guide)

I want to especially point out three of these hooks:

  • resolveId - This one's used for resolving file names, i.e. path rewriting, directory aliasing and similar
  • load - Change how specific files (determined via loadInclude) are loaded. You could potentially even fetch things from a CDN here
  • transform - Change code directly as a string. Replace, add, remove, compile, whatever you can think of

As you can see, though, not all build tools support all hooks. But that's ok. You usually can find a way to circumvent this or create logic specific to these build tools. I strongly recommend reading the official guide for this.

Here's some ideas from the top of my head:

  • A plugin that offers compiler macros like CURRENT_YEAR that get replaced at build time
  • Count all unique paddings and margins in the code base to give the user an overview
  • Automagic Brainf**k support!

How would a project now use this unplugin

Like any other plugin, mostly. In Vite, for example, a user could do this:

import { defineConfig } from 'vite'
import MyUnplugin from '@my-company/my-unplugin/vite'

export default defineConfig({
  plugins: [
    MyUnplugin(),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Sounds good - are there any real life use cases?

Indeed, there are! The most popular is unplugin-icons, which lets us install almost any icon in any project by installing, configuring, and using it.

Another real-life example is what weโ€™ve built during my time at Liip for the Swiss canton of Basel-Stadt: a design system with an installable plugin that lets other agencies create websites that automatically align with the canton's CI/CD. It does that by providing Tailwind, installing all necessary PostCSS plugins and delivering a ton of prebuilt utilities and CSS components. You can read up on it on Liip's blog!


Sooo, should everyone be writing unplugins now?

As we German-speaking people say: "Jein" (yes-and-no).

My recommendation, based on experience, is that it's sensible for things expected to be used by many different projects, as it gives you the maximum amount of freedom with little to no downsides, aside from being forced to write agnostic code.

Generally, the business logic could even live in its own package. Why not build a library that exports the functionality and use that as a dependency for an unplugin? That way, the library itself is encapsulated, testable and could be used for other purposes and in different contexts, too, even without the need for a build tool.

If you're writing a Vite package for your own project that you're never going to open-source or that doesn't make any sense at all when used without Vite, though, an unplugin seems like overkill or even a hindrance at times.

Nevertheless, what the people at UnJS built here is a fantastic piece of technology! The logical next step is to standardise build tool interfaces, much like Vite and most UnJS packages already do.

Which package do you think would be worth building an unplugin for?


I hope you enjoyed reading this article as much as I enjoyed writing it! If so, leave a โค๏ธ! I write tech articles in my free time and like to drink coffee every once in a while.

If you want to support my efforts, you can offer me a coffee โ˜•! You can also support me directly via Paypal! Or follow me on Bluesky ๐Ÿฆ‹!

Buy me a coffee button

Top comments (0)