Skip to main content
Bun’s universal plugin API extends both the runtime and the bundler. Plugins intercept imports and perform custom loading logic, such as reading files or transpiling code. They can add support for additional file types, like .scss or .yaml. In the bundler, plugins can implement framework-level features like CSS extraction, macros, and client-server code co-location.

Lifecycle hooks

Plugins register callbacks that run at various points in the lifecycle of a bundle:
  • onStart(): Run once the bundler has started a bundle
  • onResolve(): Run before a module is resolved
  • onLoad(): Run before a module is loaded
  • onBeforeParse(): Run zero-copy native addons in the parser thread before a file is parsed
  • onEnd(): Run after the bundle is complete

Reference

A rough overview of the types (see Bun’s bun.d.ts for the full type definitions):
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bbun.d.ts
type PluginBuilder = {
  onStart(callback: () => void): void;
  onResolve: (
    args: { filter: RegExp; namespace?: string },
    callback: (args: { path: string; importer: string }) => {
      path: string;
      namespace?: string;
    } | void,
  ) => void;
  onLoad: (
    args: { filter: RegExp; namespace?: string },
    defer: () => Promise<void>,
    callback: (args: { path: string }) => {
      loader?: Loader;
      contents?: string;
      exports?: Record<string, any>;
    },
  ) => void;
  onEnd(callback: (result: BuildOutput) => void | Promise<void>): void;
  config: BuildConfig;
};

type Loader =
  | "js"
  | "jsx"
  | "ts"
  | "tsx"
  | "json"
  | "jsonc"
  | "toml"
  | "yaml"
  | "file"
  | "napi"
  | "wasm"
  | "text"
  | "css"
  | "html";

Usage

A plugin is a JavaScript object with a name property and a setup function.
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bmyPlugin.ts
import type { BunPlugin } from "bun";

const myPlugin: BunPlugin = {
  name: "Custom loader",
  setup(build) {
    // implementation
  },
};
Pass it in the plugins array when calling Bun.build.
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./out",
  plugins: [myPlugin],
});

Plugin lifecycle

Namespaces

onLoad and onResolve accept an optional namespace string. Every module has a namespace. Namespaces prefix the import in transpiled code; for example, a loader with a filter: /\.yaml$/ and namespace: "yaml:" transforms an import from ./myfile.yaml into yaml:./myfile.yaml. The default namespace is "file" and you don’t need to specify it: import myModule from "./my-module.ts" is the same as import myModule from "file:./my-module.ts". Other common namespaces are:
  • "bun": for Bun-specific modules ("bun:test", "bun:sqlite")
  • "node": for Node.js modules ("node:fs", "node:path")

onStart

onStart(callback: () => void): Promise<void> | void;
Registers a callback to be run when the bundler starts a new bundle.
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
import { plugin } from "bun";

plugin({
  name: "onStart example",

  setup(build) {
    build.onStart(() => {
      console.log("Bundle started!");
    });
  },
});
The callback can return a Promise. After the bundle process has initialized, the bundler waits until all onStart() callbacks have completed before continuing. For example:
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  sourcemap: "external",
  plugins: [
    {
      name: "Sleep for 10 seconds",
      setup(build) {
        build.onStart(async () => {
          await Bun.sleep(10_000);
        });
      },
    },
    {
      name: "Log bundle time to a file",
      setup(build) {
        build.onStart(async () => {
          const now = Date.now();
          await Bun.$`echo ${now} > bundle-time.txt`;
        });
      },
    },
  ],
});
In this example, Bun waits for both onStart() callbacks to complete: the 10-second sleep and the write to bundle-time.txt.
onStart() callbacks (like every other lifecycle callback) cannot modify the build.config object. To mutate build.config, do so directly in the setup() function.

onResolve

onResolve(
  args: { filter: RegExp; namespace?: string },
  callback: (args: { path: string; importer: string }) => {
    path: string;
    namespace?: string;
  } | void,
): void;
To bundle your project, Bun walks down the dependency tree of all modules in your project. For each imported module, Bun has to find and read that module. The “finding” part is known as “resolving” a module. The onResolve() plugin lifecycle callback configures how a module is resolved. The first argument to onResolve() is an object with a filter and namespace property. The filter is a regular expression run on the import string. Together, these select which modules your custom resolution logic applies to. The second argument to onResolve() is a callback that runs for each module import Bun finds that matches the filter and namespace defined in the first argument. The callback receives the path to the matching module and can return a new path for the module. Bun reads the contents of the new path and parses it as a module. For example, redirecting all imports to images/ to ./public/images/:
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
import { plugin } from "bun";

plugin({
  name: "onResolve example",
  setup(build) {
    build.onResolve({ filter: /.*/, namespace: "file" }, args => {
      if (args.path.startsWith("images/")) {
        return {
          path: args.path.replace("images/", "./public/images/"),
        };
      }
    });
  },
});

onLoad

onLoad(
  args: { filter: RegExp; namespace?: string },
  defer: () => Promise<void>,
  callback: (args: { path: string, importer: string, namespace: string, kind: ImportKind  }) => {
    loader?: Loader;
    contents?: string;
    exports?: Record<string, any>;
  },
): void;
After Bun’s bundler has resolved a module, it reads and parses the module’s contents. The onLoad() plugin lifecycle callback modifies the contents of a module before Bun reads and parses it. Like onResolve(), the first argument to onLoad() selects which modules this invocation of onLoad() applies to. The second argument to onLoad() is a callback that runs for each matching module before Bun loads its contents into memory. The callback receives the path to the matching module, its importer (the module that imported it), its namespace, and its kind. The callback can return a new contents string for the module as well as a new loader. For example:
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
import { plugin } from "bun";

const envPlugin: BunPlugin = {
  name: "env plugin",
  setup(build) {
    build.onLoad({ filter: /env/, namespace: "file" }, args => {
      return {
        contents: `export default ${JSON.stringify(process.env)}`,
        loader: "js",
      };
    });
  },
});

Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  plugins: [envPlugin],
});

// import env from "env"
// env.FOO === "bar"
This plugin transforms all imports of the form import env from "env" into a JavaScript module that exports the current environment variables.

.defer()

One of the arguments passed to the onLoad callback is a defer function. It returns a Promise that resolves once all other modules have been loaded. Await it when a module’s contents depend on other modules.
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
import { plugin } from "bun";

plugin({
  name: "track imports",
  setup(build) {
    const transpiler = new Bun.Transpiler();

    let trackedImports: Record<string, number> = {};

    // Each module that goes through this onLoad callback
    // will record its imports in `trackedImports`
    build.onLoad({ filter: /\.ts/ }, async ({ path }) => {
      const contents = await Bun.file(path).arrayBuffer();

      const imports = transpiler.scanImports(contents);

      for (const i of imports) {
        trackedImports[i.path] = (trackedImports[i.path] || 0) + 1;
      }

      return undefined;
    });

    build.onLoad({ filter: /stats\.json/ }, async ({ defer }) => {
      // Wait for all files to be loaded, ensuring
      // that every file goes through the above `onLoad()` function
      // and their imports tracked
      await defer();

      // Emit JSON containing the stats of each import
      return {
        contents: `export default ${JSON.stringify(trackedImports)}`,
        loader: "json",
      };
    });
  },
});
The .defer() function can only be called once per onLoad callback.

Native plugins

Bun’s bundler is written in native code and uses multiple threads to load and parse modules in parallel. JavaScript plugins run on a single thread, because JavaScript itself is single-threaded. Native plugins are NAPI modules that expose lifecycle hooks as C ABI functions. They can run on multiple threads, so they run much faster than JavaScript plugins, and they skip work such as the UTF-8 -> UTF-16 conversion needed to pass strings to JavaScript. These lifecycle hooks are available to native plugins:
  • onBeforeParse(): Called on any thread before a file is parsed by Bun’s bundler.
To create a native plugin, export a C ABI function that matches the signature of the native lifecycle hook you want to implement.

Creating a native plugin in Rust

terminal
bun add -g @napi-rs/cli
napi new
Then install this crate:
terminal
cargo add bun-native-plugin
Inside lib.rs, use the bun_native_plugin::bun proc macro to define the function that implements your native plugin. Here’s an example implementing the onBeforeParse hook:
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/rust.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=c48e2f9ffc38d0c1d77ef723c617aca8lib.rs
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(())
}
To use it in Bun.build():
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
import myNativeAddon from "./my-native-addon";

Bun.build({
  entrypoints: ["./app.tsx"],
  plugins: [
    {
      name: "my-plugin",

      setup(build) {
        build.onBeforeParse(
          {
            namespace: "file",
            filter: "**/*.tsx",
          },
          {
            napiModule: myNativeAddon,
            symbol: "replace_foo_with_bar",
            // external: myNativeAddon.getSharedState()
          },
        );
      },
    },
  ],
});

onBeforeParse

onBeforeParse(
  args: { filter: RegExp; namespace?: string },
  callback: { napiModule: NapiModule; symbol: string; external?: unknown },
): void;
The onBeforeParse() callback runs immediately before Bun’s bundler parses a file. It receives the file’s contents and can optionally return new source code.
Bun can call this callback from any thread, so the NAPI module implementation must be thread-safe.

onEnd

onEnd(callback: (result: BuildOutput) => void | Promise<void>): void;
Registers a callback to be run after the bundle is complete. The callback receives the BuildOutput object containing the build results, including output files and any build messages.
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  plugins: [
    {
      name: "onEnd example",
      setup(build) {
        build.onEnd(result => {
          console.log(`Build completed with ${result.outputs.length} files`);
          for (const log of result.logs) {
            console.log(log);
          }
        });
      },
    },
  ],
});
The callback can return a Promise. The promise returned by Bun.build() does not resolve until all onEnd() callbacks have completed.
https://mintcdn.com/bun-1dd33a4e/JUhaF6Mf68z_zHyy/icons/typescript.svg?fit=max&auto=format&n=JUhaF6Mf68z_zHyy&q=85&s=7ac549adaea8d5487d8fbd58cc3ea35bindex.ts
const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  plugins: [
    {
      name: "Upload to S3",
      setup(build) {
        build.onEnd(async result => {
          if (!result.success) return;
          for (const output of result.outputs) {
            await uploadToS3(output);
          }
        });
      },
    },
  ],
});