Packages
bun_bundle
0.1.0
Bun-powered asset bundler for Elixir with fingerprinting, SRI, CSS hot-reload, and live reload. Works with any Elixir app.
Current section
Files
Jump to
Current section
Files
bun_bundle
README.md
README.md
# BunBundle
[](https://codeberg.org/w0u7/bun_bundle/actions?workflow=ci.yml)
[](https://codeberg.org/w0u7/bun_bundle/tags)
Bun-powered asset bundler for Elixir with fingerprinting, SRI, CSS hot-reload,
and live reload. Works with any Elixir app, Phoenix included.
BunBundle vendors a small JS bundler ported from the Ruby gem
[bun_bun_bundle](https://codeberg.org/w0u7/bun_bun_bundle) and runs it under
[Bun](https://bun.sh). Bun ships Lightning CSS built in, so glob imports,
nesting, autoprefixing, and minification work without a separate CSS toolchain.
No Node.js required.
## Why use BunBundle?
- **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.
- **Subresource Integrity.** Optional SRI digests for production deploys.
- **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.
In a Phoenix app, the default esbuild setup gets you JS bundling but leaves you
to wire up CSS separately (often via the standalone `tailwind` mix task).
BunBundle replaces both with a single tool: JS bundling, CSS processing
(nesting, autoprefixing, minification via built-in Lightning CSS),
fingerprinting, and live reload all in one watcher. No separate tailwind
process, no two-asset-pipeline juggling.
## Installation
Add `bun_bundle` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:bun_bundle, "~> 0.1.0", runtime: Mix.env() == :dev}
]
end
```
Then set the Bun version in `config/config.exs`:
```elixir
config :bun_bundle, version: "1.4.2"
```
And install the Bun binary:
```bash
mix bun.install
```
The binary is downloaded to `_build/bun-<target>` and is not required
to be on your `PATH`.
### Ignoring build artifacts
Add the bundled output and manifest to your `.gitignore`:
```gitignore
/priv/static/assets/
/priv/static/bun-manifest.json
```
Both are regenerated on every build, including the fingerprinted asset
names inside the manifest, so committing them only adds churn. Adjust
the paths if you override `outDir` or `manifestPath` in `config/bun.json`.
## Configuration
All bundler configuration lives in `config/bun.json` at your project root. Bun
reads it directly. BunBundle does not translate config.
Minimal example:
```json
{
"entryPoints": {
"js": ["assets/js/app.js"],
"css": ["assets/css/app.css"]
},
"outDir": "priv/static/assets",
"publicPath": "/assets",
"manifestPath": "priv/static/bun-manifest.json"
}
```
<details>
<summary>Full config example (all values shown are defaults)</summary>
```json
{
"entryPoints": {
"js": ["assets/js/app.js"],
"css": ["assets/css/app.css"]
},
"outDir": "priv/static/assets",
"publicPath": "/assets",
"manifestPath": "priv/static/bun-manifest.json",
"watchDirs": ["assets"],
"staticDirs": ["assets/images", "assets/fonts"],
"devServer": {
"host": "127.0.0.1",
"port": 3002,
"secure": false
},
"plugins": {
"css": ["aliases", "cssGlobs"],
"js": ["aliases", "jsGlobs"]
}
}
```
Creating a `bun.json` file is entirely optional. All values shown above are
defaults, you only need to specify what you want to override.
`watchDirs` entries may be glob patterns. For example, in a modular app with
multiple slices, `"slices/*/assets"` will watch every slice's assets directory
without having to list them explicitly.
If you're developing inside a Docker container, set `listenHost` so the
WebSocket server accepts connections from the host machine:
```json
{
"devServer": {
"listenHost": "0.0.0.0"
}
}
```
</details>
## 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:
```css
@import '$/assets/css/reset.css';
.logo {
background: url('$/assets/images/logo.png');
}
```
In JS:
```javascript
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:
```css
@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:
```css
@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:
```javascript
import components from 'glob:./components/**/*.js'
```
To exclude specific paths, add `not` clauses inside the string:
```javascript
import components from 'glob:./components/**/*.js not ./components/admin/**'
```
This will generate individual imports and build an object mapping. For example:
```javascript
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 `--prod` was passed (shortcut flag)
- `fingerprint`: `true` when asset filenames will be content-hashed
- `minify`: `true` when output will be minified (use this to strip
comments, banners, or dev-only branches in your plugin)
- `sourcemap`: the sourcemap kind being produced (or `null` for default)
- `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`](https://bun.sh/docs/bundler/plugins#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.
```javascript
// config/bun/banner.js
export default function banner({minify}) {
return (content, args) => {
const stamp = minify ? '' : ` (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](https://bun.sh/docs/bundler/plugins). This
gives you full access to Bun's plugin API, including `onLoad`, `onResolve`, and
custom loaders.
```javascript
// 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:
```json
{
"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](https://codeberg.org/fluck/bun_bun_bundle-plugins),
including design token generation and build notifications.
## Usage
### Mix tasks
```bash
mix bun # build once with current settings
mix bun --dev # dev build with inline sourcemaps
mix bun --prod # production build (fingerprint + minify)
mix bun.install # download the configured Bun release
```
All flags after `mix bun` are passed straight through to the bundler.
### Flags
- `--dev`: dev mode, watches files, starts the live reload server, and uses
inline sourcemaps.
- `--prod`: shortcut for `--fingerprint --minify`.
- `--fingerprint`: hash asset filenames for cache busting.
- `--minify`: minify JS and CSS output.
- `--sourcemap[=KIND]`: `inline`, `linked`, `external`, or `none`. Defaults to
`inline` in `--dev` and `linked` for builds, so production stack traces and
browser devtools stay debuggable. Pass `--sourcemap=none` when you explicitly
do not want maps shipped.
- `--sri[=ALGOS]`: compute [Subresource Integrity][sri] digests for each
asset. Pass a comma-separated list of `sha256`, `sha384`, or `sha512`
(bare `--sri` defaults to `sha384`). When digests are present, `js_tag/2`
and `css_tag/2` automatically render `integrity="..." crossorigin="anonymous"`
so browsers verify the response before executing it.
- `--debug`: verbose WebSocket logging.
[sri]: https://developer.mozilla.org/docs/Web/Security/Subresource_Integrity
### Phoenix
Wire the watcher and asset aliases:
```elixir
# config/dev.exs
config :my_app, MyAppWeb.Endpoint,
watchers: [
bun: {BunBundle, :install_and_run, [~w(--dev)]}
]
```
```elixir
# mix.exs
defp aliases do
[
"assets.setup": ["bun.install --if-missing"],
"assets.build": ["bun"],
"assets.deploy": ["bun --prod", "phx.digest"]
]
end
```
### Non-Phoenix apps
Call `BunBundle.install_and_run/1` from wherever your app boots its asset
pipeline. It installs Bun on first run then invokes the bundler with the given
args.
```elixir
BunBundle.install_and_run(~w(--dev))
```
## Rendering asset tags
BunBundle ships a manifest reader and helpers that resolve source paths to
their fingerprinted URLs. The manifest is cached in ETS and automatically
reloaded when `bun-manifest.json` changes.
### Plain functions
Framework-agnostic helpers live in `BunBundle.Helpers`. Tag functions return
`{:safe, iodata}` when `Phoenix.HTML` is loaded (the Phoenix convention, safe
to interpolate directly in `<%%>` blocks) and plain HTML strings in
environments without it. URL functions always return strings.
```elixir
import BunBundle.Helpers
asset("js/app.js")
# => "/assets/js/app-abc12345.js"
js_tag("js/app.js", defer: true)
# => <script src="/assets/js/app-abc12345.js" integrity="sha384-..." defer></script>
css_tag("css/app.css")
# => <link rel="stylesheet" href="/assets/css/app-def67890.css" integrity="sha384-...">
img_tag("images/logo.png")
# => <img src="/assets/images/logo-xyz.png" alt="Logo">
```
Underscored attribute names are hyphenated (`data_turbo_track:` becomes
`data-turbo-track`). When an `:asset_host` is configured, tags that carry SRI
hashes also get `crossorigin="anonymous"` so integrity checks pass on CDN
fetches.
Missing keys raise `BunBundle.MissingAssetError` with a fuzzy-match suggestion
when the typo is close enough.
### HEEx components for Phoenix
If `phoenix_live_view` is available, BunBundle also compiles a
`BunBundle.Component` module with HEEx-friendly components.
```heex
<BunBundle.Component.css href="css/app.css" />
<BunBundle.Component.js src="js/app.js" defer />
<BunBundle.Component.img src="images/logo.png" alt="Logo" width="128" />
```
Import the module in your `html_helpers` block to drop the prefix.
```elixir
defp html_helpers do
quote do
import BunBundle.Component
# ...
end
end
```
```heex
<.css href="css/app.css" />
<.js src="js/app.js" defer />
```
### Live reload
In development, render the reload tag in your layout to get CSS hot-reloading
and full page reloads on asset changes:
```heex
<%= BunBundle.ReloadTag.tag() %>
```
It connects to Bun's WebSocket dev server, swaps fresh stylesheets in place,
and reloads the page (preserving scroll position) for everything else. Outside
the `:dev` environment it renders an empty string, so the call is safe to leave
in a shared layout.
The environment defaults to `Mix.env()`. For releases or non-Phoenix
boots, set it explicitly:
```elixir
# config/dev.exs
config :bun_bundle, env: :dev
```
The WebSocket URL comes from the `devServer` key in `config/bun.json` (defaults
to `ws://127.0.0.1:3002`).
### Dev cache headers
For Plug-based apps, `BunBundle.Plug.DevCache` sets no-cache headers on asset
responses in development, so the browser always fetches fresh files after a
rebuild:
```elixir
# In your endpoint, or a dev-only pipeline.
plug BunBundle.Plug.DevCache
```
Like the reload tag it only acts in `:dev`; elsewhere it is a pass-through.
Un-fingerprinted CSS served in dev also gets a `?bust=<mtime>` query from
`css_tag/2`, keeping stylesheets fresh without touching fingerprinted or
production URLs.
### CDN prefix
Set an asset host to serve bundled assets from a CDN in production.
```elixir
config :bun_bundle, asset_host: "https://cdn.example.com"
```
The host is prepended to every URL returned by `asset/1` and the tag helpers.
## Migrating from esbuild
If your Phoenix app was generated with the default `esbuild` (and optionally
`tailwind`) setup, replace it with BunBundle in these steps.
1. Remove the deps in `mix.exs`. Drop `{:esbuild, ...}` and
`{:tailwind, ...}` if present.
2. Remove the config blocks in `config/config.exs`. Delete the
`config :esbuild, ...` and `config :tailwind, ...` blocks.
3. Swap the watcher in `config/dev.exs`. Replace the esbuild watcher
(and any tailwind watcher) with
```elixir
watchers: [
bun: {BunBundle, :install_and_run, [~w(--dev)]}
]
```
4. Swap the aliases in `mix.exs`.
```elixir
defp aliases do
[
"assets.setup": ["bun.install --if-missing"],
"assets.build": ["bun"],
"assets.deploy": ["bun --prod", "phx.digest"]
]
end
```
5. Create `config/bun.json` at the project root. Point `outDir` at
`priv/static/assets` to match Phoenix conventions and list your
entry points.
```json
{
"entryPoints": {
"js": ["assets/js/app.js"],
"css": ["assets/css/app.css"]
},
"outDir": "priv/static/assets",
"publicPath": "/assets",
"manifestPath": "priv/static/bun-manifest.json"
}
```
6. Delete `assets/tailwind.config.js` if you were on tailwind. Move any global
styles into your CSS entry point.
7. Drop the `NODE_PATH` env from the old watcher. Bun resolves natively.
8. Replace `~p"/assets/app.js"` and similar with `BunBundle.Component.js` /
`.css` (or the plain `js_tag` / `css_tag` helpers) so templates use
fingerprinted URLs from the manifest. See [Rendering asset
tags](#rendering-asset-tags) above.
You can now drop `phx.digest` from `assets.deploy` since Bun's own
fingerprinting is the source of truth for cache-busting.
### Resolving Phoenix JS deps
Phoenix apps import JS deps as bare specifiers like `import {LiveSocket} from
"phoenix_live_view"`. Those packages live in `deps/` and `_build/`, not
`node_modules/`. Bun resolves them via the `paths` mapping in the generated
`assets/tsconfig.json`:
```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"phoenix-colocated/*": ["../_build/dev/phoenix-colocated/*"],
"*": ["../deps/*"]
}
}
}
```
Keep this file. It also gives your editor autocomplete for Phoenix APIs.
Prefer `bun install`? Add an `assets/package.json` with `file:` entries
pointing at the same directories:
```json
{
"dependencies": {
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"phoenix-colocated": "file:../_build/dev/phoenix-colocated"
}
}
```
Then run `bun install`. This populates `assets/node_modules` with symlinks,
which Bun and your editor both resolve natively. In a plain JS project you can
delete `assets/tsconfig.json` entirely. If you use TypeScript, keep the
tsconfig but drop the `paths` block.
Either approach works. The tsconfig path is lighter (no lockfile, no `bun
install` step). The package.json path is more idiomatic Node.
## Global options
- `:version` (required). The expected Bun version.
- `:version_check` (default `true`). Warn on version drift at boot.
- `:path` (default `nil`). Override the path to the Bun binary.
- `:env` (default `Mix.env()` when available, else `:prod`). Gates live reload,
CSS cache busting, and the dev cache plug.
- `:asset_host` (default `""`). CDN prefix prepended to asset URLs.
- `:root` (default current working directory). Project root used to
locate `config/bun.json`, the manifest, and the output directory.
## Deploying with Docker
Install Bun, your JS dependencies, then run the build step:
```dockerfile
RUN mix bun.install
ENV PATH="/root/.bun/bin:${PATH}"
COPY assets/package.json assets/bun.lock ./assets/
RUN cd assets && bun install --frozen-lockfile
COPY . .
RUN mix bun --prod
```
If you only use the tsconfig path mapping (no `package.json`), skip the `bun
install` step entirely. Bun resolves Phoenix deps via `tsconfig.json` without a
lockfile.
## Prior art
- [Lucky Framework](https://luckyframework.org). This setup was originally
created for Lucky to replace the old Laravel Mix implementation.
- [bun_bun_bundle](https://codeberg.org/w0u7/bun_bun_bundle). Ruby gem. A port
of the Lucky implementation and the reference and source of the vendored JS
bundler in this repo.
This setup has been used in production in a mission-critical app since
March 2026. Our deployment process sped up significantly, and we haven't had
any issues since.
## Contributing
### Setup
```bash
git clone https://codeberg.org/w0u7/bun_bundle.git
cd bun_bundle
mix deps.get
```
### Running tests
```bash
mix test # Elixir tests (auto-installs Bun if missing)
mix bun.test # JS plugin tests (run via Bun)
```
### Linting
```bash
mix credo # static analysis
mix format --check-formatted # format check
```
### Commit conventions
We use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/).
## License
MIT