Tapping directly into the build pipeline with Bun's Plugin API — what `onLoad` and `onResolve` unlock
There's a moment of dread every time you open a bundler config file. You bounce between plugin docs just to handle one file format, flip the loader order upside down, and end up copy-pasting someone else's config from Stack Overflow. What surprised me when I first opened Bun's plugin API wasn't its flashy features — it was that the design lets you cut into that workflow and insert your own code at the right point, just by knowing a handful of hook names.
The noteworthy thing about Bun's plugin system is that the runtime (bun run) and the bundler (bun build) share the same plugin registration mechanism. That said, as we'll revisit later, not every hook works on both sides. This article covers how to build a transformation pipeline with the two hooks onResolve and onLoad, and where you get tripped up when migrating from esbuild.
Hook System Structure: What You Can Do at Each Stage
Bun registers plugins via Bun.plugin() (or the plugins array in a bun.build config), and hooks are accessed inside the setup(build) callback. For ordinary JavaScript/TypeScript plugin authors, the hooks you'll actually work with are four: onStart, onResolve, onLoad, and onEnd.
Here is a summary of each hook's role and support scope:
| Hook | Intervention point | Key return value | Support scope |
|---|---|---|---|
onStart() |
Once, immediately after bundle starts | — | Bundler only |
onResolve() |
Before module path resolution | { path, namespace } |
Runtime and bundler |
onLoad() |
Before file read | { contents, loader } |
Runtime and bundler |
onEnd() |
After bundle completes | — | Bundler only |
There is one more hook deliberately omitted from this table. onBeforeParse exists, but it is exclusive to NAPI native add-ons and cannot be registered from ordinary JS/TS plugin code. Unless you need to attach a transformation directly to the parser thread in Rust or C++, you can treat this hook as non-existent.
One practical pitfall worth flagging: if you want to modify build.config, do it directly in the body of the setup() function, not inside an onStart callback. Touching config inside onStart is silently ignored.
Namespaces: The Address System for Virtual Modules
The core mechanism connecting onResolve and onLoad is the namespace. The default is "file", and a custom namespace is an identifier used only within Bun's internal module resolution graph. This means the import statement in source code is not rewritten into something like yaml:./config.yaml; instead, the namespace value returned by onResolve determines whether a subsequent onLoad hook matches. Users can still write import cfg from "./config.yaml" as usual.
One more thing worth knowing about filter regex: the import path must contain . or : for hooks to match reliably. To intercept bare identifiers with no extension or colon (e.g., import 'something'), additional handling is required.
Three Transformation Scenarios
Scenario 1: Importing a YAML File as a JS Module
This is the basic pattern for keeping config or seed data in YAML and importing it directly in code. Route .yaml files to a custom namespace with onResolve, and perform the actual parsing in onLoad.
import { plugin } from "bun";
import { parse } from "js-yaml";
plugin({
name: "yaml-loader",
setup(build) {
build.onResolve({ filter: /\.ya?ml$/ }, (args) => ({
path: args.path,
namespace: "yaml",
}));
build.onLoad({ filter: /\.ya?ml$/, namespace: "yaml" }, async (args) => {
const text = await Bun.file(args.path).text();
const parsed = parse(text);
return {
contents: `export default ${JSON.stringify(parsed)};`,
loader: "js",
};
});
},
});A community implementation of this same pattern, bun-plugin-yml (stacksjs), already exists, so build your own only if you need a custom data format or special merge rules.
Scenario 2: Resolving a Monorepo with Path Aliases
This pattern uses onResolve to directly map internal monorepo packages without relying on tsconfig.paths. It is especially useful in CI environments where symlink resolution breaks down.
import { plugin } from "bun";
import path from "path";
const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../");
plugin({
name: "monorepo-resolver",
setup(build) {
build.onResolve({ filter: /^@company\// }, (args) => {
const packageName = args.path.replace("@company/", "");
const localPath = path.join(
MONOREPO_ROOT,
"packages",
packageName,
"src/index.ts"
);
return {
path: localPath,
namespace: "file",
};
});
},
});By also using args.importer, you can branch resolution logic per package based on which file triggered the import. Remember that __dirname is not defined in Bun's ESM environment — use import.meta.dir (or import.meta.dirname) instead.
Scenario 3: Build-Time Code Generation
This is the pattern of reading a GraphQL schema in onLoad and returning generated TypeScript source. The example below is conceptual: it uses buildSchema from the graphql package to parse the schema, then extracts top-level type names to produce simple interfaces.
import { plugin } from "bun";
import { buildSchema, GraphQLObjectType } from "graphql";
plugin({
name: "graphql-codegen",
setup(build) {
build.onResolve({ filter: /\.graphql$/ }, (args) => ({
path: args.path,
namespace: "graphql-types",
}));
build.onLoad(
{ filter: /\.graphql$/, namespace: "graphql-types" },
async (args) => {
const source = await Bun.file(args.path).text();
const schema = buildSchema(source);
const lines: string[] = [];
for (const type of Object.values(schema.getTypeMap())) {
if (type.name.startsWith("__")) continue;
if (type instanceof GraphQLObjectType) {
const fields = Object.values(type.getFields())
.map((f) => ` ${f.name}: unknown;`)
.join("\n");
lines.push(`export interface ${type.name} {\n${fields}\n}`);
}
}
return {
contents: lines.join("\n\n"),
loader: "ts",
};
}
);
},
});For production use, a tool like GraphQL Code Generator that handles scalar mapping and resolver signatures is far better, but this skeleton — which file gets intercepted and at what point — is the whole story.
An Honest Comparison with esbuild
Bun's plugin API deliberately drew from esbuild's hook structure, and the two are similar at the level of hook signatures. However, API coverage is not a perfect match, so when porting an esbuild plugin directly, there is a clear divide between what works as-is and what needs porting.
| Item | Bun plugin | esbuild plugin | Vite plugin |
|---|---|---|---|
| API entry point | Single setup(build) |
Single setup(build) |
Separate hook methods |
| Runtime reuse | Shared hooks for runtime and bundler | Bundler only | Dev server and build are separate |
| TypeScript support | Native (no transpilation needed) | Requires extra config | Requires extra config |
onDispose |
Not supported | Supported | N/A |
resolve() method |
Not supported | Supported | N/A |
initialOptions / metafile |
Partial / differs | Supported | N/A |
| Ecosystem size | Relatively small | Moderate | Largest |
In short, esbuild plugins that stay within the basic signatures of onResolve/onLoad/onStart/onEnd have a good chance of being reused in Bun without modification, but plugins that rely on esbuild-specific APIs — onDispose, build.resolve(), initialOptions manipulation, metafile post-processing — require a separate porting effort.
Three common mistakes in practice
First, applying different plugins for the dev server and production build. When the two transformation paths diverge, source maps drift and stack traces point to the wrong lines. Consolidating registration into a single shared config file is safer.
Second, the pitfall of using onResolve asynchronously in a runtime plugin. Issues caused by differences between synchronous and asynchronous handling have been reported in the community (see the reproduction repository in the references), so in a runtime context it is safer to keep returns synchronous where possible.
Third, bare module identifier filtering. If import 'virtual-module' has no . or : in the path, the filter will not match. Designing virtual module identifiers with a colon convention — like virtual:module-name — makes them much easier to handle.
Things to Check Before Writing a Plugin
There is a temptation to diagram the decision process as a flowchart, but in practice most cases require combining hooks (for example, routing a path to a different namespace while simultaneously replacing the original file content). So here is a checklist instead of a decision tree.
- If a working esbuild plugin already exists, first check whether it touches
onDispose,build.resolve(),initialOptions, ormetafile. If it does not use any of those APIs, the fastest path is to try dropping it into Bun as-is. - If you only need simple transpilation and have no reason to intervene in the module graph, calling
Bun.Transpilerprogrammatically is lighter than registering a plugin. - If you need both path mapping and content transformation, the standard approach is to assign a namespace in
onResolveand match it inonLoad. Trying to solve it with only one side quickly complicates error handling. - In workflows that produce a single executable with
bun build --compile, plugins become the place to handle environment variable inlining or config file embedding at build time. Design your hooks with the assumption that these files may not exist at runtime.
In Summary: What onResolve and onLoad Cover
For ordinary plugin authors, the hooks that are practically available are essentially just onResolve and onLoad. onBeforeParse belongs to the native add-on domain, and onStart/onEnd attach as side-effect work in a bundler context. Yet the three scenarios examined in this article — extending file formats, remapping monorepo paths, and generating code from a schema — are all achievable with just these two hooks combined. When you want to treat a new file format like a language, twist module resolution rules to fit your project, or inject generated source code at build time, all three requirements are solved in the same form on a single API surface.
That is why Bun's plugin API is best described not as "having many hooks," but as "opening few of them, with most of what you need at the ones it does open." If you need esbuild's fine-grained control or Vite's broad ecosystem, there are still good reasons to choose those tools. But if you just want to add one or two custom transformations to a backend or full-stack pipeline without learning a new hook system from scratch, these two hooks are worth serious consideration first.
References
- Plugins — Bun Bundler official docs
- Plugins — Bun Runtime official docs
- PluginBuilder.onResolve API reference
- Bun.PluginBuilder TypeScript interface
- The Bun Bundler — official blog
- esbuild vs Bun bundler comparison
- bun-plugin-yml — GitHub
- bun-css-loader — GitHub
- bun-style-loader — GitHub
- Bun Bundling: 8 Edge Cases That Bite — Medium
- bun runtime plugin onResolve sync/async bug reproduction — GitHub