Skip to content

O Bun fornece um plugin universal que pode ser usado para estender tanto o runtime quanto o bundler.

Plugins interceptam imports e executam lógica de carregamento customizada: ler arquivos, transpilar código, etc. Eles podem ser usados para adicionar suporte para tipos de arquivo adicionais, como .scss ou .yaml. No contexto do bundler do Bun, plugins podem ser usados para implementar funcionalidades de nível de framework como extração de CSS, macros, e co-localização de código cliente-servidor.

Hooks de lifecycle

Plugins podem registrar callbacks para serem executados em vários pontos do lifecycle de um bundle:

  • onStart(): Executado uma vez que o bundler iniciou um bundle
  • onResolve(): Executado antes de um módulo ser resolvido
  • onLoad(): Executado antes de um módulo ser carregado.
  • onBeforeParse(): Executa addons nativos zero-copy na thread do parser antes de um arquivo ser parseado.

Referência

Uma visão geral dos tipos (por favor consulte bun.d.ts do Bun para as definições completas de tipo):

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;
  config: BuildConfig;
};

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

Uso

Um plugin é definido como um objeto JavaScript simples contendo uma propriedade name e uma função setup.

tsx
import type { BunPlugin } from "bun";

const myPlugin: BunPlugin = {
  name: "Custom loader",
  setup(build) {
    // implementação
  },
};

Este plugin pode ser passado no array plugins ao chamar Bun.build.

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

Lifecycle do plugin

Namespaces

onLoad e onResolve aceitam uma string namespace opcional. O que é um namespace?

Todo módulo tem um namespace. Namespaces são usados para prefixar o import no código transpilado; por exemplo, um loader com filter: /\.yaml$/ e namespace: "yaml:" transformará um import de ./myfile.yaml para yaml:./myfile.yaml.

O namespace padrão é "file" e não é necessário especificá-lo, por exemplo: import myModule from "./my-module.ts" é o mesmo que import myModule from "file:./my-module.ts".

Outros namespaces comuns são:

  • "bun": para módulos específicos do Bun (e.g. "bun:test", "bun:sqlite")
  • "node": para módulos Node.js (e.g. "node:fs", "node:path")

onStart

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

Registra um callback para ser executado quando o bundler inicia um novo bundle.

ts
import { plugin } from "bun";

plugin({
  name: "onStart exemplo",

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

O callback pode retornar uma Promise. Após o processo de bundle ter inicializado, o bundler espera até que todos os callbacks onStart() tenham completado antes de continuar.

Por exemplo:

ts
const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  sourcemap: "external",
  plugins: [
    {
      name: "Dormir por 10 segundos",
      setup(build) {
        build.onStart(async () => {
          await Bun.sleep(10_000);
        });
      },
    },
    {
      name: "Log do tempo do bundle para um arquivo",
      setup(build) {
        build.onStart(async () => {
          const now = Date.now();
          await Bun.$`echo ${now} > bundle-time.txt`;
        });
      },
    },
  ],
});

No exemplo acima, o Bun espera até que o primeiro onStart() (dormindo por 10 segundos) tenha completado, assim como o segundo onStart() (escrevendo o tempo do bundle em um arquivo).

Note que callbacks onStart() (como todo outro callback de lifecycle) não têm a habilidade de modificar o objeto build.config. Se você quiser modificar build.config, deve fazê-lo diretamente na função setup().

onResolve

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

Para fazer bundle do seu projeto, o Bun percorre a árvore de dependências de todos os módulos no seu projeto. Para cada módulo importado, o Bun precisa encontrar e ler aquele módulo. A parte de "encontrar" é conhecida como "resolver" um módulo.

O callback de lifecycle onResolve() permite que você configure como um módulo é resolvido.

O primeiro argumento para onResolve() é um objeto com propriedades filter e namespace. O filter é uma expressão regular que é executada na string de import. Efetivamente, isso permite que você filtre quais módulos sua lógica de resolução customizada será aplicada.

O segundo argumento para onResolve() é um callback que é executado para cada import de módulo que o Bun encontra que corresponde ao filter e namespace definidos no primeiro argumento.

O callback recebe como entrada o caminho para o módulo correspondente. O callback pode retornar um novo caminho para o módulo. O Bun lerá o conteúdo do novo caminho e o parseará como um módulo.

Por exemplo, redirecionando todos os imports para images/ para ./public/images/:

ts
import { plugin } from "bun";

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

onLoad

ts
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;

Após o bundler do Bun ter resolvido um módulo, ele precisa ler o conteúdo do módulo e parseá-lo.

O callback de lifecycle onLoad() permite que você modifique o conteúdo de um módulo antes que ele seja lido e parseado pelo Bun.

Como onResolve(), o primeiro argumento para onLoad() permite que você filtre quais módulos esta invocação de onLoad() será aplicada.

O segundo argumento para onLoad() é um callback que é executado para cada módulo correspondente antes do Bun carregar o conteúdo do módulo na memória.

Este callback recebe como entrada o caminho para o módulo correspondente, o importer do módulo (o módulo que importou o módulo), o namespace do módulo, e o kind do módulo.

O callback pode retornar uma nova string contents para o módulo assim como um novo loader.

Por exemplo:

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"

Este plugin transformará todos os imports da forma import env from "env" em um módulo JavaScript que exporta as variáveis de ambiente atuais.

.defer()

Um dos argumentos passados para o callback onLoad é uma função defer. Esta função retorna uma Promise que é resolvida quando todos os outros módulos foram carregados.

Isso permite que você adie a execução do callback onLoad até que todos os outros módulos tenham sido carregados.

Isso é útil para retornar conteúdo de um módulo que depende de outros módulos.

Exemplo: rastreando e reportando exports não utilizados
ts
import { plugin } from "bun";

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

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

    // Cada módulo que passa por este callback onLoad
    // registrará seus imports em `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 }) => {
      // Espera todos os arquivos serem carregados, garantindo
      // que todo arquivo passe pela função `onLoad()` acima
      // e seus imports sejam rastreados
      await defer();

      // Emite JSON contendo as estatísticas de cada import
      return {
        contents: `export default ${JSON.stringify(trackedImports)}`,
        loader: "json",
      };
    });
  },
});

Note que a função .defer() atualmente tem a limitação de que só pode ser chamada uma vez por callback onLoad.

Plugins nativos

Uma das razões pelas quais o bundler do Bun é tão rápido é que ele é escrito em código nativo e aproveita o multi-threading para carregar e parsear módulos em paralelo.

No entanto, uma limitação de plugins escritos em JavaScript é que o próprio JavaScript é single-threaded.

Plugins nativos são escritos como módulos NAPI e podem ser executados em múltiplas threads. Isso permite que plugins nativos sejam executados muito mais rápido que plugins JavaScript.

Além disso, plugins nativos podem pular trabalho desnecessário como a conversão UTF-8 -> UTF-16 necessária para passar strings para JavaScript.

Estes são os seguintes hooks de lifecycle disponíveis para plugins nativos:

  • onBeforeParse(): Chamado em qualquer thread antes de um arquivo ser parseado pelo bundler do Bun.

Plugins nativos são módulos NAPI que expõem hooks de lifecycle como funções C ABI.

Para criar um plugin nativo, você deve exportar uma função C ABI que corresponde à assinatura do hook de lifecycle nativo que você quer implementar.

Criando um plugin nativo em Rust

Plugins nativos são módulos NAPI que expõem hooks de lifecycle como funções C ABI.

Para criar um plugin nativo, você deve exportar uma função C ABI que corresponde à assinatura do hook de lifecycle nativo que você quer implementar.

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

Então instale este crate:

bash
cargo add bun-native-plugin

Agora, dentro do arquivo lib.rs, usaremos a proc macro bun_native_plugin::bun para definir uma função que implementará nosso plugin nativo.

Aqui está um exemplo implementando o hook onBeforeParse:

rs
use bun_native_plugin::{define_bun_plugin, OnBeforeParse, bun, Result, anyhow, BunLoader};
use napi_derive::napi;

/// Define o plugin e seu nome
define_bun_plugin!("replace-foo-with-bar");

/// Aqui implementaremos `onBeforeParse` com código que substitui todas as ocorrências de
/// `foo` por `bar`.
///
/// Usamos a macro #[bun] para gerar parte do código boilerplate.
///
/// O argumento da função (`handle: &mut OnBeforeParse`) diz
/// à macro que esta função implementa o hook `onBeforeParse`.
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
  // Obtém o código fonte de entrada.
  let input_source_code = handle.input_source_code()?;

  // Obtém o Loader para o arquivo
  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(())
}

E para usá-lo em Bun.build():

typescript
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

ts
onBeforeParse(
  args: { filter: RegExp; namespace?: string },
  callback: { napiModule: NapiModule; symbol: string; external?: unknown },
): void;

Este callback de lifecycle é executado imediatamente antes de um arquivo ser parseado pelo bundler do Bun.

Como entrada, ele recebe o conteúdo do arquivo e pode opcionalmente retornar um novo código fonte.

Este callback pode ser chamado de qualquer thread e então a implementação do módulo napi deve ser thread-safe.

Bun by www.bunjs.com.cn edit