Skip to content

Add plugin system for bun#2020

Merged
jwoertink merged 26 commits intoluckyframework:mainfrom
wout:add-plugin-system-for-bun
Apr 2, 2026
Merged

Add plugin system for bun#2020
jwoertink merged 26 commits intoluckyframework:mainfrom
wout:add-plugin-system-for-bun

Conversation

@wout
Copy link
Copy Markdown
Contributor

@wout wout commented Mar 1, 2026

Purpose

This PR adds a plugin system for Bun, glob functionality for CSS and JS imports, and a few tweaks to Lucky (see also #2019).

Description

The plugin system hooks in to the JS and CSS transformation chains. Three plugins are included now and activated by default.

1. cssAliases

This plugin was already present but was hardcoded. It resolves $ root aliases in CSS url() references, so you can avoid the use of relative paths for images and fonts. For example: url('$/images/foo.png')url('/absolute/src/images/foo.png')

2. cssGlobs

This is a new plugin to allow glob patterns in CSS imports:

@import 'global/**/*';

This will be intercepted by the plugin and expanded to a list of imports for individual files in alphabetical order.

3. jsGlobs

In Bun there's no such thing like import.meta.glob in Vite. This plugin enables a similar thing with a special syntax for imports:

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

Which will be expanded to something like:

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
}

The components object can then be used to for example load and register a directory of Alpine.js components or Stimulus.js controllers.

Custom CSS/JS transform plugins

Plugins to hook into the CSS and JS transformation chains look as follows:

export default function luckyPlugin(context) {
  return (content, args) => content.replace(/\$.../g, 'something')
}

Or async:

export default function luckyPlugin(context) {
  return async (content, args) => {
    const result = await someAsyncWork(content)
    return result
  }
}

The context argument in the plugin factory function contains the following properties:

  • context.root (string) the Lucky project's root directory
  • context.config (object) the fully resolved config from bun.json
  • context.dev (boolean) whether the --dev flag is used or not
  • context.prod (boolean) whether the --prod flag is used or not
  • context.manifest (object) the asset manifest being built

Important

The CSS/JS transform plugins above are bundled together into one Bun plugin per type. This is necessary because Bun can only register one loader for a given path matcher.

Bun plugins

Raw Bun plugins can be added as well:

export default function bunPlugin(context) {
  return {
    name: 'my-bun-plugin',
    setup(build) {
      build.onLoad({filter: /\.xyz$/}, async args => {
        // full Bun plugin API
      })
    }
  }
}

Registering plugins

Custom plugins can be registered in the bun.json. By default, the three built-in plugins are loaded and custom plugins can be added using the full path relative to Lucky's root:

{
  "entryPoints": {
    "js": ["src/js/main.js"],
    "css": ["src/css/main.css"]
  },
  "plugins": {
    "css": ["cssAliases", "cssGlobs", "config/bun/plugins/luckyCssPlugin.js"],
    "js": ["jsGlobs"]
  },
  ...
}

Lucky::DevAssetCacheHandler

This is a new handler to disable browser caching for static assets in development. Currently I added it to the handlers listin AppServer like this:

  def middleware : Array(HTTP::Handler)
    [
      # ...
      Lucky::DevAssetCacheHandler.new(enabled: LuckyEnv.development?),
      # ...
    ] of HTTP::Handler
  end

But I wonder if that is the most elegant way to do it. Essentially this will just continue to the next handler if it's not enabled, so it's an empty call. This could also be an option of course:

    [
      # ...
      (Lucky::DevAssetCacheHandler.new if LuckyEnv.development?),
      # ...
    ].select(HTTP::Handler)

Not unrelated, in the upload implementation we'll need to do a similar thing with a Lucky::StaticFileHandler in development, so users can render images from a local upload directory if they choose to use file system uploads. This handler will also need to be added only in environments where a FileSystem storage is used. So I think it's a good idea to formalise a way to enable some handlers in certain environments.

Checklist

  • - An issue already exists detailing the issue/or feature request that this PR fixes
  • - All specs are formatted with crystal tool format spec src
  • - Inline documentation has been added and/or updated
  • - Lucky builds on docker with ./script/setup
  • - All builds and specs pass on docker with ./script/test

@wout
Copy link
Copy Markdown
Contributor Author

wout commented Mar 18, 2026

I've been using this in two projects now and so far no issues. It's a lot of JS though 😄:

image

But I think this setup will go a long way without any significant updates, unless Bun (or rather Antropic) decides to rework their API of course.

wout added 5 commits March 25, 2026 16:50
Remove comments that are just restating the method name and clean up the
code a bit.
The `cssAliases` plugin just resolved aliases inside a `url()`. The
plugin has now been renamed to `aliases` and can be used for for CSS and
JS imports as well.
Don't include the globbed directly's name itself.
In some cases where two globbed dirs contained a file with the same
name, ther could be a variable name clash resultig in a subtile bug.
@wout
Copy link
Copy Markdown
Contributor Author

wout commented Mar 28, 2026

Okay, I keep tweaking here but these are the last bits I think. 😊

I like this setup so much that I created a Ruby gem for it to use in our Rails apps: https://github.com/wout/bun_bun_bundle. While implementing it some unforeseen issues popped up.

One is a 15-year old Rails app and the codebase contains about 160 CSS, 500 JS files and 80 other assets (fonts and images) across different directories. So that was a good exercise to test this setup. The change (from Vite) was fairly easy. The whole build went from a few seconds in Vite to about 100 ms in Bun.

So now the documentation for this whole Bun setup is also as good as done. I only need to do the Lucky part now.

Vim saves files by copying the original file → file~, then writing the
new file. The fs.watch() command looses the inode and does not report a
change. In this fix both file and file~ are watched and trigger a
rebuild, and a debounce is added to prevent double builds for any given
file.
This also adds better error reporting in case there are build errors.
Copy link
Copy Markdown
Member

@jwoertink jwoertink left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to understand this a bit more, but I guess let's just get it in and moving 🥳

@jwoertink jwoertink merged commit 617cb2b into luckyframework:main Apr 2, 2026
7 of 9 checks passed
@wout
Copy link
Copy Markdown
Contributor Author

wout commented Apr 3, 2026

Great, let me know if something isn't entirely clear.

I'm going to keep the JS in this repo and in the bun_bun_bundle gem in sync manually for now. Except for a few paths in the default configs (src/ for Lucky and app/assets/ for Ruby) they are identical, including the plugins. But I don't think there will be many changes though.

The ruby gem is in production in a Rails app since this week, and our front-end dev is very happy with it. It cleaned up the codebase because CSS has a dedicated pipeline (no more CSS imports in JS) and the production build in development is also a huge bonus. And the day before we merged it in, Vite got updated to version 8, inexplicably braking some parts of Alpine JS. So that was right on time. 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants