plugin

The plug-in system of VextJS is the only extension entrance to the framework. Through plug-ins, you can inject custom capabilities into the app object, register global middleware, replace built-in implementations, and manage resource life cycles.

Basic concepts

Plug-ins are placed in the src/plugins/ directory and are automatically scanned and loaded by plugin-loader. Each plugin is defined through definePlugin(), including a name, dependency declaration and setup() initialization function.

// src/plugins/redis.ts
import { definePlugin } from "vextjs";
import Redis from "ioredis";

export default definePlugin({
  name: "redis",

  async setup(app) {
    const redis = new Redis(app.config.redis?.url ?? "redis://localhost:6379");

    // Mount custom capabilities to the app
    app.extend("redis", redis);

    //Register graceful shutdown hook
    app.onClose(async () => {
      app.logger.info("Closing Redis connection...");
      await redis.quit();
    });

    app.logger.info("Redis plugin initialized");
  },
});

Plug-in interface

interface VextPlugin {
  /** Plug-in name (unique identifier) */
  readonly name: string;

  /** List of other dependent plug-in names */
  readonly dependencies?: string[];

  /** Plug-in initialization function */
  setup(app: VextApp): Promise<void> | void;

  /** Readiness hook executed after HTTP starts listening (optional) */
  onReady?(app: VextApp): Promise<void> | void;

  /** Cleanup hooks executed during graceful shutdown (optional, in LIFO order) */
  onClose?(app: VextApp): Promise<void> | void;
}

name — unique identifier

The plugin name is used for log output, error messages, and dependency declarations. Plug-ins with the same name loaded later will overwrite the ones loaded first and can be used to replace the built-in implementation.

dependencies — dependency declaration

Declare other plugins that the current plugin depends on. plugin-loader performs topological sorting based on dependencies to ensure that dependent plugins execute setup() before the current plugin. Fail Fast reports an error when there are circular dependencies.

export default definePlugin({
  name: "user-cache",
  dependencies: ["redis"], // Make sure the redis plug-in is initialized first

  async setup(app) {
    // At this time app.redis (injected by the redis plug-in) is available
    const redis = (app as any).redis;
    // ...
  },
});

setup() — initialization function

The core logic of the plug-in. Called by plugin-loader in the bootstrap stage, it supports asynchronous operations (such as connecting to the database). Each setup() has timeout protection (default 30 seconds), and an error is automatically thrown after the timeout.

onReady() / onClose() — life cycle hook

Plug-ins can also declare onReady(app) and onClose(app) directly. plugin-loader will register them into the application life cycle after setup() is completed:

  • onReady(app): Executed after HTTP starts listening, suitable for warming up cache, checking external dependencies, and printing startup information.
  • onClose(app): Executed during graceful shutdown. All shutdown hooks clean up resources in last-registration-first-execution (LIFO) order.

Plug-in capabilities

app.extend() — Mount custom properties

Inject custom properties or methods into the app object. Only plugins can use this API.

export default definePlugin({
  name: "mailer",

  async setup(app) {
    const mailer = {
      async send(to: string, subject: string, body: string) {
        //Send email logic...
        app.logger.info({ to, subject }, "Email sent");
      },
    };

    app.extend("mailer", mailer);
  },
});

When using:

// In a route or service
await (app as any).mailer.send("user@example.com", "Welcome", "Hello!");
type tip

If you want to automatically generate plugin extension declarations, export appExtensions = defineAppExtensions<{ ... }>() in the plugin file and run:

vext typegen

Currently, lightweight scanners prioritize inline object generics:

import { defineAppExtensions, definePlugin } from "vextjs";

export const appExtensions = defineAppExtensions<{
  mailer: {
    send(to: string, subject: string, body: string): Promise<void>;
  };
}>();

export default definePlugin({
  name: "mailer",
  setup(app) {
    app.extend("mailer", {
      async send(to: string, subject: string, body: string) {
        app.logger.info({ to, subject }, "Email sent");
      },
    });
  },
});

The command will also best-effort scan app.extend("...") calls in the setup / onReady / onClose life cycle of definePlugin(), and write the results into .vext/types/app-extensions.generated.d.ts, and then access the TypeScript project through src/types/generated/index.d.ts. Complex types, imported type alias or dynamic expansion are not suitable for relying on automatic scanning. It is recommended to use handwritten declare module:

// src/types/extensions.d.ts
declare module "vextjs" {
  interface VextApp {
    mailer: {
      send(to: string, subject: string, body: string): Promise<void>;
    };
  }
}

When extended app.mailer.send() will get IDE auto-completion without the need for as any assertion.

app.use() — Register global middleware

Register global middleware in the plug-in and it will take effect on all routes. These middleware are executed after the built-in global middleware and before the route-level middleware.

export default definePlugin({
  name: "security-headers",

  setup(app) {
    app.use(async (req, res, next) => {
      await next();
      res.setHeader("X-Content-Type-Options", "nosniff");
      res.setHeader("X-Frame-Options", "DENY");
      res.setHeader("X-XSS-Protection", "1; mode=block");
      res.setHeader(
        "Strict-Transport-Security",
        "max-age=31536000; includeSubDomains",
      );
    });
  },
});
note

app.use() can only be called in setup(). Calling after route registration is complete will throw an error.

app.hooks.on() — Register runtime lifecycle hooks

Plugins can also observe the framework life cycle through app.hooks.on(name, handler). It is suitable for cross-cutting logic such as request auditing, outbound call monitoring, service call tracking, response header patch, OpenAPI document patch, etc.

export default definePlugin({
  name: "runtime-observer",

  setup(app) {
    app.hooks.on("handler:error", ({ route, error, requestId }) => {
      app.logger.error({ route: route.path, err: error, requestId });
    });

    app.hooks.on("response:before", ({ headers }) => ({
      headers: { ...headers, "x-runtime": "vext" },
    }));
  },
});

app.hooks is a framework reserved property and cannot be overridden with app.extend("hooks", ...). The plugin setup() itself will also trigger plugin:beforeSetup/afterSetup/error, but a plugin cannot observe its own beforeSetup, it can only be observed by previously loaded plugins.

app.onClose() — Graceful closing hook

Register graceful shutdown hook. When a SIGTERM / SIGINT signal is received, the framework executes all shutdown hooks in reverse order (LIFO) of registration.

Suitable for: closing database connections, refreshing log buffers, canceling scheduled tasks, etc.

export default definePlugin({
  name: "database",

  async setup(app) {
    const db = await createDatabaseConnection(app.config.database);
    app.extend("db", db);

    app.onClose(async () => {
      app.logger.info("Closing database connection...");
      await db.disconnect();
    });
  },
});

app.onReady() — Ready hook

Register ready hook. Triggered after all plug-ins are loaded and HTTP starts listening. Suitable for: warming up cache, checking external dependencies, printing startup information.

export default definePlugin({
  name: "warmup",

  setup(app) {
    app.onReady(async () => {
      // HTTP has started listening and can perform preheating operations
      app.logger.info("Warming up caches...");
      await app.services.product.warmupCache();
      app.logger.info("Cache warmup complete");
    });
  },
});

app.setValidator() — Replacement validation engine

Replace the framework's built-in parameter validation engine. schema-dsl is used by default and can be replaced by third-party verification libraries such as Zod and Yup.

import { definePlugin } from "vextjs";
import type { VextValidator } from "vextjs";
import { z } from "zod";

export default definePlugin({
  name: "zod-validator",

  setup(app) {
    const originalValidator = app.getValidator();

    const zodValidator: VextValidator = {
      compile(schema) {
        const toVextResult = (result: ReturnType<z.ZodType["safeParse"]>) =>
          result.success
            ? { valid: true, data: result.data }
            : {
                valid: false,
                errors: result.error.issues.map((issue) => ({
                  field: issue.path.join("."),
                  message: issue.message,
                })),
              };

        // If the entire location schema is Zod schema, execute safeParse directly
        if (schema instanceof z.ZodType) {
          return (data) => toVextResult(schema.safeParse(data));
        }

        // If the fields of the schema object are Zod schema, combine them into z.object
        const zodShape: Record<string, z.ZodType> = {};
        for (const [key, value] of Object.entries(schema)) {
          if (value instanceof z.ZodType) {
            zodShape[key] = value;
          }
        }

        if (Object.keys(zodShape).length > 0) {
          const zodSchema = z.object(zodShape);
          return (data) => toVextResult(zodSchema.safeParse(data));
        }

        // Non-Zod schema falls back to the default schema-dsl validator
        return originalValidator.compile(schema);
      },
    };

    app.setValidator(zodValidator);
  },
});

app.setThrow() — wraps error throws

Wraps or replaces the implementation of app.throw(). Receives the original implementation and returns the new implementation.

export default definePlugin({
  name: "error-tracker",

  setup(app) {
    app.setThrow((originalThrow) => {
      return (status, message, paramsOrCode, code) => {
        // Log errors before throwing
        app.logger.warn({ status, message }, "HTTP error thrown");
        // Call the original implementation
        originalThrow(status, message, paramsOrCode, code);
      };
    });
  },
});

app.setRateLimiter() — Replacement of current limiting implementation

Replaces built-in current restrictor. By default, flex-rate-limit is used, which can be replaced by Redis distributed current limit, etc.

export default definePlugin({
  name: "redis-rate-limit",
  dependencies: ["redis"],

  setup(app) {
    app.setRateLimiter({
      async check(key: string) {
        // Distributed current limiting based on Redis
        const count = await (app as any).redis.incr(`ratelimit:${key}`);
        if (count === 1) {
          await (app as any).redis.expire(`ratelimit:${key}`, 60);
        }
        return {
          allowed: count <= app.config.rateLimit.max,
          remaining: Math.max(0, app.config.rateLimit.max - count),
          resetAt: Date.now() + 60000,
        };
      },
    });
  },
});

app.setRequestIdGenerator() — Custom request ID

Override the request ID generation algorithm. By default crypto.randomUUID() is used.

export default definePlugin({
  name: "custom-request-id",

  setup(app) {
    let counter = 0;

    app.setRequestIdGenerator(() => {
      // Use custom format: timestamp + counter
      return `${Date.now()}-${++counter}`;
    });
  },
});

Plug-in loading process

Startup timing

In the bootstrap startup process, plugins are executed in the following stages:

1. config → load and merge configuration
2. locales → load i18n language pack
3. plugins → ⭐ topological sort + execute setup() (here)
4. middlewares → Scan middleware definition
5. services → instantiated services
6. routes → Register routes
7. HTTP monitoring → onReady hook triggered

This means:

  • app.config can be accessed in setup() (already loaded)
  • app.logger can be accessed in setup() (already initialized)
  • app.extend() / app.use() / app.onClose() / app.onReady() can be called in setup()
  • app.services cannot be accessed in setup() (the service has not been loaded yet)
  • setup() cannot assume that the route is registered

To perform an operation after all modules have been loaded, use app.onReady().

Topological sorting

plugin-loader performs topological sorting based on dependencies declarations:

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

// plugins/query-cache.ts — depends on database
definePlugin({ name: 'query-cache', dependencies: ['database'], setup: ... });

// plugins/session.ts — depends on query-cache and database
definePlugin({ name: 'session', dependencies: ['query-cache', 'database'], setup: ... });

Execution order: databasequery-cachesession

If there is a circular dependency (A → B → A), the framework will report a Fail Fast error at startup.

Timeout protection

Each setup() is protected by a timeout (default 30 seconds). If the plugin initialization times out (for example, the database connection times out), the framework will throw an explicit error message.

Practical example

Database plug-in

// src/plugins/database.ts
import { definePlugin } from "vextjs";

export default definePlugin({
  name: "database",

  async setup(app) {
    //Read database connection information from configuration
    const dbConfig = app.config.database ?? {
      host: "localhost",
      port: 5432,
      database: "myapp",
    };

    // Create database connection (example)
    const pool = await createPool(dbConfig);

    //Inject into app
    app.extend("db", {
      query: (sql: string, params?: unknown[]) => pool.query(sql, params),
      transaction: (fn: Function) => pool.transaction(fn),
    });

    //Close gracefully
    app.onClose(async () => {
      app.logger.info("Closing database pool...");
      await pool.end();
    });

    //readiness check
    app.onReady(async () => {
      try {
        await pool.query("SELECT 1");
        app.logger.info("Database connection verified");
      } catch (err) {
        app.logger.error({ err }, "Database health check failed");
      }
    });

    app.logger.info("Database plugin initialized");
  },
});async function createPool(config: any) {
  //In actual implementation, pg, mysql2 and other drivers are used
  return {
    query: async (sql: string, params?: unknown[]) => ({ rows: [] }),
    transaction: async (fn: Function) => fn(),
    end: async () => {},
  };
}

Sentry error monitoring plug-in

// src/plugins/sentry.ts
import { definePlugin } from "vextjs";

export default definePlugin({
  name: "sentry",

  setup(app) {
    const dsn = app.config.sentry?.dsn;
    if (!dsn) {
      app.logger.warn("Sentry DSN not configured, skipping initialization");
      return;
    }

    //Initialize Sentry
    // Sentry.init({ dsn });

    //Register global error catching middleware
    app.use(async (req, res, next) => {
      try {
        await next();
      } catch (err) {
        //Report to Sentry
        // Sentry.captureException(err, { extra: { requestId: req.requestId } });
        app.logger.error(
          { err, requestId: req.requestId },
          "Error captured by Sentry",
        );

        // Rethrow and let the framework's error-handler handle the response
        throw err;
      }
    });

    app.logger.info("Sentry plugin initialized");
  },
});

Scheduled task plug-in

// src/plugins/scheduler.ts
import { definePlugin } from "vextjs";

export default definePlugin({
  name: "scheduler",

  setup(app) {
    const timers: NodeJS.Timeout[] = [];

    app.extend("scheduler", {
      every(ms: number, name: string, fn: () => Promise<void>) {
        const timer = setInterval(async () => {
          try {
            await fn();
          } catch (err) {
            app.logger.error({ err, task: name }, "Scheduled task failed");
          }
        }, ms);
        timers.push(timer);
        app.logger.info({ name, intervalMs: ms }, "Scheduled task registered");
      },
    });

    //Clear all timers on graceful shutdown
    app.onClose(() => {
      for (const timer of timers) {
        clearInterval(timer);
      }
      app.logger.info(`Cleared ${timers.length} scheduled task(s)`);
    });

    //Register the scheduled task when ready
    app.onReady(async () => {
      (app as any).scheduler.every(
        60_000,
        "cleanup-expired-sessions",
        async () => {
          // await app.services.session.cleanupExpired();
          app.logger.debug("Expired sessions cleaned up");
        },
      );
    });
  },
});

Built-in plug-ins

VextJS has the following built-in plugins:

Plug-in nameDescriptionConditional loading
monsqlizeMonSQLize database ORM integrationAutomatically load when monsqlize dependency is detected

The built-in plug-in detects whether it should be loaded through shouldLoadMonSQLize(), achieving zero configuration and zero overhead - it will not be loaded at all when the corresponding dependencies are not installed.

File upload

VextJS has built-in multipart/form-data parsing, based on Node.js 20+ native Request.formData() API, with zero external dependencies. Just turn it on in configuration.

Enable built-in parsing

// src/config/default.ts
export default {
  multipart: {
    enabled: true, // Enable built-in parsing
    maxFileSize: 10 * 1024 * 1024, // The upper limit of a single file is 10MB (default)
    maxFiles: 10, // Maximum number of files at a time (default)
    // allowedMimeTypes: ['image/jpeg', 'image/png'], // Optional: MIME whitelist
  },
};

When enabled, all multipart/form-data request bodies will be automatically parsed by body-parser, and the results will be filled in req.files (type ParsedFile[]). Zero impact on performance when not enabled.

Used in routing

// src/routes/upload.ts
export default defineRoutes((app) => {
  app.post(
    "/upload",
    {
      multipart: {
        files: {
          avatar: "User avatar",
          resume: { description: "Resume file", required: true },
        },
      },
    },
    async (req, res) => {
      const avatarFile = req.files?.find((f) => f.fieldname === "avatar");
      if (!avatarFile) {
        res.json({ code: 400, message: "File not uploaded" }, 400);
        return;
      }// Verify file type
      if (!avatarFile.mimetype.startsWith("image/")) {
        res.json({ code: 400, message: "Only image formats are supported" }, 400);
        return;
      }

      // Save the file (avatarFile.buffer ensures binary integrity)
      const filename = `${Date.now()}-${avatarFile.filename}`;
      await fs.writeFile(`./uploads/${filename}`, avatarFile.buffer);

      res.json({ filename, size: avatarFile.size });
    },
  );
});

ParsedFile structure

FieldTypeDescription
fieldnamestringForm field name (avatar of <input name="avatar">)
filenamestringClient original file name
mimetypestringMIME type (such as image/jpeg)
bufferBufferComplete binary content of file
sizenumberFile size (bytes)
Fastify users

multipart.maxFileSize only limits the size of a single file; the total request body read limit is controlled by bodyParser.maxBodySize. When using Fastify, if adapter bodyLimit is additionally configured, the actual read boundary will be the smaller of the overall upper limit of adapter bodyLimit and body-parser.

Custom parsing (advanced)

If you need to use third-party libraries such as busboy for more fine-grained control (such as streaming writing to disk), you can implement it through plug-ins.

Two usage modes are supported:

  • Exclusive mode: Keep multipart.enabled as false (default), and the plugin is solely responsible for parsing
  • Coexistence mode: When multipart.enabled: true is used, the global body-parser parses first, and the plug-in detects and exits early through req.files !== undefined to avoid double parsing.

It is recommended to add guard at the beginning of the plug-in in coexistence mode so that it can be used safely in both scenarios:

// src/plugins/upload-custom.ts
import { definePlugin } from "vextjs";
import type { ParsedFile } from "vextjs";
import busboy from "busboy";

export default definePlugin({
  name: "upload-custom",

  setup(app) {
    app.use(async (req, _res, next) => {
      const ct = req.headers["content-type"] ?? "";
      if (!ct.startsWith("multipart/form-data")) {
        await next();
        return;
      }

      // guard: skip directly when the global body-parser has been parsed to avoid double processing
      if (req.files !== undefined) {
        await next();
        return;
      }

      const rawBuffer = await req._getRawBodyBuffer();

      req.files = await new Promise<ParsedFile[]>((resolve, reject) => {
        const bb = busboy({ headers: { "content-type": ct } });
        const collected: ParsedFile[] = [];

        bb.on("file", (fieldname, stream, info) => {
          const chunks: Buffer[] = [];
          stream.on("data", (chunk: Buffer) => chunks.push(chunk));
          stream.on("end", () => {
            const buffer = Buffer.concat(chunks);
            collected.push({
              fieldname,
              filename: info.filename,
              mimetype: info.mimeType,
              buffer,
              size: buffer.byteLength,
            });
          });
        });

        bb.on("finish", () => resolve(collected));
        bb.on("error", reject);
        bb.write(rawBuffer);
        bb.end();
      });

      await next();
    });
  },
});
file size limit

Control the individual file size through app.config.multipart.maxFileSize (bytes); control the total request body size through app.config.bodyParser.maxBodySize. The two have independent semantics. Fastify adapter will not use maxFileSize to expand the total request body reading limit.

Plug-ins vs middleware vs services

AspectsPluginsMiddlewareServices
Placement directorysrc/plugins/src/middlewares/src/services/
Definition methoddefinePlugin()defineMiddleware()export default class
Execution timeAt startup (one-time)Each requestEach method call
Visit appsetup(app)req.appconstructor(app)
Main responsibilitiesExtended framework capabilitiesRequest interception/processingBusiness logic
Typical use casesDatabase connection, caching, monitoringAuthentication, logging, current limitingCRUD, calculation, external API

Selection Guide:

  • Need to initialize resources (such as database connections) at startup → Plug-in
  • Need to intercept every request (like authentication check) → Middleware
  • Need to encapsulate reusable business logic → Service
  • Need to add new capabilities to appplugin (app.extend())
  • Need to replace framework built-in behavior → plug-in (app.setValidator(), etc.)

Best Practices

1. Conditional initialization

Decide whether to initialize the plug-in based on the configuration to avoid wasting resources when they are not needed:

export default definePlugin({
  name: "redis",

  async setup(app) {
    if (!app.config.redis?.enabled) {
      app.logger.info("Redis not configured, skipping");
      return;
    }

    //Initialize...
  },
});

2. Always register shutdown hooks

If the plug-in opens an external connection (database, message queue, Redis, etc.), the app.onClose() hook must be registered to ensure graceful closing:

app.extend("mq", messageQueue);
app.onClose(async () => {
  await messageQueue.close();
});

3. Explicitly declare dependencies

If a plugin depends on the injection capabilities of other plugins, be sure to declare it in dependencies instead of assuming load order:

// ✅ Correct — explicit declaration
definePlugin({
  name: "session",
  dependencies: ["redis"],
  setup(app) {
    /* ... */
  },
});

// ❌ Danger — relies on filename ordering
definePlugin({
  name: "session",
  // There are no dependencies. It is assumed that redis will be loaded first because the alphabetical order is in front.
  setup(app) {
    /* ... */
  },
});

4. Use app.onReady() to perform post-processing operations

Operations that need to wait until all modules are loaded (such as warming up the cache) should be placed in app.onReady() instead of setup():

setup(app) {
  // ❌ services have not been loaded yet during setup
  // await app.services.user.warmupCache();

  // ✅ Everything is ready when onReady
  app.onReady(async () => {
    await app.services.user.warmupCache();
  });
},

5. Error tolerance

Failure to initialize non-core plugins should not block the entire application startup:

export default definePlugin({
  name: "analytics",

  async setup(app) {
    try {
      const client = await initAnalytics(app.config.analytics);
      app.extend("analytics", client);
    } catch (err) {
      app.logger.warn(
        { err },
        "Analytics plugin init failed, continuing without analytics",
      );
      // Provide an empty implementation to prevent other code from crashing because app.analytics does not exist
      app.extend("analytics", {
        track: () => {},
        identify: () => {},
      });
    }
  },
});

Next step

  • Understand the Preload mechanism to allow the plug-in package to automatically inject pre-launch scripts (such as OpenTelemetry SDK)
  • Learn about the declarative DSL syntax of Parameter Validation
  • Learn how to use middleware with plug-ins
  • View plug-in related configuration items in Configuration
  • Explore Testing How to write tests for plugins