本文是 story-engine 插件系统的开发参考,目标"细致到可以照着做"。所有钩子名、字段名、默认值、错误码均已对照源码核实,标注了出处文件(均位于 src/kernel/plugin-system/)。
参考实现(真实存在、可直接阅读的插件):
plugins/example-stat— session 作用域:订阅事件、storage 持久化、关键字、服务、状态存档plugins/rpg-kit— 全局作用域:22 个kit_*宿主 API,状态存world.storage随会话存档plugins/rpg-commands— 插件指令!语法扩展示例
1. 系统总览#
1.1 设计原则#
- 状态纯数据:manifest、插件状态、事件参数都必须是可 JSON 序列化的纯数据。含函数/循环引用/BigInt/undefined 的状态在存档路径直接 panic(
E-SER001-F),宁可丢一个插件的状态也不产出坏存档(context.ts的assertSerializable、lifecycle.ts的collectState注释)。 - 异常不外抛:插件回调抛错绝不炸引擎主流程。事件派发逐监听器 try/catch(
lifecycle.ts#dispatch);剧本 emit → 插件是 fire-and-forget(manager.ts#dispatchFromEngine);插件 boot 失败只进failed报告,服务照常启动。 - 健康度分桶:错误按来源(sessionId,缺省
global)独立分桶计数(registry.ts#PluginRecord.healthBuckets)。分桶的理由写明在lifecycle.ts#recordError:若跨会话累计,5 个坏会话各错 1 次就能熔断插件,打挂所有正常会话。
1.2 与宿主的边界#
- 插件只通过
ctx(PluginContext)与内核交互,不 import 引擎内部模块(plugins/example-stat/src/index.ts头部注释明确了这条约定)。 - 宿主对插件的全部调用点收敛在
PluginManager(manager.ts):boot/shutdown、会话时序、事件派发、存档快照、剧本层解析(关键字/函数/指令/宿主 API)。 - 插件能扩展剧本的四样东西:事件监听(
events.on)、宿主 API(@api()/ 表达式可用)、剧本函数与关键字(registerFunction/registerKeyword)、指令新句式(!name(args))。
1.3 模块地图#
| 文件 | 职责 |
|---|---|
manifest-schema.ts |
manifest 的 zod 结构校验 + semver 解析/范围匹配 |
manifest-validator.ts |
语义校验:entry 路径监狱、DSL 语法、依赖引用合法性 |
permission.ts |
权限串 resource:action:scope 的序列化与 glob 匹配 |
capability.ts |
HMAC-SHA256 能力令牌签发/校验/撤销 |
registry.ts |
四表注册器(插件/订阅/注册项/服务 + 宿主 API 表)与依赖拓扑排序 |
context.ts |
PluginContext 通道实现 + hardened Proxy 隔离 + 纯数据校验 |
loader.ts |
插件目录发现、manifest 读取、动态 import 入口模块 |
lifecycle.ts |
四时序生命周期、事件派发、健康度、死信/审计 |
manager.ts |
门面 PluginManager:令牌签发、存储、定时器、配额强制点 |
plugin.ts |
IPlugin 契约与默认配额 DEFAULT_QUOTA |
errors.ts |
错误码常量与 PluginError |
2. 快速上手:最小可运行插件#
2.1 目录结构#
plugins/my.greet/
├── manifest.json # 身份与权限声明(纯数据,禁止写代码)
├── package.json # 可选
└── src/index.ts # 入口(entry 指向)
进程启动时 loader.ts#discoverPlugins 扫描插件根目录:每个含 manifest.json 的子目录视为一个插件,按目录名排序逐个加载;单个目录失败只进 DiscoverResult.errors,不阻断其他插件。
2.2 manifest.json#
{
"id": "my.greet",
"name": "打招呼插件",
"version": "1.0.0",
"engineVersion": "*",
"apiVersion": 1,
"entry": "./src/index.ts",
"scope": "global",
"permissions": [
{ "resource": "script", "action": "execute", "scope": "*" }
]
}
2.3 src/index.ts#
// 加载器要求:入口模块必须导出 onLoad 与 onUnload 函数(loader.ts#loadPluginFromDir)。
// 代码内 manifest 可省略;若提供,id/version/entry 必须与 manifest.json 一致。
export const manifest = {
id: 'my.greet',
version: '1.0.0',
entry: './src/index.ts',
};
export async function onLoad(ctx) {
ctx.script.registerHostAPI('greet_hi', {
params: [{ name: 'who', type: 'str' }],
returns: 'str',
handler: (_hostCtx, who) => `你好,${who}`,
});
}
export function onUnload() {
// 宿主 API 随插件 teardown 自动清理(registry.removeHostAPIsOf),无需手工注销。
}
2.4 如何被加载、如何在剧本里用#
- 把目录放到
plugins/下(由服务器装配时的PluginManagerOptions.pluginsDir决定,见manager.ts)。 - 启动进程,
PluginManager.boot()完成"发现 → 校验 → 令牌签发 → onLoad",并把注册的宿主 API 合入引擎注册表(syncHostAPIsTo,同时刷新既有会话的表达式内置表)。 - 剧本中直接调用:
set msg = greet_hi("冒险者")
say $msg
3. manifest.json 逐字段说明#
结构 schema 见 manifest-schema.ts(zod),语义校验见 manifest-validator.ts。所有结构/语义校验失败统一抛 E-REG002-R,details 携带字段级路径;boot 时该插件进 BootReport.failed,不阻断其他插件。
| 字段 | 类型 | 必填 | 校验与默认行为(出处) |
|---|---|---|---|
id |
string | 是 | 必须匹配 /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/(命名空间.名称,两段均小写开头)— manifest-schema.ts#PLUGIN_ID_PATTERN |
name |
string | 是 | 非空 |
version |
string | 是 | 仅做非空校验;建议 semver。代码内 manifest 与它不一致时加载拒绝(loader.ts) |
engineVersion |
string | 是 | 非空;boot 时用 matchesSemverRange(引擎版本, 此范围) 匹配,不匹配拒绝加载(E-REG003-F,lifecycle.ts#boot)。支持 *、^x.y.z、~x.y.z、比较符、1.2.x、\|\| |
apiVersion |
number | 是 | 必须 === 1(PLUGIN_API_VERSION,superRefine 校验) |
entry |
string | 是 | 路径监狱(manifest-validator.ts#checkEntryPath):必须 ./ 开头;拒绝绝对路径/file:///盘符;不得含 ./../空路径段;扩展名限 .js/.mjs/.cjs/.ts;normalize 后不得越出插件目录。entry 为 .js 而磁盘只有同名 .ts 时回退加载 .ts(开发期便利,loader.ts#resolveEntryFile) |
scope |
'global' \| 'session' |
是 | 枚举。global = 进程级加载一次;session = 每个新会话触发 onSessionInit,其状态参与会话存档 |
permissions |
PermissionSpec[] | 是 | 数组(可为空)。元素 { resource, action, scope?, condition? },action 枚举 read/write/execute/emit/listen。condition 只做 DSL 语法校验(只解析不求值,dsl-parser.ts#parseCondition) |
dependencies |
Record<pluginId, semverRange> | 否 | key 必须是合法插件 ID、value 必须可解析的 semver range;不得依赖自身;缺失硬依赖 → 该插件不加载(E-REG003-F);循环依赖 → 整个 boot 失败(E-REG004-F) |
optionalDependencies |
string[] | 否 | 每项必须是合法插件 ID;缺失不阻断加载 |
quota |
PluginQuota | 否 | 缺省用 DEFAULT_QUOTA(见 §3.1)。逐项可覆盖 |
providesServices |
{ name, version }[] |
否 | version 必须是合法 semver。ctx.services.provide 前会核对声明 |
consumesServices |
{ name, version }[] |
否 | version 必须是合法 semver。ctx.services.resolve 前会核对声明 |
storageSchemaVersion |
number | 否 | 非负整数。私有状态结构版本;与存档中 __schemaVersion 不匹配时自动调 migrateState(lifecycle.ts#restoreState) |
完整示例(取自真实插件,字段全覆盖):
{
"id": "example.stat",
"name": "示例统计插件",
"version": "1.0.0",
"engineVersion": ">=0.1.0",
"apiVersion": 1,
"entry": "./src/index.ts",
"scope": "session",
"permissions": [
{ "resource": "event", "action": "listen", "scope": "*" },
{ "resource": "event", "action": "emit", "scope": "*" },
{ "resource": "storage", "action": "read", "scope": "*" },
{ "resource": "storage", "action": "write", "scope": "*" },
{ "resource": "script", "action": "execute", "scope": "*" },
{ "resource": "service", "action": "write", "scope": "*" },
{ "resource": "service", "action": "read", "scope": "*" }
],
"dependencies": { "isekai.core": "^1.0.0" },
"optionalDependencies": ["nice.extra"],
"quota": {
"maxEventSubscriptions": 50,
"maxHostCallsPerSec": 100,
"maxStorageBytes": 1048576,
"maxTimers": 10
},
"providesServices": [{ "name": "example.stat.reader", "version": "1.0.0" }],
"consumesServices": [{ "name": "other.svc", "version": "^1.0.0" }],
"storageSchemaVersion": 1
}
3.1 默认配额(plugin.ts#DEFAULT_QUOTA)#
| 配额项 | 默认值 | 超限表现 |
|---|---|---|
maxEventSubscriptions |
50 | events.on 抛 E-QTA001-W,订阅不落表 |
maxHostCallsPerSec |
100 | 滑动 1 秒窗口(manager.ts#countHostCall),超限抛 E-QTA001-W |
maxStorageBytes |
1 MB(1048576 字节) | storage.set 时按整份 JSON 字节数检查,超限抛 E-QTA001-W |
maxTimers |
10 | 建定时器抛 E-QTA001-W;另有限制:间隔最小 10ms |
计入 maxHostCallsPerSec 的通道(manager.ts 内核实现逐条核对):关键字/函数调用(wrapHostCall)、storage.get/storage.set、events.emit、events.awaitEvent、指令 handler(callDirective)。
4. IPlugin 全部 8 个生命周期钩子#
契约定义在 plugin.ts#IPlugin。入口模块除 manifest 外需导出 onLoad/onUnload(必须),其余 6 个钩子可选。签名一律以 ctx: PluginContext 为第一参数(除 healthCheck)。
4.1 onLoad(ctx): Promise<void>#
- 调用时机:boot 的最后一步,按依赖拓扑序逐个调用(
lifecycle.ts#boot第 5 步)。session 作用域插件此处拿到的是sessionId = null的全局上下文。 - 能做什么:注册事件订阅、服务、关键字/函数/宿主 API/指令;做初始化 IO。
- 返回值/异常:任一插件 onLoad 抛错 → 该插件标记
failed、摘除其半途注册的所有痕迹(五张表 + 定时器 + 令牌,teardownPlugin),然后已加载插件按逆序全部回滚卸载,BootReport.started清空(fail-fast + 回滚,lifecycle.ts#boot)。 - 启动次数持久化的范例见
plugins/example-stat/src/index.ts(ctx.storage记boots)。
4.2 onUnload(ctx): void | Promise<void>#
- 调用时机:三个场景——① shutdown 按加载逆序;② boot 回滚逆序;③ 熔断卸载(
unloadOne,lifecycle.ts)。 - 能做什么:释放 onLoad 申请的一切。订阅/注册项/服务/宿主 API/定时器/令牌由内核统一摘除,无需(也不应)手工逐个注销。
- 异常处理:onUnload 抛错只记 warn 日志,不影响卸载流程继续(
unloadOne内 try/catch)。 - 注意:从未 active 的插件(如 onLoad 失败)shutdown 时不会再被调 onUnload,只摘表(
lifecycle.ts#shutdown的isServeable判断)。
4.3 onSessionInit?(ctx, session): void#
- 调用时机:仅
scope: "session"的插件、且处于可服务状态(active/degraded);会话创建时按加载顺序逐个调用(lifecycle.ts#onSessionInit)。 - 能做什么:初始化会话级状态。只有 onSessionInit 成功过的会话才会被记入
rec.sessionIds,其状态才会参与存档(collectState的过滤条件)。 - 返回值/异常:成功 →
recordSuccess(健康度清零该来源桶);抛错 →recordError(..., 'onSessionInit', sessionId),计入健康度。异步 init 期间会话已关闭时跳过绑定(防死会话 id 泄漏)。
4.4 onSessionDestroy?(ctx, session): void#
- 调用时机:会话销毁时逆序调用;且只对真正
onSessionInit成功过的会话调用(init 未完成即销毁的竞态下跳过)。 - 异常处理:抛错只记 warn,不计健康度(
lifecycle.ts#onSessionDestroy的 try/catch 只打日志)。 - 随后内核自动:移除该会话的订阅、删除该会话的健康桶、清理该会话名下的全部插件定时器、撤销该会话的全部令牌。
4.5 healthCheck?(): { status: 'ok' | 'degraded' | 'failed'; message?: string }#
- 调用时机:
PluginManager.healthCheckOf(pluginId)巡检时;也可经/api/plugins观察。 - 异常处理:healthCheck 自身抛错按
failed处理并带上错误消息(manager.ts#healthCheckOf)。插件记录已是failed(熔断/禁用)时直接报"插件已被熔断/禁用"。
4.6 serializeState?(session): Record<string, unknown>#
- 调用时机:会话存档时(
lifecycle.ts#collectState),仅 session 作用域 + 已绑定该会话 + 可服务的插件参与。 - 返回值处理:返回
undefined→ 不并入存档;返回值先过assertSerializable纯数据校验,含函数/symbol/BigInt/undefined/循环引用 →E-SER001-Fpanic,该插件状态不并入存档并计入健康度(错误位置serializeState)。 - 收集结果以
manifest.id为键并入SaveState.plugins。
4.7 deserializeState?(session, state): void#
- 调用时机:读档时(
lifecycle.ts#restoreState),在migrateState(如需)之后调用。state 先过纯数据校验。 - 副作用:成功后该会话被记入
rec.sessionIds(后续存档继续收集),并recordSuccess;抛错计健康度(位置deserializeState)。
4.8 migrateState?(oldState: unknown, fromVersion: number): unknown#
- 调用时机:读档时,若 manifest 声明了
storageSchemaVersion且与存档数据中的__schemaVersion不相等(lifecycle.ts#restoreState)。fromVersion取存档中的版本号(非数字时传 0)。 - 异常处理:抛错计健康度(位置
migrateState),该插件本次状态不恢复。 - 迁移写法范例见
plugins/example-stat/src/index.ts的migrateState(v0 → v1 补默认字段)。
5. PluginContext(ctx)通道详解#
ctx 经 hardened Proxy 隔离交付(见 §6)。字段白名单(context.ts#ALLOWED_KEYS,共 10 个):pluginId / token / manifest / logger / events / services / script / storage / timer / serialize。
5.1 身份字段与 logger#
| API | 说明 |
|---|---|
ctx.pluginId |
插件 ID(只读) |
ctx.manifest |
权威 manifest 视图(来自 manifest.json) |
ctx.token |
能力令牌只读视图:tokenId/pluginId/sessionId/permissions/issuedAt/expiresAt/signature(capability.ts#CapabilityToken) |
ctx.logger.info/warn/error(msg, meta?) |
pino 子 logger,自动带 plugin: <id> 字段(manager.ts#makePluginLogger) |
5.2 events 通道(context.ts)#
需要权限:on/awaitEvent → event:listen;emit → event:emit。
| API | 签名要点 | 行为 |
|---|---|---|
events.on(event, handler, opts?) |
opts:{ priority?: number, timeoutMs?: number },默认 priority: 0、timeoutMs: 500 |
返回 Disposable(dispose() 退订)。事件名必须匹配点分小写段 /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/,否则 E-EVT001-W。handler 可同步可异步;异步超时被强杀(E-EVT002-R)。权限 scope 按 sessionId ?? '*' 运行时校验,因此 manifest 里 scope 写 "*" 才能覆盖任意会话 |
events.emit(event, args) |
args 数组 | 派发给其他监听者;计入调用频次配额 |
events.awaitEvent(event, timeoutMs?) |
默认超时 5000ms | 一次性等待,到达返回 args 数组;超时自动摘除内部订阅并抛 E-EVT002-R(manager.ts#awaitEvent,超时 timer 也登记管理,防 unhandledRejection) |
派发时序(lifecycle.ts#dispatch):事件名校验 → 收集订阅(全会话监听的也命中)→ priority 降序 → 同步直调 / 异步带超时强杀 → 逐个计健康度 → 无任何监听者时进死信队列(环形 200 条,DEAD_LETTER_LIMIT)。
5.3 services 通道#
| API | 权限 | 额外约束 |
|---|---|---|
services.resolve(name, versionRange) |
service:read |
必须先在 manifest consumesServices 声明,否则 E-CAP003-R。解析在满足 range 的提供者中取版本最高者;找不到抛 E-REG003-F(registry.ts#resolveService) |
services.provide(name, version, impl) |
service:write |
必须先在 manifest providesServices 声明,否则 E-CAP003-R。version 必须合法 semver;同插件同版本重复提供视为覆盖(幂等) |
5.4 script 通道(四个注册器对比)#
全部需要权限 script:execute,全部返回 Disposable。名字合法性在 registry.ts#addRegistration 校验,重名抛 E-REG001-R(与是谁注册的无关)。
| API | 名字规则(registry.ts) | 剧本侧用法 | 关键差异 |
|---|---|---|---|
registerKeyword(name, handler) |
/^@[a-z]+\.[a-z]+:[a-z_]+$/,如 @ex.stat:show |
引擎侧关键字语法(预留槽位) | handler 必须同步;经 wrapHostCall 包装:计入频次配额、抛错计健康度、返回 Promise 直接拒绝(E-REG002-R,表达式通道是同步求值,async 会污染 vars 和存档) |
registerFunction(name, handler) |
/^[a-z]+\.[a-z]+(_[a-z]+)*$/,如 my.fn |
表达式中 my.fn(...) |
同上(同步 + 配额 + 健康度) |
registerHostAPI(name, def) |
非空字符串 | 剧本 @api() / 表达式调用 |
def 形状见下。跨插件重名抛 E-REG001-R,同插件重复注册视为覆盖(registry.ts#registerHostAPI)。boot 后经 syncHostAPIsTo 合入引擎注册表并刷新内置表 |
registerDirective(name, handler) |
与事件名同模式(点分小写段),如 rpg.roll |
剧本语句 !rpg.roll(2) |
语句级、无返回值;handler 额外收到 (args, sessionId, session),可调 session.getVars() 拿活引用直接写会话变量。计入频次配额;抛错计健康度后重新抛出,VM 包装为 E3003 上报客户端(vm.ts 的 onDirective catch) |
registerHostAPI 与 registerDirective 的差异(最容易混淆的一对):
| 维度 | registerHostAPI | registerDirective |
|---|---|---|
| 剧本写法 | 表达式内 name(args) / @name() |
语句行 !name(args) |
| 有无返回值 | 有,参与表达式求值 | 无(语句级) |
| handler 签名 | (hostCtx, ...args),hostCtx 为 { world, vars, emit }(host-api.ts#HostContext) |
(args, sessionId, session?),session 是 Session 对象 |
| 参数校验 | 引擎按 params 类型表校验(不匹配 E3001) |
无类型校验,args 原样数组 |
| 剧本中取值 | 直接 set x = name(...) |
写会话变量间接传值 |
HostAPIDef 参数/返回类型(types/context.d.ts#HostAPIDefInput、engine/runtime/host-api.ts):
params:Array<{ name: string, type: 'str' | 'num' | 'bool' | 'any', optional?: boolean }>;类型按typeof匹配,个数不匹配或类型不符 →E3001returns:'void' | 'str' | 'num' | 'bool' | 'any' | 'promise'。注意:returns为void或promise的 API 不会进入表达式内置表(buildExprBuiltins过滤),即不能在表达式中求值clientSide: true:调用转发客户端执行,服务端 handler 不被调用,客户端回host_result后resumeHost恢复(host-api.ts、vm.ts#resumeHost)- 剧本调用未注册的 API →
E3002;handler 抛错 →E3003
5.5 storage 通道#
需要权限:get → storage:read;set → storage:write。
- 按插件隔离的 KV,落盘为
savesDir/plugins/<pluginId>.json(manager.ts#PluginStorageStore),带内存缓存,写盘走 tmp 文件 + rename 原子替换。 - 每个插件的 storage 操作强制串行排队(
manager.ts#enqueueStorage):"await get → 改 → await set" 的读改写在并发事件下不会丢更新。 set超过maxStorageBytes(整份 JSON 字节数)抛E-QTA001-W。- 与 serialize 是两条独立链路:storage 即时落盘(跨会话/跨重启),serialize 随会话存档走。
5.6 timer 通道#
timer.setTimeout(fn, ms)/timer.setInterval(fn, ms)→ 返回字符串 id;timer.clear(id)清除(无需权限)。- 建/清定时器需权限
timer:write;受maxTimers配额;间隔最小 10ms,ms非有限数或 <10 抛E-QTA001-W(manager.ts#addTimer)。 - 回调统一走
safeRun:同步抛错或 async rejection 计健康度(位置timer),成功才记录成功(manager.ts#safeRun)。 - 插件被摘除(卸载/熔断/禁用)或会话销毁时,名下定时器全部清除。
5.7 serialize 通道#
工具型通道,等价于手动触发实例的状态钩子(context.ts#serialize):
serialize.save()→ 调实例serializeState(当前会话),返回前过assertSerializable(不纯 →E-SER001-F);无会话或未实现钩子时返回{}。serialize.load(state)→ state 先过纯数据校验,再转发给实例deserializeState(当前会话, state)。
5.8 权限申请与对照表#
怎么申请:在 manifest.json 的 permissions 数组里逐条声明,每条是一个 PermissionSpec:
{
"permissions": [
{ "resource": "script", "action": "execute", "scope": "*" },
{ "resource": "event", "action": "listen", "scope": "*" },
{ "resource": "storage", "action": "write", "scope": "*", "condition": "..." }
]
}
resource:资源类别,任意非空字符串;引擎内置检查点使用的资源有event/service/script/storage/timer(manifest-schema.ts#permissionSpecSchema)action:枚举,只有五个 ——read/write/execute/emit/listen(zod enum,写别的值 manifest 校验直接失败)scope:可选,缺省*;global插件按会话匹配时通常填*(scope 按运行时 sessionId 匹配)condition:可选的条件 DSL 字符串;加载期只做语法解析不求值,语法错误 →E-REG002-R(manifest-validator.ts#92)
匹配规则(permission.ts):
- 序列化格式
resource:action:scope;已授予串与本次请求做三方比对 resource与scope支持*通配(含空串),action必须精确相等——通配不放行"读"当"写",防止权限被放大- 校验失败按原因映射:缺失
E-CAP001/ 过期E-CAP002/ 权限不足E-CAP003/ 已撤销E-CAP004(capability.ts);拒绝即抛PluginError,并计入插件健康度分桶
通道 → 权限对照表:
| 通道调用 | 所需权限(resource:action) | 额外门槛 |
|---|---|---|
events.on / events.awaitEvent |
event:listen |
事件名格式;scope 按会话匹配 |
events.emit |
event:emit |
事件名格式 |
services.resolve |
service:read |
manifest consumesServices 已声明 |
services.provide |
service:write |
manifest providesServices 已声明 |
script.* 四个注册器(宿主 API / 指令 / 关键字 / 关键字别名) |
script:execute |
名字规则 + 重名检查(重名 E-REG001-R) |
storage.get / storage.set |
storage:read / storage:write |
字节配额(maxStorageBytes) |
timer.setTimeout / setInterval |
timer:write |
数量(maxTimers)与间隔(≥10ms)配额 |
timer.clear、logger.*、serialize.* |
无 | — |
内置插件的实际声明(参照):
| 插件 | permissions 声明 | 为什么 |
|---|---|---|
rpg-kit |
script:execute:*、event:listen:*、event:emit:* |
注册宿主 API + emit combat_start 等事件 + 监听会话事件 |
rpg-commands |
script:execute:*、storage:read:*、storage:write:* |
!rpg.roll 结果要写会话存储(last_roll) |
isekai-core(本地) |
script:execute:*、event:listen:*、event:emit:* |
溯回/时间事件双通道 |
isekai-codex(本地) |
script:execute:*、event:listen:* |
只注册 API + 监听,不 emit |
漏申报的实际表现:插件代码调用了未声明权限的通道(例如没写 event:emit 就 ctx.events.emit(...))→ 能力令牌校验失败 → E-CAP003 PluginError 抛出 → 该插件健康度 +1(连续 3 次降级、5 次熔断,见 §7)。manifest 校验期不会发现这种"用到才爆"的漏报——上插件前对着 §5.8 的表把用到的通道核一遍。
5.9 全权限申请完整示例(已通过真实服务器装配验证)#
下面是一个声明并使用全部 8 条权限的可运行插件(仓库本地 plugins/perm-demo/,pnpm e2e:plugins 装配验证通过)。抄走改个 ID 就能用。
manifest.json(8 条权限逐条对应代码里的用法):
{
"id": "perm.demo",
"name": "全权限申请示例插件",
"version": "1.0.0",
"engineVersion": "*",
"apiVersion": 1,
"entry": "./src/index.ts",
"scope": "session",
"permissions": [
{ "resource": "script", "action": "execute", "scope": "*" },
{ "resource": "event", "action": "listen", "scope": "*" },
{ "resource": "event", "action": "emit", "scope": "*" },
{ "resource": "storage", "action": "read", "scope": "*" },
{ "resource": "storage", "action": "write", "scope": "*" },
{ "resource": "timer", "action": "write", "scope": "*" },
{ "resource": "service", "action": "read", "scope": "*" },
{ "resource": "service", "action": "write", "scope": "*" }
],
"providesServices": [
{ "name": "perm.demo.clock", "version": "1.0.0" }
],
"consumesServices": [
{ "name": "perm.demo.clock", "version": "1.0.0" }
],
"quota": {
"maxEventSubscriptions": 10,
"maxHostCallsPerSec": 50,
"maxStorageBytes": 65536,
"maxTimers": 4
},
"storageSchemaVersion": 1
}
src/index.ts(注释里的【权限 n】与 manifest 逐条对应):
/** 最小上下文契约(与引擎 PluginContext 对齐的子集,避免依赖引擎内部路径)。 */
interface PluginContextLike {
readonly pluginId: string;
readonly logger: {
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
};
readonly events: {
on(event: string, handler: (args: unknown[]) => void | Promise<void>,
opts?: { priority?: number; timeoutMs?: number }): { dispose(): void };
emit(event: string, args: unknown[]): void;
};
readonly services: {
provide(name: string, version: string, impl: unknown): void;
resolve<T>(name: string, versionRange: string): T;
};
readonly script: {
registerHostAPI(name: string, def: {
params: Array<{ name: string; type: 'str' | 'num' | 'bool' | 'any' }>;
returns: 'void' | 'str' | 'num' | 'bool' | 'any';
handler: (hostCtx: unknown, ...args: never[]) => unknown;
}): unknown;
registerDirective(
name: string,
handler: (args: unknown[], sessionId: string | null, session?: unknown) => void,
): { dispose(): void };
};
readonly storage: {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T): Promise<void>;
};
readonly timer: {
setTimeout(fn: () => void, ms: number): string;
setInterval(fn: () => void, ms: number): string;
clear(id: string): void;
};
readonly serialize: {
save(): Record<string, unknown>;
load(state: Record<string, unknown>): void;
};
}
interface SessionLike {
readonly id: string;
}
interface DemoState {
booted: boolean;
sessions: number;
ticks: number;
}
const state: DemoState = { booted: false, sessions: 0, ticks: 0 };
/** 与 manifest.json 保持一致的三元组(manifest.json 才是权威)。 */
export const manifest = {
id: 'perm.demo',
version: '1.0.0',
entry: './src/index.ts',
};
/** 服务名(须与 manifest.providesServices 声明一致)。 */
const SERVICE = 'perm.demo.clock';
// ---------------------------------------------------------------- 1) 启动:全局一次
export async function onLoad(ctx: PluginContextLike): Promise<void> {
state.booted = true;
// 【权限 4+5 storage:read/write】插件私有持久化(按插件隔离,随存档保存)。
const seen = (await ctx.storage.get<number>('boot_count')) ?? 0;
await ctx.storage.set('boot_count', seen + 1);
// 【权限 2 event:listen】监听剧本/客户端派发的事件。
ctx.events.on('demo_ping', (args) => {
ctx.logger.info({ args }, 'perm-demo 收到 demo_ping');
});
// 【权限 8 service:write】提供服务;其他插件可 resolve('perm.demo.clock', '^1.0.0')。
ctx.services.provide(SERVICE, '1.0.0', {
now(): number {
return Date.now();
},
});
// 【权限 7 service:read】消费服务(需 manifest.consumesServices 声明)。
const clock = ctx.services.resolve<{ now(): number }>(SERVICE, '^1.0.0');
ctx.logger.info({ now: clock.now() }, 'perm-demo 服务自测通过');
// 【权限 1 script:execute → 宿主 API】有返回值,剧本里 set x = demo_hi("...")。
ctx.script.registerHostAPI('demo_hi', {
params: [{ name: 'who', type: 'str' }],
returns: 'str',
handler: (_hostCtx, who) => `你好,${String(who)}(来自 perm-demo)`,
});
// 【权限 1 script:execute → 插件指令】语句级,剧本里 !demo.roll(3)。
ctx.script.registerDirective('demo.roll', (args, _sessionId, session) => {
const n = Math.max(1, Math.min(10, Number(args[0]) || 1));
const roll = 1 + Math.floor(Math.random() * 6) * n;
if (session && typeof session === 'object') {
const vars = (session as { getVars?: () => Record<string, unknown> }).getVars?.();
if (vars) vars['last_roll'] = roll;
}
ctx.logger.info(`demo.roll → ${roll}`);
});
ctx.logger.info({ boot_count: seen + 1 }, 'perm-demo loaded(全权限示例)');
}
// ---------------------------------------------------------------- 2) 会话级:每个会话一次
export function onSessionInit(ctx: PluginContextLike, session: SessionLike): void {
state.sessions += 1;
// 【权限 3 event:emit】向会话广播事件(客户端可监听,剧本 on/await 可接收)。
ctx.events.emit('demo_hello', [session.id]);
// 【权限 6 timer:write】定时器必须挂在具体会话上才会被该会话的调度器 tick。
// ——这就是为什么示例放在 onSessionInit 而不是 onLoad:global 上下文没有会话,定时器不会跑。
const id = ctx.timer.setTimeout(() => {
state.ticks += 1;
ctx.logger.info({ session: session.id }, 'perm-demo 定时器触发(1s)');
}, 1000);
ctx.logger.info({ timer: id }, 'perm-demo 已注册会话定时器');
}
export function onSessionDestroy(_ctx: PluginContextLike, _session: SessionLike): void {
// 清理可在事件 on() 的 dispose() / timer.clear(id) 中做;本示例无长驻资源。
}
// 【硬性要求】入口模块必须同时导出 onLoad 与 onUnload,缺任何一个加载失败(E-REG002-R)。
export function onUnload(ctx: PluginContextLike): void {
ctx.logger.info({ sessions: state.sessions, ticks: state.ticks }, 'perm-demo unloaded');
}
// ---------------------------------------------------------------- 3) 存档:状态进出
export function serializeState(): Record<string, unknown> {
return { __schemaVersion: 1, sessions: state.sessions, ticks: state.ticks };
}
export function deserializeState(_session: SessionLike, saved: Record<string, unknown>): void {
if (typeof saved.sessions === 'number') state.sessions = saved.sessions;
if (typeof saved.ticks === 'number') state.ticks = saved.ticks;
}
剧本里的用法:
set msg = demo_hi("冒险者") # 宿主 API(有返回值)
!demo.roll(3) # 插件指令(语句级,写会话变量 last_roll)
本示例编写过程中真实踩到的三个坑(都已在示例中规避):
consumesServices[].version是精确 semver,不接受^/~范围——写了直接E-REG002-R。范围匹配只出现在运行时services.resolve(name, versionRange)的第二个参数里。- 入口模块必须同时导出
onLoad与onUnload,缺任何一个(哪怕你不需要清理)都在加载期报"入口模块必须导出 onLoad(ctx) 与 onUnload(ctx)"(E-REG002-R)。 timer:write的定时器要挂在具体会话上才会被调度——session 作用域插件把定时器放onSessionInit,别放onLoad(global 上下文没有会话,定时器永远不跑)。
验证方式:把目录放进 plugins/,跑 pnpm e2e:plugins,日志出现 perm-demo loaded(全权限示例) 即装配成功。
6. 隔离模型:hardened Proxy 与能力令牌#
6.1 ctx 的 Proxy 陷阱(context.ts#createPluginContext)#
- get:白名单字段返回 hardened 包装值;敏感字段集合(
fs/process/globalThis/require/constructor/__proto__/eval/Function等,见SENSITIVE_KEYS)返回 undefined(不抛错)——避免插件用 try/catch 探测内核;其余字段一律抛E-CAP003-R。 - set / deleteProperty / defineProperty:一律抛
E-CAP003-R(上下文只读)。 - ownKeys:只返回白名单键;getOwnPropertyDescriptor 恒返回 undefined——否则插件可经
Object.getOwnPropertyDescriptor(ctx, 'events').value拿到未经包装的原始实现(源码注释记载了实测 PoC)。 - hardened 包装递归到底:通道函数(
hardenFn)与子对象(hardenObj)同样被 Proxy 包住:constructor/prototype/__proto__/call/apply/bind等属性不可见、getPrototypeOf恒为 null、symbol 访问返回 undefined、写入即抛。堵死ctx.events.constructor.constructor('return process')()式原型链逃逸——这段攻击路径在context.ts头注释里有原文记载。
插件视角:插件能看到的只有白名单内的 10 个字段及其方法;ctx.fs、ctx.process 静默返回 undefined;ctx.foo 抛 E-CAP003-R;Object.keys(ctx) 只得到白名单;任何写入/删除/定义属性的操作抛 E-CAP003-R。
6.2 能力令牌与权限模型#
令牌是插件获得内核能力的唯一凭证(capability.ts):
token = HMAC-SHA256(pluginId + tokenId + issuedAt, secret);secret 进程启动时crypto.randomBytes(32)随机生成,不落盘不入存档——重启后旧令牌自然失效,因此存档恢复必然伴随重新签发。- 校验顺序(
CapabilityAuthority.verify):令牌缺失/签名不符 →E-CAP001-R;已撤销 →E-CAP004-R;已过期 →E-CAP002-R;权限不覆盖 →E-CAP003-R。签名比较用timingSafeEqual防侧信道。 - 撤销粒度:单令牌 / 单插件全部 / 单会话全部(
revokeToken/revokePlugin/revokeSession)。插件卸载/熔断/禁用、会话销毁都会触发撤销。
manifest 权限与令牌的关系:boot 时 serializePermissions(manifest.permissions)(plugin.ts)把每条声明序列化为 resource:action:scope 字符串集合,签进令牌。之后 ctx 每个通道方法调用前都拿令牌做 verify(required)。
权限匹配规则(permission.ts):
- 权限串三段
resource:action:scope,scope 缺省视为*; resource与scope支持*通配(glob,匹配任意串);action必须精确相等(防"读"被当"写"放行);- 未声明对应权限(或声明不含所需 action/scope)时调用同步抛
E-CAP003-R,消息形如权限不足:需要 storage:write:*。
7. 健康度系统#
阈值常量(lifecycle.ts):HEALTH_DEGRADE_THRESHOLD = 3、HEALTH_CIRCUIT_THRESHOLD = 5。
7.1 计数与降级/熔断#
- 错误按来源分桶:
bucketKey = sessionId ?? 'global',桶计数 = "该来源连续报错次数"(recordError)。 - 同一来源连续第 3 次 → 降级:状态转
degraded,审计health.degrade,日志E-HLT001-W。降级期间插件仍接收事件、仍可被调用(isServeable含 degraded)。 - 同一来源连续第 5 次 → 熔断卸载:状态转
failed,审计health.circuit_break,日志E-HLT002-R,异步执行卸载(订阅/注册项/服务/宿主 API/定时器全部摘除、令牌撤销、引擎侧 API 副本同步删除)。 - 恢复条件:该来源一次成功即清零该桶;但全部桶都清零才解除降级(
recordSuccess的注释解释了原因:否则 B 会话的一次成功会洗掉 A 会话造成的降级)。熔断(failed)不可自愈,只能经PluginManager.enable(pluginId)重走 onLoad 恢复(manager.ts#enable,恢复时清空健康桶)。
7.2 计入错误的点(逐处核对源码)#
| 位置 | 出处 | 说明 |
|---|---|---|
| 事件 handler 抛错/超时 | lifecycle.ts#dispatch |
位置 event:<名字> |
onSessionInit 抛错 |
lifecycle.ts#onSessionInit |
位置 onSessionInit |
serializeState 校验失败/抛错 |
lifecycle.ts#collectState |
存档路径 |
migrateState / deserializeState 抛错 |
lifecycle.ts#restoreState |
读档路径 |
| 定时器回调抛错 / async rejection | manager.ts#safeRun |
位置 timer |
| 指令 handler 抛错 | manager.ts kernel registerDirective |
位置 directive,先计数再 rethrow |
| 关键字/函数 handler 抛错、async handler | manager.ts#wrapHostCall |
位置 hostCall |
对剧本调用的实际影响:插件降级后剧本侧暂无感知(API 仍可调用);一旦熔断,该插件的宿主 API 从引擎注册表删除(onPluginTornDown 钩子同步摘除),剧本再调用即报 E3002(未注册),指令同理。
8. 状态序列化与存档#
8.1 什么能进 storage / serializeState#
| 值 | 结果 |
|---|---|
| null / boolean / number / string | 可以 |
| 纯对象 / 数组(内容递归可序列化) | 可以 |
function / symbol / bigint / undefined |
E-SER001-F(context.ts#assertSerializable,报错带字段路径) |
| 循环引用 | E-SER001-F |
NaN/Infinity 等非标准 JSON number |
校验器不拦截,但 JSON.stringify 后变形,自行避免 |
8.2 存档/读档时插件的钩子顺序#
存档(lifecycle.ts#collectState,经 manager.saveSnapshot 并入 SaveState.plugins):
- 遍历插件(按加载顺序),逐个过滤:session 作用域 +
sessionIds含当前会话 + 可服务; - 调
serializeState(session)→assertSerializable→ 失败计健康度并跳过该插件; - 结果以
manifest.id为键并入存档。
读档(lifecycle.ts#restoreState,经 manager.loadSnapshot):
- 对存档
plugins里的每个插件(按存档键顺序): - 若声明了
storageSchemaVersion且数据带__schemaVersion且不等 → 先调migrateState(old, fromVersion); - 迁移结果过
assertSerializable→ 调deserializeState(session, state); - 成功 → 会话记入
sessionIds(后续存档继续收集)+recordSuccess;失败 → 计健康度,该插件状态不恢复。
约定要点:
- global 作用域插件不参与会话存档,跨会话数据走自己的
ctx.storage; __schemaVersion字段是插件自己在serializeState返回值里写的(见plugins/example-stat),内核只读取比对;- 插件状态在存档中的键 =
manifest.id(registry.ts#PluginRecord.stateKey)。
9. 进阶示例:状态 + 宿主 API + 指令三件套#
目录:plugins/my.counter/(manifest 同 §2.2,权限另加 storage 与 script)。
{
"id": "my.counter",
"name": "计数器插件",
"version": "1.0.0",
"engineVersion": "*",
"apiVersion": 1,
"entry": "./src/index.ts",
"scope": "global",
"permissions": [
{ "resource": "script", "action": "execute", "scope": "*" },
{ "resource": "storage", "action": "read", "scope": "*" },
{ "resource": "storage", "action": "write", "scope": "*" }
],
"storageSchemaVersion": 1
}
// plugins/my.counter/src/index.ts
export const manifest = {
id: 'my.counter',
version: '1.0.0',
entry: './src/index.ts',
};
export async function onLoad(ctx) {
// ① 宿主 API:表达式可求值(returns: 'num')。
ctx.script.registerHostAPI('counter_get', {
params: [],
returns: 'num',
handler: () => total,
});
// ② 指令:剧本写 !counter.add(n) 即触发,直接写会话变量。
ctx.script.registerDirective('counter.add', (args, _sessionId, session) => {
total += Number(args[0]) || 1;
const vars = session?.getVars?.();
if (vars) vars['counter_total'] = total; // 活引用,下一句剧本立即可读
// async IIFE 必须 self-catch,否则 storage 异常裸奔成 unhandledRejection
void ctx.storage.set('total', total).catch((err) => ctx.logger.warn({ err }, '持久化失败'));
});
}
export function onUnload() { /* 注册项随 teardown 自动清理 */ }
剧本用法(指令与表达式混用):
!counter.add(3)
if counter_total >= 3
say "计数已达 $counter_total"
end
设计要点(提炼自 plugins/rpg-kit 与 plugins/rpg-commands 的真实写法):
- 领域校验在插件内闭环:非法输入返回 false/空串而非抛错——抛错会进健康度,连续 5 次熔断插件;
- 需要值给表达式用就注册宿主 API(
returns不能是void/promise);需要副作用就注册指令; - 指令 handler 同步执行于 VM 指令流内,不要在里面调
session.handle*之类驱动方法(重入风险);写变量、写 storage、ctx.logger是安全副作用; - 插件自身 storage 放跨会话数据;会话内游戏状态可学 rpg-kit 存
world.storage(随会话存档自动进出)。
10. 调试与测试#
10.1 日志与观测#
- 插件日志是 pino 结构化输出:
ctx.logger自动带plugin: <id>字段;内核侧module字段为plugin-manager/plugin-lifecycle。 - boot 报告
BootReport:started(按加载顺序)/failed(含每个失败的 PluginError);manifest 校验失败、依赖缺失、onLoad 抛错都只进 failed,服务照常启动。 - 审计日志环形 500 条(
AUDIT_LIMIT):每次订阅/派发/服务/定时器/健康度事件都有AuditEntry;死信队列环形 200 条保留"无监听者"的事件,用于排查订阅时机。 - HTTP 巡检:
curl localhost:3000/api/plugins(src/transport/http-routes.ts);配置了ADMIN_TOKEN时需带Bearer/x-admin-token头(src/config.ts)。 - 端到端链路验证:
pnpm e2e:plugins(package.jsonscripts,对应scripts/e2e-plugins.ts)。
10.2 如何写插件的单元测试#
参照 tests/plugin-system.test.ts(约 1200 行、十组用例)的成熟套路:
- 临时目录落盘插件:在项目根下建临时目录,
writeFileSync写 manifest.json 与入口文件(该测试文件顶部tmpDir/writePlugin助手);每个用例独立子目录,规避动态 import 的模块缓存。 loadPluginFromDir(dir)做加载断言(manifest 校验失败 / 路径穿越 / 模块形状)。new PluginManager({ pluginsDir, savesDir, ... })做端到端:boot → onSessionInit(需传 sessions 查找表)→ dispatch → saveSnapshot → shutdown。manager.internals暴露{ registry, authority }供测试探针(manager.ts#get internals)。- 隔离行为测试用
createPluginContext+ 内核间谍(kernelSpy,每个方法vi.fn())直接构造 ctx 断言 Proxy 行为。
游戏子系统型插件可参照 tests/rpg-kit.test.ts:直接调宿主 API 的 handler(hostCtx, ...) 断言领域逻辑,不经 VM。
10.3 常见错误对照表(以 errors.ts 为准)#
| 码 | 含义 | 典型触发场景 |
|---|---|---|
E-REG001-R |
ID / 注册项 / 宿主 API 重名 | 两个插件注册同名宿主 API;同插件重复注册同一关键字 |
E-REG002-R |
manifest 校验失败 / 插件异常兜底 | manifest 缺字段、entry 越狱、JSON 非法、缺 onLoad/onUnload 导出、条件 DSL 语法错、async 剧本函数 |
E-REG003-F |
依赖缺失 / 服务解析失败 / 引擎版本不兼容 / 未注册指令 | 硬依赖不在、resolveService 找不到、engineVersion 范围不匹配、剧本调了未注册指令(客户端表现为 E3003) |
E-REG004-F |
循环依赖 | A 依赖 B、B 依赖 A——整个 boot 失败 |
E-CAP001-R |
令牌缺失/无效 | 令牌被伪造或进程重启后复用旧令牌 |
E-CAP002-R |
令牌过期 | 签发时设了 ttl/expiresAt 且已到期 |
E-CAP003-R |
权限不足 / 上下文越权 | 未声明权限调通道、写 ctx 字段、访问非白名单字段、services 未在 manifest 声明 |
E-CAP004-R |
令牌已撤销 | 插件被卸载/熔断/禁用或会话销毁后仍持旧令牌调用 |
E-QTA001-W |
配额超限 | 订阅数、宿主调用频次、storage 字节、定时器数或间隔 <10ms |
E-EVT001-W |
事件名不合规 | 事件名不是点分小写段 |
E-EVT002-R |
事件监听/等待超时 | 异步监听器超过 timeoutMs(默认 500ms);awaitEvent 超时(默认 5000ms) |
E-HLT001-W |
健康降级 | 同一来源连续报错 3 次 |
E-HLT002-R |
熔断卸载 | 同一来源连续报错 5 次 |
E-SER001-F |
状态不可序列化 | serializeState/storage 值含函数、symbol、BigInt、undefined 或循环引用 |
剧本层错误码(src/engine/vm/vm-error.ts 的 ERROR_CODES):E3001 宿主 API 签名不匹配、E3002 未注册的宿主 API/指令、E3003 宿主调用失败(含指令 handler 抛错的包装)、E5002 存档不可序列化。完整协议层对照见 protocol-and-error-codes.md。
11. 相关文档#
- architecture-and-operations.md — 内核与运维全景
- story-authoring-guide.md — 剧本语法(含
!指令与表达式宿主 API 用法) - protocol-and-error-codes.md — 协议与错误码全表
- development-guide.md — 工程开发环境