Let's say you have a JavaScript application that runs in bun and you've identified some bottleneck that you'd like to optimize.
Rewriting it in a more performant language may just be the solution you need.
As a modern JS runtime, Bun supports Foreign Function Interface (FFI) to call libraries written in other languages that support exposing C ABIs, like C, C , Rust and Zig.
In this post, we'll go over how one may use it, and conclude whether one can benefit from it.
This example is using Rust. Creating a shared library with C bindings looks differently in other languages but the idea remains the same.
Bun exposes its FFI API through bun:ffi module.
The entrypoint is a dlopen function. It takes a path that's either absolute or relative to the current working directory to the library file (the build output with a .so extension for Linux, .dylib for macOS or .dll for Windows) and an object with the signatures of functions you want to import.
It returns an object with a close method which you may use to close the library once it's not needed anymore and symbols property which is an object containing the functions you chose.
import { dlopen, FFIType, read, suffix, toArrayBuffer, type Pointer, } from "bun:ffi"; // Both your script and your library don't typically change their locations // Use `import.meta.dirname` to make your script independent from the cwd const DLL_PATH = import.meta.dirname + `/../../rust-lib/target/release/library.${suffix}`; function main() { // Deconstruct object to get functions // but collect `close` method into object // to avoid using `this` in a wrong scope const { symbols: { do_work }, ...dll } = dlopen(DLL_PATH, { do_work: { args: [FFIType.ptr, FFIType.ptr, "usize", "usize"], returns: FFIType.void, }, }); /* ... */ // It is unclear whether it is required or recommended to call `close` // an example says `JSCallback` instances specifically need to be closed // Note that using `symbols` after calling `close` is undefined behaviour dll.close(); } main();
As you may notice, the supported types that bun accepts through FFI are limited to numbers, including pointers.
Notably size_t or usize is missing from the list of supported types, even though the code for it exists as of bun version 1.1.34.
Bun doesn't offer any help in passing data more complex than a C string. That means you'll have to work with pointers yourself.
Let's see how to pass a pointer from JavaScript to Rust ...
{ reconstruct_slice: { args: [FFIType.ptr, "usize"], returns: FFIType.void, }, } const array = new BigInt64Array([0, 1, 3]); // Bun automatically converts `TypedArray`s into pointers reconstruct_slice(array, array.length);
/// Reconstruct a `slice` that was initialized in JavaScript unsafe fn reconstruct_slice( array_ptr: *const i64, length: libc::size_t, ) -> &[i64] { // Even though here it's not null, it's good practice to check assert!(!array_ptr.is_null()); // Unaligned pointer can lead to undefined behaviour assert!(array_ptr.is_aligned()); // Check that the array doesn't "wrap around" the address space assert!(length < usize::MAX / 4); let _: &[i64] = unsafe { slice::from_raw_parts(array_ptr, length) }; }
... and how to return a pointer from Rust to JavaScript.
{ allocate_buffer: { args: [], returns: FFIType.ptr, }, as_pointer: { args: ["usize"], returns: FFIType.ptr, }, } // Hardcoding this value for 64-bit systems const BYTES_IN_PTR = 8; const box: Pointer = allocate_buffer()!; const ptr: number = read.ptr(box); // Reading the value next to `ptr` const length: number = read.ptr(box, BYTES_IN_PTR); // Hardcoding `byteOffset` to be 0 because Rust guarantees that // Buffer holds `i32` values which take 4 bytes // Note how we need to call a no-op function `as_pointer` because // `toArrayBuffer` takes a `Pointer` but `read.ptr` returns a `number` const _buffer = toArrayBuffer(as_pointer(ptr)!, 0, length * 4);
#[no_mangle] pub extern "C" fn allocate_buffer() -> Box<[usize; 2]> { let buffer: Vec<i32> = vec![0; 10]; let memory: ManuallyDrop<Vec<i32>> = ManuallyDrop::new(buffer); let ptr: *const i32 = memory.as_ptr(); let length: usize = memory.len(); // Unlike a `Vec`, `Box` is FFI compatible and will not drop // its data when crossing the FFI // Additionally, a `Box<T>` where `T` is `Sized` will be a thin pointer Box::new([ptr as usize, length]) } #[no_mangle] pub const extern "C" fn as_pointer(ptr: usize) -> usize { ptr }
Rust doesn't know JS is taking ownership of the data on the other side, so you have to explicitly tell it to not deallocate the data on the heap using ManuallyDrop. Other languages that manage memory will have to do something similar.
As we can see, it's possible to allocate memory in both JS and Rust, and neither can safely manage others memory.
Let's choose where you should allocate your memory and how.
There are 3 methods of delegating memory cleanup to Rust from JS and all have their pros and cons.
Use FinalizationRegistry to request a cleanup callback during garbage collection by tracking the object in JavaScript.
import { dlopen, FFIType, read, suffix, toArrayBuffer, type Pointer, } from "bun:ffi"; // Both your script and your library don't typically change their locations // Use `import.meta.dirname` to make your script independent from the cwd const DLL_PATH = import.meta.dirname + `/../../rust-lib/target/release/library.${suffix}`; function main() { // Deconstruct object to get functions // but collect `close` method into object // to avoid using `this` in a wrong scope const { symbols: { do_work }, ...dll } = dlopen(DLL_PATH, { do_work: { args: [FFIType.ptr, FFIType.ptr, "usize", "usize"], returns: FFIType.void, }, }); /* ... */ // It is unclear whether it is required or recommended to call `close` // an example says `JSCallback` instances specifically need to be closed // Note that using `symbols` after calling `close` is undefined behaviour dll.close(); } main();
{ reconstruct_slice: { args: [FFIType.ptr, "usize"], returns: FFIType.void, }, } const array = new BigInt64Array([0, 1, 3]); // Bun automatically converts `TypedArray`s into pointers reconstruct_slice(array, array.length);
Delegate garbage collection tracking to bun to call a cleanup callback.
When passing 4 parameters to toArrayBuffer, the 4th one must be a C function to be called on cleanup.
However, when passing 5 parameters, the 5th parameter is the function and the 4th parameter must be a context pointer that gets passed it.
/// Reconstruct a `slice` that was initialized in JavaScript unsafe fn reconstruct_slice( array_ptr: *const i64, length: libc::size_t, ) -> &[i64] { // Even though here it's not null, it's good practice to check assert!(!array_ptr.is_null()); // Unaligned pointer can lead to undefined behaviour assert!(array_ptr.is_aligned()); // Check that the array doesn't "wrap around" the address space assert!(length < usize::MAX / 4); let _: &[i64] = unsafe { slice::from_raw_parts(array_ptr, length) }; }
{ allocate_buffer: { args: [], returns: FFIType.ptr, }, as_pointer: { args: ["usize"], returns: FFIType.ptr, }, } // Hardcoding this value for 64-bit systems const BYTES_IN_PTR = 8; const box: Pointer = allocate_buffer()!; const ptr: number = read.ptr(box); // Reading the value next to `ptr` const length: number = read.ptr(box, BYTES_IN_PTR); // Hardcoding `byteOffset` to be 0 because Rust guarantees that // Buffer holds `i32` values which take 4 bytes // Note how we need to call a no-op function `as_pointer` because // `toArrayBuffer` takes a `Pointer` but `read.ptr` returns a `number` const _buffer = toArrayBuffer(as_pointer(ptr)!, 0, length * 4);
Just drop the memory yourself after you don't need it anymore.
Luckily TypeScript has a very helpful Disposable interface for this and the using keyword.
It's an equivalent to Python's with or C#'s using keywords.
See the docs for it
#[no_mangle] pub extern "C" fn allocate_buffer() -> Box<[usize; 2]> { let buffer: Vec<i32> = vec![0; 10]; let memory: ManuallyDrop<Vec<i32>> = ManuallyDrop::new(buffer); let ptr: *const i32 = memory.as_ptr(); let length: usize = memory.len(); // Unlike a `Vec`, `Box` is FFI compatible and will not drop // its data when crossing the FFI // Additionally, a `Box<T>` where `T` is `Sized` will be a thin pointer Box::new([ptr as usize, length]) } #[no_mangle] pub const extern "C" fn as_pointer(ptr: usize) -> usize { ptr }
{ drop_buffer: { args: [FFIType.ptr], returns: FFIType.void, }, } const registry = new FinalizationRegistry((box: Pointer): void => { drop_buffer(box); }); registry.register(buffer, box);
This is much simpler and safer as deallocating is handled for you.
However, there is a significant drawback.
Since you can't manage JavaScript's memory in Rust, you can't go over the buffer's capacity as that will cause a deallocation. That means you have to know buffer size before passing it to Rust.
Not knowing how many buffers you need beforehand will also incur a lot of overhead as you'll be going back and forth through FFI just to allocate.
/// # Safety /// /// This call assumes neither the box nor the buffer have been mutated in JS #[no_mangle] pub unsafe extern "C" fn drop_buffer(raw: *mut [usize; 2]) { let box_: Box<[usize; 2]> = unsafe { Box::from_raw(raw) }; let ptr: *mut i32 = box_[0] as *mut i32; let length: usize = box_[1]; let buffer: Vec<i32> = unsafe { Vec::from_raw_parts(ptr, length, length) }; drop(buffer); }
{ box_value: { args: ["usize"], returns: FFIType.ptr, }, drop_box: { args: [FFIType.ptr], returns: FFIType.void, }, drop_buffer: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void, }, } // Bun expects the context to specifically be a pointer const finalizationCtx: Pointer = box_value(length)!; // Note that despite the presence of these extra parameters in the docs, // they're absent from `@types/bun` //@ts-expect-error see above const buffer = toArrayBuffer( as_pointer(ptr)!, 0, length * 4, //@ts-expect-error see above finalizationCtx, drop_buffer, ); // Don't leak the box used to pass buffer through FFI drop_box(box);
If the output you're expecting from the library is a string you may have considered the microoptimization of returning a vector of u16 rather than a string since typically JavaScript engines use UTF-16 under the hood.
However, that would be a mistake because transforming your string to a C string and using bun's cstring type will be mildly faster.
Here's a benchmark done using a nice benchmark library mitata
import { dlopen, FFIType, read, suffix, toArrayBuffer, type Pointer, } from "bun:ffi"; // Both your script and your library don't typically change their locations // Use `import.meta.dirname` to make your script independent from the cwd const DLL_PATH = import.meta.dirname + `/../../rust-lib/target/release/library.${suffix}`; function main() { // Deconstruct object to get functions // but collect `close` method into object // to avoid using `this` in a wrong scope const { symbols: { do_work }, ...dll } = dlopen(DLL_PATH, { do_work: { args: [FFIType.ptr, FFIType.ptr, "usize", "usize"], returns: FFIType.void, }, }); /* ... */ // It is unclear whether it is required or recommended to call `close` // an example says `JSCallback` instances specifically need to be closed // Note that using `symbols` after calling `close` is undefined behaviour dll.close(); } main();
{ reconstruct_slice: { args: [FFIType.ptr, "usize"], returns: FFIType.void, }, } const array = new BigInt64Array([0, 1, 3]); // Bun automatically converts `TypedArray`s into pointers reconstruct_slice(array, array.length);
/// Reconstruct a `slice` that was initialized in JavaScript unsafe fn reconstruct_slice( array_ptr: *const i64, length: libc::size_t, ) -> &[i64] { // Even though here it's not null, it's good practice to check assert!(!array_ptr.is_null()); // Unaligned pointer can lead to undefined behaviour assert!(array_ptr.is_aligned()); // Check that the array doesn't "wrap around" the address space assert!(length < usize::MAX / 4); let _: &[i64] = unsafe { slice::from_raw_parts(array_ptr, length) }; }
It's time to address the elephant in the room that is WebAssembly.
Should you choose nice existing WASM bindings over dealing with C ABI?
The answer is probably neither.
Introducing another language to your codebase will require more than just a single bottleneck to be worth it DX-wise and performance-wise.
Here is a benchmark for a simple range function in JS, WASM and Rust.
{ allocate_buffer: { args: [], returns: FFIType.ptr, }, as_pointer: { args: ["usize"], returns: FFIType.ptr, }, } // Hardcoding this value for 64-bit systems const BYTES_IN_PTR = 8; const box: Pointer = allocate_buffer()!; const ptr: number = read.ptr(box); // Reading the value next to `ptr` const length: number = read.ptr(box, BYTES_IN_PTR); // Hardcoding `byteOffset` to be 0 because Rust guarantees that // Buffer holds `i32` values which take 4 bytes // Note how we need to call a no-op function `as_pointer` because // `toArrayBuffer` takes a `Pointer` but `read.ptr` returns a `number` const _buffer = toArrayBuffer(as_pointer(ptr)!, 0, length * 4);
#[no_mangle] pub extern "C" fn allocate_buffer() -> Box<[usize; 2]> { let buffer: Vec<i32> = vec![0; 10]; let memory: ManuallyDrop<Vec<i32>> = ManuallyDrop::new(buffer); let ptr: *const i32 = memory.as_ptr(); let length: usize = memory.len(); // Unlike a `Vec`, `Box` is FFI compatible and will not drop // its data when crossing the FFI // Additionally, a `Box<T>` where `T` is `Sized` will be a thin pointer Box::new([ptr as usize, length]) } #[no_mangle] pub const extern "C" fn as_pointer(ptr: usize) -> usize { ptr }
{ drop_buffer: { args: [FFIType.ptr], returns: FFIType.void, }, } const registry = new FinalizationRegistry((box: Pointer): void => { drop_buffer(box); }); registry.register(buffer, box);
Native library barely beats out WASM and consistently loses to the pure TypeScript implementation.
And that's it for this tutorial for/exploration of bun:ffi module. Hopefully we all have walked away from this a little bit more educated.
Feel free to share thoughts and questions in the comments
The above is the detailed content of How to and Should you use Bun FFI. For more information, please follow other related articles on the PHP Chinese website!