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 bundling
BunBunBundle leverages Bun's native bundler which is orders of magnitude faster than traditional Node.js-based tools. Your assets are built in milliseconds, not seconds.
CSS hot-reloading
CSS changes are hot-reloaded in the browser without a full page refresh. Your state stays intact, your scroll position is preserved, and you see changes instantly.
Asset fingerprinting
Every asset is fingerprinted with a content-based hash in production, so browsers always fetch the right version.
No surprises in production
Development and production builds go through the exact same pipeline. The only differences are fingerprinting and minification being enabled in production, but nothing is holding you back from enabling them in development as well.
Extensible plugin system
BunBunBundle comes with built-in plugins for root aliases, CSS glob imports, and JS glob imports. Plugins are simple, plain JS files, so you can create your own JS/CSS transformers, and raw Bun plugins are supported as well.
Just one dependency: Bun
The bundler ships with the gem. Bun is the only external requirement, so other than that, there are no dev dependencies.
Installation
- Add the gem to your
Gemfile:
gem 'bun_bun_bundle'
Run
bundle installMake 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'orgem 'propshaft'from yourGemfile - Delete
config/initializers/assets.rbif 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') %>
</head>
<body>
<%= bun_img_tag('images/logo.png', alt: 'My App') %>
<%= bun_js_tag('js/app.js', defer: true) %>
<%= bun_reload_tag %>
</body>
</html>
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 yourGemfile - Delete
config/assets.js,package.json, andnode_modules/
- Set up the Hanami integration:
# config/app.rb
require 'bun_bun_bundle/hanami'
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.
- 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
- 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:
| Helper | Description |
|---|---|
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') %>
CLI
Build your assets using the bundled CLI:
# Development: builds, watches, and starts the live reload server
bun_bun_bundle dev
# Production: builds with fingerprinting and minification
bun_bun_bundle build
# Development with a production build (fingerprinting + minification)
bun_bun_bundle dev --prod
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",
"staticDirs": ["app/assets/images", "app/assets/fonts"],
"devServer": {
"host": "127.0.0.1",
"port": 3002,
"secure": false
},
"plugins": {
"css": ["aliases", "cssGlobs"],
"js": ["aliases", "jsGlobs"]
}
}
All values shown above are defaults. You only need to specify what you want to override.
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.
[!NOTE] A warning is logged if the pattern matches no files.
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'
This will generate individual imports and builds 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 = {
'components/theme': _glob_components_theme,
'components/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 with root
(project root path) and prod (boolean indicating production mode). What the
factory returns determines the plugin type.
Simple transform plugins
A simple transform plugin returns a function that receives the file content as
a string and an args object with the file's path. 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 => {
const stamp = prod ? '' : ` (dev build ${new Date().toISOString()})`
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"]
}
}
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)
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. I hope you enjoy it too!
Contributing
We use conventional commits.
- Fork it
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'feat: new feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Wout - creator and maintainer