Plugins

Extend Vercube with config, runtime, CLI, and dev-only hooks - Vite-style plugins in vercube.config.ts

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 inside vercube.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

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.

vercube.config.ts
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:

MethodWhen it runsPurpose
**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 pluginsBind 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 configCall **ctx.register(MyCommand)**
**hooks(ctx)**Only the **vercube dev** parent process, after config loadctx.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

Fieldvercube dev parentvercube CLIBundled worker (createApp)
config / configure
cli / setupCLIskipped
hooksskippedskipped
setup / useskippedskipped

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 for BasePlugin subclasses
  • **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** in vercube.config.ts (no addPlugin in createApp).
  • **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.

Previous

Custom CLI Command

Learn how to create custom CLI commands in Vercube using class-based decorators, register them in vercube.config.ts, and inject services via the DI container

Next