Bun, Bun, Bundle

A self-contained asset bundler for Ruby powered by Bun. No development dependencies, no complex configuration. Lightning fast builds with CSS hot-reloading, fingerprinting, live reload, and a flexible plugin system. Works with Rails, Hanami, or any Rack app.

Why use BunBunBundle?

  • Lightning fast. Bun's native bundler builds assets in milliseconds.
  • CSS hot-reloading. Instant changes without a full page refresh.
  • Asset fingerprinting. Fast, content-based file hashing.
  • No surprises in production. Dev and prod go through the same pipeline.
  • Extensible. Plugins are simple, tiny JavaScript files.
  • One dependency: Bun. Everything is included, no other dev dependencies.

CI Gem Version

[!Note] The original repository is hosted at Codeberg. The GitHub repo is just a mirror.

Installation

  1. Add the gem to your Gemfile:
   gem 'bun_bun_bundle'
  1. Run bundle install

  2. Make sure Bun is installed:

   curl -fsSL https://bun.sh/install | bash

Usage with Rails

BunBunBundle completely bypasses the Rails asset pipeline. If you're adding it to an existing app, you can remove Sprockets/Propshaft:

  • Remove gem 'sprockets-rails' or gem 'propshaft' from your Gemfile
  • Delete config/initializers/assets.rb if present

For new apps, generate them without the asset pipeline:

rails new myapp --minimal --skip-asset-pipeline --skip-javascript

The gem auto-configures itself through a Railtie. All helpers are available in your views immediately:

<!DOCTYPE html>
<html>
<head>
  <%= bun_css_tag('css/app.css') %>
  <%= bun_js_tag('js/app.js', defer: true) %>
  <%= bun_reload_tag %>
</head>
<body>
  <%= bun_img_tag('images/logo.png', alt: 'My App') %>
</body>
</html>

[!NOTE] The DevCacheMiddleware is automatically inserted in development to prevent stale asset caching.

Usage with Hanami

Hanami ships with its own esbuild-based asset pipeline. Since BunBunBundle replaces it entirely, you can clean up the default setup:

  • Remove gem 'hanami-assets' from your Gemfile
  • Delete config/assets.js
  • Remove all dev dependencies from package.json
  1. Set up the Hanami integration:
   # config/app.rb

   require 'bun_bun_bundle'

   module MyApp
     class App < Hanami::App
       BunBunBundle.setup(root: root, hanami: config)
     end
   end

This loads the manifest, and in development automatically registers the cache-busting middleware and configures the CSP to allow the live reload script and WebSocket connection.

  1. Include the helpers in your views:
   # app/views/helpers.rb

   module MyApp
     module Views
       module Helpers
         include BunBunBundle::Helpers
         include BunBunBundle::ReloadTag
       end
     end
   end
  1. Use them in your templates:
   <%= bun_css_tag('css/app.css') %>
   <%= bun_js_tag('js/app.js') %>
   <%= bun_reload_tag %>

Usage with any Rack app

require 'bun_bun_bundle'

BunBunBundle.setup(root: __dir__)

# Optionally set a CDN host
BunBunBundle.asset_host = 'https://cdn.example.com'

Helpers

All helpers are prefixed with bun_ to avoid conflicts with existing framework helpers:

  • bun_asset('images/logo.png'): returns the fingerprinted asset path
  • bun_js_tag('js/app.js'): generates a <script> tag
  • bun_css_tag('css/app.css'): generates a <link> tag
  • bun_img_tag('images/logo.png'): generates an <img> tag
  • bun_reload_tag: live reload script (only renders in development)

All tag helpers accept additional HTML attributes:

<%= bun_js_tag('js/app.js', defer: true, async: true) %>
<%= bun_css_tag('css/app.css', media: 'print') %>
<%= bun_img_tag('images/logo.png', alt: 'My App', class: 'logo') %>

Data and aria attributes can be passed as nested hashes (Rails-style) or with underscores (Lucky-style). Underscores are converted to hyphens automatically:

<%= bun_js_tag('js/app.js', data: { turbo_track: "reload" }) %>
<%= bun_js_tag('js/app.js', data_turbo_track: "reload") %>
<%# Both render: data-turbo-track="reload" %>

CLI

Build your assets using the bundled CLI (bbb is available as a shorter alias):

# Development: builds, watches, and starts the live reload server
bun_bun_bundle dev

# Build assets
bun_bun_bundle build

# Build with fingerprinting and minification
bun_bun_bundle build --prod

# Development with verbose WebSocket logging
bun_bun_bundle dev --debug

[!NOTE] When running from a Procfile (e.g. with Overmind or Foreman), use bundle exec bun_bun_bundle to ensure the correct gem version is loaded.

Configuration

Place a config/bun.json in your project root:

{
  "entryPoints": {
    "js": ["app/assets/js/app.js"],
    "css": ["app/assets/css/app.css"]
  },
  "outDir": "public/assets",
  "publicPath": "/assets",
  "manifestPath": "public/bun-manifest.json",
  "watchDirs": ["app/assets"],
  "staticDirs": ["app/assets/images", "app/assets/fonts"],
  "devServer": {
    "host": "127.0.0.1",
    "port": 3002,
    "secure": false
  },
  "plugins": {
    "css": ["aliases", "cssGlobs"],
    "js": ["aliases", "jsGlobs"]
  }
}

[!TIP] Creating a bun.json file is entirely optional. All values shown above are defaults, you only need to specify what you want to override.

If you're developing inside a Docker container, set listenHost so the WebSocket server accepts connections from the host machine:

{
  "devServer": {
    "listenHost": "0.0.0.0"
  }
}

Plugins

Three plugins are included out of the box.

aliases

Resolves $/ root aliases to absolute paths in both CSS and JS files. This lets you reference assets and modules from the project root without worrying about relative paths.

In CSS:

@import '$/app/assets/css/reset.css';

.logo {
  background: url('$/app/assets/images/logo.png');
}

In JS:

import utils from '$/lib/utils.js'

All $/ references are resolved to your project root.

cssGlobs

Expands glob patterns in CSS @import statements. Instead of manually listing every file, you can import an entire directory at once:

@import './components/**/*.css';

This will be expanded into individual @import lines for each matching file, sorted alphabetically. A warning is logged if the pattern matches no files.

To exclude specific paths, add one or more not clauses:

@import './components/**/*.css' not './components/admin/**' not
  './components/internal/**';

[!WARNING] Always include the file extension in glob patterns (e.g., **/*.css instead of **/*). Without it, editor temp files like Vim's ~ backups will be picked up by the glob, causing build failures during development.

jsGlobs

Compiles glob imports into an object that maps file paths to their default exports. Use the special glob: prefix in an import statement:

import components from 'glob:./components/**/*.js'

To exclude specific paths, add not clauses inside the string:

import components from 'glob:./components/**/*.js not ./components/admin/**'

This will generate individual imports and build an object mapping. For example:

import _glob_components_theme from './components/theme.js'
import _glob_components_shared_tooltip from './components/shared/tooltip.js'
const components = {
  'theme': _glob_components_theme,
  'shared/tooltip': _glob_components_shared_tooltip
}

[!NOTE] If no files match the pattern, an empty object is assigned.

Custom plugins

Custom plugins are JS files referenced by their path in the config. Each file must export a factory function that receives a context object. What the factory returns determines the plugin type.

The context object has the following properties:

  • root: absolute path to the project root
  • config: the resolved bun.json configuration object
  • dev: true when running in development mode
  • prod: true when running in production mode
  • manifest: the current asset manifest object

Simple transform plugins

A simple transform plugin returns a function that receives the file content as a string and an args object from Bun's onLoad hook (containing path, loader, etc.). It should return the transformed content. The transform can be synchronous or asynchronous.

Transforms are chained in the order they appear in the config, so each transform receives the output of the previous one.

// config/bun/banner.js

export default function banner({prod}) {
  return (content, args) => {
    const stamp = prod ? '' : ` (dev ${args.path})`
    return `/* My App${stamp} */\n${content}`
  }
}

Raw Bun plugins

If the factory returns an object with a setup method instead of a function, it is treated as a raw Bun plugin. This gives you full access to Bun's plugin API, including onLoad, onResolve, and custom loaders.

// config/bun/svg.js

export default function svg() {
  return {
    name: 'svg-loader',
    setup(build) {
      build.onLoad({filter: /\.svg$/}, async args => {
        const text = await Bun.file(args.path).text()
        return {
          contents: `export default ${JSON.stringify(text)}`,
          loader: 'js'
        }
      })
    }
  }
}

Registering custom plugins

Reference custom plugins by their file path in your config:

{
  "plugins": {
    "css": ["aliases", "cssGlobs", "config/bun/banner.js"],
    "js": ["aliases", "jsGlobs", "config/bun/svg.js"]
  }
}

[!WARNING] The order of the plugins matters here. For example, the aliases plugin needs to resolve the paths first before the glob plugin can do its work. Keep that in mind for your own plugins too.

Community plugins

A collection of ready-made plugins is available at bun_bun_bundle-plugins, including design token generation and build notifications.

Project structure

your-app/
├── app/
│   └── assets/
│       ├── css/
│       │   └── app.css       # CSS entry point
│       ├── js/
│       │   └── app.js        # JS entry point
│       ├── images/           # Static images (copied + fingerprinted)
│       └── fonts/            # Static fonts (copied + fingerprinted)
├── config/
│   └── bun.json              # Optional bundler configuration
└── public/
    ├── assets/               # Built assets (generated)
    └── bun-manifest.json     # Asset manifest (generated)

Deploying with Docker

Install Bun, your JS dependencies, then run the build step:

RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
RUN bundle exec bun_bun_bundle build --prod

[!NOTE] If you're using BunBunBundle in Rails as your only asset pipeline, you can skip the rails assets:precompile step entirely.

Origins

BunBunBundle was originally built for Fluck, a self-hostable website builder using Lucky Framework. I wanted to have a fast and modern asset bundler that would require minimal maintenance in the long term.

Bun was the natural choice because it does almost everything:

  • JS bundling, tree-shaking, and minification
  • CSS processing and minification (through the built-in LightningCSS library)
  • WebSocket server for hot and live reloading
  • Content hashing for asset fingerprints
  • Extendability with simple plugins

It's also fast and reliable. We use this setup heavily in two Lucky apps and it is rock solid. It has since been adopted by Lucky as the default builder.

This Gem was born because I wanted to have the same setup in my Ruby apps as well. We now use it for an important Rails app in production.

I hope you enjoy it too!

Contributing

Setup

git clone https://codeberg.org/w0u7/bun_bun_bundle.git
cd bun_bun_bundle
bundle install

Running tests

bundle exec rake test       # run all tests
bundle exec rake test:ruby  # Ruby tests only (Minitest, in spec/)
bundle exec rake test:bun   # JS tests only (Bun, plugin tests)

[!NOTE] bundle exec rake with no arguments runs all tests too.

Linting

bundle exec rubocop

Commit conventions

We use conventional commits.

Submitting changes

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'feat: new feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

  • Wout - creator and maintainer

License

MIT