Plugin lifecycle and hooks
It has a name and some hooks. A hook is an object carrying a handler — there is one form, and rollup's bare-function shorthand is refused rather than accepted, because taking both would make the filter, the order and the context argument optional extras on somebody else's design.
const text = { name: "text", transform: { filter: { id: /\.txt$/ }, handler(code, id, ctx) { return { code: `export default ${JSON.stringify(code.trim())};`, type: "js", }; }, }, };
That is a complete, working plugin: it makes import greeting from "./hello.txt" mean the file's contents. Hand it to a build and run it —
import { build } from "runtime:build"; const bundle = await build({ input: "app/main.js", plugins: [text] }); const { output } = await bundle.generate({ format: "esm", codeSplitting: false }); console.log(output[0].code); await bundle.close();
esdev build.mjs
Returning null — or nothing — means not mine, and the next plugin gets a turn. It is the one rollup convention worth keeping, because a hook has to be able to decline. Anything else must be the object: a bare string of code is refused by name.
type is worth the eight characters. It says how the code you just returned should be treated, and omitting it leaves that to the extension — which for .txt is not JavaScript, so the module you carefully wrote comes out the far side as a string literal instead of an export. Set it whenever the code you return is a different language from the file it came from: "js", "jsx", "ts", "css".
The eight hooks
| Hook | Handler | Returns |
|---|---|---|
start | (ctx) | { dependsOn }, or nothing |
resolve | (source, importer, ctx) | { id, external?, virtual? }, or null |
load | (id, ctx) | { code, type?, map?, dependsOn? }, or null |
transform | (code, id, ctx) | { code, type?, map?, dependsOn? }, or null |
end | (error, ctx) | nothing |
bundle | (output, ctx) | nothing |
html | (html, id, ctx) | { html?, tags? }, or null — esdev's HTML targets only |
finish | (targets, ctx) | nothing — esdev build only |
Eight, against rollup's twenty-odd. Each is a promise whatever bundler sits behind this has to keep, so the list is short on purpose. Data comes first and the context comes last, with nothing positional in between: anything one particular hook needs to say — isEntry, on a resolve — rides on the context rather than shifting the signature.
html: the document
An HTML target's document goes through the plugins too. id is its path, and the answer is a replacement document, tags to insert, or both:
html: { order: "pre", handler: (html, id, ctx) => ({ tags: [{ tag: "script", attrs: { type: "module", src: "./src/boot.ts" }, injectTo: "body" }], }), }
| Tag field | |
|---|---|
tag | The element's name |
attrs | Values are escaped; true writes the attribute bare, false leaves it out |
children | Written as given |
injectTo | "head" (default, before </head>), "head-prepend", "body", "body-prepend" |
order: "pre" sees the source, before esdev reads its references, so a script or stylesheet it adds is built exactly as if the author had written it. Unordered and "post" see the written document, with ctx.bundle listing what the build produced; fileName is relative to the output directory, so a file's URL is / + it:
html: { handler: (html, id, ctx) => ({ tags: ctx.bundle .filter((file) => file.type === "chunk" && file.isEntry) .flatMap((file) => file.imports) .map((file) => ({ tag: "link", attrs: { rel: "modulepreload", href: `/${file}` } })), }), }
It runs in esdev build and esdev start alike; the dev loop's update client is added after every plugin. The rest of the document is left byte for byte.
finish: after the whole build
finish runs once per esdev build, after every target is built and every "then": "run" step has run, and before anything moves into place. It is where output that needs the whole build goes: prerendered pages, a sitemap, a search index.
import { write } from "runtime:fs"; finish: { async handler(targets, ctx) { const { outDir, files } = targets.web; // Which file each entry and lazy module became, for a prerenderer's preloads. const entries = files.filter((file) => file.isEntry || file.isDynamicEntry); const manifest = Object.fromEntries(entries.map((file) => [file.facadeModuleId, file.fileName])); await write(`${outDir}/manifest.json`, JSON.stringify(manifest)); }, }
targets is keyed by target name:
| Field | |
|---|---|
platform | "browser" or "server" |
outDir | The absolute directory the target was written to, inside the build's staging directory. For an out file, its directory. |
files | The chunks and assets the bundler wrote there, in the bundle shape, plus an HTML target's document. fileName is relative to outDir. Stylesheets and imported or copied assets are not listed; read outDir for them. |
Write files into an outDir with runtime:fs; they are deployed with the rest of the build. If the hook throws, the build fails and nothing is deployed. ctx.emit() is refused, because every bundle is already written.
It does not run in esdev start, whose output is not a deployment. With esdev build --target, targets holds only the targets that were built. ctx.command is "build"; ctx.platform and ctx.target are absent. Plugins run in pre, unordered, post order, and each sees what the ones before it wrote.
jsx: what only the compiler can do
Not a hook. A hook is handed a module's source and hands source back, which is enough for almost everything — but the JSX pass runs inside the bundler, and your plugin has no way to reach it.
export default { name: "react-refresh", jsx: { refresh: true }, transform: { filter: { id: /\.[jt]sx$/ }, handler }, };
jsx.refresh asks for a registration per component and a signature per hook-using function — what a component-refresh scheme matches components up by so an edit re-renders in place instead of remounting. Finding those needs the syntax tree the compiler already has. The per-module half is yours, in a transform: the registrations are a global call, and it has to mean "register under this module's id" while this module is evaluating.
It is honoured only in a hot dev build (ctx.hot) — the calls it inserts reach globals that only a hot loop installs, so emitting them into a release build would ship calls to something undefined.
Always write a filter
transform: { filter: { id: /\.mdx$/, code: /^---/ }, handler(code, id, ctx) { … }, }
filter.id | a string (exact, not a substring), a RegExp, or an array of either |
filter.code | the same, matched against the source — transform only |
| both given | a module has to satisfy each; within one, any pattern will do |
This is the part with no equivalent in rollup, and the part that decides whether your dev server feels fast. In rollup a hook that returns null costs a function call. Here it costs a round trip into your isolate, so an unfiltered transform is one crossing per module in the graph — four hundred of them on a middling app, to reach the one .mdx file you wanted. The filter is matched on the host's side, before anything crosses.
A pattern the matcher cannot evaluate — JavaScript's regular expressions are larger than what it supports, so lookbehind and backreferences are out — stops filtering rather than failing. The hook is then called for everything and your own code decides, because excluding modules a plugin was meant to see is the expensive way to be wrong.
start and end run once for the whole build, with no module in hand, so a filter on either is refused rather than ignored.
A module with no file
resolve says where a specifier points; load says what is there. Together they are a virtual module — a specifier that exists on no disk, whose contents the plugin invents.
const nav = { name: "nav", resolve: { filter: { id: "@app/nav" }, handler: () => ({ id: "@app/nav", virtual: true }), }, load: { filter: { id: "@app/nav" }, async handler(id, ctx) { return { code: `export default ${JSON.stringify(await readNav())};`, dependsOn: ["docs/_meta.js"], }; }, }, };
virtual: true is how you say there is no file behind the id. Rollup signals that by gluing a NUL byte to the front of the id, and every bundler descended from it inherited the convention; here the notation is the backend's business, applied and stripped where it belongs, so your load filter still matches the id you actually wrote.