プラグイン

プラグインAPIを使用すると、ビルドプロセスのさまざまな部分にコードを挿入できます。APIの他の部分とは異なり、コマンドラインからは使用できません。プラグインAPIを使用するには、JavaScriptまたはGoコードを記述する必要があります。また、プラグインはtransform APIではなく、build APIでのみ使用できます。

プラグインの検索

既存のesbuildプラグインを探している場合は、既存のesbuildプラグインのリストを確認してください。このリストにあるプラグインは、作者によって意図的に追加されており、esbuildコミュニティの他のユーザーが使用することを目的としています。

esbuildプラグインを共有したい場合は、

  1. npmに公開して、他のユーザーがインストールできるようにしてください。
  2. 既存のesbuildプラグインのリストに追加して、他のユーザーが見つけられるようにしてください。

プラグインの使用

esbuildプラグインは、namesetup関数を持つオブジェクトです。build API呼び出しに配列で渡されます。setup関数は、ビルドAPI呼び出しごとに1回実行されます。

ビルド時に現在の環境変数をインポートできる簡単なプラグインの例を次に示します。

JS Go
import * as esbuild from 'esbuild'

let envPlugin = {
  name: 'env',
  setup(build) {
    // Intercept import paths called "env" so esbuild doesn't attempt
    // to map them to a file system location. Tag them with the "env-ns"
    // namespace to reserve them for this plugin.
    build.onResolve({ filter: /^env$/ }, args => ({
      path: args.path,
      namespace: 'env-ns',
    }))

    // Load paths tagged with the "env-ns" namespace and behave as if
    // they point to a JSON file containing the environment variables.
    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }))
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [envPlugin],
})
package main

import "encoding/json"
import "os"
import "strings"
import "github.com/evanw/esbuild/pkg/api"

var envPlugin = api.Plugin{
  Name: "env",
  Setup: func(build api.PluginBuild) {
    // Intercept import paths called "env" so esbuild doesn't attempt
    // to map them to a file system location. Tag them with the "env-ns"
    // namespace to reserve them for this plugin.
    build.OnResolve(api.OnResolveOptions{Filter: `^env$`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        return api.OnResolveResult{
          Path:      args.Path,
          Namespace: "env-ns",
        }, nil
      })

    // Load paths tagged with the "env-ns" namespace and behave as if
    // they point to a JSON file containing the environment variables.
    build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: "env-ns"},
      func(args api.OnLoadArgs) (api.OnLoadResult, error) {
        mappings := make(map[string]string)
        for _, item := range os.Environ() {
          if equals := strings.IndexByte(item, '='); equals != -1 {
            mappings[item[:equals]] = item[equals+1:]
          }
        }
        bytes, err := json.Marshal(mappings)
        if err != nil {
          return api.OnLoadResult{}, err
        }
        contents := string(bytes)
        return api.OnLoadResult{
          Contents: &contents,
          Loader:   api.LoaderJSON,
        }, nil
      })
  },
}

func main() {
  result := api.Build(api.BuildOptions{
    EntryPoints: []string{"app.js"},
    Bundle:      true,
    Outfile:     "out.js",
    Plugins:     []api.Plugin{envPlugin},
    Write:       true,
  })

  if len(result.Errors) > 0 {
    os.Exit(1)
  }
}

次のように使用します

import { PATH } from 'env'
console.log(`PATH is ${PATH}`)

概念

esbuildのプラグインの作成は、他のバンドラーのプラグインの作成とは少し異なります。プラグインを開発する前に、以下の概念を理解することが重要です。

名前空間

すべてのモジュールには、関連付けられた名前空間があります。デフォルトでは、esbuildはファイルシステム上のファイルに対応するfile名前空間で動作します。しかし、esbuildはファイルシステムに対応する場所を持たない「仮想」モジュールも処理できます。これが発生する1つのケースは、stdinを使用してモジュールが提供される場合です。

プラグインを使用して仮想モジュールを作成できます。仮想モジュールは通常、ファイルシステムモジュールと区別するためにfile以外の名前空間を使用します。通常、名前空間はそれらを作成したプラグインに固有です。たとえば、以下のサンプルのHTTPプラグインは、ダウンロードされたファイルにhttp-url名前空間を使用します。

フィルター

すべてのコールバックは、フィルターとして正規表現を提供する必要があります。これは、esbuildがパスがフィルターと一致しない場合にコールバックの呼び出しをスキップするために使用されます。これはパフォーマンスのために行われます。esbuildの高度に並列化された内部からシングルスレッドのJavaScriptコードを呼び出すことはコストがかかり、速度を最大にするために可能な限り避ける必要があります。

可能な限り、JavaScriptコードでフィルタリングする代わりに、フィルター正規表現を使用するようにしてください。正規表現はJavaScriptを呼び出さずにesbuild内で評価されるため、こちらの方が高速です。たとえば、以下のサンプルのHTTPプラグインは、^https?://のフィルターを使用して、プラグインを実行する際のパフォーマンスオーバーヘッドがhttp://またはhttps://で始まるパスに対してのみ発生するようにしています。

許可される正規表現構文は、Goの正規表現エンジンでサポートされている構文です。これはJavaScriptとは少し異なります。具体的には、先読み、後読み、後方参照はサポートされていません。Goの正規表現エンジンは、JavaScriptの正規表現に影響を与える可能性のある、壊滅的な指数時間最悪ケースのパフォーマンス問題を回避するように設計されています。

名前空間もフィルタリングに使用できることに注意してください。コールバックはフィルター正規表現を提供する必要がありますが、オプションで名前空間を提供して、一致するパスをさらに制限することもできます。これは、仮想モジュールがどこから来たのかを「記憶」するのに役立ちます。名前空間は正規表現ではなく、完全な文字列一致テストを使用して照合されるため、モジュールパスとは異なり、任意のデータを格納することを意図していないことに注意してください。

On-resolveコールバック

onResolveを使用して追加されたコールバックは、esbuildがビルドする各モジュール内の各インポートパスで実行されます。コールバックは、esbuildがパス解決を行う方法をカスタマイズできます。たとえば、インポートパスをインターセプトして、他の場所にリダイレクトできます。また、パスを外部としてマークすることもできます。次に例を示します。

JS Go
import * as esbuild from 'esbuild'
import path from 'node:path'

let exampleOnResolvePlugin = {
  name: 'example',
  setup(build) {
    // Redirect all paths starting with "images/" to "./public/images/"
    build.onResolve({ filter: /^images\// }, args => {
      return { path: path.join(args.resolveDir, 'public', args.path) }
    })

    // Mark all paths starting with "http://" or "https://" as external
    build.onResolve({ filter: /^https?:\/\// }, args => {
      return { path: args.path, external: true }
    })
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [exampleOnResolvePlugin],
  loader: { '.png': 'binary' },
})
package main

import "os"
import "path/filepath"
import "github.com/evanw/esbuild/pkg/api"

var exampleOnResolvePlugin = api.Plugin{
  Name: "example",
  Setup: func(build api.PluginBuild) {
    // Redirect all paths starting with "images/" to "./public/images/"
    build.OnResolve(api.OnResolveOptions{Filter: `^images/`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        return api.OnResolveResult{
          Path: filepath.Join(args.ResolveDir, "public", args.Path),
        }, nil
      })

    // Mark all paths starting with "http://" or "https://" as external
    build.OnResolve(api.OnResolveOptions{Filter: `^https?://`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        return api.OnResolveResult{
          Path:     args.Path,
          External: true,
        }, nil
      })
  },
}

func main() {
  result := api.Build(api.BuildOptions{
    EntryPoints: []string{"app.js"},
    Bundle:      true,
    Outfile:     "out.js",
    Plugins:     []api.Plugin{exampleOnResolvePlugin},
    Write:       true,
    Loader: map[string]api.Loader{
      ".png": api.LoaderBinary,
    },
  })

  if len(result.Errors) > 0 {
    os.Exit(1)
  }
}

コールバックは、パスを提供せずに返して、パス解決の責任を次のコールバックに渡すことができます。特定のインポートパスについて、すべてのプラグインからのすべてのonResolveコールバックは、登録された順序で、いずれかがパス解決の責任を引き受けるまで実行されます。コールバックがパスを返さない場合、esbuildはデフォルトのパス解決ロジックを実行します。

多くのコールバックが同時に実行されている可能性があることに注意してください。JavaScriptでは、コールバックがfs.existsSync()など、別のスレッドで実行できる負荷の高い作業を行う場合、コールバックをasyncにしてawait(この場合はfs.promises.exists()を使用)を使用して、他のコードがその間に実行できるようにする必要があります。Goでは、各コールバックは別のゴルーチンで実行される場合があります。プラグインが共有データ構造体を使用する場合は、適切な同期がとれていることを確認してください。

On-resolveオプション

onResolve APIは、setup関数内で呼び出されることを意図しており、特定の状況でトリガーされるコールバックを登録します。いくつかのオプションがあります。

JS Go
interface OnResolveOptions {
  filter: RegExp;
  namespace?: string;
}
type OnResolveOptions struct {
  Filter    string
  Namespace string
}

On-resolve引数

esbuildがonResolveによって登録されたコールバックを呼び出すと、インポートされたパスに関する情報を含むこれらの引数が提供されます。

JS Go
interface OnResolveArgs {
  path: string;
  importer: string;
  namespace: string;
  resolveDir: string;
  kind: ResolveKind;
  pluginData: any;
}

type ResolveKind =
  | 'entry-point'
  | 'import-statement'
  | 'require-call'
  | 'dynamic-import'
  | 'require-resolve'
  | 'import-rule'
  | 'composes-from'
  | 'url-token'
type OnResolveArgs struct {
  Path       string
  Importer   string
  Namespace  string
  ResolveDir string
  Kind       ResolveKind
  PluginData interface{}
}

const (
  ResolveEntryPoint        ResolveKind
  ResolveJSImportStatement ResolveKind
  ResolveJSRequireCall     ResolveKind
  ResolveJSDynamicImport   ResolveKind
  ResolveJSRequireResolve  ResolveKind
  ResolveCSSImportRule     ResolveKind
  ResolveCSSComposesFrom   ResolveKind
  ResolveCSSURLToken       ResolveKind
)

On-resolve結果

これは、onResolveを使用して追加されたコールバックによって返される可能性のあるオブジェクトであり、カスタムパス解決を提供します。パスを提供せずにコールバックから返したい場合は、デフォルト値を返すだけです(JavaScriptではundefined、GoではOnResolveResult{})。返される可能性のあるオプションのプロパティは次のとおりです。

JS Go
interface OnResolveResult {
  errors?: Message[];
  external?: boolean;
  namespace?: string;
  path?: string;
  pluginData?: any;
  pluginName?: string;
  sideEffects?: boolean;
  suffix?: string;
  warnings?: Message[];
  watchDirs?: string[];
  watchFiles?: string[];
}

interface Message {
  text: string;
  location: Location | null;
  detail: any; // The original error from a JavaScript plugin, if applicable
}

interface Location {
  file: string;
  namespace: string;
  line: number; // 1-based
  column: number; // 0-based, in bytes
  length: number; // in bytes
  lineText: string;
}
type OnResolveResult struct {
  Errors      []Message
  External    bool
  Namespace   string
  Path        string
  PluginData  interface{}
  PluginName  string
  SideEffects SideEffects
  Suffix      string
  Warnings    []Message
  WatchDirs   []string
  WatchFiles  []string
}

type Message struct {
  Text     string
  Location *Location
  Detail   interface{} // The original error from a Go plugin, if applicable
}

type Location struct {
  File      string
  Namespace string
  Line      int // 1-based
  Column    int // 0-based, in bytes
  Length    int // in bytes
  LineText  string
}

オンロードコールバック

`onLoad`を使用して追加されたコールバックは、外部としてマークされていない一意のパス/名前空間ペアごとに実行されます。その仕事は、モジュールの内容を返し、esbuildにそれをどのように解釈するかを指示することです。`.txt`ファイルを単語の配列に変換するサンプルプラグインを次に示します

JS Go
import * as esbuild from 'esbuild'
import fs from 'node:fs'

let exampleOnLoadPlugin = {
  name: 'example',
  setup(build) {
    // Load ".txt" files and return an array of words
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      let text = await fs.promises.readFile(args.path, 'utf8')
      return {
        contents: JSON.stringify(text.split(/\s+/)),
        loader: 'json',
      }
    })
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [exampleOnLoadPlugin],
})
package main

import "encoding/json"
import "io/ioutil"
import "os"
import "strings"
import "github.com/evanw/esbuild/pkg/api"

var exampleOnLoadPlugin = api.Plugin{
  Name: "example",
  Setup: func(build api.PluginBuild) {
    // Load ".txt" files and return an array of words
    build.OnLoad(api.OnLoadOptions{Filter: `\.txt$`},
      func(args api.OnLoadArgs) (api.OnLoadResult, error) {
        text, err := ioutil.ReadFile(args.Path)
        if err != nil {
          return api.OnLoadResult{}, err
        }
        bytes, err := json.Marshal(strings.Fields(string(text)))
        if err != nil {
          return api.OnLoadResult{}, err
        }
        contents := string(bytes)
        return api.OnLoadResult{
          Contents: &contents,
          Loader:   api.LoaderJSON,
        }, nil
      })
  },
}

func main() {
  result := api.Build(api.BuildOptions{
    EntryPoints: []string{"app.js"},
    Bundle:      true,
    Outfile:     "out.js",
    Plugins:     []api.Plugin{exampleOnLoadPlugin},
    Write:       true,
  })

  if len(result.Errors) > 0 {
    os.Exit(1)
  }
}

コールバックは、モジュールの内容を提供せずに返すことができます。その場合、モジュールをロードする責任は、次に登録されたコールバックに渡されます。特定のモジュールの場合、すべてのプラグインからのすべての`onLoad`コールバックは、モジュールのロードの責任を負うまで、登録された順序で実行されます。モジュールの内容を返すコールバックがない場合、esbuildはデフォルトのモジュールロードロジックを実行します。

多くのコールバックが同時に実行されている可能性があることに注意してください。JavaScriptでは、コールバックが`fs.readFileSync()`などの別のスレッドで実行できる負荷の高い作業を行う場合、コールバックを`async`にして`await`(この場合は`fs.promises.readFile()`を使用)を使用して、他のコードがその間に実行できるようにする必要があります。Goでは、各コールバックは別のゴルーチンで実行される場合があります。プラグインが共有データ構造体を使用する場合は、適切な同期が設定されていることを確認してください。

オンロードオプション

`onLoad` APIは`setup`関数内で呼び出されることを意図しており、特定の状況でトリガーされるコールバックを登録します。いくつかのオプションが必要です

JS Go
interface OnLoadOptions {
  filter: RegExp;
  namespace?: string;
}
type OnLoadOptions struct {
  Filter    string
  Namespace string
}

オンロード引数

esbuildが`onLoad`によって登録されたコールバックを呼び出すと、ロードするモジュールに関する情報を含むこれらの引数が提供されます

JS Go
interface OnLoadArgs {
  path: string;
  namespace: string;
  suffix: string;
  pluginData: any;
  with: Record<string, string>;
}
type OnLoadArgs struct {
  Path       string
  Namespace  string
  Suffix     string
  PluginData interface{}
  With       map[string]string
}

オンロード結果

これは、`onLoad`を使用して追加されたコールバックによって返されることができ、モジュールの内容を提供するオブジェクトです。内容を提供せずにコールバックから返したい場合は、デフォルト値を返すだけです(JavaScriptでは`undefined`、Goでは`OnLoadResult {}`)。返されることができるオプションのプロパティは次のとおりです

JS Go
interface OnLoadResult {
  contents?: string | Uint8Array;
  errors?: Message[];
  loader?: Loader;
  pluginData?: any;
  pluginName?: string;
  resolveDir?: string;
  warnings?: Message[];
  watchDirs?: string[];
  watchFiles?: string[];
}

interface Message {
  text: string;
  location: Location | null;
  detail: any; // The original error from a JavaScript plugin, if applicable
}

interface Location {
  file: string;
  namespace: string;
  line: number; // 1-based
  column: number; // 0-based, in bytes
  length: number; // in bytes
  lineText: string;
}
type OnLoadResult struct {
  Contents   *string
  Errors     []Message
  Loader     Loader
  PluginData interface{}
  PluginName string
  ResolveDir string
  Warnings   []Message
  WatchDirs  []string
  WatchFiles []string
}

type Message struct {
  Text     string
  Location *Location
  Detail   interface{} // The original error from a Go plugin, if applicable
}

type Location struct {
  File      string
  Namespace string
  Line      int // 1-based
  Column    int // 0-based, in bytes
  Length    int // in bytes
  LineText  string
}

プラグインのキャッシュ

esbuild は非常に高速であるため、esbuild でビルドする場合、プラグインの評価が主なボトルネックになることがよくあります。キャッシュの無効化はプラグイン固有であるため、プラグインの評価のキャッシュは esbuild 自体の一部ではなく、各プラグインに任されています。高速化のためにキャッシュを必要とする低速なプラグインを作成している場合は、キャッシュロジックを自分で記述する必要があります。

キャッシュは、基本的にプラグインを表す変換関数をメモ化するマップです。マップのキーには通常、変換関数の入力が含まれ、マップの値には通常、変換関数の出力が含まれます。さらに、マップには通常、時間の経過とともにサイズが大きくなり続けるのを防ぐために、何らかの形式の最低使用頻度キャッシュエビクションポリシーがあります。

キャッシュは、メモリ(esbuild の 再構築 API での使用に有益)、ディスク(個別のビルドスクリプト呼び出し間でのキャッシュに有益)、またはサーバー(異なる開発者マシン間で共有できる非常に遅い変換に有益)に保存できます。キャッシュをどこに保存するかはケース固有であり、プラグインによって異なります。

簡単なキャッシュの例を次に示します。*.example 形式のファイルの内容を入力として受け取り、JavaScript に変換する関数 slowTransform() をキャッシュするとします。esbuild の 再構築 API で使用した場合に、この関数への冗長な呼び出しを回避するメモリ内キャッシュは、次のようになります。

import fs from 'node:fs'

let examplePlugin = {
  name: 'example',
  setup(build) {
    let cache = new Map

    build.onLoad({ filter: /\.example$/ }, async (args) => {
      let input = await fs.promises.readFile(args.path, 'utf8')
      let key = args.path
      let value = cache.get(key)

      if (!value || value.input !== input) {
        let contents = slowTransform(input)
        value = { input, output: { contents } }
        cache.set(key, value)
      }

      return value.output
    })
  }
}

上記のキャッシュコードに関するいくつかの重要な注意点

開始時コールバック

新しいビルドの開始時に通知される開始時コールバックを登録します。これは、初期ビルドだけでなく、すべてのビルドでトリガーされるため、再構築監視モード、および サーブモード に特に役立ちます。開始時コールバックを追加する方法は次のとおりです。

JS Go
let examplePlugin = {
  name: 'example',
  setup(build) {
    build.onStart(() => {
      console.log('build started')
    })
  },
}
package main

import "fmt"
import "github.com/evanw/esbuild/pkg/api"
import "os"

var examplePlugin = api.Plugin{
  Name: "example",
  Setup: func(build api.PluginBuild) {
    build.OnStart(func() (api.OnStartResult, error) {
      fmt.Fprintf(os.Stderr, "build started\n")
      return api.OnStartResult{}, nil
    })
  },
}

func main() {
}

開始時コールバックは複数回実行される可能性があるため、初期化に使用しないでください。何かを初期化したい場合は、プラグインの初期化コードを setup 関数内に直接配置するだけです。

開始時コールバックは async にすることができ、promise を返すことができます。すべてのプラグインからのすべての開始時コールバックは同時に実行され、ビルドはすべての開始時コールバックが完了するまで待ってから続行します。開始時コールバックは、必要に応じてエラーや警告を返して、ビルドに含めることができます。

開始時コールバックには、ビルドオプション を変更する機能がないことに注意してください。初期ビルドオプションは setup 関数内でのみ変更でき、setup が返されると一度だけ使用されます。最初のビルド以降のすべてのビルドは同じ初期オプションを再利用するため、初期オプションは再利用されず、開始コールバック内で行われた build.initialOptions への変更は無視されます。

終了時コールバック

新しいビルドの終了時に通知される終了時コールバックを登録します。これは、初期ビルドだけでなく、すべてのビルドでトリガーされるため、再構築監視モード、および サーブモード に特に役立ちます。終了時コールバックを追加する方法は次のとおりです。

JS Go
let examplePlugin = {
  name: 'example',
  setup(build) {
    build.onEnd(result => {
      console.log(`build ended with ${result.errors.length} errors`)
    })
  },
}
package main

import "fmt"
import "github.com/evanw/esbuild/pkg/api"
import "os"

var examplePlugin = api.Plugin{
  Name: "example",
  Setup: func(build api.PluginBuild) {
    build.OnEnd(func(result *api.BuildResult) (api.OnEndResult, error) {
      fmt.Fprintf(os.Stderr, "build ended with %d errors\n", len(result.Errors))
      return api.OnEndResult{}, nil
    })
  },
}

func main() {
}

すべての終了時コールバックは順番に実行され、各コールバックは最終的なビルド結果にアクセスできます。ビルド結果を返す前に変更し、promise を返すことでビルドの終了を遅らせることができます。ビルドグラフを検査できるようにするには、初期オプションメタファイル 設定を有効にする必要があります。ビルドグラフは、ビルド結果オブジェクトの metafile プロパティとして返されます。

破棄時コールバック

プラグインが使用されなくなったときにクリーンアップを実行するためのon-disposeコールバックを登録します。ビルドが成功したかどうかに関係なく、すべてのbuild()呼び出しの後、および特定のビルドコンテキストでの最初のdispose()呼び出しの後に呼び出されます。on-disposeコールバックを追加する方法は次のとおりです。

JS Go
let examplePlugin = {
  name: 'example',
  setup(build) {
    build.onDispose(() => {
      console.log('This plugin is no longer used')
    })
  },
}
package main

import "fmt"
import "github.com/evanw/esbuild/pkg/api"

var examplePlugin = api.Plugin{
  Name: "example",
  Setup: func(build api.PluginBuild) {
    build.OnDispose(func() {
      fmt.Println("This plugin is no longer used")
    })
  },
}

func main() {
}

ビルドオプションへのアクセス

プラグインは、setupメソッド内から初期ビルドオプションにアクセスできます。これにより、ビルドの構成方法を検査し、ビルドが開始される前にビルドオプションを変更できます。次に例を示します。

JS Go
let examplePlugin = {
  name: 'auto-node-env',
  setup(build) {
    const options = build.initialOptions
    options.define = options.define || {}
    options.define['process.env.NODE_ENV'] =
      options.minify ? '"production"' : '"development"'
  },
}
package main

import "github.com/evanw/esbuild/pkg/api"

var examplePlugin = api.Plugin{
  Name: "auto-node-env",
  Setup: func(build api.PluginBuild) {
    options := build.InitialOptions
    if options.Define == nil {
      options.Define = map[string]string{}
    }
    if options.MinifyWhitespace && options.MinifyIdentifiers && options.MinifySyntax {
      options.Define[`process.env.NODE_ENV`] = `"production"`
    } else {
      options.Define[`process.env.NODE_ENV`] = `"development"`
    }
  },
}

func main() {
}

ビルドの開始後にビルドオプションを変更しても、ビルドには影響しないことに注意してください。特に、リビルドウォッチモード、およびサーブモードは、最初のビルドが開始された後にプラグインがビルドオプションオブジェクトを変更した場合、ビルドオプションを更新しません。

パスの解決

プラグインがon-resolveコールバックから結果を返すと、その結果はesbuildの組み込みパス解決を完全に置き換えます。これにより、プラグインはパス解決の動作を完全に制御できますが、esbuildに既に組み込まれている動作と同様の動作をさせたい場合は、プラグインがその動作の一部を再実装する必要がある場合があります。たとえば、プラグインはユーザーのnode_modulesディレクトリでパッケージを検索したい場合があります。これはesbuildが既に実装していることです。

esbuildの組み込み動作を再実装する代わりに、プラグインはesbuildのパス解決を手動で実行し、結果を検査するオプションがあります。これにより、esbuildのパス解決の入力または出力、あるいはその両方を調整できます。次に例を示します。

JS Go
import * as esbuild from 'esbuild'

let examplePlugin = {
  name: 'example',
  setup(build) {
    build.onResolve({ filter: /^example$/ }, async () => {
      const result = await build.resolve('./foo', {
        kind: 'import-statement',
        resolveDir: './bar',
      })
      if (result.errors.length > 0) {
        return { errors: result.errors }
      }
      return { path: result.path, external: true }
    })
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [examplePlugin],
})
package main

import "os"
import "github.com/evanw/esbuild/pkg/api"

var examplePlugin = api.Plugin{
  Name: "example",
  Setup: func(build api.PluginBuild) {
    build.OnResolve(api.OnResolveOptions{Filter: `^example$`},
      func(api.OnResolveArgs) (api.OnResolveResult, error) {
        result := build.Resolve("./foo", api.ResolveOptions{
          Kind:       api.ResolveJSImportStatement,
          ResolveDir: "./bar",
        })
        if len(result.Errors) > 0 {
          return api.OnResolveResult{Errors: result.Errors}, nil
        }
        return api.OnResolveResult{Path: result.Path, External: true}, nil
      })
  },
}

func main() {
  result := api.Build(api.BuildOptions{
    EntryPoints: []string{"app.js"},
    Bundle:      true,
    Outfile:     "out.js",
    Plugins:     []api.Plugin{examplePlugin},
    Write:       true,
  })

  if len(result.Errors) > 0 {
    os.Exit(1)
  }
}

このプラグインは、パスexampleへのインポートをインターセプトし、esbuildにディレクトリ./barでインポート./fooを解決するように指示し、esbuildが返すパスを外部と見なし、exampleのインポートをその外部パスにマップします。

このAPIについて知っておくべき追加事項を以下に示します。

解決オプション

resolve関数は、解決するパスを最初の引数として、オプションのプロパティを持つオブジェクトを2番目の引数として受け取ります。このオプションオブジェクトは、onResolveに渡される引数と非常によく似ています。使用可能なオプションは次のとおりです。

JS Go
interface ResolveOptions {
  kind: ResolveKind;
  importer?: string;
  namespace?: string;
  resolveDir?: string;
  pluginData?: any;
}

type ResolveKind =
  | 'entry-point'
  | 'import-statement'
  | 'require-call'
  | 'dynamic-import'
  | 'require-resolve'
  | 'import-rule'
  | 'url-token'
type ResolveOptions struct {
  Kind       ResolveKind
  Importer   string
  Namespace  string
  ResolveDir string
  PluginData interface{}
}

const (
  ResolveEntryPoint        ResolveKind
  ResolveJSImportStatement ResolveKind
  ResolveJSRequireCall     ResolveKind
  ResolveJSDynamicImport   ResolveKind
  ResolveJSRequireResolve  ResolveKind
  ResolveCSSImportRule     ResolveKind
  ResolveCSSURLToken       ResolveKind
)

解決結果

resolve関数は、プラグインがonResolveコールバックから返すことができるものと非常によく似たオブジェクトを返します。次のプロパティがあります。

JS Go
export interface ResolveResult {
  errors: Message[];
  external: boolean;
  namespace: string;
  path: string;
  pluginData: any;
  sideEffects: boolean;
  suffix: string;
  warnings: Message[];
}

interface Message {
  text: string;
  location: Location | null;
  detail: any; // The original error from a JavaScript plugin, if applicable
}

interface Location {
  file: string;
  namespace: string;
  line: number; // 1-based
  column: number; // 0-based, in bytes
  length: number; // in bytes
  lineText: string;
}
type ResolveResult struct {
  Errors      []Message
  External    bool
  Namespace   string
  Path        string
  PluginData  interface{}
  SideEffects bool
  Suffix      string
  Warnings    []Message
}

type Message struct {
  Text     string
  Location *Location
  Detail   interface{} // The original error from a Go plugin, if applicable
}

type Location struct {
  File      string
  Namespace string
  Line      int // 1-based
  Column    int // 0-based, in bytes
  Length    int // in bytes
  LineText  string
}

プラグインの例

以下のプラグインの例は、プラグインAPIで実行できるさまざまな種類のことを理解してもらうためのものです。

HTTPプラグイン

この例では、ファイルシステムパス以外のパス形式の使用、名前空間固有のパス解決、解決コールバックとロードコールバックの併用について説明します。

このプラグインを使用すると、HTTP URLをJavaScriptコードにインポートできます。コードはビルド時に自動的にダウンロードされます。次のワークフローを有効にします。

import { zip } from 'https://unpkg.com/lodash-es@4.17.15/lodash.js'
console.log(zip([1, 2], ['a', 'b']))

これは、次のプラグインで実現できます。実際の使用ではダウンロードをキャッシュする必要がありますが、この例では簡潔にするためにキャッシュは省略されています。

JS Go
import * as esbuild from 'esbuild'
import https from 'node:https'
import http from 'node:http'

let httpPlugin = {
  name: 'http',
  setup(build) {
    // Intercept import paths starting with "http:" and "https:" so
    // esbuild doesn't attempt to map them to a file system location.
    // Tag them with the "http-url" namespace to associate them with
    // this plugin.
    build.onResolve({ filter: /^https?:\/\// }, args => ({
      path: args.path,
      namespace: 'http-url',
    }))

    // We also want to intercept all import paths inside downloaded
    // files and resolve them against the original URL. All of these
    // files will be in the "http-url" namespace. Make sure to keep
    // the newly resolved URL in the "http-url" namespace so imports
    // inside it will also be resolved as URLs recursively.
    build.onResolve({ filter: /.*/, namespace: 'http-url' }, args => ({
      path: new URL(args.path, args.importer).toString(),
      namespace: 'http-url',
    }))

    // When a URL is loaded, we want to actually download the content
    // from the internet. This has just enough logic to be able to
    // handle the example import from unpkg.com but in reality this
    // would probably need to be more complex.
    build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => {
      let contents = await new Promise((resolve, reject) => {
        function fetch(url) {
          console.log(`Downloading: ${url}`)
          let lib = url.startsWith('https') ? https : http
          let req = lib.get(url, res => {
            if ([301, 302, 307].includes(res.statusCode)) {
              fetch(new URL(res.headers.location, url).toString())
              req.abort()
            } else if (res.statusCode === 200) {
              let chunks = []
              res.on('data', chunk => chunks.push(chunk))
              res.on('end', () => resolve(Buffer.concat(chunks)))
            } else {
              reject(new Error(`GET ${url} failed: status ${res.statusCode}`))
            }
          }).on('error', reject)
        }
        fetch(args.path)
      })
      return { contents }
    })
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [httpPlugin],
})
package main

import "io/ioutil"
import "net/http"
import "net/url"
import "os"
import "github.com/evanw/esbuild/pkg/api"

var httpPlugin = api.Plugin{
  Name: "http",
  Setup: func(build api.PluginBuild) {
    // Intercept import paths starting with "http:" and "https:" so
    // esbuild doesn't attempt to map them to a file system location.
    // Tag them with the "http-url" namespace to associate them with
    // this plugin.
    build.OnResolve(api.OnResolveOptions{Filter: `^https?://`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        return api.OnResolveResult{
          Path:      args.Path,
          Namespace: "http-url",
        }, nil
      })

    // We also want to intercept all import paths inside downloaded
    // files and resolve them against the original URL. All of these
    // files will be in the "http-url" namespace. Make sure to keep
    // the newly resolved URL in the "http-url" namespace so imports
    // inside it will also be resolved as URLs recursively.
    build.OnResolve(api.OnResolveOptions{Filter: ".*", Namespace: "http-url"},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        base, err := url.Parse(args.Importer)
        if err != nil {
          return api.OnResolveResult{}, err
        }
        relative, err := url.Parse(args.Path)
        if err != nil {
          return api.OnResolveResult{}, err
        }
        return api.OnResolveResult{
          Path:      base.ResolveReference(relative).String(),
          Namespace: "http-url",
        }, nil
      })

    // When a URL is loaded, we want to actually download the content
    // from the internet. This has just enough logic to be able to
    // handle the example import from unpkg.com but in reality this
    // would probably need to be more complex.
    build.OnLoad(api.OnLoadOptions{Filter: ".*", Namespace: "http-url"},
      func(args api.OnLoadArgs) (api.OnLoadResult, error) {
        res, err := http.Get(args.Path)
        if err != nil {
          return api.OnLoadResult{}, err
        }
        defer res.Body.Close()
        bytes, err := ioutil.ReadAll(res.Body)
        if err != nil {
          return api.OnLoadResult{}, err
        }
        contents := string(bytes)
        return api.OnLoadResult{Contents: &contents}, nil
      })
  },
}

func main() {
  result := api.Build(api.BuildOptions{
    EntryPoints: []string{"app.js"},
    Bundle:      true,
    Outfile:     "out.js",
    Plugins:     []api.Plugin{httpPlugin},
    Write:       true,
  })

  if len(result.Errors) > 0 {
    os.Exit(1)
  }
}

プラグインは最初にリゾルバーを使用して、http://およびhttps:// URLをhttp-url名前空間に移動します。名前空間を設定すると、esbuildはこれらのパスをファイルシステムパスとして扱いません。次に、http-url名前空間のローダーがモジュールをダウンロードし、コンテンツをesbuildに返します。そこから、http-url名前空間のモジュール内のインポートパスの別のリゾルバーが相対パスを取得し、インポートするモジュールのURLに対して解決することで、それらを完全なURLに変換します。その後、ダウンロードされたモジュールが追加のモジュールを再帰的にダウンロードできるように、ローダーにフィードバックされます。

WebAssemblyプラグイン

この例では、バイナリデータの操作、importステートメントを使用した仮想モジュールの作成、異なる名前空間での同じパスの再利用について説明します。

このプラグインを使用すると、.wasmファイルをJavaScriptコードにインポートできます。WebAssemblyファイル自体を生成しません。これは、別のツールで行うか、このプラグインの例をニーズに合わせて変更することで行うことができます。次のワークフローを有効にします。

import load from './example.wasm'
load(imports).then(exports => { ... })

.wasmファイルをインポートすると、このプラグインは、デフォルトのエクスポートとしてエクスポートされたWebAssemblyモジュールをロードする単一の関数を持つ、wasm-stub名前空間に仮想JavaScriptモジュールを生成します。そのスタブモジュールは次のようになります。

import wasm from '/path/to/example.wasm'
export default (imports) =>
  WebAssembly.instantiate(wasm, imports).then(
    result => result.instance.exports)

次に、そのスタブモジュールは、esbuildの組み込みバイナリローダーを使用して、wasm-binary名前空間の別のモジュールとしてWebAssemblyファイル自体をインポートします。これは、.wasmファイルをインポートすると、実際には2つの仮想モジュールが生成されることを意味します。プラグインのコードは次のとおりです。

JS Go
import * as esbuild from 'esbuild'
import path from 'node:path'
import fs from 'node:fs'

let wasmPlugin = {
  name: 'wasm',
  setup(build) {
    // Resolve ".wasm" files to a path with a namespace
    build.onResolve({ filter: /\.wasm$/ }, args => {
      // If this is the import inside the stub module, import the
      // binary itself. Put the path in the "wasm-binary" namespace
      // to tell our binary load callback to load the binary file.
      if (args.namespace === 'wasm-stub') {
        return {
          path: args.path,
          namespace: 'wasm-binary',
        }
      }

      // Otherwise, generate the JavaScript stub module for this
      // ".wasm" file. Put it in the "wasm-stub" namespace to tell
      // our stub load callback to fill it with JavaScript.
      //
      // Resolve relative paths to absolute paths here since this
      // resolve callback is given "resolveDir", the directory to
      // resolve imports against.
      if (args.resolveDir === '') {
        return // Ignore unresolvable paths
      }
      return {
        path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path),
        namespace: 'wasm-stub',
      }
    })

    // Virtual modules in the "wasm-stub" namespace are filled with
    // the JavaScript code for compiling the WebAssembly binary. The
    // binary itself is imported from a second virtual module.
    build.onLoad({ filter: /.*/, namespace: 'wasm-stub' }, async (args) => ({
      contents: `import wasm from ${JSON.stringify(args.path)}
        export default (imports) =>
          WebAssembly.instantiate(wasm, imports).then(
            result => result.instance.exports)`,
    }))

    // Virtual modules in the "wasm-binary" namespace contain the
    // actual bytes of the WebAssembly file. This uses esbuild's
    // built-in "binary" loader instead of manually embedding the
    // binary data inside JavaScript code ourselves.
    build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({
      contents: await fs.promises.readFile(args.path),
      loader: 'binary',
    }))
  },
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [wasmPlugin],
})
package main

import "encoding/json"
import "io/ioutil"
import "os"
import "path/filepath"
import "github.com/evanw/esbuild/pkg/api"

var wasmPlugin = api.Plugin{
  Name: "wasm",
  Setup: func(build api.PluginBuild) {
    // Resolve ".wasm" files to a path with a namespace
    build.OnResolve(api.OnResolveOptions{Filter: `\.wasm$`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        // If this is the import inside the stub module, import the
        // binary itself. Put the path in the "wasm-binary" namespace
        // to tell our binary load callback to load the binary file.
        if args.Namespace == "wasm-stub" {
          return api.OnResolveResult{
            Path:      args.Path,
            Namespace: "wasm-binary",
          }, nil
        }

        // Otherwise, generate the JavaScript stub module for this
        // ".wasm" file. Put it in the "wasm-stub" namespace to tell
        // our stub load callback to fill it with JavaScript.
        //
        // Resolve relative paths to absolute paths here since this
        // resolve callback is given "resolveDir", the directory to
        // resolve imports against.
        if args.ResolveDir == "" {
          return api.OnResolveResult{}, nil // Ignore unresolvable paths
        }
        if !filepath.IsAbs(args.Path) {
          args.Path = filepath.Join(args.ResolveDir, args.Path)
        }
        return api.OnResolveResult{
          Path:      args.Path,
          Namespace: "wasm-stub",
        }, nil
      })

    // Virtual modules in the "wasm-stub" namespace are filled with
    // the JavaScript code for compiling the WebAssembly binary. The
    // binary itself is imported from a second virtual module.
    build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: "wasm-stub"},
      func(args api.OnLoadArgs) (api.OnLoadResult, error) {
        bytes, err := json.Marshal(args.Path)
        if err != nil {
          return api.OnLoadResult{}, err
        }
        contents := `import wasm from ` + string(bytes) + `
          export default (imports) =>
            WebAssembly.instantiate(wasm, imports).then(
              result => result.instance.exports)`
        return api.OnLoadResult{Contents: &contents}, nil
      })

    // Virtual modules in the "wasm-binary" namespace contain the
    // actual bytes of the WebAssembly file. This uses esbuild's
    // built-in "binary" loader instead of manually embedding the
    // binary data inside JavaScript code ourselves.
    build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: "wasm-binary"},
      func(args api.OnLoadArgs) (api.OnLoadResult, error) {
        bytes, err := ioutil.ReadFile(args.Path)
        if err != nil {
          return api.OnLoadResult{}, err
        }
        contents := string(bytes)
        return api.OnLoadResult{
          Contents: &contents,
          Loader:   api.LoaderBinary,
        }, nil
      })
  },
}

func main() {
  result := api.Build(api.BuildOptions{
    EntryPoints: []string{"app.js"},
    Bundle:      true,
    Outfile:     "out.js",
    Plugins:     []api.Plugin{wasmPlugin},
    Write:       true,
  })

  if len(result.Errors) > 0 {
    os.Exit(1)
  }
}

プラグインは複数の手順で動作します. まず、解決コールバックは通常のモジュールで.wasmパスをキャプチャし、それらをwasm-stub名前空間に移動します. 次に、wasm-stub名前空間のロードコールバックは、ローダー関数をエクスポートし、.wasmパスをインポートするJavaScriptスタブモジュールを生成します. これにより、解決コールバックが再び呼び出され、今回はパスがwasm-binary名前空間に移動します. 次に、wasm-binary名前空間の2番目のロードコールバックにより、binaryローダーを使用してWebAssemblyファイルがロードされます。これは、esbuildにファイル自体をバンドルに埋め込むように指示します.

Svelteプラグイン

この例では、JavaScriptへのコンパイル言語のサポート、警告とエラーの報告、ソースマップの統合について説明します。

このプラグインを使用すると、Svelteフレームワークの.svelteファイルをバンドルできます。HTMLのような構文でコードを記述し、SvelteコンパイラによってJavaScriptに変換されます。Svelteコードは次のようになります。

<script>
  let a = 1;
  let b = 2;
</script>
<input type="number" bind:value={a}>
<input type="number" bind:value={b}>
<p>{a} + {b} = {a + b}</p>

このコードをSvelteコンパイラでコンパイルすると、svelte/internalパッケージに依存し、コンポーネントをdefaultエクスポートを使用して単一のクラスとしてエクスポートするJavaScriptモジュールが生成されます。これは、.svelteファイルを独立してコンパイルできることを意味し、Svelteはesbuildプラグインに適しています。このプラグインは、次のように.svelteファイルをインポートすることによってトリガーされます。

import Button from './button.svelte'

プラグインのコードは次のとおりです(SvelteコンパイラはJavaScriptで記述されているため、このプラグインのGoバージョンはありません)。

import * as esbuild from 'esbuild'
import * as svelte from 'svelte/compiler'
import path from 'node:path'
import fs from 'node:fs'

let sveltePlugin = {
  name: 'svelte',
  setup(build) {
    build.onLoad({ filter: /\.svelte$/ }, async (args) => {
      // This converts a message in Svelte's format to esbuild's format
      let convertMessage = ({ message, start, end }) => {
        let location
        if (start && end) {
          let lineText = source.split(/\r\n|\r|\n/g)[start.line - 1]
          let lineEnd = start.line === end.line ? end.column : lineText.length
          location = {
            file: filename,
            line: start.line,
            column: start.column,
            length: lineEnd - start.column,
            lineText,
          }
        }
        return { text: message, location }
      }

      // Load the file from the file system
      let source = await fs.promises.readFile(args.path, 'utf8')
      let filename = path.relative(process.cwd(), args.path)

      // Convert Svelte syntax to JavaScript
      try {
        let { js, warnings } = svelte.compile(source, { filename })
        let contents = js.code + `//# sourceMappingURL=` + js.map.toUrl()
        return { contents, warnings: warnings.map(convertMessage) }
      } catch (e) {
        return { errors: [convertMessage(e)] }
      }
    })
  }
}

await esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [sveltePlugin],
})

このプラグインは、コードの取得元を気にすることなく、ロードされたコードをJavaScriptに変換するだけでよいため、解決コールバックではなく、ロードコールバックのみが必要です。

生成されたJavaScriptに//# sourceMappingURL=コメントを追加して、esbuildが生成されたJavaScriptを元のソースコードにどのようにマッピングするかを指示します。ビルド中にソースマップが有効になっている場合、esbuildはこれを使用して、最終的なソースマップで生成された位置が中間JavaScriptコードではなく、元のSvelteファイルにマッピングされるようにします。

プラグインAPIの制限

このAPIは、すべてのユースケースを網羅することを意図していません。バンドルプロセスのすべての部分にフックすることはできません。たとえば、現在、ASTを直接変更することはできません。この制限は、esbuildの優れたパフォーマンス特性を維持するため、およびメンテナンスの負担となり、ASTの変更を伴う改善を妨げる可能性のある過剰なAPIサーフェスを公開しないようにするために存在します。

esbuildを捉える1つの方法は、Webの「リンカ」と考えることです。ネイティブコードのリンカと同様に、esbuildの役割は、一連のファイルを取得し、それらの間の参照を解決してバインドし、すべてのコードがリンクされた単一のファイルを生成することです。プラグインの役割は、最終的にリンクされる個々のファイルを生成することです。

esbuildのプラグインは、比較的スコープが限定されており、ビルドの小さな側面のみをカスタマイズする場合に最適に機能します。たとえば、カスタム形式(例:YAML)の特別な設定ファイル用のプラグインは非常に適切です。使用するプラグインが多いほど、特にプラグインがJavaScriptで記述されている場合、ビルドは遅くなります。プラグインがビルド内のすべてのファイルに適用される場合、ビルドは非常に遅くなる可能性があります。キャッシングが適用可能な場合、プラグイン自体によって行う必要があります。