No Node
The Asset Pipeline has had many changes over the years, from not needing NodeJS when using Sprockets, to supporting NodeJS to manage JS dependencies through npm packages, to requiring NodeJS by default with Webpacker, and to not needing NodeJS by default again with ImportMaps.
ImportMaps is a good way to not have NodeJS as a dependency of the application, but it has many limitations (like the lack of TypeScript support) and it requires a lot of work to migrate to it when upgrading older applications.
In this blog post, we will see how to use Bun to remove the need to install NodeJS system-wide, how to use the standalone binary to not require an installation step, and at the same time keep using npm packages as needed to make the transition easier.
What is Bun
Bun is a toolkit of NodeJS-compatible functionalities that allows you to install npm packages, build assets, and more.
What about esbuild and others?
We can also use esbuild’s standalone binary for a similar process, but it has the limitation that it is only a bundler and cannot handle the process of downloading npm packages and resolving dependencies, so it will still require either NodeJS or Bun or something else to provide those files (we could have the npm packages copied inside the repo for example as a last resort).
There are other tools like Vite , but those depend on NodeJS (or any other JavaScript runtime) to be available in the system.
System-wide or Standalone binary?
There are different ways of installing Bun , but our objective in this blog post is to remove the need of having a JavaScript runtime installed system-wide, so we’ll go with the standalone binary.
This will make it easier to set up the application, and also remove possible conflicting versions when working with multiple projects, so we don’t need to also depend on a NodeJS version manager like nvm , asdf or mise to pick which version of NodeJS to use in each project.
Downloading the Standalone binary
For every Bun release , we can see the list of binaries for the different operating systems and CPU architectures under the Assets section.
We can download the file we need for our machine and put it in the root of the project. Now we can run the bun command to install and bundle JS code.
Update scripts
Applications can have many different setups, but it’s common (and a default for new applications) to include a Procfile.dev file that will run multiple processes for the application using foreman .
By default, Rails adds a js: ... line in this file when using things like webpack, bun, or esbuild.
We’ll have to update this file and use js: ./bun run build --watch as the bundler of our assets. Note that we are using ./bun to reference the standalone binary at the root of the project (this can change if you put the binary somewhere else). We need to reference the relative binary in case bun is also installed system-wide.
Docker
When working with Docker, it’s really common to pick a Ruby image for our application and either add extra steps in the Dockerfile to install NodeJS or to pick a non-official Ruby image that would include both Ruby and also NodeJS already built in.
When we use this approach with Bun’s standalone binary, now we can use the official Ruby image, copy our code inside the container, and we have a fully functional application with assets compilation.
We created a sample application to show how this works. You can clone the repo and run docker compose run web bash, then run node to verify it doesn’t exist, and then run docker compose up to start the application.
Now you can open localhost:3000 in your browser and see the greeting message that is added by a function in a TypeScript file, imported in application.js and all bundled by Bun. You can also see the import statements to use @hotwired/turbo-rails and the Stimulus controllers are also compiled.
Migrating to Bun
Our recommendation is to start by following the steps to add jsbundling-rails (if not there already) and then run rails javascript:install:bun. Then add the standalone binary and change the configurations and conventions to match what Bun expects.
Remember to run
./bun installbefore deleting your lockfile, so it gets migrated by Bun and you don’t end up with different versions of your packages.
From Webpack/Webpacker
If we are using an application that still uses the Webpacker gem, we need to make some changes.
Webpack can do a lot of things, but we’ll just cover the very basics here from a standard Rails setup. If you use plugins and a complex Webpack configuration, you’ll need to check how to move those to something that is compatible with Bun.
Packs
When working with Webpack, the convention was to create a packs folder under app/javascript so the Webpacker gem would know which files to generate. With Bun, we have to define the entrypoints in the bun.config.js file instead.
include tags
The Webpacker gem adds the include_pack_tag helper method that checks Webpacker’s settings to generate the script tags in the final HTML response. We have to change these to the older javascript_include_tag from Sprockets, since bun will compile the files that can then be handled by Sprockets (or Propshaft) to serve them.
From esbuild
If this is your case, chances are you are already using jsbundling-rails. The official docs of Bun include a page explaining the differences between the 2 projects and how to migrate if we are using more complex esbuild setups.
The assets:precompile task
One caveat we have to consider is that the jsbundling-rails is not prepared to deal with bun as a standalone binary and it will try to run the bun command at the system level .
To avoid this, we can patch the Jsbundling::Tasks class to define the relative standalone binary:
# in an initializer for example
module Jsbundling
module Tasks
def install_command
"./bun install"
end
def build_command
"./bun bun.config.js"
end
end
end
Finally, if the workflows use a JavaScript compressor that depends on the execjs gem after the recompilation process, we can add a custom ExternalRuntime configuration:
ExecJS::Runtimes::StandaloneBun = ExecJS::ExternalRuntime.new(
name: "Bun.sh",
command: ["./bun"],
runner_path: ExecJS.root + "/support/bun_runner.js",
encoding: "UTF-8"
)
And then we can define the EXECJS_RUNTIME=StandaloneBun environment variable to tell ExecJS to use it.
Conclusion
After all this, we can see how the setup of the application was simplified. We can even commit the bun binary into the repository so it’s there already and can be used in any process like CI, a staging environment, or compiling assets during a production deploy (though you may get a warning about the file being too big for git).
We also don’t need to worry about a NodeJS version manager, since each project can include a standalone binary that won’t interfere with the one of another project.
Do you need help addressing tech debt and improving the dev experience? We can help!