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.


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.*
},
});
Field Type Required Description
name string yes Unique name; appears in logs and error messages
apiVersion 1 yes Discriminates from the legacy shape
setup (ctx: PluginContext) => void | Promise<void> yes Called 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.


All hooks are registered inside setup via ctx.hooks:

Hook When it fires Signature
onConfigResolved Once, after config is loaded (config: ExtForgeConfig) => void
onManifestTransform Once per browser, after manifest assembly (manifest, browser) => manifest
onBuildStart Once per browser build ({ browser, dev }) => void
onBuildEntry Once per entry point (entry) => entry | void
onBuildEnd Once per browser, after all entries bundled (result) => void
onCssTransform Per stylesheet, after the base CSS processor (ctx) => string | void
onDevReload Each HMR reload event in dev mode (event) => void

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


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.


onCssTransform runs for every stylesheet ExtForge emits, after the base CSS strategy (config.css) has run. Each hook receives the previous step’s output as ctx.code, so plugins can chain. Here’s a plugin that runs every stylesheet through Lightning CSS for autoprefixing and minification:

import type { ExtForgePluginV1 } from 'extforge';
import { transform } from 'lightningcss';
export function presetLightning(): ExtForgePluginV1 {
return {
name: 'extforge:preset-lightning',
apiVersion: 1,
setup({ hooks }) {
hooks.onCssTransform((ctx) => {
const { code } = transform({
filename: ctx.file,
code: Buffer.from(ctx.code),
minify: !ctx.dev,
});
return code.toString();
});
},
};
}

Register it in your config:

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

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.


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.


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.