Plugin Hooks
Plugins use lifecycle hooks for build-time side effects and low-level bundler
customization. Define shared state in setup() and return the hooks that need
it. Use generated contributions instead when the
behavior should be represented declaratively in the framework IR.
Lifecycle
For plugins created with definePlugin(), typed values stay flat across these
stages: configure and setup use ctx.options; emitIR() uses
ctx.options and ctx.pages[].options; emitPageIR() uses ctx.options
and ctx.pageOptions.
| Hook | Purpose |
|---|---|
configureBundler(config, ctx) | Mutate the selected bundler config |
beforeBuild(ctx) | Run after fresh bundler facts arrive and before evjs links or emits canonical output |
transformOutput(output, ctx) | Adjust linked AssetGroup contents or add deployment metadata |
transformHtml(doc, ctx) | Mutate one HTML document at a time; receives the current manifest result fields |
afterBuild({ output, isRebuild }) | Emit final artifacts after build |
dispose(ctx) | Cleanup |
See Plugin Authoring for the configure() and setup()
contracts that run before these hooks.
Rebuild and Watch Behavior
Each afterBuild() hook receives an isolated snapshot of the canonical build
result. Mutating that snapshot is local to the hook and cannot change the input
seen by later hooks or deployment adapters.
In dev, beforeBuild() and afterBuild() run as a pair for the initial output
with isRebuild: false, then for every evjs-observable output cycle with
isRebuild: true. beforeBuild() means fresh bundler facts are available and
evjs is about to link and publish canonical output; it is not the underlying
bundler's compile-start callback.
Neither hook runs when bundling fails before producing fresh facts. If
beforeBuild(), linking, an output transform, HTML emission, or publication
fails, afterBuild() does not run. prepare and inspect stage framework
state without publishing output, so they trigger neither hook.
afterBuild() is deliberately post-publication. If it fails, evjs reports the
build or fail-stops the dev session, but it does not roll back canonical output
or artifacts already emitted by earlier afterBuild() hooks.
dispose() runs at most once for each setup snapshot, in reverse plugin order,
when a production build ends, a dev server closes, a config reload replaces
the snapshot, or setup/initialization rolls back. It does not run after an
ordinary dev rebuild.
The setup(), emitIR(), and configureBundler() contexts expose
addWatchFile() for analysis/config dependencies. BeforeBuildContext
deliberately does not; output, HTML, and disposal contexts do not either.
Changing an analysis dependency reuses the committed config,
Application options, and setup hooks, then reruns contributions and graph
analysis. Read changing watched data inside emitIR() rather than
caching it in setup().
configureBundler() context addWatchFile() registers an effective bundler-config
dependency. Changing it stages a complete config and plugin snapshot before
applying the resulting plan update. If the selected adapter cannot safely
replace that configuration in place, the update fails closed with an explicit
restart diagnostic instead of continuing with mixed or stale state.
Build Output Ownership
transformOutput() may adjust only linked AssetGroup contents and deployment
metadata. deployment must be a plain, losslessly JSON-serializable object.
Functions, accessors, non-finite numbers, negative zero, unsafe keys, sparse
arrays, and cycles are rejected immediately after the hook that introduced
them, before later output hooks or publication run.
Every other BuildOutput field remains framework-owned, including:
- the build id, output paths, and public path;
- runtime endpoints and transport;
- server entry, renderers, functions, and routes;
- Application, Page, RSC, and PPR semantics.
Hooks cannot add, remove, or reorder framework records or arrays. In particular, a hook cannot add, remove, or rename Applications, Pages, Routes, or Documents; reorder Routes; change Page paths or Route ownership; or change Document file names and static aliases. Configure those values before graph linking.
HTML Transform Context
transformHtml() receives one parsed document for each emitted static HTML file
and for each Page-specific request-time document shell compiled during the
build. Branch on ctx.owner.kind instead of guessing from filenames.
transformHtml(doc, ctx) {
doc.head?.appendChild(doc.createComment(` build ${ctx.buildId} `));
if (ctx.owner.kind === "application") {
doc.documentElement?.setAttribute("data-app", ctx.applicationId);
}
if (ctx.owner.kind === "page") {
doc.documentElement?.setAttribute("data-page", ctx.owner.pageId);
}
}
Context fields include:
ctx.documentIdandctx.applicationId;ctx.owner:{ kind: "application" },{ kind: "page", pageId }, or{ kind: "plugin", pluginId };ctx.fileNameandctx.template;fileNameis a logical Document filename for a request-time shell and is not emitted as a static file;ctx.assets;ctx.output, the current build output;ctx.buildIdandctx.publicPath.
The document type is HtmlDocument, a bundler-agnostic subset of standard DOM
APIs:
import type { HtmlDocument } from "@evjs/ev/plugin";
Final Build Result
afterBuild() receives the final build output, framework runtime, and canonical
deployment metadata:
setup() {
return {
afterBuild({
output,
frameworkRuntime,
deploymentMetadata,
isRebuild,
}) {
console.log("Apps:", Object.keys(output.apps));
console.log("Pages:", Object.keys(output.pages));
console.log("Runtime routing:", frameworkRuntime?.routing.kind);
console.log("Server entry:", deploymentMetadata.server.entry);
console.log("Deploy routes:", deploymentMetadata.routes.length);
console.log("Rebuild:", isRebuild);
},
};
}
Deployment plugins should prefer deploymentMetadata for routes, documents,
assets, and the server entry. Plugins that need the complete internal build
graph can still inspect output in memory. Runtime-aware plugins can inspect
frameworkRuntime; deployment planning should use deploymentMetadata rather
than deriving split client/server manifests. HTML hooks receive the same result
fields plus document-specific fields such as ctx.owner, ctx.fileName, and
ctx.assets.
Bundler Config
definePlugin() creates a bundler-agnostic plugin by default, so the same
factory can be installed with Utoopack or webpack. Use adapter helpers for
type-safe low-level changes; each helper runs its callback only for its own
adapter and supplies that adapter's concrete config type.
The finalized BuildPlan remains authoritative for framework runtime endpoints
and output ownership. A configureBundler() hook may customize supported loader,
resolution, optimization, and similar low-level settings, but it cannot
override framework client/server output paths. Adapters validate those paths
against the BuildPlan after hooks run, even when recursive cleaning is disabled.
Any plugin-owned clean output must also stay inside the framework-owned
distDir without overlapping client or server output.
Framework-owned client and server configs must also preserve their exact entry
set and each entry's BuildPlan import after every hook. Use generated
contributions to change framework startup composition. A webpack-only plugin
may add a separately named config for an independent artifact, but it must use
an explicit, portably non-overlapping output.path; aliases that differ only
by case still conflict. Utoopack's single framework config cannot accept additional
entries.
For Utoopack:
import { merge, utoopack } from "@evjs/bundler-utoopack";
import { definePlugin } from "@evjs/ev/plugin";
export const yamlPlugin = definePlugin({
id: "yaml-support",
setup() {
return {
configureBundler: utoopack((cfg) => {
merge(cfg, {
module: {
rules: {
".yaml": { type: "json" },
},
},
});
}),
};
},
});
For webpack projects, select the webpack adapter and use its typed helper.
defineConfig() infers the bundler config type from the adapter, and the
helper callback receives the complete Configuration[] set:
import { defineConfig } from "@evjs/ev";
import { webpack, webpackAdapter } from "@evjs/bundler-webpack";
import { definePlugin } from "@evjs/ev/plugin";
const webpackAlias = definePlugin({
id: "webpack-alias",
setup() {
return {
configureBundler: webpack((configs) => {
for (const config of configs) {
config.resolve ??= {};
config.resolve.alias ??= {};
config.resolve.alias["@app"] = "./src";
}
}),
};
},
});
export default defineConfig({
bundler: webpackAdapter,
plugins: [webpackAlias()],
});
For complete examples, continue with Plugin Recipes.