Call Rust from TypeScript with Bun
The Announcement
Hi there! đź‘‹ A few days ago I stumbled across a very exciting announcement in the Bun v1.0 presentation.
In the section about how eslint plugins integrate natively into bun, there was a small example showcasing a rust file directly being imported into a TypeScript file.
import { add } from "./math.rs";
A “bun enthusiast” (who after a little digging through the bun discord server turned out to be tr1ckydev) used Buns foreign function interface (FFI1) functionalities to achieve this. After more searching I found out that tr1ckydev is working on a package called hyperimport which at the time of writing is still an empty repository.
I was very excited about this and wanted to try it out myself. So I created a small example project to see how it works.
Warning: This is a very early stage of the bun ecosystem and the FFI is still in development. I’m strongly discouraging using an implementation in this barebone state anywhere. There are still a lot of caveats and rough edges, at the end this is just a showcase implementation.
If you’re interested in digging deeper, I’ll append the links to the specifications I’m referring to at the end of the page.
The Experiment
Turns out, it’s not even that hard to get a working example. I’m not talking about a full-blown production-ready setup, but getting a small working example is pretty straightforward.
I’m also linking the repository here, so you can follow along if you want to.
Before we get startet we need Bun and rust installed
The central code is in the src/main.ts
file. It’s also the file with the most, let’s say, “magic” going on.
As in the example simply adding two numbers (or isize
in rust terms) is not fast enough, so instead, we do it blazingly fast in rust.
// main.ts
import { add } from "./add.rs";
console.log(add(1, 2));
When was the last time a simplified “Hello world” example was this exciting?
The add.rs
file contains only a plain function that adds two numbers and returns the result . We expose it via the C Application Binary Interface (ABI) to make it callable from the Bun FFI1 implementation.
// add.rs
#[no_mangle]
pub extern "C" fn add(a: isize, b: isize) -> isize {
a + b
}
With this set up, we’ll now implement a loader for the rust file using the Bun loader plugin API2. There are already a few loaders available, next to the standard ones like js
, ts
or tsx
also wasm
imports just to name a few. To also support the rs
extension, we’ll need to add a custom loader first.
To do this, bun exposes a type alias called BunPlugin
which we can use to create our loader. The setup
function receives an object holding an onLoad
-Method which filters and transforms the files we want to load. After creating it, we can register it using the plugin
function.
// rust.ts
import { plugin, type BunPlugin } from "bun";
const myPlugin: BunPlugin = {
name: "RustPlugin",
setup(build) {
build.onLoad(
{
filter: /\.rs$/, // filter for imported .rs files
},
(args) => {
// transforms
return {
exports: {}, // here the ABI will be injected
loader: "object",
};
},
);
},
};
plugin(myPlugin);
With this setup, we can now focus on the loaders implementation. The loader has two responsibilities:
- Compile the rust file to a shared library
- Link the shared library to the bun runtime
To compile the rust file into a shared library the bun docs recommend using the cdylib
target3:
rustc --crate-type cdylib add.rs
To translate this into Buns typescript syntax, we invoke Bun.spawnSync
supplying the rust-files path as the last argument.
// rust.ts
// ...
Bun.spawnSync(["rustc", "--crate-type=cdylib", args.path], {
env: process.env,
});
// ...
With this not so safe implementation the file should get compiled into a libadd.dylib
file (on mac). Now, we need to link this file to the bun runtime. Bun also has a trick for that up its sleeve.
We’ll use the dlopen
function from the bun:ffi
module to load the shared library into the runtime4.
// rust.ts
import { dlopen, FFIType, suffix } from "bun:ffi";
// ...
const path = `lib${filename}.${suffix}`;
const lib = dlopen(path, {
add: {
args: [FFIType.i32, FFIType.i32],
returns: FFIType.i32,
},
});
return {
exports: lib.symbols,
loader: "object",
};
// ...
The last step is telling bun to load the plugin before executing any code. Bun uses a bunfig.toml
file to configure the runtime. We’ll add the plugin file to the preload
array5.
# bunfig.toml
preload = ["./rust.ts"]
Now, if we run bun run main.ts
we should see the result of the add
function in the console.
And here is the whole implementation for the loader plugin:
// rust.ts
import { plugin, type BunPlugin } from "bun";
import { dlopen, FFIType, suffix } from "bun:ffi";
const myPlugin: BunPlugin = {
name: "RustPlugin",
setup(build) {
build.onLoad(
{
filter: /\.rs$/,
},
(args) => {
Bun.spawnSync(["rustc", "--crate-type=cdylib", args.path], {
env: process.env,
});
// get rust file name
const filename = args.path.split("/").pop()!.split(".")[0];
const path = `lib${filename}.${suffix}`;
const lib = dlopen(path, {
add: {
args: [FFIType.i32, FFIType.i32],
returns: FFIType.i32,
},
});
return {
exports: lib.symbols,
loader: "object",
};
},
);
},
};
plugin(myPlugin);
Conclusion
Unfortunately the dlopen
function needs to know the shape of the function you want to import. So for now, this solution will only work for rust files with add
functions. There might be a way to get around this, but I haven’t found it yet.
I hope this was an interesting read and you learned something new. I’m very excited about the bun ecosystem and can’t wait to see what the future holds. Hopefully this will be a good starting point for you to experiment with bun and the FFI.