插件

VextJS 的插件系统负责应用启动阶段的能力扩展。通过插件,你可以向 app 对象注入自定义能力、注册全局中间件、替换内置实现、管理资源生命周期。路由、服务、Adapter与构建工具有各自的入口,职责边界见架构规范。

先用下方“完整示例与验证”运行一个无需外部服务的插件应用。其余示例用于说明特定接口;Redis、数据库、监控SDK及业务service需要应用自行提供,不能把所有片段同时复制到plugins目录。

基本概念

插件放在 src/plugins/ 目录下,由 plugin-loader 自动扫描加载。每个插件通过 definePlugin() 定义,包含名称、依赖声明和 setup() 初始化函数。

扫描递归支持.ts/.js/.mjs/.cjs,排除_、.开头文件/目录、.test.、.spec.及.d.ts。普通辅助模块放在扫描目录外或明确排除的位置;安装npm包不会自动把它变为用户插件,需要由这里的入口导出插件。

完整示例与验证

在已完成快速开始的独立 TypeScript 练习项目中使用以下四文件。保留该项目的 npm scripts 和 tsconfig,合并基础配置;插件目录只放本例两个插件。这里验证依赖排序、扩展、全局中间件、ready 和 close,无需 Redis 或数据库。若已有 local/provider 等配置覆盖端口,先确认实际监听地址。

// src/config/default.ts
export default { port: 3000, adapter: "native", frontend: { enabled: false } };
// src/plugins/store.ts
import { defineAppExtensions, definePlugin } from "vextjs";

export const appExtensions = defineAppExtensions<{
  demoState: {
    requests: number;
    ready: boolean;
    events: string[];
  };
}>();

export default definePlugin({
  name: "demo-store",
  setup(app) {
    const state = { requests: 0, ready: false, events: [] as string[] };
    app.extend("demoState", state);
    app.onClose(() => {
      state.events.push("store");
      app.logger.info({ events: [...state.events] }, "Demo plugin close order");
    });
  },
});
// src/plugins/consumer.ts
import { definePlugin } from "vextjs";

export default definePlugin({
  name: "demo-consumer",
  dependencies: ["demo-store"],
  setup(app) {
    const state = app.demoState as {
      requests: number;
      ready: boolean;
      events: string[];
    };
    app.use(async (_req, res, next) => {
      state.requests += 1;
      res.setHeader("x-demo-plugin", "active");
      await next();
    });
    app.onReady(() => {
      state.ready = true;
    });
    app.onClose(() => {
      state.events.push("consumer");
    });
  },
});
// src/routes/plugin-info.ts
import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  app.get("/", {}, async (_req, res) => {
    const state = app.demoState as { requests: number; ready: boolean };
    res.json({ requests: state.requests, ready: state.ready });
  });
});
  1. 运行 npm run dev,在另一终端连续两次请求 http://127.0.0.1:3000/plugin-info,例如执行 curl -i(PowerShell 使用 curl.exe -i)加该 URL。
  2. 无其他请求时,两次均应为200并含 x-demo-plugin: active;data.ready=true,data.requests 依次为1/2。浏览器可能额外请求favicon,因此验证精确计数时使用上述命令。
  3. 在启动终端按 Ctrl+C 正常停止,观察 Demo plugin close order 日志中的 events: ["consumer", "store"],验证后注册的consumer先关闭。日志级别需允许info;强制结束进程不会保证执行关闭钩子。
  4. 执行 npm run build(快速开始的build脚本包含 --typecheck),再执行 npm start,重复请求和正常关闭。新进程的计数从零开始。

测试辅助也能在保存 app.app.demoState 引用后调用 await app.close(),检查同一events顺序;这验证应用关闭逻辑,不能代替CLI收到信号的验证。

appExtensions 为类型生成器提供显式声明;真正创建并挂载状态的是setup中的 app.extend(),只有类型声明不会产生运行时能力。

把consumer的依赖名临时改成不存在的名称,启动应报告缺失依赖;恢复后重试。重复插件名同样应报错,不应默默覆盖。测试辅助需显式传plugins: true,见测试API。

Redis 接入示意

以下是可选接入示意,需先安装 ioredis 并准备可访问的Redis服务。创建客户端不等于已验证连接成功;初始化失败和取消时的清理由插件负责。它不属于上面的四文件示例。

// 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");

    // 向 app 挂载自定义能力
    app.extend("redis", redis);

    // 注册优雅关闭钩子
    app.onClose(async () => {
      app.logger.info("Closing Redis connection...");
      await redis.quit();
    });

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

插件接口

interface VextPlugin {
  /** 插件名称(唯一标识) */
  readonly name: string;

  /** 依赖的其他插件名称列表 */
  readonly dependencies?: string[];

  /** 插件初始化函数 */
  setup(
    app: VextPluginContext,
    context: VextPluginSetupContext,
  ): Promise<void> | void;

  /** HTTP 开始监听后执行的就绪钩子(可选) */
  onReady?(app: VextPluginContext): Promise<void> | void;

  /** 优雅关闭时执行的清理钩子(可选,按 LIFO 顺序) */
  onClose?(app: VextPluginContext): Promise<void> | void;
}

以上类型从vextjs导入;VextPluginSetupContext提供只读signal: AbortSignal。VextPluginContext不暴露路由注册方法,路由仍放在defineRoutes()中。

name — 唯一标识

插件名称用于日志输出、错误信息和依赖声明。扫描到两个同名用户插件时会在执行setup之前报错,不会覆盖。内置MonSQLize在独立阶段初始化,不是用户插件拓扑图中的节点;不要用同名文件或dependencies: ["monsqlize"]尝试替换/声明它。

dependencies — 依赖声明

声明当前插件依赖的其他用户插件名称(不是文件路径或npm包名)。plugin-loader 根据依赖关系进行拓扑排序,确保依赖的插件先于当前插件执行 setup()。缺失依赖或循环依赖时 Fail Fast 报错;依赖插件setup提前返回也不代表它提供了目标能力,消费者仍需匹配启用条件。

export default definePlugin({
  name: "user-cache",
  dependencies: ["redis"], // 确保 redis 插件先初始化

  async setup(app) {
    // 此时 app.redis(由 redis 插件注入)已可用
    const redis = (app as any).redis;
    // ...
  },
});

setup() — 初始化函数

插件的核心逻辑。在 bootstrap 阶段由 plugin-loader 调用,支持异步操作(如连接数据库)。第二个参数是 { signal: AbortSignal },应把 signal 传给支持取消的 I/O。每个 setup() 都有硬超时保护(当前标准入口为30秒,依赖事件循环能够执行计时回调)。失败或超时后,signal会中止,受管理的框架mutation会回滚,setup的受控写入facade随后被撤销。迟到 continuation 不能再通过它调用受控方法或写入顶层属性;这不能阻止用户继续修改捕获的嵌套对象,也不能撤销外部I/O。

async setup(app, { signal }) {
  const response = await fetch(app.config.remotePluginUrl, { signal });
  if (!response.ok) throw new Error(`Remote plugin configuration: HTTP ${response.status}`);
  app.extend("remotePluginData", await response.json());
}

onReady() / onClose() — 生命周期钩子

插件也可以直接声明 onReady(app) 与 onClose(app)。plugin-loader 会在 setup() 完成后把它们注册到应用生命周期中:

  • onReady(app):HTTP 开始监听后执行,适合预热缓存、检查外部依赖、打印启动信息。
  • onClose(app):优雅关闭时执行,所有关闭钩子按后注册先执行(LIFO)顺序清理资源。

setup成功后其mutation facade也会撤销;不要在迟到任务中调用setup参数上的extend/setter。已注册回调可以读取app或使用捕获的client。回滚仅覆盖受管理的框架状态,不会自动撤销网络写入或关闭未成功登记的外部资源;超时不能强制终止任意JavaScript/I/O。关闭还受应用整体shutdown期限限制。

插件能力

app.extend() — 挂载自定义属性

向 app 对象注入自定义属性或方法。应在插件setup中声明;名称须为合法JavaScript标识符,不能覆盖已有属性、保留名或继承属性。更换validator/logger等使用对应setter。

export default definePlugin({
  name: "mailer",

  async setup(app) {
    const mailer = {
      async send(to: string, subject: string, body: string) {
        // 发送邮件逻辑...
        app.logger.info({ to, subject }, "Email sent");
      },
    };

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

使用时:

// 在路由或服务中
await (app as any).mailer.send("user@example.com", "Welcome", "Hello!");
类型提示

如果你希望自动生成插件扩展声明,可在插件文件中导出 appExtensions = defineAppExtensions<{ ... }>(),并运行:

npm exec -- vext typegen

当前轻量扫描器优先识别内联对象泛型:

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");
      },
    });
  },
});

命令也会 best-effort 扫描 definePlugin() 的 setup / onReady / onClose 生命周期内的 app.extend("...") 调用,并将结果写入 .vext/types/app-extensions.generated.d.ts,再通过 src/types/generated/index.d.ts 接入 TypeScript 项目。复杂类型、导入 type alias 或动态扩展不适合依赖自动扫描,建议使用手写 declare module:

// src/types/extensions.d.ts
import "vextjs";

declare module "vextjs" {
  interface VextApp {
    mailer: {
      send(to: string, subject: string, body: string): Promise<void>;
    };
  }
}

扩展后 app.mailer.send() 将获得 IDE 自动补全,无需 as any 断言。

app.use() — 注册全局中间件

在插件中注册全局中间件。它们位于请求元数据、解析、包装等全局基础层之后,显式启用的CSRF与路由链之前;前面的步骤若已短路或抛错,插件中间件不会执行。完整顺序见中间件指南。

import { definePlugin, securityHeaders } from "vextjs";

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

  setup(app) {
    app.use(securityHeaders({ preset: "strict" }));
  },
});

应用级浏览器安全响应头请优先使用 config.securityHeaders,这样错误响应、404、测试辅助和 dev soft reload 会保持一致。插件形式更适合局部迁移或特殊组合。

注意

app.use() 只能在 setup() 中调用。路由注册完成后再调用将抛出错误。

app.hooks.on() — 注册运行时生命周期 hook

插件也可以通过 app.hooks.on(name, handler) 观察框架生命周期。它适合做请求审计、出站调用监控、service 调用追踪、响应 header patch、OpenAPI 文档补丁等横切逻辑。

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 是框架保留属性,不能用 app.extend("hooks", ...) 覆盖。插件 setup() 本身也会触发 plugin:beforeSetup/afterSetup/error,但一个插件不能观察自己的 beforeSetup,只能被此前已加载的插件观察。

app.onClose() — 优雅关闭钩子

注册优雅关闭钩子。当收到 SIGTERM / SIGINT 信号时,框架按注册的逆序(LIFO)执行所有关闭钩子。

适合关闭数据库连接、刷新日志缓冲区、取消定时任务等。下方的 createDatabaseConnection 需由应用实现,并返回有 disconnect() 方法的客户端;使用独立的 sqlDatabase 配置与 sql 扩展名,避免覆盖内置 app.db。

export default definePlugin({
  name: "database",

  async setup(app) {
    const db = await createDatabaseConnection(app.config.sqlDatabase);
    try {
      app.extend("sql", db);
      app.onClose(async () => {
        app.logger.info("Closing database connection...");
        await db.disconnect();
      });
    } catch (error) {
      // 例如extend命名冲突:此时不能依赖未登记成功的关闭钩子。
      await db.disconnect();
      throw error;
    }
  },
});

app.onReady() — 就绪钩子

注册就绪钩子。所有插件加载完成、HTTP 开始监听之后触发。适合:预热缓存、检查外部依赖、打印启动信息。

export default definePlugin({
  name: "warmup",

  setup(app) {
    app.onReady(async () => {
      // HTTP 已开始监听,可以执行预热操作
      app.logger.info("Warming up caches...");
      await app.services.product.warmupCache();
      app.logger.info("Cache warmup complete");
    });
  },
});

app.setValidator() — 替换校验引擎

替换框架内置的同步参数校验引擎。默认使用schema-dsl;下面演示保持合同的代理适配器。需要Zod实现时,使用参数校验指南中的DSL翻译示例,不把Zod实例直接放入RouteOptions.validate。

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

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

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

    const validator: VextValidator = {
      compile(schema) {
        const validate = originalValidator.compile(schema);
        return (input) => validate(input);
      },
    };

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

替换应发生在路由注册及service编译schema之前。返回值须保留valid/data/errors合同;替换运行时引擎不放宽build、Doctor、OpenAPI和client使用的静态路由语法。

app.setThrow() — 包装错误抛出

包装或替换 app.throw() 的实现。接收原始实现,返回保留全部重载及 never 返回语义的新实现;包括 i18n 快捷方式、位置参数和对象参数。下面通过 Proxy 原样转发参数,不把调用限制为四个位置参数。

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

  setup(app) {
    const logger = app.logger;
    app.setThrow(
      (originalThrow) =>
        new Proxy(originalThrow, {
          apply(target, thisArg, args) {
            logger.warn("app.throw called");
            return Reflect.apply(target, thisArg, args);
          },
        }),
    );
  },
});

app.setRateLimiter() — 替换限流实现

替换内置限流器。普通Redis需求优先配置内置store,见请求限流。下面是固定窗口内存示意,使用全局max/window;自定义check只收到key,不会自动收到路由额度覆盖。先在配置启用rateLimit。

export default definePlugin({
  name: "custom-rate-limit",

  setup(app) {
    const counters = new Map<string, { count: number; expires: number }>();
    const { max, window: windowSeconds } = app.config.rateLimit;
    app.setRateLimiter({
      async check(key: string) {
        const now = Date.now();
        let entry = counters.get(key);
        if (!entry || entry.expires <= now) {
          entry = { count: 0, expires: now + windowSeconds * 1000 };
          counters.set(key, entry);
        }
        entry.count += 1;
        return {
          allowed: entry.count <= max,
          remaining: Math.max(0, max - entry.count),
          resetAt: Math.ceil(entry.expires / 1000), // 绝对Unix秒,不是毫秒
        };
      },
    });
    app.onClose(() => counters.clear());
  },
});

该Map只在同key再次访问时淘汰旧窗口,未实现全局容量/过期清理,也不跨进程共享;不能直接作为无界客户端集合的生产store。生产自定义实现应补齐这些资源约束。

app.setRequestIdGenerator() — 自定义请求 ID

覆盖请求 ID 的生成算法。默认使用 crypto.randomUUID()。

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

  setup(app) {
    let counter = 0;

    app.setRequestIdGenerator(() => {
      // 使用自定义格式:时间戳 + 计数器
      return `${Date.now()}-${++counter}`;
    });
  },
});

插件加载流程

启动时序

在 bootstrap 启动流程中,插件在以下阶段执行:

1. config    → 加载并合并配置
2. locales / 内置数据库 / fetch等启动能力 → 按启用条件初始化
3. plugins   → ⭐ 用户插件拓扑排序 + 执行 setup()(此处)
4. middlewares → 扫描中间件定义
5. services  → 实例化服务
6. routes    → 注册路由
7. HTTP 监听 → onReady 钩子触发

这意味着:

  • ✅ setup() 中可以访问 app.config(已加载)
  • ✅ setup() 中可以访问 app.logger(已初始化)
  • ✅ setup() 中可以调用 app.extend() / app.use() / app.onClose() / app.onReady()
  • ❌ setup() 中不能访问 app.services(服务尚未加载)
  • ❌ setup() 中不能假设路由已注册

如需在所有模块加载完成后执行操作,使用 app.onReady()。

拓扑排序

plugin-loader 根据 dependencies 声明进行拓扑排序:

// plugins/database.ts — 无依赖,最先执行
definePlugin({ name: 'database', setup: ... });

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

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

执行顺序:database → query-cache → session

如果存在循环依赖(A → B → A),框架会在启动时 Fail Fast 报错。

超时保护

开发、生产和测试的自动 Plugin Loader 都使用 config.plugin.setupTimeout,默认 30_000 毫秒。该值须为 1~2,147,483,647 的整数,修改需重启。createTestApp({ setupPlugins }) 的手工注册回调不经过自动 loader,期限由调用方负责。

初始化超时时,框架会中止 context.signal、回滚受管理的setup mutation,并关闭该setup参数的受控写入入口。插件仍负责自己在取消前创建的外部资源,应把signal传给支持取消的操作,并在自己的失败/取消路径关闭未完成初始化的client。此机制不能中断阻塞事件循环的同步代码,也不能强制终止任意异步I/O;不自动套用于独立初始化的内置数据库插件。

实战示例

数据库插件

下面仅演示连接池接口与关闭钩子,createPool是空实现,不能用于验证真实事务。内置MonSQLize使用config.database与app.db;自建SQL插件应使用自己的配置名和扩展名,避免冲突。

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

export default definePlugin({
  name: "database",

  async setup(app) {
    // 从配置中读取数据库连接信息
    const dbConfig = app.config.sqlDatabase ?? {
      host: "localhost",
      port: 5432,
      database: "myapp",
    };

    // 创建数据库连接(示例)
    const pool = await createPool(dbConfig);

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

    // 优雅关闭
    app.onClose(async () => {
      app.logger.info("Closing database pool...");
      await pool.end();
    });

    // 就绪检查
    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) {
  // 实际实现中使用 pg、mysql2 等驱动
  return {
    query: async (sql: string, params?: unknown[]) => ({ rows: [] }),
    transaction: async (fn: Function) => fn(),
    end: async () => {},
  };
}

Sentry 错误监控插件

这是接入位置示意,SDK调用被注释,没有完成实际上报。中间件catch只捕获从其next传播出来的异常,不能保证覆盖启动、后台任务或已被内层处理的所有错误;运行时观察入口另见Hooks。

// 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;
    }

    // 初始化 Sentry
    // Sentry.init({ dsn });

    // 注册全局错误捕获中间件
    app.use(async (req, res, next) => {
      try {
        await next();
      } catch (err) {
        // 上报到 Sentry
        // Sentry.captureException(err, { extra: { requestId: req.requestId } });
        app.logger.error(
          { err, requestId: req.requestId },
          "Error captured by Sentry",
        );

        // 重新抛出,让框架的 error-handler 处理响应
        throw err;
      }
    });

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

定时任务插件

下面的setInterval是进程内示意:任务可能重叠,多worker会重复执行,清除timer不会取消已开始的任务。需要随应用启动的定时任务、重叠跳过和 Redis 多副本协调时,使用Jobs。Jobs 不提供队列、持久运行记录或停机补跑。

// 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");
      },
    });

    // 优雅关闭时清除所有定时器
    app.onClose(() => {
      for (const timer of timers) {
        clearInterval(timer);
      }
      app.logger.info(`Cleared ${timers.length} scheduled task(s)`);
    });

    // 就绪后注册定时任务
    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");
        },
      );
    });
  },
});

内置插件

VextJS 内置了以下插件:

插件名说明条件加载
monsqlizeMonSQLize 数据库 ORM 集成config.database为非空对象时进入初始化;未配置、null或空对象跳过

内置插件通过 shouldLoadMonSQLize() 检查数据库配置,无需手动注册。没有数据库配置时跳过;已经启用却缺少运行依赖或配置错误时应修复启动错误,不能依赖静默跳过。详见数据库。

当前没有database.enabled关闭开关;database: { enabled: false }仍为非空对象,会进入初始化并因缺少database.config报错。

文件上传

VextJS 内置 multipart/form-data 解析,基于 Node.js 20+ 原生 Request.formData() API,零外部依赖。只需在配置中开启即可。

开启内置解析

// src/config/default.ts
export default {
  multipart: {
    enabled: true, // 开启内置解析
    maxFileSize: 10 * 1024 * 1024, // 单文件上限 10MB(默认)
    maxFiles: 10, // 单次最多文件数(默认)
    // allowedMimeTypes: ['image/jpeg', 'image/png'],  // 可选:MIME 白名单
  },
};

开启后,multipart/form-data请求进入内置解析,结果填充到req.files(ParsedFile[]);普通文本字段不会自动填入req.body。未开启时跳过内置multipart解析分支。

路由中使用

// src/routes/upload.ts
import { defineRoutes } from "vextjs";
import { mkdir, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";

export default defineRoutes((app) => {
  app.post(
    "/",
    {
      multipart: {
        enabled: true,
        files: {
          avatar: "用户头像",
          resume: { description: "简历文件", required: true },
        },
      },
    },
    async (req, res) => {
      const avatarFile = req.files?.find((f) => f.fieldname === "avatar");
      if (!avatarFile) {
        res.json({ code: 400, message: "未上传文件" }, 400);
        return;
      }

      // 校验文件类型
      if (!avatarFile.mimetype.startsWith("image/")) {
        res.json({ code: 400, message: "仅支持图片格式" }, 400);
        return;
      }

      // 保存文件(avatarFile.buffer 保证二进制完整)
      const filename = randomUUID();
      const uploadDir = path.resolve("uploads");
      await mkdir(uploadDir, { recursive: true });
      await writeFile(path.join(uploadDir, filename), avatarFile.buffer);

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

全局解析关闭时,使用 multipart.enabled: true 可让单个路由启用内置解析。全局解析开启时,路由也可以设置 multipart.enabled: false 跳过内置解析。files 字段还会用于生成 OpenAPI multipart/form-data 文档,并在运行时校验 required 文件字段。

文件名前缀使请求地址为/upload;本片段要求multipart中同时提供avatar和resume。MIME是客户端声明,不是内容检测。全局解析已拒绝的请求不会被路由覆盖恢复;非multipart请求不会触发files.required,所以handler仍检查文件。完整配置、curl和413/415复验见文件上传。

ParsedFile 结构

字段类型说明
fieldnamestring表单字段名(<input name="avatar"> 的 avatar)
filenamestring客户端原始文件名
mimetypestringMIME 类型(如 image/jpeg)
bufferBuffer文件完整二进制内容
sizenumber文件大小(字节)
Fastify 用户

multipart.maxFileSize 只限制单个文件大小;总请求体读取上限由 bodyParser.maxBodySize 控制。使用 Fastify 时,如额外配置 adapter bodyLimit,实际读取边界会取 adapter bodyLimit 与 body-parser 总体上限中的较小值。

自定义解析(高级)

如需使用busboy等第三方库进行更细粒度的控制,可通过插件实现。下面是已安装busboy及其类型后的局部接线示例:它先读取完整rawBuffer,再收集文件到内存,不是流式写盘方案。

支持两种使用模式:

  • 独占模式:将 multipart.enabled 保持 false(默认),由插件全权负责解析
  • 共存模式:multipart.enabled: true 时全局 body-parser 先解析,插件通过 req.files !== undefined 检测并提前退出,避免双重解析

推荐共存模式时在插件开头加 guard,使两种场景均能安全使用:

// 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:全局 body-parser 已解析时直接跳过,避免双重处理
      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();
    });
  },
});
文件大小限制

app.config.multipart.maxFileSize只由内置解析器执行,自定义解析器必须自行实现limits、truncated检查和错误清理;上述接线片段没有实现这些限制。总请求体仍受bodyParser/adapter读取边界约束,maxFileSize不会扩大该上限。

插件 vs 中间件 vs 服务

方面插件中间件服务
放置目录src/plugins/src/middlewares/src/services/
定义方式definePlugin()defineMiddleware()export default class
执行时机启动时(一次性)每个请求每次方法调用
访问 appsetup(app)req.appconstructor(app)
主要职责扩展框架能力请求拦截/处理业务逻辑
典型用例数据库连接、缓存、监控认证、日志、限流CRUD、计算、外部 API

选择指南:

  • 需要在启动时初始化资源(如数据库连接)→ 插件
  • 需要拦截每个请求(如认证检查)→ 中间件
  • 需要封装可复用的业务逻辑 → 服务
  • 需要向 app 添加新能力 → 插件(app.extend())
  • 需要替换框架内置行为 → 插件(app.setValidator() 等)

最佳实践

1. 条件初始化

根据配置决定是否初始化插件,避免在不需要时浪费资源:

export default definePlugin({
  name: "redis",

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

    // 初始化...
  },
});

2. 始终注册关闭钩子

如果插件打开了外部连接(数据库、消息队列、Redis 等),应注册 app.onClose() 或插件的 onClose 为正常关闭提供清理路径,避免为同一资源重复登记。清理受应用总关闭期限约束;setup失败/超时会回滚已登记的钩子,仍需由插件处理该次初始化的资源释放,不能只依赖正常关闭路径:

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

3. 明确声明依赖

如果插件依赖其他插件的注入能力,务必在 dependencies 中声明,而非假设加载顺序:

// ✅ 正确 — 显式声明
definePlugin({
  name: "session",
  dependencies: ["redis"],
  setup(app) {
    /* ... */
  },
});

// ❌ 危险 — 依赖文件名排序
definePlugin({
  name: "session",
  // 没有 dependencies,假设 redis 因为字母序在前面会先加载
  setup(app) {
    /* ... */
  },
});

4. 使用 app.onReady() 执行后置操作

需要等到所有模块加载完成再执行的操作(如预热缓存),应放在 app.onReady() 而非 setup() 中:

setup(app) {
  // ❌ setup 时 services 尚未加载
  // await app.services.user.warmupCache();

  // ✅ onReady 时一切就绪
  app.onReady(async () => {
    await app.services.user.warmupCache();
  });
},

5. 错误容忍

只有业务允许降级的可选能力才适合捕获初始化失败并提供空实现;必需的数据库、鉴权等能力应让启动失败。下面initAnalytics由应用提供,创建到一半的client也须自行清理:

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",
      );
      // 提供空实现,避免其他代码因 app.analytics 不存在而崩溃
      app.extend("analytics", {
        track: () => {},
        identify: () => {},
      });
    }
  },
});

排查与复验

症状处理复验
插件没加载核对实际src根、扩展名、排除规则及default export启动日志与plugin-info响应
already registered / 缺失依赖name唯一,dependencies使用用户插件name恢复后重启
setup context is closed把框架mutation留在setup内,外部client单独捕获等待异步任务结束并检查日志
超时仍占用连接signal需传递,catch/finally关闭未完成client取消后确认连接被释放
服务在setup不存在后置操作放onReady,资源初始化仍在setupready后执行操作
extend报冲突使用独立扩展名;内置能力用setter启动与调用均成功

下一步

  • 了解 预加载 (Preload) 机制,让插件包自动注入启动前脚本(如 OpenTelemetry SDK)
  • 了解 参数校验 的声明式 DSL 语法
  • 学习 中间件 如何配合插件使用
  • 查看 配置 中插件相关的配置项
  • 探索 测试 如何为插件编写测试