Plugin API

This page details the plug-in system API of VextJS, including plug-in definitions, middleware definition helper functions and related types.

Overview

Plugins are the only extension entry to the VextJS framework. Through plug-ins you can:

  • Mount custom properties to app (app.extend())
  • Register global middleware (app.use())
  • Register graceful close hook (app.onClose())
  • Register readiness hook (app.onReady())
  • Register runtime lifecycle hook (app.hooks.on())
  • Replace built-in implementation (app.setValidator() / app.setThrow() / app.setRateLimiter())

Plugin files live in src/plugins/, where plugin-loader scans them at startup. This page follows definition → lifecycle → loading → middleware helpers → types/resources. Its local examples do not form one complete project.


definePlugin

definePlugin is the recommended way to create plugins and provides type inference and IDE auto-completion support.

Function signature

function definePlugin(plugin: VextPlugin): VextPlugin;

Receives a VextPlugin object and returns it unchanged (for type annotation only).

Basic usage

// src/plugins/demo-cache.ts
import { definePlugin } from "vextjs";

export default definePlugin({
  name: "demo-cache",
  setup(app) {
    const cache = new Map<string, string>();
    app.extend("demoCache", cache);
    app.onClose(() => {
      cache.clear();
    });
  },
});

This shows definition and close registration only. The Map has no TTL, capacity limit, or persistence. See the Plugins Guide for external resources such as Redis.

defineAppExtensions

Declare explicit generated types for values exposed through app.extend():

function defineAppExtensions<T extends Record<string, unknown>>(): T;

Export a top-level value named appExtensions from the plugin file:

import { defineAppExtensions } from "vextjs";

export const appExtensions = defineAppExtensions<{
  demoCache: Map<string, string>;
}>();

At runtime this returns an empty object. It neither creates the Map, calls app.extend(), nor validates the actual value. npm exec -- vext typegen reads the declaration and generates app types; it must agree with the value mounted in setup(). See Project Structure for generated files and TypeScript integration.


VextPlugin

Plug-in interface definition.

interface VextPlugin {
  readonly name: string;
  readonly dependencies?: string[];
  setup(
    app: VextPluginContext,
    context: VextPluginSetupContext,
  ): Promise<void> | void;
  onReady?(app: VextPluginContext): Promise<void> | void;
  onClose?(app: VextPluginContext): Promise<void> | void;
}

VextPluginContext supplies config, logger, hooks, services, adapter, cache, fetch, and extension/replacement/lifecycle methods, according to lifecycle stage. Its type does not provide app.get/post/... route registration; use defineRoutes(). Custom values use a string index and may need explicit type declarations or narrowing.

VextPluginSetupContext exposes readonly signal: AbortSignal only as the second setup() argument. onReady and onClose do not receive it.

name

Plug-in name, globally unique identifier.

readonly name: string;

Used for logs, errors, and dependencies. Duplicate user plugin names fail before setup; later scanning does not overwrite earlier plugins. Use the relevant app.set*() API to replace framework capabilities, and avoid built-in extension names for custom resources.

export default definePlugin({
  name: "my-plugin", // unique identifier
  setup(app) {
    /* ... */
  },
});

dependencies

List of other plugin names that it depends on (optional).

readonly dependencies?: string[];

plugin-loader topologically sorts user plugins so declared dependencies finish setup before the current plugin. Each dependency name must exist among user plugins scanned in this run; missing or cyclic dependencies fail startup.

export default definePlugin({
  name: "user-cache",
  dependencies: ["redis", "sql-database"],
  async setup(app) {
    // Their setup completed; connection readiness depends on their implementations.
    const userCache = new UserCacheService(app.redis, app.sql);
    app.extend("userCache", userCache);
  },
});

This fragment assumes two user plugins named redis and sql-database mount app.redis and app.sql, and the app provides UserCacheService.

Warning

Circular dependencies can cause startup failure:

[vextjs] Circular dependency detected in plugins: redis → sql-database → redis

setup(app, context)

The plug-in initialization function is called by plugin-loader in step ② of bootstrap.

setup(
  app: VextPluginContext,
  context: VextPluginSetupContext,
): Promise<void> | void;

Parameters:

ParametersTypeDescription
appVextPluginContextRevocable setup facade; app.use() is available and app.services has not been injected yet
contextVextPluginSetupContextSetup lifecycle context containing signal: AbortSignal for cancellable I/O

Key Notes:

  • Can be a synchronous or asynchronous function
  • plugin-loader sets a hard timeout (default 30 seconds) for each setup(). Failure or timeout aborts context.signal, rolls back controlled setup-stage framework mutations, revokes the facade, then throws. Event-loop scheduling is required; synchronous blocking code cannot be forcibly interrupted.
  • The setup facade is revoked after success too. A late asynchronous continuation cannot call controlled extend, use, or lifecycle registration or assign app top-level properties. This does not stop nested object mutation or external I/O; plugins must honor cancellation and clean their own resources.
  • Execution order is determined by dependencies topological sorting
  • app.services has not been injected when setup() is executed (service-loader is executed after plugin-loader), and the service cannot be accessed
  • If the plugin object declares onReady(app) / onClose(app), plugin-loader will automatically register these two life cycle hooks after setup() is successful.
  • app.hooks.on() can be used to register runtime hooks such as request/validation/response/fetch/service/plugin/OpenAPI. For details, see Application instance hooks
import { definePlugin } from "vextjs";

export default definePlugin({
  name: "demo-state",
  setup(app) {
    const state = { requests: 0 };
    app.extend("demoState", state);
    app.use(async (_req, _res, next) => {
      state.requests += 1;
      await next();
    });
    app.onReady(() => {
      app.logger.info("demo-state ready");
    });
    app.onClose(() => {
      app.logger.info({ requests: state.requests }, "demo-state closed");
    });
  },
});

onReady(app) / onClose(app)

The plug-in life cycle hook is an optional field, which is equivalent to manually calling app.onReady() / app.onClose() in setup(), but the semantics are clearer.

export default definePlugin({
  name: "warmup",
  setup(app) {
    app.extend("warmupState", new Map());
  },
  async onReady(app) {
    app.logger.info("warmup plugin is ready");
  },
  async onClose(app) {
    app.logger.info("warmup plugin closed");
  },
});
  • onReady(app): In normal CLI start, runs after HTTP begins listening, useful for warming caches or reporting readiness but unable to prevent requests before listen. See Testing Guide for test-helper trigger conditions.
  • onClose(app): executed during graceful shutdown; multiple shutdown hooks are executed in LIFO order.

Register a particular cleanup either on the object or through app.onClose(), avoiding duplicates. Setup failure/timeout rolls back setup-stage registration, so onClose alone cannot clean resources created before failure.


Plug-in loading mechanism

Automatic scanning

plugin-loader recursively scans .ts, .js, .mjs, and .cjs under src/plugins/. It skips files/directories beginning _ or ., .test./.spec. files, and .d.ts. Each loaded file should default-export a VextPlugin. Built output loads from the actual plugins/ build directory (normally dist/plugins/); JavaScript source mode loads from source.

src/plugins/
  ├── database.ts → definePlugin({ name: 'database', ... })
  ├── redis.ts → definePlugin({ name: 'redis', ... })
  └── auth.ts → definePlugin({ name: 'auth', ... })

Topological sorting

Automatically calculate the execution order based on the dependencies field:

// database.ts — no dependencies, executed first
definePlugin({ name: 'database', setup(app) { ... } })

// redis.ts — no dependencies, parallel to database
definePlugin({ name: 'redis', setup(app) { ... } })

// auth.ts — depends on database and redis
definePlugin({
  name: 'auth',
  dependencies: ['database', 'redis'],
  setup(app) { ... },
})

One order is database → redis → auth. Dependency-free candidates are name-sorted; declare dependencies for required order instead of relying on filenames or scanning accidents.

Timeout protection

Each automatically loaded user plugin setup defaults to 30 seconds. Dev/start/testing entries support config.plugin.setupTimeout in milliseconds, an integer from 1 through 2,147,483,647. Timeout still aborts the signal, revokes the facade, and rolls back uncommitted mutations; pass the signal to cancellable downstream work. The manual setupPlugins callback is outside this loader deadline.

Built-in plug-ins

VextJS has a built-in monsqlize plug-in (database abstraction layer), which is created through createMonSQLizePlugin():

import { createMonSQLizePlugin } from "vextjs";

Although public, this factory's built-in plugin is not in the scanned user-plugin dependency graph. Enabling the built-in database does not make dependencies: ["monsqlize"] a valid user-plugin dependency. Give custom SQL pools separate config and extension names, such as sqlDatabase / sql.


defineMiddleware

Creates a middleware without configuration. It returns the original function marked with a __tag Symbol for loader recognition; the marker does not validate business logic or input data.

Function signature

function defineMiddleware(middleware: VextMiddleware): TaggedMiddleware;

Basic usage

This authentication fragment requires an app-owned verifyJWT implementation to verify signature and expiry and a req.user type extension. If also using framework RouteOptions.auth, populate req.auth; assigning a private req.user does not establish framework identity.

// src/middlewares/auth.ts
import { defineMiddleware } from "vextjs";

export default defineMiddleware(async (req, _res, next) => {
  const token = req.headers.authorization?.replace("Bearer ", "");

  if (!token) {
    req.app.throw(401, "Authentication token not provided");
  }

  try {
    const decoded = await verifyJWT(token);
    req.user = decoded;
  } catch {
    req.app.throw(401, "The authentication token is invalid or expired");
  }

  await next();
});

VextMiddleware type

type VextMiddleware = (
  req: VextRequest,
  res: VextResponse,
  next: () => Promise<void>,
) => Promise<void> | void;

Three parameters:

ParametersTypeDescription
reqVextRequestRequest object
resVextResponseresponse object
next() => Promise<void>Call the next middleware / handler

Onion model

The middleware implements the onion model through await next(), which can be processed before and after the handler is executed:

export default defineMiddleware(async (req, res, next) => {
  //── before handler (request entry stage)──
  const start = Date.now();
  console.log(`→ ${req.method} ${req.path}`);

  await next(); // Execute handler and subsequent middleware

  //── after handler (response return stage)──
  const duration = Date.now() - start;
  console.log(`← ${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
});

Call-stack order:

Request → middleware A(before) → middleware B(before) → handler → middleware B(after) → middleware A(after)

This is call-stack order, not a response buffering guarantee. A handler may have sent or started sending; set headers before await next() if required. A throw can skip normal after code, so use finally for work that must run on success and failure.

Short circuit response

Not calling next() can short-circuit the request and the handler will not be executed:

const blockedIPs = new Set(["192.0.2.10"]); // Example addresses.

export default defineMiddleware(async (req, res, next) => {
  // IP blacklist check
  if (blockedIPs.has(req.ip)) {
    res.status(403).json({ message: "Access Denied" });
    return; //Do not call next()
  }

  await next();
});

Error handling

Errors thrown or awaited within the request chain reach framework error-handler. Background Promises and timer errors outside that chain do not have this guarantee:

export default defineMiddleware(async (req, _res, next) => {
  if (!req.headers.authorization) {
    // Use app.throw to throw standard HTTP errors
    req.app.throw(401, "Authentication token not provided");
    // Equivalent to throw new HttpError(401, 'No authentication token provided')
  }

  await next();
});

If the middleware encounters an "HTTP error that I want to actively return to the caller", it is recommended to use req.app.throw(...). If it is an unexpected runtime failure, you can also directly throw new Error("..."), and the framework will convert it to 500; if you need to return field-level verification details, you should throw VextValidationError.


defineMiddlewareFactory

Create a middleware factory with configuration. Receive configuration parameters and return the middleware function.

Function signature

function defineMiddlewareFactory<TOptions = unknown>(
  factory: (options?: TOptions) => VextMiddleware,
): TaggedMiddlewareFactory<TOptions>;

Basic usage

// src/middlewares/role.ts
import { defineMiddlewareFactory } from "vextjs";

interface RoleOptions {
  required: string | string[];
}

export default defineMiddlewareFactory<RoleOptions>((options) => {
  if (!options) throw new Error("role middleware requires a required option");
  const requiredRoles = Array.isArray(options.required)
    ? options.required
    : [options.required];

  return async (req, _res, next) => {
    const user = req.user;
    if (!user) return req.app.throw(401, "Not authenticated");

    if (!requiredRoles.includes(user.role)) {
      req.app.throw(403, "Insufficient permissions", {
        required: requiredRoles.join(", "),
        current: user.role,
      });
    }

    await next();
  };
});

Configuration transfer

The configuration of the middleware factory is passed through the config.middlewares whitelist:

// src/config/default.ts
export default {
  middlewares: [
    { name: "auth" }, // No configuration middleware
    { name: "role", options: { required: "admin" } }, // Factory middleware + configuration
    { name: "client-cache", options: { maxAge: 300 } }, // Factory middleware + configuration
  ],
};

Route references can override defaults. Route options replace the allowlist default as a whole rather than merging fields. When neither place supplies options, the factory receives undefined and must default or throw explicitly:

app.get(
  "/admin/users",
  {
    middlewares: [
      "auth",
      { name: "role", options: { required: ["admin", "superadmin"] } },
    ],
  },
  handler,
);

More examples

Client cache header middleware:

// src/middlewares/client-cache.ts
import { defineMiddlewareFactory } from "vextjs";

interface ClientCacheOptions {
  maxAge: number; // Cache-Control max-age, in seconds
}

export default defineMiddlewareFactory<ClientCacheOptions>((options) => {
  return async (req, res, next) => {
    await next();
    res.setHeader("Cache-Control", `public, max-age=${options.maxAge}`);
  };
});
Tip

Route-level response caching does not require custom middleware. Please use the cache field of route options directly; its TTL configuration unit is milliseconds. app.cache is the response cache control surface and is only used by invalidate(), delete(), clear() and stats().

Rate limiting: use the built-in limiter:

Do not copy a permanent process-level Map into a middleware factory. Enable Vext's built-in limiter globally and use the route override for narrower limits:

// src/config/default.ts
export default {
  rateLimit: {
    enabled: true,
    max: 100,
    window: 60, // seconds
    keyBy: "ip",
  },
};

// A stricter limit for one route; false disables it for a route.
app.post(
  "/login",
  { override: { rateLimit: { max: 5, window: 60 } } },
  handler,
);

The default built-in store is process-local, so workers and application instances count independently. Configure a shared backend with rateLimit.store: { type: "redis", url } to retain the built-in algorithm and route quota overrides. Use app.setRateLimiter() when you need a custom limiter implementation; this call does not enable rate limiting by itself.

The custom branch calls only check(key) and does not automatically pass the route's max/window. The custom implementation owns its quota algorithm and window. Built-in middleware still selects keys, skips routes with false, sets response headers, and emits 429 responses. Configuration values in rate limit headers do not prove the custom algorithm applies those quotas. See Custom limiter for the complete boundary.


isMiddleware / isMiddlewareFactory

Type checking helper function, used to determine whether a value is a middleware created by defineMiddleware / defineMiddlewareFactory.

Function signature

function isMiddleware(value: unknown): value is TaggedMiddleware;
function isMiddlewareFactory(value: unknown): value is TaggedMiddlewareFactory;

Usage

import { isMiddleware, isMiddlewareFactory } from "vextjs";

const middlewareModule = await import("./middlewares/auth.ts");
const exported = middlewareModule.default;

if (isMiddleware(exported)) {
  // No configuration middleware, use it directly
  adapter.registerMiddleware(exported);
} else if (isMiddlewareFactory(exported)) {
  //Factory middleware, you need to pass in options and get the middleware instance after calling
  const middleware = exported(options);
  adapter.registerMiddleware(middleware);
}
Tip

These two functions are usually used by the middleware-loader inside the framework, and user code rarely needs to call them directly.


VextErrorMiddleware

Error middleware type (used internally by the framework).

type VextErrorMiddleware = (
  error: Error,
  req: VextRequest,
  res: VextResponse,
  next: () => Promise<void>,
) => Promise<void> | void;

Different from ordinary middleware, error middleware receives one more error parameter. The framework's built-in error-handler uses this type. Users usually do not need to create error middleware directly, error-handler already provides complete error handling logic.


TaggedMiddleware / TaggedMiddlewareFactory

The middleware type marked by Symbol is used by middleware-loader to distinguish between ordinary functions and framework middleware.

interface TaggedMiddleware extends VextMiddleware {
  [MIDDLEWARE_SYMBOL]: true;
}

interface TaggedMiddlewareFactory {
  (options: unknown): VextMiddleware;
  [MIDDLEWARE_FACTORY_SYMBOL]: true;
}

Symbol constant

import { MIDDLEWARE_SYMBOL, MIDDLEWARE_FACTORY_SYMBOL } from "vextjs";

These Symbols are automatically attached by defineMiddleware / defineMiddlewareFactory and do not need to be set manually by user code.


Middleware file organization

Directory structure

src/middlewares/
  ├── auth.ts → defineMiddleware(...) // Authentication middleware
  ├── role.ts → defineMiddlewareFactory(...) // Role verification (with configuration)
  ├── client-cache.ts → defineMiddlewareFactory(...) // Client cache header middleware (with configuration)
  └── request-logger.ts → defineMiddleware(...) // Request log

Middleware registration process

  1. middleware-loader scans the src/middlewares/ directory
  2. Match based on file name and config.middlewares whitelist
  3. Use isMiddleware() / isMiddlewareFactory() to distinguish types
  4. Factory middleware calls factory(options) to obtain the middleware instance
  5. Register to the middleware definition mapping (Map<string, VextMiddleware>)
  6. Reference by name when registering the route

Configure whitelist

Only middleware declared in config.middlewares can be referenced in routes options.middlewares:

// src/config/default.ts
export default {
  middlewares: [
    { name: "auth" },
    { name: "role", options: { required: "user" } },
  ],
};

Middleware not declared in the whitelist will throw a startup error when referenced in a route.


Built-in middleware

VextJS provides the following built-in middleware, which is automatically registered by bootstrap and does not require manual configuration:

MiddlewareFunctionDescription
Request IDcreateRequestIdMiddleware()Request ID generation/transmission
CORScreateCorsMiddleware()Cross-domain resource sharing
Body ParsercreateBodyParserMiddleware()Request body parsing
Rate LimitcreateRateLimitMiddleware()Rate Limit
Response WrapperresponseWrapperExport packaging
Access LogcreateAccessLogMiddleware()Access Log
Error HandlercreateErrorHandler()Error Handling

These middlewares can configure their behavior through config (see Configuration Items), but cannot be registered repeatedly through app.use().

Execution order

Execution order of built-in middleware (from outside to inside):

Request entry
  → requestId ← Generate/transmit requestId
  → cors ← CORS preflight
  → bodyParser ← Parse the request body
  → rateLimit ← Rate limit check
  → responseWrapper ← Open export packaging
  → accessLog ← Record request start time
  → [Global middleware] ← The plug-in is registered through app.use()
  → [Routing middleware] ← referenced by routing options.middlewares
  → [validate] ← Parameter verification
  → handler ← routing processing function
  ← accessLog ← Record time consumption and status code
  ← responseWrapper ← Wrap response
  ← errorHandler ← Capture unhandled errors
response return

Best practices for plug-in development

1. Naming convention

  • plugin name uses kebab-case: 'my-plugin'
  • The file name is consistent with the plug-in name: src/plugins/my-plugin.ts

2. Type declaration

Use declare module to provide type hints for extended properties:

// types/vext.d.ts
declare module "vextjs" {
  interface VextApp {
    redis: import("ioredis").Redis;
    db: import("./db").DatabasePool;
  }

  interface VextRequest {
    user?: {
      id: string;
      role: string;
    };
  }

  interface VextConfig {
    redis?: {
      host: string;
      port: number;
    };
    database?: {
      connectionString: string;
    };
  }
}

3. Resource cleanup

Always clean up resources created by plugins in onClose:

export default definePlugin({
  name: "database",
  async setup(app) {
    const pool = await createPool(app.config.database);
    app.extend("db", pool);

    // ✅ Be sure to register to close the hook
    app.onClose(async () => {
      await pool.end();
      app.logger.info("Database connection pool has been closed");
    });
  },
});

4. Error handling

Errors in setup() can cause startup to fail. Ensure that critical resources are initialized with error handling:

export default definePlugin({
  name: "database",
  async setup(app) {
    try {
      const pool = await createPool(app.config.database);
      app.extend("db", pool);
    } catch (err) {
      app.logger.fatal({ error: err }, "Database connection failed");
      throw err; // Rethrow to prevent startup
    }
  },
});

5. Optional dependencies

If a plugin depends on an extended property of another plugin, but the dependency is optional:

export default definePlugin({
  name: "user-cache",
  // Do not declare dependencies, check manually
  setup(app) {
    if (app.redis) {
      // Redis is available, enable caching
      app.extend("userCache", new CachedUserService(app.redis));
    } else {
      // Redis is unavailable, downgrade to no-cache mode
      app.logger.warn("Redis is not configured and user caching is disabled");
      app.extend("userCache", new UserService());
    }
  },
});

Type import

import {
  definePlugin,
  defineMiddleware,
  defineMiddlewareFactory,
  isMiddleware,
  isMiddlewareFactory,
  MIDDLEWARE_SYMBOL,
  MIDDLEWARE_FACTORY_SYMBOL,
} from "vextjs";

import type {
  VextPlugin,
  VextMiddleware,
  VextErrorMiddleware,
  VextHandler,
  VextDefinedMiddleware,
  VextMiddlewareFactory,
  VextMiddlewareExport,
  TaggedMiddleware,
  TaggedMiddlewareFactory,
} from "vextjs";