Plugin context and ordering

The last argument of every handler, never this.

MemberDescription
ctx.resolve(source, importer?, options?)Asks the build's own resolver, mid-hook. null when nothing resolves.
ctx.emit({ type, … })Adds a chunk or an asset to a build already running; returns a reference id. Refused in finish, where every bundle is already written.
ctx.warn(msg) / info / debugDiagnostics. Warnings come back from the build, prefixed with your plugin's name.
ctx.error(msg)Fails the build. It throws — it does not return, because you are saying the build cannot continue.
ctx.isEntryOn resolve: whether this specifier is an entry.
ctx.command"build", "start", "test" or "run". Absent in a build a program starts with runtime:build.
ctx.platform"browser" or "server" ("node" for a runtime:build that asked for it): what the compiled code runs against.
ctx.targetThe project target being built, by name. Absent outside a project build.
ctx.hottrue for a browser build in a hot dev loop, where modules are replaced in place (component refresh).
ctx.bundleOn an html hook not ordered pre: what the build produced, fileName relative to the output directory (html).
ctx.typeOn transform: what the module is now — "css", "jsx", "js", … Not always what the extension says, since a pass ordered pre may already have changed it.

It is an argument so that an arrow-function handler keeps it. Rollup's context-as-this is silently lost by an arrow, and a hook whose this.resolve is undefined fails a long way from the arrow that caused it.

It is also live only while its hook runs. Stash ctx and call it later and it throws — by then it may name a build that no longer exists.

Where a hook runs

One plugin serves every build and every command, so it is told where it is running rather than declared per target. These are facts, not modes: a framework's SPA, SSG or SSR is the plugin's own option, and the plugin combines it with ctx.platform.

JavaScript
export default (options) => ({
  name: "framework",
  transform: {
    filter: { id: /\.jsx$/ },
    handler(code, id, ctx) {
      const mode =
        ctx.platform === "server" ? options.mode : options.mode === "spa" ? "csr" : "hydrate";
      return { code: compile(code, id, mode), type: "js" };
    },
  },
});
Wherecommandplatformtargethot
esdev build, a server targetbuildserverits namefalse
esdev start, a browser targetstartbrowserits nametrue unless --no-hot
esdev build <entry>buildthe entry's—false
esdev testtestserver—false
esdev test --dom, --browsertestbrowser—false
esdev <file>runserver—false

A value that does not apply is absent, not null.

Calling ctx.resolve from your own resolve hook

Pass { skipSelf: true }, or your hook resolves through itself and the build never finishes:

JavaScript
const found = await ctx.resolve(source, importer, { skipSelf: true });

emit puts a file beside the output, or adds an entry to a build in flight:

JavaScript
ctx.emit({ type: "asset", fileName: "meta.json", source: '{"built":true}' });
ctx.emit({ type: "chunk", id: "/app/admin.js" });

order

JavaScript
transform: { order: "pre", filter: { id: /\.mdx$/ }, handler }

"pre" runs before the unordered plugins and "post" after, for when one pass has to see a module before another does. Within a group, plugins run in the order you listed them.

TEXT
order: pre-one -> normal -> post-one

It orders you against esdev's own passes too, not only against the other plugins. esdev:css-modules runs post: it finishes whatever the plugins made CSS, and steps aside for a module whose ctx.type is no longer "css". A plugin that turns a stylesheet into JavaScript says pre, so a plugin that expects CSS still sees the stylesheet before it does:

JavaScript
transform: {
  order: "pre",
  filter: { id: /\.css$/ },
  handler: (code, id) => ({ code: compileTailwind(code, id), type: "js" }),
}

What the build hands back

JavaScript
const { output, watchFiles, warnings } = await bundle.generate({ format: "esm" });

watchFiles is every file the build read plus every file a plugin declared, which is what makes an incremental dev server possible: pair it with runtime:watch and drop the three cached chunks whose dependencies changed rather than all forty. warnings carries the bundler's and yours.

When you get it wrong

The declaration is checked when build() is called, not at the generate() three lines later, so the rejection lands on the line that wrote it.

WrittenWhat it says
transform(code, id) {}p.transform: a hook is an object, not a function — write { handler(...) {} }, optionally with filter and order
transfrom: { … }p: unknown hook "transfrom". Did you mean "transform"?
start: { filter: … }p.start: this hook runs once, for the whole build, so it cannot be filtered
load: { filter: { code: … } }p.load: only transform can filter on code — it is the only hook given any
order: "first"p.transform: order must be "pre" or "post", got "first"
handler: () => "export {}"transform must return an object or null — return { code } instead

A handler that throws fails the build with what it threw, stack and all — a plugin error arriving as a bare "build failed" would be the worst outcome of running hooks on a different thread from the bundler.

Last updated on
Edit this page