Bun

插件

Bun 提供了一个通用的插件 API,可以用来扩展 *runtime* 和 *bundler*。

插件会拦截导入并执行自定义加载逻辑:读取文件、转译代码等。它们可用于添加对其他文件类型的支持,例如 .scss.yaml。在 Bun 的 bundler 上下文中,插件可用于实现框架级别的功能,例如 CSS 提取、宏和客户端-服务器代码的共置。

生命周期钩子

插件可以注册回调,以便在 bundle 的生命周期的各个点执行。

  • onStart():bundler 启动 bundle 后运行。
  • onResolve():在模块解析之前运行。
  • onLoad():在模块加载之前运行。
  • onEnd():bundle 完成后运行。
  • onBeforeParse():在文件被解析之前,在解析器线程中运行零拷贝的本地插件。

Reference

类型的大致概览(请参阅 Bun 的 bun.d.ts 以获取完整的类型定义)

type PluginBuilder = {
  onStart(callback: () => void): void;
  onEnd(callback: (result: BuildOutput) => void | Promise<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;
  config: BuildConfig;
};

type Loader = "js" | "jsx" | "ts" | "tsx" | "css" | "json" | "toml";

用法

插件定义为一个简单的 JavaScript 对象,包含一个 name 属性和一个 setup 函数。

myPlugin.ts
import type { BunPlugin } from "bun";

const myPlugin: BunPlugin = {
  name: "Custom loader",
  setup(build) {
    // implementation
  },
};

此插件可以在调用 Bun.build 时传递到 plugins 数组中。

await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./out",
  plugins: [myPlugin],
});

插件生命周期

命名空间

onLoadonResolve 接受一个可选的 namespace 字符串。什么是命名空间?

每个模块都有一个命名空间。命名空间用于在转译的代码中为导入添加前缀;例如,一个带有 filter: /\.yaml$/namespace: "yaml:" 的 loader 会将从 ./myfile.yaml 的导入转换为 yaml:./myfile.yaml

默认命名空间是 "file",无需指定,例如:import myModule from "./my-module.ts"import myModule from "file:./my-module.ts" 相同。

其他常用命名空间是

  • "bun":用于 Bun 特有的模块(例如 "bun:test""bun:sqlite"
  • "node":用于 Node.js 模块(例如 "node:fs""node:path"

onStart

onStart(callback: () => void): Promise<void> | void;

注册一个回调,在 bundler 启动新 bundle 时运行。

import { plugin } from "bun";

plugin({
  name: "onStart example",

  setup(build) {
    build.onStart(() => {
      console.log("Bundle started!");
    });
  },
});

回调可以返回一个 Promise。在 bundle 进程初始化后,bundler 会等待所有 onStart() 回调完成,然后继续。

例如

const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  sourcemap: "external",
  plugins: [
    {
      name: "Sleep for 10 seconds",
      setup(build) {
        build.onStart(async () => {
          await Bunlog.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`;
        });
      },
    },
  ],
});

在上面的示例中,Bun 将等待第一个 onStart()(睡眠 10 秒)完成,*以及* 第二个 onStart()(将 bundle 时间写入文件)完成。

请注意,onStart() 回调(与其他所有生命周期回调一样)不能修改 build.config 对象。如果要修改 build.config,必须直接在 setup() 函数中进行。

onResolve

onResolve(
  args: { filter: RegExp; namespace?: string },
  callback: (args: { path: string; importer: string }) => {
    path: string;
    namespace?: string;
  } | void,
): void;

要 bundle 您的项目,Bun 会遍历您项目中所有模块的依赖关系树。对于每个导入的模块,Bun 实际上需要找到并读取该模块。 "寻找" 部分称为 "解析" 模块。

onResolve() 插件生命周期回调允许您配置模块的解析方式。

onResolve() 的第一个参数是一个对象,其中包含 filternamespace 属性。filter 是一个正则表达式,它会在导入字符串上运行。有效地,这些允许您过滤哪些模块将应用您的自定义解析逻辑。

onResolve() 的第二个参数是一个回调,它会为 Bun 找到的与第一个参数中定义的 filternamespace 匹配的每个模块导入运行。

回调接收匹配模块的*路径*作为输入。回调可以返回模块的*新路径*。Bun 将读取*新路径*的内容并将其解析为模块。

例如,将所有导入 images/ 的重定向到 ./public/images/

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;

在 Bun 的 bundler 解析了模块后,它需要读取模块的内容并解析它。

onLoad() 插件生命周期回调允许您在 Bun 读取和解析模块内容之前修改模块的*内容*。

onResolve() 类似,onLoad() 的第一个参数允许您过滤此 onLoad() 调用将应用的模块。

onLoad() 的第二个参数是一个回调,它会在 Bun 将模块内容加载到内存*之前*为每个匹配的模块运行。

此回调接收匹配模块的*路径*、模块的*导入者*(导入该模块的模块)、模块的*命名空间*以及模块的*类型*作为输入。

回调可以返回模块的新 contents 字符串以及新的 loader

例如

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"

此插件会将所有形式为 import env from "env" 的导入转换为导出现有环境变量的 JavaScript 模块。

.defer()

传递给 onLoad 回调的参数之一是 defer 函数。此函数返回一个 Promise,当所有*其他*模块都已加载时,该 Promise 将解析。

这允许您将 onLoad 回调的执行延迟到所有其他模块加载完成之后。

这对于返回依赖于其他模块的模块内容很有用。

示例:跟踪和报告未使用的导出
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",
      };
    });
  },
});

请注意,.defer() 函数目前有一个限制,即每个 onLoad 回调只能调用一次。

onEnd

onEnd(callback: (result: BuildOutput) => void | Promise<void>): void;

注册一个回调,在 bundler 完成一个 bundle(无论成功与否)时运行。

回调接收 BuildOutput 对象,其中包含

  • success:一个布尔值,指示构建是否成功
  • outputs:生成的构建工件数组
  • logs:构建消息数组(警告、错误等)

这对于后处理、清理、通知或自定义错误处理很有用。

await Bun.build({
  entrypoints: ["./index.ts"],
  outdir: "./out",
  plugins: [
    {
      name: "onEnd example",
      setup(build) {
        build.onEnd(result => {
          if (result.success) {
            console.log(
              `✅ Build succeeded with ${result.outputs.length} outputs`,
            );
          } else {
            console.error(`❌ Build failed with ${result.logs.length} errors`);
          }
        });
      },
    },
  ],
});

onEnd 回调在以下时机被调用:

  • build promise 解析或拒绝**之前**
  • 所有 bundling 完成**之后**
  • 按照它们被注册的顺序**

多个插件可以注册 onEnd 回调,它们都将按顺序调用。如果 onEnd 回调返回一个 promise,build 将等待它解析后再继续。

Native 插件

Bun 的 bundler 如此之快的原因之一是它使用原生代码编写,并利用多线程并行加载和解析模块。

然而,用 JavaScript 编写的插件的一个限制是 JavaScript 本身是单线程的。

Native 插件是用 NAPI 模块编写的,可以在多个线程上运行。这使得 Native 插件的运行速度比 JavaScript 插件快得多。

此外,Native 插件可以跳过不必要的工作,例如将字符串传递给 JavaScript 所需的 UTF-8 -> UTF-16 转换。

以下是 Native 插件可用的生命周期钩子:

  • onBeforeParse():在 Bun bundler 解析文件之前,在任何线程上调用。

Native 插件是 NAPI 模块,它们将生命周期钩子公开为 C ABI 函数。

要创建 Native 插件,必须导出一个 C ABI 函数,该函数与您要实现的 Native 生命周期钩子的签名匹配。

在 Rust 中创建 Native 插件

Native 插件是 NAPI 模块,它们将生命周期钩子公开为 C ABI 函数。

要创建 Native 插件,必须导出一个 C ABI 函数,该函数与您要实现的 Native 生命周期钩子的签名匹配。

bun add -g @napi-rs/cli
napi new

然后安装此 crate

cargo add bun-native-plugin

现在,在 lib.rs 文件中,我们将使用 bun_native_plugin::bun proc 宏来定义一个函数,该函数 将实现我们的 Native 插件。

这是一个实现 onBeforeParse 钩子的示例

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(())
}

以及如何在 Bun.build() 中使用它

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;

此生命周期回调在 Bun bundler 解析文件之前立即运行。

作为输入,它接收文件的内容,并可选择返回新的源代码。

此回调可以从任何线程调用,因此 napi 模块实现必须是线程安全的。