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 importations 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 points 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écuter des addons natifs zéro-copy dans le thread d'analyse 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 de types complètes) :

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.

ts
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 de 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'importation dans le code transpilé ; par exemple, un chargeur avec filter: /\.yaml$/ et namespace: "yaml:" transformera une importation 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).

NOTE

Les callbacks `onStart()` (comme tous les autres callbacks de cycle de vie) n'ont pas la possibilité de modifier l'objet `build.config`. Si vous souhaitez 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 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'importation. 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 importation de module que Bun trouve qui correspond au filter et namespace définis dans le premier argument.

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

Par exemple, rediriger toutes les importations 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 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 du module correspondant, l'importateur du module (le module qui a importé le module), le namespace du module et le type 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 toutes les importations 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 signalement des exports inutilisés

ts
import { plugin } from "bun";

plugin({
  name: "suivi des importations",
  setup(build) {
    const transpiler = new Bun.Transpiler();

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

    // Chaque module qui passe par ce callback onLoad
    // enregistrera ses importations 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, garantissant
      // que chaque fichier passe par la fonction `onLoad()` ci-dessus
      // et que leurs importations soient suivies
      await defer();

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

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 en tant que 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 sauter 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 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 ce crate :

bash
cargo add bun-native-plugin

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

Voici un exemple implémentant le hook onBeforeParse :

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

/// Définit 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() :

ts
import myNativeAddon from "./my-native-addon";

Bun.build({
  entrypoints: ["./app.tsx"],
  plugins: [
    {
      name: "mon-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 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.

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