Why is Bundling a Browser Extension So Hard?

Have you ever tried to make a browser extension? Despite sharing the same technologies as the rest of the web, it seems to me that extension developers are a rare breed - and the amount of tooling that exists in the web extension space seems to reflect this.

My latest predicament revolves around wanting to use ES6 modules to structure extension code. Modules are pretty widely supported across browsers I care about in 2020, but there’s one small problem: Firefox doesn’t support them in extension content scripts. (A content script is just some Javascript (and/or CSS) that your extension runs in the context of a webpage.) Since the bulk of an extension’s logic is often handled in a content script, not being able to use modules in this part of your extension’s code is a bit of a drag.

It’s obviously possible to split your code into multiple files even without the benefit of modules, but it’s messy. It requires adding properties to the window so you can access your stuff, and while global namespace pollution isn’t a huge concern in content scripts thanks to something Firefox calls “X-ray vision,” it does require you to keep track of the order your various scripts are loaded to ensure you don’t try to use one of your files before it’s loaded. This gets especially tricky if your project includes circular dependencies, and if you use external libraries, you’re stuck either pulling them into your project manually or writing your own scripts to automate the process. There doesn’t seem to be any generic solution for this.

So recently I’ve been looking for a build platform that might help me solve these problems. Tools like Rollup and Webpack have no problem converting from module-based source files to more widely-compatible formats, and most even support dead code elimination through static analysis of your imported modules. Great! But of course, it’s not as easy as that.

Browser extensions can have multiple content scripts, a background script, and additional worker scripts. The browser is responsible for executing all these scripts at the appropriate times in the appropriate contexts, and all these individual entry points are defined in your extension’s manifest.json file. However, because some of those scripts will end up sharing dependencies (you may have a utility module that contains functions used by a both content script and a background script, for example), it’s inefficient to just package each entry point as its own monolithic blob. That would mean every entry point would contain duplicate copies of the shared code.

This isn’t a problem unique to browser extensions, and bundlers have come up with a solution: code splitting. This is when the bundler takes in multiple entry points, analyzes them for shared dependencies, and creates bundles for them that are dynamically imported at runtime by each entry point bundle. For example, if you have an entry point a that depends on b and c, and another entry point d that relies on c and e, bundling would give you just three bundles: one that contains a and b, one that contains d and e, and one that contains the shared dependency c.

This approach is brilliant for web applications, because it allows you to lower the number of requests needed to load your app, but that was never the issue we were trying to solve for browser extensions. A browser extension doesn’t care much about how many script files it loads, because every request is just a hit to the local disk, rather than an actual internet request. We only care about getting code that doesn’t use unsupported ES6 features, and that doesn’t duplicate shared parts of our code. And if a bundler is generating bundles that rely on each other… surprise, they’re doing it by emitting dynamic import() expressions as part of the entry bundles (or by combining web requests and and eval, which isn’t allowed in content scripts either). So that solution is out.

The core of the issue is this: Whereas most bundlers try to emulate the import/export relationship between modules, breaking an extension script into multiple files involves adding multiple files to manifest.json. Extension scripts can have dependencies, but they’re not listed as part of the entry point in anything like an import statement; instead, dependencies of that file have to be loaded beforehand by placing them earlier in the manifest’s list of files to load. This also means that dependencies can only expose things to entry points by setting global variables, and the entry point needs to be written with prior knowledge of what those variables will be. So for a bundler to really work for extensions, it needs to be able to convert the dependency tree of its source files into a flat, ordered list of files to load, where imports and exports are converted to namespaced values on the global object.

The issue here, I think, is that bundlers just aren’t familiar with what I’m looking for. They’re built to cater to an audience that’s building web applications, not browser extensions. They have all the capabilities I’d need, really—they can convert modules to IIFE-based scripts that write their exports to window properties, and they can do static analysis to facilitate lightweight code sharing, there’s just not a solution I’ve found that can put it all together yet.

I’ve started working on a project that should hopefully do everything I need it to, but it’s messy - it’s going to be built on Rollup, but it’s going to require a custom plugin that creates additional Rollup processes to perform all the transformations necessary. Hopefully I can get it usable, even if it feels like complete overkill for what seems like it should be a simple problem to tackle.

In any case, go thank the dev of your favorite browser extension. I don’t envy folks like the uBlock Origin maintainers who have resorted to a bunch of shell scripts for every platform they build for.