Sitename

esbuild - Plugins


The plugin API is new and still experimental. It may change in the future before version 1.0.0 of esbuild as new use cases are uncovered. You can follow the tracking issue for updates about this feature.

The plugin API allows you to inject code into various parts of the build process. Unlike the rest of the API, it's not available from the command line. You must write either JavaScript or Go code to use the plugin API. Plugins can also only be used with the build API, not with the transform API.

#Finding plugins

If you're looking for an existing esbuild plugin, you should check out the list of existing esbuild plugins. Plugins on this list have been deliberately added by the author and are intended to be used by others in the esbuild community.

If you want to share your esbuild plugin, you should:

  1. Publish it to npm so others can install it.
  2. Add it to the list of existing esbuild plugins so others can find it.

#Using plugins

An esbuild plugin is an object with a name and a setup function. They are passed in an array to the build API call. The setup function is run once for each build API call.

Here's a simple plugin example that allows you to import the current environment variables at build time:

let envPlugin = {
  name: 'env',
  setup(build) {
    
    
    
    build.onResolve({ filter: /^env$/ }, args => ({
      path: args.path,
      namespace: 'env-ns',
    }))

    
    
    build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
      contents: JSON.stringify(process.env),
      loader: 'json',
    }))
  },
}

require('esbuild').build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [envPlugin],
}).catch(() => process.exit(1))
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) {
    
    
    
    build.OnResolve(api.OnResolveOptions{Filter: `^env$`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        return api.OnResolveResult{
          Path:      args.Path,
          Namespace: "env-ns",
        }, nil
      })

    
    
    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)
  }
}

You would use it like this:

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

#Concepts

Writing a plugin for esbuild works a little differently than writing a plugin for other bundlers. The concepts below are important to understand before developing your plugin:

#Namespaces

Every module has an associated namespace. By default esbuild operates in the file namespace, which corresponds to files on the file system. But esbuild can also handle "virtual" modules that don't have a corresponding location on the file system. One case when this happens is when a module is provided using stdin.

Plugins can be used to create virtual modules. Virtual modules usually use a namespace other than file to distinguish them from file system modules. Usually the namespace is specific to the plugin that created them. For example, the sample HTTP plugin below uses the http-url namespace for downloaded files.

#Filters

Every callback must provide a regular expression as a filter. This is used by esbuild to skip calling the callback when the path doesn't match its filter, which is done for performance. Calling from esbuild's highly-parallel internals into single-threaded JavaScript code is expensive and should be avoided whenever possible for maximum speed.

You should try to use the filter regular expression instead of using JavaScript code for filtering whenever you can. This is faster because the regular expression is evaluated inside of esbuild without calling out to JavaScript at all. For example, the sample HTTP plugin below uses a filter of ^https?:// to ensure that the performance overhead of running the plugin is only incurred for paths that start with http:// or https://.

The allowed regular expression syntax is the syntax supported by Go's regular expression engine. This is slightly different than JavaScript. Specifically, look-ahead, look-behind, and backreferences are not supported. Go's regular expression engine is designed to avoid the catastrophic exponential-time worst case performance issues that can affect JavaScript regular expressions.

Note that namespaces can also be used for filtering. Callbacks must provide a filter regular expression but can optionally also provide a namespace to further restrict what paths are matched. This can be useful for "remembering" where a virtual module came from. Keep in mind that namespaces are matched using an exact string equality test instead of a regular expression, so unlike module paths they are not intended for storing arbitrary data.

#Resolve callbacks

A callback added using onResolve will be run on each import path in each module that esbuild builds. The callback can customize how esbuild does path resolution. For example, it can intercept import paths and redirect them somewhere else. It can also mark paths as external. Here is an example:

let exampleOnResolvePlugin = {
  name: 'example',
  setup(build) {
    let path = require('path')

    
    build.onResolve({ filter: /^images\// }, args => {
      return { path: path.join(args.resolveDir, 'public', args.path) }
    })

    
    build.onResolve({ filter: /^https?:\/\// }, args => {
      return { path: args.path, external: true }
    })
  },
}

require('esbuild').build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [exampleOnResolvePlugin],
  loader: { '.png': 'binary' },
}).catch(() => process.exit(1))
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) {
    
    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
      })

    
    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)
  }
}

The callback can return without providing a path to pass on responsibility for path resolution to the next callback. For a given import path, all onResolve callbacks from all plugins will be run in the order they were registered until one takes responsibility for path resolution. If no callback returns a path, esbuild will run its default path resolution logic.

Keep in mind that many callbacks may be running concurrently. In JavaScript, if your callback does expensive work that can run on another thread such as fs.existsSync(), you should make the callback async and use await (in this case with fs.promises.exists()) to allow other code to run in the meantime. In Go, each callback may be run on a separate goroutine. Make sure you have appropriate synchronization in place if your plugin uses any shared data structures.

#Resolve options

The onResolve API is meant to be called within the setup function and registers a callback to be triggered in certain situations. It takes a few options:

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

#Resolve arguments

When esbuild calls the callback registered by onResolve, it will provide these arguments with information about the imported path:

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'
  | '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
  ResolveCSSURLToken       ResolveKind
)

#Resolve results

This is the object that can be returned by a callback added using onResolve to provide a custom path resolution. If you would like to return from the callback without providing a path, just return the default value (so undefined in JavaScript and OnResolveResult{} in Go). Here are the optional properties that can be returned:

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

interface Message {
  text: string;
  location: Location | null;
  detail: any; 
}

interface Location {
  file: string;
  namespace: string;
  line: number; 
  column: number; 
  length: number; 
  lineText: string;
}
type OnResolveResult struct {
  Errors     []Message
  External   bool
  Namespace  string
  Path       string
  PluginData interface{}
  PluginName string
  Warnings   []Message
  WatchDirs  []string
  WatchFiles []string
}

type Message struct {
  Text     string
  Location *Location
  Detail   interface{} 
}

type Location struct {
  File      string
  Namespace string
  Line      int 
  Column    int 
  Length    int 
  LineText  string
}

#Load callbacks

A callback added using onLoad will be run for each unique path/namespace pair that has not been marked as external. Its job is to return the contents of the module and to tell esbuild how to interpret it. Here's an example plugin that converts .txt files into an array of words:

let exampleOnLoadPlugin = {
  name: 'example',
  setup(build) {
    let fs = require('fs')

    
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      let text = await fs.promises.readFile(args.path, 'utf8')
      return {
        contents: JSON.stringify(text.split(/\s+/)),
        loader: 'json',
      }
    })
  },
}

require('esbuild').build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [exampleOnLoadPlugin],
}).catch(() => process.exit(1))
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) {
    
    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)
  }
}

The callback can return without providing the contents of the module. In that case the responsibility for loading the module is passed to the next registered callback. For a given module, all onLoad callbacks from all plugins will be run in the order they were registered until one takes responsibility for loading the module. If no callback returns contents for the module, esbuild will run its default module loading logic.

Keep in mind that many callbacks may be running concurrently. In JavaScript, if your callback does expensive work that can run on another thread such as fs.readFileSync(), you should make the callback async and use await (in this case with fs.promises.readFile()) to allow other code to run in the meantime. In Go, each callback may be run on a separate goroutine. Make sure you have appropriate synchronization in place if your plugin uses any shared data structures.

#Load options

The onLoad API is meant to be called within the setup function and registers a callback to be triggered in certain situations. It takes a few options:

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

#Load arguments

When esbuild calls the callback registered by onLoad, it will provide these arguments with information about the module to load:

interface OnLoadArgs {
  path: string;
  namespace: string;
  pluginData: any;
}
type OnLoadArgs struct {
  Path       string
  Namespace  string
  PluginData interface{}
}

#Load results

This is the object that can be returned by a callback added using onLoad to provide the contents of a module. If you would like to return from the callback without providing any contents, just return the default value (so undefined in JavaScript and OnLoadResult{} in Go). Here are the optional properties that can be returned:

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

interface Location {
  file: string;
  namespace: string;
  line: number; 
  column: number; 
  length: number; 
  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{} 
}

type Location struct {
  File      string
  Namespace string
  Line      int 
  Column    int 
  Length    int 
  LineText  string
}

#Caching your plugin

Since esbuild is so fast, it's often the case that plugin evaluation is the main bottleneck when building with esbuild. Caching of plugin evaluation is left up to each plugin instead of being a part of esbuild itself because cache invalidation is plugin-specific. If you are writing a slow plugin that needs a cache to be fast, you will have to write the cache logic yourself.

A cache is essentially a map that memoizes the transform function that represents your plugin. The keys of the map usually contain the inputs to your transform function and the values of the map usually contain the outputs of your transform function. In addition, the map usually has some form of least-recently-used cache eviction policy to avoid continually growing larger in size over time.

The cache can either be stored in memory (beneficial for use with esbuild's incremental build API), on disk (beneficial for caching across separate build script invocations), or even on a server (beneficial for really slow transforms that can be shared between different developer machines). Where to store the cache is case-specific and depends on your plugin.

Here is a simple caching example. Say we want to cache the function slowTransform() that takes as input the contents of a file in the *.example format and transforms it to JavaScript. An in-memory cache that avoids redundant calls to this function when used with esbuild's incremental build API) might look something like this:

let examplePlugin = {
  name: 'example',
  setup(build) {
    let fs = require('fs')
    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
    })
  }
}

Some important caveats about the caching code above:

#Start callbacks

Register a start callback to be notified when a new build starts. This triggers for all builds, not just the initial build, so it's especially useful for incremental builds, watch mode, and the serve API. Here's how to add a start callback:

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() {
}

You should not use a start callback for initialization since it can be run multiple times. If you want to initialize something, just put your plugin initialization code directly inside the setup function instead.

The start callback can be async and can return a promise. However, the build does not wait for the promise to be resolved before starting, so a slow start callback will not necessarily slow down the build. All start callbacks are also run concurrently, not consecutively. The returned promise is purely for error reporting, and matters when the start callback needs to do an asynchronous operation that may fail or may produce warnings. If your plugin needs to wait for an asynchronous task in your start callback to complete before any resolve or load callbacks are run, you will need to have your resolve or load callbacks block on that asynchronous task.

Note that start callbacks do not have the ability to mutate the build options. The initial build options can only be modified within the setup function and are consumed once setup returns. All builds after the first one reuse the same initial options so the initial options are never re-consumed, and modifications to build.initialOptions that are done within the start callback are ignored.

#End callbacks

Register a end callback to be notified when a new build ends. This triggers for all builds, not just the initial build, so it's especially useful for incremental builds, watch mode, and the serve API. Here's how to add an end callback:

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) {
      fmt.Fprintf(os.Stderr, "build ended with %d errors\n", len(result.Errors))
    })
  },
}

func main() {
}

All end callbacks are run in serial and each callback is given access to the final build result. It can modify the build result before returning and can delay the end of the build by returning a promise. If you want to be able to inspect the build graph, you should enable the metafile setting on the initial options and the build graph will be returned as the metafile property on the build result object.

#Accessing build options

Plugins can access the initial build options from within the setup method. This lets you inspect how the build is configured as well as modify the build options before the build starts. Here is an example:

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() {
}

Note that modifications to the build options after the build starts do not affect the build. In particular, incremental rebuilds, watch mode, and serve mode do not update their build options if plugins mutate the build options object after the first build has started.

#Example plugins

The example plugins below are meant to give you an idea of the different types of things you can do with the plugin API.

#HTTP plugin

This example demonstrates: using a path format other than file system paths, namespace-specific path resolution, using resolve and load callbacks together.

This plugin allows you to import HTTP URLs into JavaScript code. The code will automatically be downloaded at build time. It enables the following workflow:

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

This can be accomplished with the following plugin. Note that for real usage the downloads should be cached, but caching has been omitted from this example for brevity:

let httpPlugin = {
  name: 'http',
  setup(build) {
    let https = require('https')
    let http = require('http')

    
    
    
    
    build.onResolve({ filter: /^https?:\/\// }, args => ({
      path: args.path,
      namespace: 'http-url',
    }))

    
    
    
    
    
    build.onResolve({ filter: /.*/, namespace: 'http-url' }, args => ({
      path: new URL(args.path, args.importer).toString(),
      namespace: 'http-url',
    }))

    
    
    
    
    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 }
    })
  },
}

require('esbuild').build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [httpPlugin],
}).catch(() => process.exit(1))
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) {
    
    
    
    
    build.OnResolve(api.OnResolveOptions{Filter: `^https?://`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        return api.OnResolveResult{
          Path:      args.Path,
          Namespace: "http-url",
        }, nil
      })

    
    
    
    
    
    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
      })

    
    
    
    
    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)
  }
}

The plugin first uses a resolver to move http:// and https:// URLs to the http-url namespace. Setting the namespace tells esbuild to not treat these paths as file system paths. Then, a loader for the http-url namespace downloads the module and returns the contents to esbuild. From there, another resolver for import paths inside modules in the http-url namespace picks up relative paths and translates them into full URLs by resolving them against the importing module's URL. That then feeds back into the loader allowing downloaded modules to download additional modules recursively.

#WebAssembly plugin

This example demonstrates: working with binary data, creating virtual; modules using import statements, re-using the same path with different namespaces.

This plugin allows you to import .wasm files into JavaScript code. It does not generate the WebAssembly files themselves; that can either be done by another tool or by modifying this example plugin to suit your needs. It enables the following workflow:

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

When you import a .wasm file, this plugin generates a virtual JavaScript module in the wasm-stub namespace with a single function that loads the WebAssembly module exported as the default export. That stub module looks something like this:

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

Then that stub module imports the WebAssembly file itself as another module in the wasm-binary namespace using esbuild's built-in binary loader. This means importing a .wasm file actually generates two virtual modules. Here's the code for the plugin:

let wasmPlugin = {
  name: 'wasm',
  setup(build) {
    let path = require('path')
    let fs = require('fs')

    
    build.onResolve({ filter: /\.wasm$/ }, args => {
      
      
      
      if (args.namespace === 'wasm-stub') {
        return {
          path: args.path,
          namespace: 'wasm-binary',
        }
      }

      
      
      
      
      
      
      
      if (args.resolveDir === '') {
        return 
      }
      return {
        path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path),
        namespace: 'wasm-stub',
      }
    })

    
    
    
    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)`,
    }))

    
    
    
    
    build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, async (args) => ({
      contents: await fs.promises.readFile(args.path),
      loader: 'binary',
    }))
  },
}

require('esbuild').build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [wasmPlugin],
}).catch(() => process.exit(1))
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) {
    
    build.OnResolve(api.OnResolveOptions{Filter: `\.wasm$`},
      func(args api.OnResolveArgs) (api.OnResolveResult, error) {
        
        
        
        if args.Namespace == "wasm-stub" {
          return api.OnResolveResult{
            Path:      args.Path,
            Namespace: "wasm-binary",
          }, nil
        }

        
        
        
        
        
        
        
        if args.ResolveDir == "" {
          return api.OnResolveResult{}, nil 
        }
        if !filepath.IsAbs(args.Path) {
          args.Path = filepath.Join(args.ResolveDir, args.Path)
        }
        return api.OnResolveResult{
          Path:      args.Path,
          Namespace: "wasm-stub",
        }, nil
      })

    
    
    
    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
      })

    
    
    
    
    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)
  }
}

The plugin works in multiple steps. First, a resolve callback captures .wasm paths in normal modules and moves them to the wasm-stub namespace. Then load callback for the wasm-stub namespace generates a JavaScript stub module that exports the loader function and imports the .wasm path. This invokes the resolve callback again which this time moves the path to the wasm-binary namespace. Then the second load callback for the wasm-binary namespace causes the WebAssembly file to be loaded using the binary loader, which tells esbuild to embed the file itself in the bundle.

#Svelte plugin

This example demonstrates: supporting a compile-to-JavaScript language, reporting warnings and errors, integrating source maps.

This plugin allows you to bundle .svelte files, which are from the Svelte framework. You write code in an HTML-like syntax that is then converted to JavaScript by the Svelte compiler. Svelte code looks something like this:

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

Compiling this code with the Svelte compiler generates a JavaScript module that depends on the svelte/internal package and that exports the component as a a single class using the default export. This means .svelte files can be compiled independently, which makes Svelte a good fit for an esbuild plugin. This plugin is triggered by importing a .svelte file like this:

import Button from './button.svelte'

Here's the code for the plugin (there is no Go version of this plugin because the Svelte compiler is written in JavaScript):

let sveltePlugin = {
  name: 'svelte',
  setup(build) {
    let svelte = require('svelte/compiler')
    let path = require('path')
    let fs = require('fs')

    build.onLoad({ filter: /\.svelte$/ }, async (args) => {
      
      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 }
      }

      
      let source = await fs.promises.readFile(args.path, 'utf8')
      let filename = path.relative(process.cwd(), args.path)

      
      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)] }
      }
    })
  }
}

require('esbuild').build({
  entryPoints: ['app.js'],
  bundle: true,
  outfile: 'out.js',
  plugins: [sveltePlugin],
}).catch(() => process.exit(1))

This plugin only needs a load callback, not a resolve callback, because it's simple enough that it just needs to transform the loaded code into JavaScript without worrying about where the code comes from.

It appends a //# sourceMappingURL= comment to the generated JavaScript to tell esbuild how to map the generated JavaScript back to the original source code. If source maps are enabled during the build, esbuild will use this to ensure that the generated positions in the final source map are mapped all the way back to the original Svelte file instead of to the intermediate JavaScript code.

#Plugin API limitations

This API does not intend to cover all use cases. It's not possible to hook into every part of the bundling process. For example, it's not currently possible to modify the AST directly. This restriction exists to preserve the excellent performance characteristics of esbuild as well as to avoid exposing too much API surface which would be a maintenance burden and would prevent improvements that involve changing the AST.

One way to think about esbuild is as a "linker" for the web. Just like a linker for native code, esbuild's job is to take a set of files, resolve and bind references between them, and generate a single file containing all of the code linked together. A plugin's job is to generate the individual files that end up being linked.

Plugins in esbuild work best when they are relatively scoped and only customize a small aspect of the build. For example, a plugin for a special configuration file in a custom format (e.g. YAML) is very appropriate. The more plugins you use, the slower your build will get, especially if your plugin is written in JavaScript. If a plugin applies to every file in your build, then your build will likely be very slow. If caching is applicable, it must be done by the plugin itself.