Skip to content

Plugins

ExtForge plugins let you hook into every stage of the build: config resolution, manifest generation, entry point transformation, and dev reloads. Built-in presets (presetReact()) are plugins too — they use the same API.


The plugin shape

A v1 plugin is a plain object:

import type { ExtForgePluginV1 } from 'extforge';
const myPlugin = (): ExtForgePluginV1 => ({
name: 'my-plugin',
apiVersion: 1,
setup(ctx) {
// register hooks via ctx.hooks.*
},
});
FieldTypeRequiredDescription
namestringyesUnique name; appears in logs and error messages
apiVersion1yesDiscriminates from the legacy shape
setup(ctx: PluginContext) => void | Promise<void>yesCalled once after config resolution; register hooks here

The ctx object passed to setup gives you the resolved config, project paths, a scoped logger, and the hook registration methods.


Hooks summary

All hooks are registered inside setup via ctx.hooks:

HookWhen it firesSignature
onConfigResolvedOnce, after config is loaded(config: ExtForgeConfig) => void
onManifestTransformOnce per browser, after manifest assembly(manifest, browser) => manifest
onBuildStartOnce per browser build({ browser, dev }) => void
onBuildEntryOnce per entry point(entry) => entry | void
onBuildEndOnce per browser, after all entries bundled(result) => void
onDevReloadEach HMR reload event in dev mode(event) => void

See /reference/plugins/api/ for full type signatures.


Plugin ordering

Plugins run in the order they are resolved:

  1. Built-in plugins (presetReact(), etc.) — injected first based on framework
  2. User plugins — in the order declared in plugins: [...]

For reduce-style hooks (onManifestTransform and onBuildEntry), the output of each plugin is the input to the next. This means the first plugin in the chain sees the base manifest; the last sees the fully transformed one. Order matters if two plugins modify the same field.


Worked example 1: presetTailwind()

A plugin that enables esbuild minification and runs a pre-build step before each browser build:

import type { ExtForgePluginV1 } from 'extforge';
import { spawnSync } from 'node:child_process';
export function presetTailwind(): ExtForgePluginV1 {
return {
name: 'extforge:preset-tailwind',
apiVersion: 1,
setup({ hooks, logger }) {
// Run Tailwind CLI before each browser build
hooks.onBuildStart(({ browser }) => {
logger.info(`[preset-tailwind] Running tailwindcss for ${browser}`);
spawnSync('npx', ['tailwindcss', '-i', 'src/styles/globals.css', '-o', 'src/styles/out.css'], {
stdio: 'pipe',
});
});
// Enable minification in all esbuild entry builds
hooks.onBuildEntry((entry) => ({
...entry,
esbuildOptions: {
...(entry.esbuildOptions ?? {}),
minify: true,
},
}));
},
};
}

Register it in your config:

import { defineConfig } from 'extforge';
import { presetTailwind } from './plugins/preset-tailwind';
export default defineConfig({
plugins: [presetTailwind()],
});

Worked example 2: manifest stamp plugin

A plugin that adds a custom field to the generated manifest using onManifestTransform:

import type { ExtForgePluginV1 } from 'extforge';
interface StampOptions {
buildId: string;
}
export function manifestStamp(options: StampOptions): ExtForgePluginV1 {
return {
name: 'extforge:manifest-stamp',
apiVersion: 1,
setup({ hooks }) {
hooks.onManifestTransform((manifest, browser) => ({
...manifest,
// Chrome allows arbitrary extra fields; other browsers may strip them
_buildId: options.buildId,
_builtFor: browser,
}));
},
};
}

Usage:

export default defineConfig({
plugins: [
manifestStamp({ buildId: process.env.GITHUB_SHA ?? 'local' }),
],
});

onManifestTransform receives the current manifest object and must return the (possibly modified) manifest. The browser argument lets you apply overrides only for specific targets.


Error handling

If a plugin throws during setup or any hook, ExtForge wraps the error in EXT_PLUGIN_FAILED and surfaces the plugin name and hook name alongside the original message:

EXT_PLUGIN_FAILED
Plugin "extforge:manifest-stamp" failed in onManifestTransform: Cannot read property 'x' of undefined

The build exits with code 1. Fix the plugin and re-run.


Legacy plugin shape

ExtForge still accepts the pre-v1 shape for backwards compatibility:

const legacyPlugin = {
name: 'legacy-compat',
// no apiVersion field
setup(config) { /* config is ExtForgeConfig */ },
buildStart() { /* called before each build */ },
buildEnd(result) { /* called after each build */ },
};

The runner detects the missing apiVersion and adapts the legacy plugin automatically. Legacy plugins do not have access to onManifestTransform, onBuildEntry, or onDevReload. Migrate to apiVersion: 1 to use those hooks.

See /reference/plugins/api/ for the complete hook type reference, and /reference/plugins/preset-react/ for the built-in React preset docs.