Plugins
Plugins let you change the merged configuration, bind services in the runtime worker, register CLI commands, and subscribe to dev-only events in the parent process (vercube dev). Register them in **defineConfig({ plugins: [...] })**, or keep using **app.addPlugin()** in createApp’s setup (without listing the class in config).
One pipeline, two ways to write it
There is a single plugin model: an object with optional lifecycle fields (config, setup, cli, **hooks**, …). Nothing "different" runs in the engine depending on how you authored it.
- Canonical (recommended) - extend
**BasePlugin**. Fits the rest of Vercube (OOP, DI, packages you publish, anything that grows beyond a few lines). - Syntax sugar -
**defineVercubePlugin({ ... })** (or a plain object / factory that returns the same shape). Same fields, less ceremony for small, local snippets right insidevercube.config.ts- similar in spirit to a quick inline plugin in Vite.
Prefer classes for shared or long-lived plugins; use **defineVercubePlugin** when a class would be noisy. Both can appear in the same plugins array.
Declaring plugins in vercube.config.ts
import { defineConfig, withPluginOptions } from '@vercube/core';
import { HealthPlugin } from './src/Plugins/HealthPlugin';
export default defineConfig({
plugins: [
HealthPlugin,
withPluginOptions(HealthPlugin, { externals: ['some-native-module'] }),
defineVercubePlugin({
name: 'inline',
config: () => ({ server: { port: 3000 } }),
setup: (app) => {
/* bind services when the worker starts */
},
}),
],
});
The list accepts:
- Classes extending
**BasePlugin**(recommended default) **[Class, options]**tuples- Factories -
() => ({ ... })(often returns the same object as below) - Objects - including those returned from
**defineVercubePlugin** (syntax sugar for typing / readability)
Options for a class plugin
Why not "just infer" inside plugins: [...]?plugins is typed as a mixed list (classes, tuples, objects, factories). TypeScript does not keep a strong link between the first and second element of **[Class, options]** in that position, so **options** is often widened to **unknown. There is no good way to get automatic strict typing there without either a small function or an explicit **satisfies on the tuple.
Recommended - withPluginOptions(class, options)
Options are checked against **BasePlugin<TOptions>** (inference from the class; **NoInfer** avoids bad widening). Runtime it is still a normal **[Class, options]** tuple.
import { defineConfig, withPluginOptions } from '@vercube/core';
import { HealthPlugin } from './src/Plugins/HealthPlugin';
export default defineConfig({
plugins: [withPluginOptions(HealthPlugin, { externals: ['my-native-addon'] })],
});
Zero runtime (types only) - if you prefer not to call a function:
[HealthPlugin, { externals: ['my-native-addon'] }] satisfies PluginWithOptions<typeof HealthPlugin>
Define options on the class as **BasePlugin<MyOptions>** (see **HealthPlugin** / **HealthPluginOptions** in **examples/custom-plugin**).
Do not list the same class twice with different options unless you want two independent plugin instances.
Hook order (enforce)
Like Vite, a plugin can run its config hook earlier or later. Works the same on **BasePlugin** (via configure) and on inline / **defineVercubePlugin** objects (config hook):
defineVercubePlugin({
name: 'runs-first',
enforce: 'pre',
config: (cfg) => ({ /* ... */ }),
})
Order is: all enforce: 'pre', then default order, then enforce: 'post'.
Class-based plugins (BasePlugin)
BasePlugin supports optional methods that map to the unified pipeline:
| Method | When it runs | Purpose |
|---|---|---|
**configure(config, options?)** | While resolving config (CLI, vercube dev parent, worker) | Return a partial config; merged so your values override previous keys |
**setup(app, options?)** | Worker runtime, after PluginsRegistry runs addPlugin plugins | Bind services, controllers, start background work |
**use(app, options?)** | Legacy: used if **setup** is not defined (for plugins registered only via configure pipeline) | Same as runtime attach |
**setupCLI(ctx)** | Config resolution when the CLI loads the config | Call **ctx.register(MyCommand)** |
**hooks(ctx)** | Only the **vercube dev** parent process, after config load | ctx.hooks is the dev **Hookable** (bundler-watch:*, dev:reload, …). Narrow with import type { DevKitTypes } from '@vercube/devkit' for typed event names |
The package **@vercube/devkit** implements that parent process (bundler watch + worker fork); the plugin method is named **hooks** because it wires into that Hookable.
import { BasePlugin } from '@vercube/core';
import type { App, ConfigTypes, VercubePluginCliContext, VercubePluginHooksContext } from '@vercube/core';
import type { DevKitTypes } from '@vercube/devkit';
export class MyPlugin extends BasePlugin {
public override name = 'MyPlugin';
public override configure(_cfg: ConfigTypes.Config) {
return { logLevel: 'info' };
}
public override setup(app: App) {
// app.container.bind(...)
}
public override setupCLI(ctx: VercubePluginCliContext) {
ctx.register(MyCommand);
}
public override hooks(ctx: VercubePluginHooksContext) {
const hookable = ctx.hooks as DevKitTypes.App['hooks'];
hookable.hook('dev:reload', () => {
/* optional side effects in parent dev process */
});
}
}
Avoid registering the same plugin both in **plugins: []** and **app.addPlugin()** - hooks would run twice.
Syntax sugar: defineVercubePlugin
**defineVercubePlugin(obj)** is a typed helper that returns the same plugin object you could write literally. It does not add a second runtime or ruleset - it only helps TypeScript and keeps small one-off plugins readable next to **defineConfig**.
import { defineVercubePlugin } from '@vercube/core';
import type { DevKitTypes } from '@vercube/devkit';
export const portPlugin = defineVercubePlugin({
name: 'port',
config: () => ({ server: { port: 4000 } }),
setup: async (app) => {
/* ... */
},
cli: ({ register }) => {
// register(MyCmd);
},
hooks: (ctx) => {
(ctx.hooks as DevKitTypes.App['hooks']).hook('bundler-watch:end', () => {});
},
});
Use **vercubePluginFromClass(SchemaPlugin, options)** when you already have a **BasePlugin** class (e.g. from **@vercube/schema**) but want it next to inline objects in the same plugins array without wrapping it in a new class.
Where hooks run
| Field | vercube dev parent | vercube CLI | Bundled worker (createApp) |
|---|---|---|---|
config / configure | ✅ | ✅ | ✅ |
cli / setupCLI | ✅ | ✅ | skipped |
hooks | ✅ | skipped | skipped |
setup / use | skipped | skipped | ✅ |
loadVercubeConfig always runs the config and cli phases so the worker and tooling share one merged config. The dev parent (via **@vercube/devkit**) calls **invokeVercubePluginDevHooks** after each load (and again when vercube.config.ts changes during watch).
API reference
**defineVercubePlugin(plugin)**- syntax sugar: typed helper; returns the plugin object unchanged**withPluginOptions(PluginClass, options)** - recommended for class + options: infers**TOptions** from the class; emits a**[Class, options]** tuple**PluginWithOptions<typeof MyPlugin>** /**InferPluginOptions<typeof MyPlugin>**- type-only: use with**satisfies**if you want zero runtime calls**vercubePluginFromClass(Class, options?, name?)**- adapter forBasePluginsubclasses**loadVercubeConfig(overrides?, opts?)**-opts.cwd,opts.import(CLI jiti),opts.command**applyVercubePluginHooks(config, env)**- advanced: run the config + CLI pipeline on an in-memory config**invokeVercubePluginDevHooks(plugins, ctx)**- advanced: run each plugin’s**hooks()**in the dev parent; used by**@vercube/devkit**
Types
All plugin-related public types and the **PluginTypes** namespace live in @vercube/core source as a single module: **Types/Plugin.ts** (interfaces **VercubePlugin**, **VercubePluginEnv**, **VercubePluginCliContext**, **VercubePluginHooksContext**, unions **VercubePluginInput**, **PluginWithOptions**, **InferPluginOptions**, etc.).
app.addPlugin(class, options?)
Still supported: registers a BasePlugin subclass on the DI **PluginsRegistry** before config-file plugin setup hooks run.
Example in the repo
The **[examples/custom-plugin](https://github.com/vercube/vercube/tree/main/examples/custom-plugin)** app is the hands-on sample:
**HealthPlugin**is listed only under**plugins**invercube.config.ts(noaddPluginincreateApp).**configure**- optional**externals**for Rolldown (via**withPluginOptions(HealthPlugin, { … })**or an options tuple).**setup**- binds**HealthController**→**GET /_health/**.**setupCLI**- registers**vercube plugin-info**.
Clone or copy it with giget (see that folder’s README). It is also listed on the Examples docs page next to other starter projects.
Config loading: vercube.config.ts is loaded with jiti, which does not resolve tsconfig **paths** (e.g. @/). Any module pulled in from that file should use relative imports, or aliases will fail at runtime when you run vercube dev / the CLI.