Skip to content

Bun fornisce un'API plugin universale che può essere usata per estendere sia il runtime che il bundler.

I plugin intercettano gli import ed eseguono logica di caricamento personalizzata: lettura di file, transpiling di codice, ecc. Possono essere usati per aggiungere supporto per tipi di file aggiuntivi, come .scss o .yaml. Nel contesto del bundler di Bun, i plugin possono essere usati per implementare funzionalità a livello di framework come l'estrazione CSS, le macro e la co-locazione del codice client-server.

Hook del ciclo di vita

I plugin possono registrare callback da eseguire in vari punti del ciclo di vita di un bundle:

  • onStart(): Eseguito una volta che il bundler ha avviato un bundle
  • onResolve(): Eseguito prima che un modulo venga risolto
  • onLoad(): Eseguito prima che un modulo venga caricato.
  • onBeforeParse(): Esegue addon nativi zero-copy nel thread del parser prima che un file venga parsato.

Riferimento

Una panoramica approssimativa dei tipi (fare riferimento a bun.d.ts di Bun per le definizioni complete dei tipi):

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

Utilizzo

Un plugin è definito come un semplice oggetto JavaScript contenente una proprietà name e una funzione setup.

tsx
import type { BunPlugin } from "bun";

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

Questo plugin può essere passato nell'array plugins quando si chiama Bun.build.

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

Ciclo di vita del plugin

Namespace

onLoad e onResolve accettano una stringa namespace opzionale. Cos'è un namespace?

Ogni modulo ha un namespace. I namespace sono usati per prefissare l'import nel codice transpilato; per esempio, un loader con filter: /\.yaml$/ e namespace: "yaml:" trasformerà un import da ./myfile.yaml in yaml:./myfile.yaml.

Il namespace predefinito è "file" e non è necessario specificarlo, per esempio: import myModule from "./my-module.ts" è lo stesso di import myModule from "file:./my-module.ts".

Altri namespace comuni sono:

  • "bun": per moduli specifici di Bun (es. "bun:test", "bun:sqlite")
  • "node": per moduli Node.js (es. "node:fs", "node:path")

onStart

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

Registra una callback da eseguire quando il bundler avvia un nuovo bundle.

ts
import { plugin } from "bun";

plugin({
  name: "Esempio onStart",

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

La callback può restituire una Promise. Dopo che il processo di bundle è stato inizializzato, il bundler attende che tutte le callback onStart() siano completate prima di continuare.

Per esempio:

ts
const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  sourcemap: "external",
  plugins: [
    {
      name: "Dormi per 10 secondi",
      setup(build) {
        build.onStart(async () => {
          await Bun.sleep(10_000);
        });
      },
    },
    {
      name: "Registra il tempo di bundle in un file",
      setup(build) {
        build.onStart(async () => {
          const now = Date.now();
          await Bun.$`echo ${now} > bundle-time.txt`;
        });
      },
    },
  ],
});

Nell'esempio sopra, Bun attenderà che il primo onStart() (dormire per 10 secondi) sia completato, così come il secondo onStart() (scrivere il tempo di bundle in un file).

Nota che le callback onStart() (come ogni altra callback del ciclo di vita) non hanno la capacità di modificare l'oggetto build.config. Se vuoi mutare build.config, devi farlo direttamente nella funzione setup().

onResolve

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

Per fare il bundle del tuo progetto, Bun scende lungo l'albero delle dipendenze di tutti i moduli nel tuo progetto. Per ogni modulo importato, Bun deve effettivamente trovare e leggere quel modulo. La parte del "trovare" è conosciuta come "risolvere" un modulo.

La callback del ciclo di vita del plugin onResolve() ti permette di configurare come un modulo viene risolto.

Il primo argomento di onResolve() è un oggetto con una proprietà filter e namespace. Il filter è un'espressione regolare che viene eseguita sulla stringa di import. Effettivamente, questi ti permettono di filtrare quali moduli la tua logica di risoluzione personalizzata applicherà.

Il secondo argomento di onResolve() è una callback che viene eseguita per ogni import di modulo che Bun trova che corrisponde al filter e namespace definiti nel primo argomento.

La callback riceve come input il percorso del modulo corrispondente. La callback può restituire un nuovo percorso per il modulo. Bun leggerà i contenuti del nuovo percorso e lo parserà come un modulo.

Per esempio, reindirizzare tutti gli import a images/ a ./public/images/:

ts
import { plugin } from "bun";

plugin({
  name: "Esempio onResolve",
  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;

Dopo che il bundler di Bun ha risolto un modulo, deve leggere i contenuti del modulo e parsarlo.

La callback del ciclo di vita del plugin onLoad() ti permette di modificare i contenuti di un modulo prima che venga letto e parsato da Bun.

Come onResolve(), il primo argomento di onLoad() ti permette di filtrare quali moduli questa invocazione di onLoad() applicherà.

Il secondo argomento di onLoad() è una callback che viene eseguita per ogni modulo corrispondente prima che Bun carichi i contenuti del modulo in memoria.

Questa callback riceve come input il percorso del modulo corrispondente, l'importer del modulo (il modulo che ha importato il modulo), il namespace del modulo e il kind del modulo.

La callback può restituire una nuova stringa contents per il modulo così come un nuovo loader.

Per esempio:

ts
import { plugin } from "bun";

const envPlugin: BunPlugin = {
  name: "plugin env",
  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"

Questo plugin trasformerà tutti gli import della forma import env from "env" in un modulo JavaScript che esporta le variabili d'ambiente correnti.

.defer()

Uno degli argomenti passati alla callback onLoad è una funzione defer. Questa funzione restituisce una Promise che viene risolta quando tutti gli altri moduli sono stati caricati.

Questo ti permette di ritardare l'esecuzione della callback onLoad fino a quando tutti gli altri moduli sono stati caricati.

Questo è utile per restituire i contenuti di un modulo che dipende da altri moduli.

Esempio: tracciare e riportare export non usati
ts
import { plugin } from "bun";

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

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

    // Ogni modulo che passa attraverso questa callback onLoad
    // registrerà i suoi import 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 }) => {
      // Attendi che tutti i file siano caricati, assicurando
      // che ogni file passi attraverso la funzione `onLoad()` sopra
      // e i loro import siano tracciati
      await defer();

      // Emetti JSON contenente le statistiche di ogni import
      return {
        contents: `export default ${JSON.stringify(trackedImports)}`,
        loader: "json",
      };
    });
  },
});

Nota che la funzione .defer() ha attualmente la limitazione che può essere chiamata solo una volta per callback onLoad.

Plugin nativi

Uno dei motivi per cui il bundler di Bun è così veloce è che è scritto in codice nativo e sfrutta il multi-threading per caricare e parsare i moduli in parallelo.

Tuttavia, una limitazione dei plugin scritti in JavaScript è che JavaScript stesso è single-threaded.

I plugin nativi sono scritti come moduli NAPI e possono essere eseguiti su più thread. Questo permette ai plugin nativi di essere eseguiti molto più velocemente dei plugin JavaScript.

Inoltre, i plugin nativi possono saltare lavoro non necessario come la conversione UTF-8 -> UTF-16 necessaria per passare le stringhe a JavaScript.

Questi sono i seguenti hook del ciclo di vita disponibili per i plugin nativi:

  • onBeforeParse(): Chiamato su qualsiasi thread prima che un file venga parsato dal bundler di Bun.

I plugin nativi sono moduli NAPI che espongono hook del ciclo di vita come funzioni C ABI.

Per creare un plugin nativo, devi esportare una funzione C ABI che corrisponde alla firma dell'hook del ciclo di vita nativo che vuoi implementare.

Creare un plugin nativo in Rust

I plugin nativi sono moduli NAPI che espongono hook del ciclo di vita come funzioni C ABI.

Per creare un plugin nativo, devi esportare una funzione C ABI che corrisponde alla firma dell'hook del ciclo di vita nativo che vuoi implementare.

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

Poi installa questo crate:

bash
cargo add bun-native-plugin

Ora, all'interno del file lib.rs, useremo la proc macro bun_native_plugin::bun per definire una funzione che implementerà il nostro plugin nativo.

Ecco un esempio che implementa l'hook onBeforeParse:

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

/// Definisci il plugin e il suo nome
define_bun_plugin!("replace-foo-with-bar");

/// Qui implementeremo `onBeforeParse` con codice che sostituisce tutte le occorrenze di
/// `foo` con `bar`.
///
/// Usiamo la macro #[bun] per generare parte del codice boilerplate.
///
/// L'argomento della funzione (`handle: &mut OnBeforeParse`) dice
/// alla macro che questa funzione implementa l'hook `onBeforeParse`.
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
  // Recupera il codice sorgente di input.
  let input_source_code = handle.input_source_code()?;

  // Ottieni il Loader per il 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(())
}

E per usarlo in 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;

Questa callback del ciclo di vita viene eseguita immediatamente prima che un file venga parsato dal bundler di Bun.

Come input, riceve i contenuti del file e può opzionalmente restituire nuovo codice sorgente.

Questa callback può essere chiamata da qualsiasi thread e quindi l'implementazione del modulo napi deve essere thread-safe.

Bun a cura di www.bunjs.com.cn