3 releases
0.1.2 | Jan 21, 2025 |
---|---|
0.1.1 | Jan 21, 2025 |
0.1.0 | Jan 15, 2025 |
#372 in Concurrency
344 downloads per month
39KB
473 lines
⚠️ Note: This is an advanced and experimental API recommended only for plugin developers who are familiar with systems programming and the C ABI. Use with caution.
Bun Native Plugins
This crate provides a Rustified wrapper over the Bun's native bundler plugin C API.
Some advantages to native bundler plugins as opposed to regular ones implemented in JS are:
- Native plugins take full advantage of Bun's parallelized bundler pipeline and run on multiple threads at the same time
- Unlike JS, native plugins don't need to do the UTF-8 <-> UTF-16 source code string conversions
What are native bundler plugins exactly? Precisely, they are NAPI modules which expose a C ABI function which implement a plugin lifecycle hook.
The currently supported lifecycle hooks are:
onBeforeParse
(called immediately before a file is parsed, allows you to modify the source code of the file)
Getting started
Since native bundler plugins are NAPI modules, the easiest way to get started is to create a new napi-rs project:
bun add -g @napi-rs/cli
napi new
Then install this crate:
cargo add bun-native-plugin
Now, inside the lib.rs
file, we'll use the bun_native_plugin::bun
proc macro to define a function which
will implement our native plugin.
Here's an example implementing the onBeforeParse
hook:
use bun_native_plugin::{define_bun_plugin, OnBeforeParse, bun, Result, anyhow, BunLoader};
use napi_derive::napi;
/// Define the plugin and its name
define_bun_plugin!("replace-foo-with-bar");
/// Here we'll implement `onBeforeParse` with code that replaces all occurrences of
/// `foo` with `bar`.
///
/// We use the #[bun] macro to generate some of the boilerplate code.
///
/// The argument of the function (`handle: &mut OnBeforeParse`) tells
/// the macro that this function implements the `onBeforeParse` hook.
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
// Fetch the input source code.
let input_source_code = handle.input_source_code()?;
// Get the Loader for the file
let loader = handle.output_loader();
let output_source_code = input_source_code.replace("foo", "bar");
handle.set_output_source_code(output_source_code, BunLoader::BUN_LOADER_JSX);
Ok(())
}
Internally, the #[bun]
macro wraps your code and declares a C ABI function which implements
the function signature of onBeforeParse
plugins in Bun's C API for bundler plugins.
Then it calls your code. The wrapper looks roughly like this:
pub extern "C" fn replace_foo_with_bar(
args: *const bun_native_plugin::sys::OnBeforeParseArguments,
result: *mut bun_native_plugin::sys::OnBeforeParseResult,
) {
// The actual code you wrote is inlined here
fn __replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
// Fetch the input source code.
let input_source_code = handle.input_source_code()?;
// Get the Loader for the file
let loader = handle.output_loader();
let output_source_code = input_source_code.replace("foo", "bar");
handle.set_output_source_code(output_source_code, BunLoader::BUN_LOADER_JSX);
Ok(())
}
let args = unsafe { &*args };
let mut handle = OnBeforeParse::from_raw(args, result) {
Ok(handle) => handle,
Err(_) => {
return;
}
};
if let Err(e) = __replace_fo_with_bar(&handle) {
handle.log_err(&e.to_string());
}
}
Now, let's compile this NAPI module. If you're using napi-rs, the package.json
should have a build
script you can run:
bun run build
This will produce a .node
file in the project directory.
With the compiled NAPI module, you can now register the plugin from JS:
const result = await Bun.build({
entrypoints: ["index.ts"],
plugins: [
{
name: "replace-foo-with-bar",
setup(build) {
const napiModule = require("path/to/napi_module.node");
// Register the `onBeforeParse` hook to run on all `.ts` files.
// We tell it to use function we implemented inside of our `lib.rs` code.
build.onBeforeParse(
{ filter: /\.ts/ },
{ napiModule, symbol: "replace_foo_with_bar" },
);
},
},
],
});
Very important information
Error handling and panics
In the case that the value of the Result
your plugin function returns is an Err(...)
, the error will be logged to Bun's bundler.
It is highly advised that you return all errors and avoid .unwrap()
'ing or .expecting()
'ing results.
The #[bun]
wrapper macro actually runs your code inside of a panic::catch_unwind
,
which may catch some panics but not guaranteed to catch all panics.
Therefore, it is recommended to avoid panics at all costs.
Passing state to and from JS: External
One way to communicate data from your plugin and JS and vice versa is through the NAPI's External type.
An External in NAPI is like an opaque pointer to data that can be passed to and from JS. Inside your NAPI module, you can retrieve the pointer and modify the data.
As an example that extends our getting started example above, let's say you wanted to count the number of foo
's that the native plugin encounters.
You would expose a NAPI module function which creates this state. Recall that state in native plugins must be threadsafe. This usually means
that your state must be Sync
:
struct PluginState {
foo_count: std::sync::atomic::AtomicU32,
}
#[napi]
pub fn create_plugin_state() -> External<PluginState> {
let external = External::new(PluginState {
foo_count: 0,
});
external
}
#[napi]
pub fn get_foo_count(plugin_state: External<PluginState>) -> u32 {
let plugin_state: &PluginState = &plugin_state;
plugin_state.foo_count.load(std::sync::atomic::Ordering::Relaxed)
}
When you register your plugin from Javascript, you call the napi module function to create the external and then pass it:
const napiModule = require("path/to/napi_module.node");
const pluginState = napiModule.createPluginState();
const result = await Bun.build({
entrypoints: ["index.ts"],
plugins: [
{
name: "replace-foo-with-bar",
setup(build) {
build.onBeforeParse(
{ filter: /\.ts/ },
{
napiModule,
symbol: "on_before_parse_plugin_impl",
// pass our NAPI external which contains our plugin state here
external: pluginState,
},
);
},
},
],
});
console.log("Total `foo`s encountered: ", pluginState.getFooCount());
Finally, from the native implementation of your plugin, you can extract the external:
#[bun]
pub fn on_before_parse_plugin_impl(handle: &mut OnBeforeParse) {
// This operation is only safe if you pass in an external when registering the plugin.
// If you don't, this could lead to a segfault or access of undefined memory.
let plugin_state: &PluginState =
unsafe { handle.external().and_then(|state| state.ok_or(Error::Unknown))? };
// Fetch our source code again
let input_source_code = handle.input_source_code()?;
// Count the number of `foo`s and add it to our state
let foo_count = source_code.matches("foo").count() as u32;
plugin_state.foo_count.fetch_add(foo_count, std::sync::atomic::Ordering::Relaxed);
}
Concurrency
Your plugin function can be called on any thread at any time and possibly multiple times at once.
Therefore, you must design any state management to be threadsafe.
Dependencies
~1–6.5MB
~39K SLoC