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.
[!Note] The original repository is hosted at Codeberg. The GitHub repo is just a mirror.
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') %>
<%= 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
DevCacheMiddlewareis 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 - Remove all dev dependencies from
package.json
- 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.
- 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:
bun_asset('images/logo.png'): returns the fingerprinted asset pathbun_js_tag('js/app.js'): generates a<script>tagbun_css_tag('css/app.css'): generates a<link>tagbun_img_tag('images/logo.png'): generates an<img>tagbun_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_bundleto 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.jsonfile 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.,
**/*.cssinstead 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 rootconfig: the resolvedbun.jsonconfiguration objectdev:truewhen running in development modeprod:truewhen running in production modemanifest: 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:precompilestep 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 rakewith no arguments runs all tests too.
Linting
bundle exec rubocop
Commit conventions
We use conventional commits.
Submitting changes
- 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