Skip to content
nourlwasavailable
Go back

Using Bun Bundler

For me, Bun has been one of the most exciting things to happen to JavaScript in the past few years. It’s fast, simple to use, and works well out of the box. Almost everything that works in Node works in Bun, and it also offers a few features that feel unusually well designed for the JavaScript ecosystem.

This post is about one of those features: Bun Bundler. More specifically, it’s about my experience using bun build --compile, how I used it to build Colors on the Curve, the issues I ran into, and the parts of the experience that stood out to me.

I originally built colors-on-the-curve because I wanted a tool with capabilities I couldn’t really find elsewhere. But the project also became an excuse to explore Bun in more depth. Once Bun’s bundler gained the ability to compile Bun programs into cross-platform, single-file executables, I knew I wanted to try building something with that workflow.

That decision shaped the structure of the project. colors-on-the-curve lives in a single repository where the different parts of the application reference shared code directly, without becoming a full monorepo. It is not especially conventional, but it worked well for this project. Bun also has first-class workspace support, so if I wanted to push it further in that direction, the tooling is already there.

COTC builds to three different targets: an npm package, a TUI executable for multiple platforms, and a standalone HTML file. The src directory looks like this:

src/
├── lib/
├── cli/
└── web/

The lib directory contains the core functionality of the project and depends on only one external package: ntc-ts. Both web and cli use that shared library and build their own interfaces on top of it.

That means:

Building lib as an npm package was easily the simplest part. I targeted bun, used src/lib/index.ts as the entry point, built the package, then ran bun publish from the output directory. Honestly, logging into npm was harder than packaging the library.

The more interesting problems showed up when I started building the cli and web targets.

One issue came from using Bun.file() with direct file paths. At first I had some parts of the code loading assets that way, but apparently, those paths are resolved at runtime, which becomes a problem once you are compiling the program into a standalone artifact. In hindsight, that behavior makes perfect sense.

Fortunately, Bun’s documentation is quite good. The bundler docs explain that assets should be imported using import ... with { type: "file" } when you want them embedded properly. Once I followed that pattern everywhere it mattered, the issue was straightforward to fix. I still kept the normal file-based behavior in places where the program genuinely needed to read from or write to the user’s file system.

Another issue was platform-specific dependencies. Bun only installs what your current machine needs, which is usually exactly what you want. But if you are trying to compile binaries for multiple platforms, that becomes a problem. Some packages ship prebuilt binaries by architecture and operating system, and Bun will only install the variant for the machine you are currently on. That led to errors like this:

$ bun scripts/build-cli.ts
Building for target: bun-linux-x64
11771 | var module = await import(`@opentui/core-${process.platform}-${process.arch}/index.ts`);
                                  ^
error: Could not resolve: "@opentui/core-linux-x64/index.ts". Maybe you need to "bun install"?
    at xyz\colors-on-the-curve\node_modules\@opentui\core\index-e89anq5x.js:11771:27

Bun v1.3.11 (Windows x64)
error: script "build-cli" exited with code 1

The fix was simple once I found it: run bun install --os="*" --cpu="*". That forces Bun to install all platform variants instead of only the one for the current machine.

What took longer was figuring out that this was the solution in the first place. That part was not especially obvious from the documentation, so I had to piece it together from scattered information. Still, once I understood the problem, the build process became surprisingly clean. I could generate the binaries with one command and run them directly without requiring any extra installation step.

The most impressive part of the whole experience, though, was building the web version as a standalone HTML file.

Over the years, a lot of tools have promised me that I could build a web project and have it “just work.” In practice, that has often meant some caveat: maybe the generated HTML still depends on separate JavaScript files, maybe opening it locally triggers CORS issues, or maybe it only works if you serve it from a local web server. I have run into all of those cases before.

So I was genuinely impressed that bun build --compile --target=browser produced a single HTML file that I could open directly in my browser and use immediately. No server setup, no missing assets, no special handling. It simply worked. I could also upload that same file to a host and get the same result. At the time of writing, I am hosting it on Cloudflare Pages, and that workflow has been pleasantly uneventful.

That said, it was not completely frictionless.

One issue I ran into was with JSZip. Even though JSZip supports browser environments, Bun did not correctly pick the browser entry point during bundling. Instead, it tried to bundle the Node version, detected Node-specific APIs, and failed.

I ended up working around that by explicitly redirecting the import to JSZip’s browser build using a Bun Bundler plugin:

const jszipBrowser = join(
  import.meta.dir,
  "../node_modules/jszip/dist/jszip.min.js"
);

const build = await Bun.build({
  entrypoints: config.entryPoints.web,
  compile: true,
  target: "browser",
  outdir: config.outDir.web,
  minify: true,
  format: "esm",

  plugins: [
    {
      name: "browser-shims",
      setup(build) {
        // Redirect jszip to its pre-built browser bundle to avoid Node.js builtins
        build.onResolve({ filter: /^jszip$/ }, () => ({
          path: jszipBrowser,
        }));
      },
    },
  ],
});

That workaround was actually fairly painless to implement. There is also an open issue in the Bun repository about this behavior, and I left a comment there as well.

Once I fixed the asset import issue and the JSZip resolution problem, the web build worked reliably. The result was a single, very large HTML file that opened correctly in every browser I tested. To be fair, my testing was limited to Safari and Chromium, but that was enough to make the experience feel surprisingly robust.

Overall, Bun’s developer experience has been one of the most compelling parts of using it. Having a runtime, package manager, test runner, and bundler all bundled into one coherent tool makes JavaScript development feel much lighter. It removes a lot of the small frictions that usually accumulate in the tooling layer.

More than anything, Bun made this project fun to build. And that alone makes me want to keep building more with it.


Share this post on:

Next Post
Making Colors on the Curve