为什么是 Pluxel?

从 Koishi 与 Cordis 的实践出发,理解 Pluxel 对依赖身份、Context、生命周期和构建语义的选择。

Pluxel 是对 Cordis v3 插件思想的一次再诠释。我们延续 Context、依赖注入和资源随插件回收的模型,希望把业务依赖的声明、类型和运行时身份进一步连接起来:开发者写下导入和构造函数参数,构建工具记录依赖关系,运行时据此管理启动、替换与清理。

这篇文章解释这套选择从何而来,以及它带来的能力和限制。阅读顺序是:实践来源 → Cordis 的依赖模型 → Pluxel 的选择 → 实现取舍与代价。想先体验应用,可以从快速开始创建项目,再回来了解这些设计。

从 Koishi Plugin 实践出发

我们从 2023 年开始编写 Koishi 插件。Koishi 的插件运行时建立在 Cordis 之上;本文引用的 @koishijs/core 提交明确依赖 Cordis 3.18.1。Pluxel 对依赖模型的思考,来自持续编写、组合、替换插件和处理其资源生命周期的经验。

Cordis 的应用也已超出 Koishi 生态。DeepSeek 官方的 DeepSeek Harness 将其 “everything is a plugin” 架构标记为 powered by Cordis,并在仓库中维护和发布 Cordis 4.0.1。这些实践说明了插件模型可以服务于不同类型的应用。

下面的代码与类型对比以链接中固定提交的 Cordis v3 为依据。DeepSeek Harness 的采用是模型背景,不作为推断 Cordis v4 实现细节的依据。

从 Cordis 学到的 Plugin 模型

Cordis 把功能组织成具有 Context 和生命周期的插件,这给应用组合提供了几个重要基础:

  • 插件是能力与副作用的边界。 一个功能可以连同它的监听器、定时器和其他资源一起装载、卸载。
  • Context 关联调用者与资源所有者。 共享服务处理调用时,需要知道注册和清理属于哪个插件。
  • 依赖可用性参与生命周期。 必需服务尚未出现时等待,服务变化时卸载并按条件重新加载插件。
  • scope 和 effect 管理清理。 资源跟随所属作用域释放,动态替换不必重启整个应用。

Pluxel 保留了这些判断。我们进一步关注的问题是:同一条业务依赖,如何在编辑器、构建产物和运行时中保持可追溯的关联?

Cordis 如何连接类型与运行时

理解差异,需要分清 Cordis 中两个配合工作的部分:TypeScript 为 Context 提供类型,运行时根据服务名称和依赖声明决定插件何时可用。

Declaration merging 提供 Context 类型

Cordis v3 的 service package 通过 module augmentation 扩展 Context。以 timer package 为例,下面保留类型扩展与服务注册,省略具体计时器实现:

import { Context, Service } from '@cordisjs/core'

declare module '@cordisjs/core' {
	interface Context {
		timer: TimerService
		setTimeout(callback: () => void, delay: number): () => void
	}
}

export class TimerService extends Service {
	constructor(ctx: Context) {
		super(ctx, 'timer')
		ctx.mixin('timer', ['setTimeout'])
	}

	// setTimeout 的具体实现见下方源码链接。
}

当包含这些声明的 package 进入 TypeScript program 后,timersetTimeout 会合并进 Context interface,编辑器因而能检查 ctx.timerctx.setTimeout() 的使用。完整实现见 timer package 源码

declare module 建立静态类型表面;服务是否已经注册、当前是否可用、何时释放,则由 Cordis runtime 处理。类型中存在某个属性,与当前插件可以使用该服务,是需要分别表达的两件事。

一个名称连接注册、依赖和访问

database 服务为例,下面三个片段分别位于 provider 和 consumer 中:

// provider:以 database 名称注册服务
super(ctx, 'database')

// consumer:声明自己依赖 database 服务
export const inject = ['database']

// consumer:通过 Context 使用服务
ctx.database.get(table, id)

database 同时连接服务注册、依赖 metadata 和 Context 属性。Cordis 的 Service 文档 明确约定:必需服务为真值时插件才加载;服务变化时插件卸载;新值仍可用时插件重新加载。依赖可用性和动态生命周期已经是这套模型的一部分。

类型扩展可以检查 ctx.database 的属性访问,却不会自动生成或同步 inject: ['database']。对应源码中的 Inject 类型为 string[] | Dict<Inject.Meta>。如果 consumer 改用另一个已声明的 Context 属性,还需要同时维护依赖 metadata。

这种按名称连接的模型保留了运行时绑定的灵活性:consumer 可以声明所需的服务名称,而不必通过值导入绑定提供该服务的具体插件。常量、lint 或封装可以帮助维护名称的一致性;在上面的基础写法中,这几个位置仍是分别声明的。

Pluxel 为什么选择另一种依赖表达

在长期维护多个插件包时,我们希望构建工具能够沿源码回答:这个参数依赖哪个包的哪个插件导出?声明的依赖是否就是代码正在使用的依赖?这个身份能否继续用于配置、日志、替换和发行检查?

这对应三个具体问题:

  1. 声明与使用需要一起维护。 独立的依赖名称列表和属性访问容易在修改时不同步,尤其是两边各自仍满足 TypeScript 类型检查时。
  2. 服务名称没有直接表达来源。 名称能够参与运行时查找,但它本身不包含包根入口、具名导出或构建时记录的来源关系。
  3. 一部分错误希望提前发现。 服务是否存在仍需运行时判断;可追溯的导入、参数与包边界错误,则可以交给构建阶段检查。

Pluxel 因此把必需插件依赖写在构造函数中:

import { BasePlugin, Plugin } from '@pluxel/runtime'
import { AccountsPlugin } from '@acme/accounts'

@Plugin({ displayName: 'Billing' })
export class BillingPlugin extends BasePlugin {
	constructor(private readonly accounts: AccountsPlugin) {
		super()
	}
}

构建工具在 TypeScript 擦除类型之前,记录 AccountsPlugin 的值导入来源及构造函数参数位置。包根入口与具名导出确定插件定义,参数声明确定依赖边;运行时根据这些 metadata 注入当前的 provider 实例。

业务插件不再另写一份 inject 列表,也不通过扩展 Context 属性注册自己。构造函数依赖的具体规则见插件依赖与生命周期

两种选择的区别

比较点Cordis v3 的上述 service 写法Pluxel 的必需插件依赖
consumer 如何声明依赖inject 中的服务名称构造函数中的插件参数与值导入
consumer 如何访问能力对应的 Context 属性注入的插件实例
类型从哪里来Context 的类型扩展和服务类型导入的插件类型与构造函数签名
依赖关系如何进入运行时运行时读取名称与依赖 metadata构建工具从源码生成依赖 metadata
provider 变化时怎么办依赖服务变化驱动卸载和重新加载依赖图与插件实例生命周期驱动替换、重启和清理
主要取舍保留按名称进行运行时绑定的灵活性以源码和构建约束换取可追溯的依赖身份

两者都提供类型支持,也都处理动态生命周期。Pluxel 增加的约束服务于源码、构建与运行时身份的一致性;这些检查也不能替代实际启动、业务校验或运行时的依赖可用性判断。

Pluxel 的能力边界

明确业务依赖之后,还需要决定哪些能力进入插件依赖图,哪些由宿主统一提供,哪些只属于插件内部。把这几种关系混在一起,会使资源归属和替换行为难以解释。

能力关系标准表达身份与生命周期
必需插件依赖构造函数参数与值导入进入依赖图,由插件定义和当前运行实例决定
可选插件集成类型导入、definePluginRef()plugins.use()进入可选依赖图,provider 实例变化时重启受影响的 consumer
宿主公共设施root 创建前确定的 Context 能力由宿主组合,调用和注册绑定明确的 Context
插件内部组成普通类、函数、effects scope 或 PluginPart随所属插件的运行实例回收

每次插件启动都会创建新的运行实例,称为一个 generation。替换、回滚和关闭需要处理的是这些具体实例及其资源,不能只按类名判断一个旧句柄是否仍可使用。

业务依赖进入 Plugin graph

必需依赖使用前面的构造函数写法。可选集成则只导入 provider 的类型,用 definePluginRef() 建立保留来源信息的引用,再通过 plugins.use() 使用。可选 provider 不存在时,插件仍能完成自己的核心功能;provider 的运行实例变化时,运行时会重启相关 consumer。

两种关系都参与插件依赖图。可选引用使代码不必为“可能不存在的集成”建立直接的值导入,同时仍让工具链追溯它指向的插件。

普通计算和数据转换可以保持为函数或类。需要在同一个插件内部拆分配置、注册与清理时,再使用 PluginPart;它不产生一个可以独立启停的治理节点。

宿主公共设施进入 Context

HTTP、配置、日志、effects、commands 和持久化等由宿主提供。公开的 @pluxel/context 是独立于 Plugin Runtime 的 Context host kernel;需要构建独立宿主的应用,可以在创建 root 前组合能力集合。

这个 kernel 负责同步、严格惰性的能力投影:只有访问时才构造所需的值,能力集合一经编译便不能修改。它本身不负责资源启动,也不调用能力的 prepare()dispose();资源生命周期由使用它的宿主管理。

Pluxel Runtime 使用同一个 kernel,在创建 root Context 时一次性编译官方能力。业务插件不能在模块执行或 init() 阶段动态改写 Runtime 的 Context 能力集合,第三方业务能力继续通过插件依赖图组合。Context 暴露可使用的能力和当前资源归属,不直接暴露完整宿主配置。

能力可以选择不同的共享范围:

作用域在 Runtime 中的含义
root整个宿主共享的能力或后端
scope一个插件 generation 及其 Part、依赖调用视图共享的状态
owner-view后端可以共享,但每个插件、Part 或依赖调用边有自己的访问视图

owner-view 是绑定明确 Context 的能力视图。例如,日志和事件服务可以共用后端,但每个调用者的日志归属、订阅和清理项保持独立。它们不依赖共享对象上一个会被异步调用反复改写的“当前 ctx”。独立宿主的组合方式见 Context 参考

原生 HTTP 与框架生命周期如何配合

ctx.elysia 是严格惰性创建的真实 Elysia 2 application。同一个 generation 的插件与全部 Part 共享这一个 app,Elysia 中声明的路径就是最终产品路径,插件继续使用上游的路由、schema、hook 和 stream API。

Pluxel 补充的是它与插件依赖图的关系:在 init() 完成后 compile/seal,随 generation 原子发布或撤销路由,并把请求接纳、退出等待和流式响应体纳入资源生命周期。监听端口以及面向 srvx 的 HTTP carrier 接入由宿主拥有。这样,插件可以使用原生 HTTP 框架,同时让接口的可用性跟随自己的运行实例。具体用法见 HTTP 指南

为什么本地能力优先保持 Proxy-free

这里关注的是调用者身份和资源归属能否保持稳定。已知成员集合可以用普通对象、类或一次编译的 property descriptor 表达时,Pluxel 优先采用这些方式,让缓存句柄、反射和异步并发下的行为更容易解释。

目前几个关键位置遵循这一选择:

  • Context getter 与 owner-view:能力形状预先编译,视图持有固定 Context,不通过 Proxy 改写共享对象上的调用者。
  • 插件依赖的 caller facade:provider 构造后固定可调用的成员表面,每次接纳的调用使用独立的普通 receiver,以保持并发调用的上下文归属。
  • 具名 EvtChannel:直接接收 owner Context,consumer 的订阅通过缓存的普通 facade 绑定到 effects。
  • ctx.events:共享 root emitter 后端,每个 owner 取得绑定固定 Context 的普通服务视图,订阅进入自己的清理作用域。
  • 配置快照:每个 revision 返回深冻结的普通 snapshot。configs.use() 初始化时的占位 token 也保持冻结;初始化完成前读取配置由工具链拒绝。
  • ctx.elysia:直接提供真实的上游实例,不用 Proxy 再包一层 HTTP API。

这种选择允许在确有需要的边界使用 Proxy。Workbench 的浏览器 Cap’n Web RPC stub 就是一个例子:远端方法来自运行时已经擦除类型的契约,本地没有可预编译的方法 schema,Proxy 能自然表达远程调用。

这个 RPC 边界不承担后端插件的依赖身份、Context owner 或生命周期排序。远端 observer 仍是显式的 Cap’n Web target,订阅由返回的 disposer 和 socket epoch 管理;socket epoch 可以理解为当前连接会话的有效期。网络断开或句柄被释放后,不能继续把旧 observer 当作有效订阅。

为什么称为 typed meta-framework

TypeScript 源码本身参与形成框架语义。这是 Pluxel 将类型信息、构建工具和运行时一起设计的原因:

源码中的声明框架得到什么后续用于哪里
构造函数参数与值导入必需依赖边依赖检查、启动顺序与注入
可选引用中的类型来源可选依赖边provider 可用性变化与 consumer 重启
包根入口与具名导出稳定的 Plugin definition identity配置、日志、插件目录与替换
Valibot schema配置类型、默认值、归一化与校验宿主配置和 Workbench 表单
构建时生成的 metadata可消费的插件与资源描述static catalog、dynamic source、发行与管理界面

类型、构建产物和运行时依赖图使用同一套插件身份。Static host 可以冻结和审计发行所需的完整闭包;dynamic host 在固定基线上增加可变来源的发现。两种 Vite 宿主都支持替换和 HMR,业务插件的写法保持一致。

这也解释了为什么 Pluxel 要求插件经过对应工具链:普通 TypeScript 类型擦除不会自动保留这些框架需要的关系。

性能取舍

把一部分工作提前,不代表运行时没有成本。Pluxel 在构建或 generation 构造阶段处理部分依赖解析和 caller 成员表面编译;运行时仍需提交依赖图变化、判断是否接纳调用,并为调用分配独立 receiver。

Context 的实现也区分了身份查找与日常访问。host 编译时用 capability descriptor 的对象身份建立映射,再分配 numeric slot;成功构造的能力值按 root、scope 或 owner-view 缓存。常规的 ctx.foo 缓存访问直接读取预编译 slot,不在 getter 热路径重复进行 descriptor 的字符串或 Map 查找。

这里使用 Map 是为了保留 descriptor 对象身份。Object.create(null) 适合字符串或 symbol key,不能直接替代以对象身份作为 key 的语义。这个细节说明了为何要分别考虑模型需要的身份语义、编译成本和热路径成本。

Pluxel 与 Cordis v4 目前没有同场、同语义的 benchmark,不能据此判断哪个框架更快。相关实现由 @pluxel/context 等包的 benchmark 持续验证;趋势数据也不能直接变成跨机器的延迟或吞吐承诺。这里的主要目标是依赖检查、身份一致性和可撤销调用,性能结论需要对应的测量支持。

选择与代价

这些能力建立在明确的约束之上:

  • 插件源码必须经过 Pluxel 的 Vite/Rolldown 处理,保留构建所需的依赖与资源信息。
  • 可发布插件必须来自可追溯的包根具名导出;无法确定稳定来源的动态写法会被构建工具拒绝。
  • 插件作者需要遵循依赖图和 generation 生命周期:长期资源登记清理,旧实例退出后不继续复用其句柄。
  • 应用显式选择插件清单和宿主能力,并承担它们的组合与交付配置。

这些限制适合需要长期维护插件组合、配置、热更新、可审计发行和资源回收的应用。它们也减少了一部分开放字符串注册和任意动态装配的自由度。是否值得采用,取决于应用是否需要这套可追溯的依赖与生命周期模型。

要实际体验,可以用快速开始创建已配置好工具链的项目;要继续理解作者模型,阅读插件依赖与生命周期。框架实现和约束的索引在工程文档

最后更新于

本页目录