Skip to content

Bun fournit une API de plugin universelle qui peut être utilisée pour étendre à la fois le runtime et le bundler.

Les plugins interceptent les imports et effectuent une logique de chargement personnalisée : lecture de fichiers, transpilation de code, etc. Ils peuvent être utilisés pour ajouter la prise en charge de types de fichiers supplémentaires, comme .scss ou .yaml. Dans le contexte du bundler de Bun, les plugins peuvent être utilisés pour implémenter des fonctionnalités au niveau du framework comme l'extraction CSS, les macros et la co-localisation du code client-serveur.

Hooks de cycle de vie

Les plugins peuvent enregistrer des callbacks à exécuter à différents moments du cycle de vie d'un bundle :

  • onStart() : Exécuté une fois que le bundler a démarré un bundle
  • onResolve() : Exécuté avant qu'un module soit résolu
  • onLoad() : Exécuté avant qu'un module soit chargé.
  • onBeforeParse() : Exécute des addons natifs zero-copy dans le thread du parseur avant qu'un fichier soit analysé.

Référence

Un aperçu approximatif des types (veuillez vous référer à bun.d.ts de Bun pour les définitions complètes des types) :

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

Utilisation

Un plugin est défini comme un simple objet JavaScript contenant une propriété name et une fonction setup.

tsx
import type { BunPlugin } from "bun";

const myPlugin: BunPlugin = {
  name: "Chargeur personnalisé",
  setup(build) {
    // implémentation
  },
};

Ce plugin peut être passé dans le tableau plugins lors de l'appel à Bun.build.

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

Cycle de vie des plugins

Namespaces

onLoad et onResolve acceptent une chaîne namespace optionnelle. Qu'est-ce qu'un namespace ?

Chaque module a un namespace. Les namespaces sont utilisés pour préfixer l'import dans le code transpilé ; par exemple, un chargeur avec un filter: /\.yaml$/ et namespace: "yaml:" transformera un import de ./myfile.yaml en yaml:./myfile.yaml.

Le namespace par défaut est "file" et il n'est pas nécessaire de le spécifier, par exemple : import myModule from "./my-module.ts" est la même chose que import myModule from "file:./my-module.ts".

D'autres namespaces courants sont :

  • "bun" : pour les modules spécifiques à Bun (par exemple "bun:test", "bun:sqlite")
  • "node" : pour les modules Node.js (par exemple "node:fs", "node:path")

onStart

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

Enregistre un callback à exécuter lorsque le bundler démarre un nouveau bundle.

ts
import { plugin } from "bun";

plugin({
  name: "exemple onStart",

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

Le callback peut retourner une Promise. Après que le processus de bundle ait été initialisé, le bundler attend que tous les callbacks onStart() soient terminés avant de continuer.

Par exemple :

ts
const result = await Bun.build({
  entrypoints: ["./app.ts"],
  outdir: "./dist",
  sourcemap: "external",
  plugins: [
    {
      name: "Dormir pendant 10 secondes",
      setup(build) {
        build.onStart(async () => {
          await Bun.sleep(10_000);
        });
      },
    },
    {
      name: "Journaliser l'heure du bundle dans un fichier",
      setup(build) {
        build.onStart(async () => {
          const now = Date.now();
          await Bun.$`echo ${now} > bundle-time.txt`;
        });
      },
    },
  ],
});

Dans l'exemple ci-dessus, Bun attendra que le premier onStart() (dormir pendant 10 secondes) soit terminé, ainsi que le second onStart() (écrire l'heure du bundle dans un fichier).

Notez que les callbacks onStart() (comme tous les autres callbacks de cycle de vie) n'ont pas la capacité de modifier l'objet build.config. Si vous voulez modifier build.config, vous devez le faire directement dans la fonction setup().

onResolve

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

Pour bundler votre projet, Bun parcourt l'arbre de dépendances de tous les modules de votre projet. Pour chaque module importé, Bun doit en fait trouver et lire ce module. La partie "trouver" est connue sous le nom de "résolution" d'un module.

Le callback de cycle de vie du plugin onResolve() vous permet de configurer comment un module est résolu.

Le premier argument de onResolve() est un objet avec une propriété filter et namespace. Le filter est une expression régulière qui est exécutée sur la chaîne d'import. En effet, cela vous permet de filtrer les modules auxquels votre logique de résolution personnalisée s'appliquera.

Le deuxième argument de onResolve() est un callback qui est exécuté pour chaque import de module que Bun trouve qui correspond au filter et au namespace définis dans le premier argument.

Le callback reçoit en entrée le chemin vers le module correspondant. Le callback peut retourner un nouveau chemin pour le module. Bun lira le contenu du nouveau chemin et l'analysera comme un module.

Par exemple, rediriger tous les imports vers images/ vers ./public/images/ :

ts
import { plugin } from "bun";

plugin({
  name: "exemple 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;

Après que le bundler de Bun ait résolu un module, il doit lire le contenu du module et l'analyser.

Le callback de cycle de vie du plugin onLoad() vous permet de modifier le contenu d'un module avant qu'il ne soit lu et analysé par Bun.

Comme onResolve(), le premier argument de onLoad() vous permet de filtrer les modules auxquels cette invocation de onLoad() s'appliquera.

Le deuxième argument de onLoad() est un callback qui est exécuté pour chaque module correspondant avant que Bun ne charge le contenu du module en mémoire.

Ce callback reçoit en entrée le chemin vers le module correspondant, l'importer du module (le module qui a importé le module), le namespace du module, et le kind du module.

Le callback peut retourner une nouvelle chaîne contents pour le module ainsi qu'un nouveau loader.

Par exemple :

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"

Ce plugin transformera tous les imports de la forme import env from "env" en un module JavaScript qui exporte les variables d'environnement actuelles.

.defer()

L'un des arguments passés au callback onLoad est une fonction defer. Cette fonction retourne une Promise qui est résolue lorsque tous les autres modules ont été chargés.

Cela vous permet de retarder l'exécution du callback onLoad jusqu'à ce que tous les autres modules aient été chargés.

Ceci est utile pour retourner le contenu d'un module qui dépend d'autres modules.

Exemple : suivi et rapport des exports inutilisés
ts
import { plugin } from "bun";

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

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

    // Chaque module qui passe par ce callback onLoad
    // enregistrera ses imports dans `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 }) => {
      // Attendre que tous les fichiers soient chargés, en s'assurant
      // que chaque fichier passe par la fonction `onLoad()` ci-dessus
      // et que leurs imports soient suivis
      await defer();

      // Émettre le JSON contenant les statistiques de chaque import
      return {
        contents: `export default ${JSON.stringify(trackedImports)}`,
        loader: "json",
      };
    });
  },
});

Notez que la fonction .defer() a actuellement la limitation qu'elle ne peut être appelée qu'une seule fois par callback onLoad.

Plugins natifs

L'une des raisons pour lesquelles le bundler de Bun est si rapide est qu'il est écrit en code natif et exploite le multi-threading pour charger et analyser les modules en parallèle.

Cependant, une limitation des plugins écrits en JavaScript est que JavaScript lui-même est mono-threadé.

Les plugins natifs sont écrits comme des modules NAPI et peuvent être exécutés sur plusieurs threads. Cela permet aux plugins natifs de s'exécuter beaucoup plus rapidement que les plugins JavaScript.

De plus, les plugins natifs peuvent éviter un travail inutile tel que la conversion UTF-8 -> UTF-16 nécessaire pour passer des chaînes à JavaScript.

Voici les hooks de cycle de vie suivants qui sont disponibles pour les plugins natifs :

  • onBeforeParse() : Appelé sur n'importe quel thread avant qu'un fichier ne soit analysé par le bundler de Bun.

Les plugins natifs sont des modules NAPI qui exposent des hooks de cycle de vie en tant que fonctions C ABI.

Pour créer un plugin natif, vous devez exporter une fonction C ABI qui correspond à la signature du hook de cycle de vie natif que vous souhaitez implémenter.

Création d'un plugin natif en Rust

Les plugins natifs sont des modules NAPI qui exposent des hooks de cycle de vie en tant que fonctions C ABI.

Pour créer un plugin natif, vous devez exporter une fonction C ABI qui correspond à la signature du hook de cycle de vie natif que vous souhaitez implémenter.

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

Ensuite, installez cette crate :

bash
cargo add bun-native-plugin

Maintenant, à l'intérieur du fichier lib.rs, nous utiliserons la macro de procédure bun_native_plugin::bun pour définir une fonction qui implémentera notre plugin natif.

Voici un exemple implémentant le hook onBeforeParse :

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

/// Définir le plugin et son nom
define_bun_plugin!("replace-foo-with-bar");

/// Ici nous implémenterons `onBeforeParse` avec du code qui remplace toutes les occurrences de
/// `foo` par `bar`.
///
/// Nous utilisons la macro #[bun] pour générer une partie du code boilerplate.
///
/// L'argument de la fonction (`handle: &mut OnBeforeParse`) indique
/// à la macro que cette fonction implémente le hook `onBeforeParse`.
#[bun]
pub fn replace_foo_with_bar(handle: &mut OnBeforeParse) -> Result<()> {
  // Récupérer le code source d'entrée.
  let input_source_code = handle.input_source_code()?;

  // Obtenir le Loader pour le fichier
  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(())
}

Et pour l'utiliser dans 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;

Ce callback de cycle de vie est exécuté immédiatement avant qu'un fichier ne soit analysé par le bundler de Bun.

En entrée, il reçoit le contenu du fichier et peut optionnellement retourner un nouveau code source.

Ce callback peut être appelé depuis n'importe quel thread, donc l'implémentation du module napi doit être thread-safe.

Bun édité par www.bunjs.com.cn