Skip to main content

Plugin Hooks

Plugin hooks are for build-time side effects, HTML changes, final deployment files, and low-level bundler customization. Keep lifecycle-hook state in setup() and return only the hooks that need it.

Use Generating Code when a plugin needs to add a module or attach code to a page or entry. Generated contributions are easier to inspect and compose than writing temporary files from hooks.

Lifecycle at a glance​

emitIR() and emitPageIR() declare generated code and must be deterministic. They run after config and plugin settings resolve, and before setup(). evjs may call them more than once. If either method fails, setup() does not run.

Because generation precedes setup(), generated contributions cannot read state initialized there. Share immutable inputs through plugin options or authored files, and keep network, process, watcher, and other disposable state inside the hooks returned from setup().

configure() and setup() are introduced in Plugin Development. Plugins created with definePlugin() receive their typed application options as ctx.options.

HookUse it for
configureBundler(config, ctx)Adapter-specific loaders, resolution, optimization, or other low-level settings
clientDevMiddleware({ signal })Intercept client development HTTP requests before static and HTML fallback handling
devServerReady({ origin, signal })Connect development tools after the client listener is available
beforeBuild(ctx)Start work that needs fresh bundler results before output is finalized
transformOutput(output, ctx)Adjust asset groups or add deployment metadata
transformHtml(document, ctx)Change one generated HTML document or request-time document shell
afterBuild(result)Emit platform files or report the completed build
dispose(ctx)Release resources created by setup() or development hooks

Keep state in setup()​

Create long-lived resources once and close them in dispose():

import { definePlugin } from "@evjs/ev/plugin";

export const reporter = definePlugin({
id: "reporter",
setup(ctx) {
const client = createReporter(ctx.options);

return {
afterBuild({ deploymentMetadata, isRebuild }) {
client.record({ deploymentMetadata, isRebuild });
},
async dispose() {
await client.close();
},
};
},
});

dispose() runs at most once for each successful setup and in reverse plugin order. Make cleanup safe when earlier work completed only partially.

Watch plugin inputs​

setup(), emitIR(), and configureBundler() contexts provide addWatchFile() for project-local files that affect plugin behavior:

setup(ctx) {
ctx.addWatchFile("./config/analytics.json");
}

When a watched input changes, evjs refreshes the development environment as needed. Read generation-specific data in emitIR() so generated code changes with that input. Do not watch generated .ev or dist files.

Development readiness​

Use devServerReady() when an external tool needs the actual client origin:

setup() {
let closeTools: (() => Promise<void>) | undefined;

return {
async devServerReady({ origin, signal }) {
const tools = await connectDevTools({ origin, signal });
closeTools = () => tools.close();
},
async dispose() {
await closeTools?.();
},
};
}
  • origin is the listener URL reported by the active bundler.
  • signal aborts when the development environment is closing.
  • Forward or observe the signal and let asynchronous work settle promptly.
  • This hook runs in development only; it does not mean the first application output or the server runtime is ready.

Keep output-dependent work in afterBuild().

Client development middleware​

clientDevMiddleware() registers Node request/response middleware in stable plugin order before the client development server's fallback:

setup() {
return {
clientDevMiddleware({ signal: sessionSignal }) {
return async (request, response, next, { origin, signal }) => {
if (!request.url?.startsWith("/__plugin/")) return next();
if (signal.aborted || sessionSignal.aborted) return;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ origin }));
};
},
};
}

The hook may return one middleware, an array, or undefined. Await next() when work must continue after downstream handling; pass an error to next() or throw to produce the adapter's diagnostic response. The request context exposes the actual public origin and the immutable development Session's abort signal. WebSocket upgrades bypass plugin middleware and are forwarded transparently to the bundler. The built-in Utoopack and Webpack adapters support this capability; selecting an adapter that does not declare dev.clientMiddleware fails before its listener starts.

Build and rebuild​

beforeBuild() and afterBuild() run only when bundling produces a valid output cycle. prepare and inspect do not call them.

In development:

  • the first successful output uses isRebuild: false;
  • later successful output cycles use isRebuild: true;
  • a failed cycle does not call afterBuild().

afterBuild() runs after framework output files have been published. A failure from that hook still fails a production build, so use it for required artifacts and handle optional reporting failures explicitly.

Transform build output​

transformOutput() may adjust linked asset-group contents and add plugin deployment metadata. Deployment metadata must be plain lossless JSON.

Do not use an output hook to rename pages, routes, documents, runtime paths, or framework output directories. Those choices belong to application config, page config, or a declarative generated contribution.

Transform HTML​

transformHtml() receives a parsed HtmlDocument and context for one document:

transformHtml(document, ctx) {
document.head?.appendChild(
document.createComment(` build ${ctx.buildId} `),
);

if (ctx.owner.kind === "page") {
document.documentElement?.setAttribute(
"data-page",
ctx.owner.pageId,
);
}
}

Useful context fields include:

  • documentId, applicationId, fileName, and template;
  • owner, which identifies an application, page, or plugin document;
  • assets, buildId, and publicPath;
  • the current output for advanced inspection.

Branch on owner.kind rather than guessing ownership from filenames. Import the document type from the public plugin entry:

import type { HtmlDocument } from "@evjs/ev/plugin";

For simple meta, link, script, or style additions, prefer the declarative html.tag slot documented in Generating Code.

Use the final build result​

afterBuild() exposes focused values for common deployment work:

setup() {
return {
afterBuild({ deploymentMetadata, frameworkRuntime, isRebuild }) {
writePlatformManifest({
assets: deploymentMetadata.assets,
routes: deploymentMetadata.routes,
server: deploymentMetadata.server,
runtime: frameworkRuntime,
isRebuild,
});
},
};
}

Prefer deploymentMetadata for routes, documents, assets, and the server entry. Use the broader output only when a plugin truly needs build-time asset details that the deployment projection does not contain.

Configure a bundler​

definePlugin() is bundler-agnostic by default. Use adapter helpers for typed low-level changes; each helper runs only for its adapter.

Utoopack example:

import { merge, utoopack } from "@evjs/bundler-utoopack";
import { definePlugin } from "@evjs/ev/plugin";

export const yamlPlugin = definePlugin({
id: "yaml-support",
setup() {
return {
configureBundler: utoopack((config) => {
merge(config, {
module: {
rules: {
".yaml": { type: "json" },
},
},
});
}),
};
},
});

Webpack example:

import { webpack } from "@evjs/bundler-webpack";

configureBundler: webpack((configs) => {
for (const config of configs) {
config.resolve ??= {};
config.resolve.alias ??= {};
config.resolve.alias["@app"] = "./src";
}
});

Bundler hooks can customize supported low-level settings, but cannot replace framework page entries or client/server output directories. Use generated contributions to change startup composition.

Add terminal shortcuts​

Interactive shortcuts are descriptor declarations rather than lifecycle hooks:

const tools = definePlugin({
id: "tools",
cliShortcuts() {
return [
{
key: "u",
description: "show dev url",
action(session) {
console.log(session.origin);
},
},
];
},
});

Keys are one non-whitespace character. Actions receive the current client origin and a close() method for the full ev dev run. See Local Development for application controls.

For small end-to-end examples, continue with Plugin Recipes.