插件开发文档
插件系统开发文档(addons-host)
1. 文档目的
这份文档面向后续插件开发者,描述当前仓库里已经落地的插件系统实现方式、目录约定、运行机制和开发流程。
先统一一个概念和约束:
- 产品和后台文案里叫“插件”
- 代码实现里实际使用的是
addon/addons-host - 核心层只允许做“扩展点 / SDK 暴露”,不写这项业务逻辑本身;业务逻辑与渲染放到 addon 内部完成
- 后文里“插件”和“addon”表示同一套系统。
1.1 强制开发约束
以下约束同时适用于插件前台 UI 和后台 UI,默认视为强制规范,不是“建议即可”:
- 插件 UI 必须优先复用宿主系统 UI;浏览器侧优先使用
sdk.ui和sdk.custom - 插件前台默认使用 Tailwind utility class 直接组织模板字符串 / JSX / HTML 结构,不再以“大量语义 class + 整页自定义 CSS 皮肤”作为默认方案
- Tailwind utility class 必须优先使用宿主当前
shadcn/ui语义 token,例如bg-card、bg-background、border-border、text-foreground、text-muted-foreground、ring-ring - 宿主已经有对应 UI / 交互模式时,插件不要自己重写一套视觉和交互
- 宿主已有组件但还没暴露给插件时,优先补宿主 SDK 暴露,再让插件复用;不要在插件里复制宿主组件源码
- 当前仓库的宿主 Tailwind 已在
src/app/globals.css通过@source '../../addons/**/*.{ts,tsx,js,jsx,mjs,cjs,mdx,md,html}'扫描 addon 源码;插件前台禁止再引入第二套 Tailwind 配置 / PostCSS 流水线,也禁止引入https://cdn.tailwindcss.com - 只有在 Tailwind utility class 难以表达的场景下,插件才允许自己写 CSS;例如刮刮卡涂层画布、复杂伪元素、复杂动画、第三方组件覆盖。自定义 CSS 只补足缺失能力,不要覆盖或重造宿主基础控件
- 插件前后台页面的布局、卡片、按钮、输入框、选择器、弹层等,默认按宿主当前
shadcn/ui体系组织 - 插件采用组件模式时,优先通过
sdk.ui/sdk.custom复用宿主shadcn/ui体系;不要直接 import 宿主内部组件源码 - 如果宿主页面外层已经提供了带边框的 Card / Panel 壳,插件第一层不要再额外包一层同级带边框 Card;插件第一层应优先直接输出布局容器、标题区和内容区,只在内部子分区再按需使用 Card
插件代码结构也要遵守分层约束:
- 插件前端交互模块默认采用类似 MVC 的分层设计,至少拆成
model、controller、view model只负责纯数据逻辑,例如配置归一化、API 请求体 / 响应快照转换、标签 / 节点选项整理controller负责状态管理、异步动作编排、事件入口,不直接堆大段渲染结构view负责 UI 组合和展示,不把请求构造、数据清洗、复杂副作用混在渲染函数里- 不要把“页面结构 + 请求提交 + 数据转换 + 样式拼装”混在同一个大文件或同一个大函数里
- 宿主侧如果要为插件新增前端能力,也应优先遵守相同思路:先抽象能力,再暴露给插件消费
2. 当前实现边界
当前代码里,插件系统已经稳定支持以下能力:
slot:向宿主预留插槽注入内容surface:接管宿主现有区块的默认渲染,未命中时回退宿主实现action hook:在宿主生命周期点执行副作用逻辑waterfall:同步串行改写宿主数据asyncWaterfall:异步串行改写宿主数据public page:注册前台页面admin page:注册后台页面public api:注册前台 APIadmin api:注册后台 APIprovider:统一 provider registry / capability runtime;payment、auth、captcha、external-auth、editor、navigation、emoji、upload已有宿主接线config:插件级配置读写secret:插件敏感配置读写background job:插件自定义后台任务注册、入队与删除data:结构化插件数据读写data migration:按 schema version 执行插件数据迁移lifecycle hook:安装、升级、卸载前的插件自定义生命周期逻辑permissions:注册期与执行期权限校验external fetch guard:插件执行期外网访问守卫install / upgrade / uninstall / sync / clear-cache / enable / disable / remove:安装与管理动作
当前还没有通用运行时支持的能力:
- 插件自带 Prisma schema 自动并表
- 插件 SQL migration 自动发现与自动执行器
- 动态参数路由匹配
dependencies/conflicts自动校验install.requiresRestart自动执行逻辑
2.1 典型业务场景支持矩阵
这几个场景很容易被误以为“插件已经支持”,这里单独说明当前真实状态。这里的重点不是“能不能归类成 hook”,而是“当前宿主实际通过哪种机制开放”:
| 场景 | 当前状态 | 主要机制 | 说明 |
|---|---|---|---|
| 支付网关 / 支付通道接入 | 部分支持 | payment provider |
已有专门的 payment provider 接线,可接入下单、查单、回调处理;支付成功后的派生副作用可再配合 payment.paid.* action hook |
| 登录表单增加字段 / 校验 | 部分支持 | slot + auth provider |
已支持通过 auth form slot 注入额外字段,并通过 auth provider 做服务端校验; |
| 注册表单增加字段 / 校验 | 部分支持 | slot + auth provider |
已支持通过 auth form slot 注入额外字段,并通过 auth provider 做服务端校验; |
| 新增验证码类型 | 部分支持 | slot + captcha provider |
已支持登录 / 注册 / 发帖页插件验证码 UI 注入,以及 captcha provider 服务端校验;登录 / 注册场景下可与系统验证码共存 |
| 追加第三方 / 聚合登录入口 | 部分支持 | slot + external auth bridge |
已支持插件通过 auth slot 追加登录入口,并通过宿主 external auth bridge 复用现有账号创建 / 绑定 / 登录流程;登录后的副作用可配合 auth.login.* 与 auth.identity.* hook |
| 应用导航 / 页脚导航增加链接 | 部分支持 | navigation provider |
当前主要是 provider 注入;如果只是改写主导航数组,也可以使用 navigation.primary.items asyncWaterfall |
| Markdown / 选择器表情扩展 | 部分支持 | emoji provider |
已支持 emoji provider 注入 Markdown 短码表情,站点渲染和编辑器 / 私信表情面板会复用 |
| 替换发帖编辑器 / 评论编辑器 | 部分支持 | editor provider |
宿主已接入 editor provider,可按场景替换 post / comment 等编辑器,默认编辑器仍作为 fallback |
| 给现有编辑器工具栏追加按钮 | 部分支持 | editor provider.toolbarItems |
默认 RefinedRichPostEditor 已支持通过 toolbarItems 追加按钮;整块替换后的自定义编辑器是否复用这套按钮,由对应 provider 自行决定 |
| 替换系统图片上传接口 | 部分支持 | upload provider |
已支持插件接管 /api/upload 和站点图标上传;插件未处理当前文件时,宿主继续回退到内置 local / s3 上传 |
| 替换宿主已有页面区块 | 已支持 | surface |
宿主继续负责数据加载和页面骨架,插件只决定该区块渲染宿主默认 UI,还是渲染插件实现 |
换句话说:
hook更适合表达生命周期副作用和数据串改,不适合替代需要 UI 注入、能力注册、协议桥接的场景provider更适合表达“我提供一种可被宿主消费的能力”,例如支付、验证码、导航、表情、编辑器、上传slot更适合表达“我需要把一块 UI 插到宿主现有页面里”surface更适合表达“我想保留宿主数据流,但替换它原本那块 UI”- 部分业务场景会同时使用多种机制,例如第三方登录入口通常是
slot + bridge + action hook - 这张矩阵描述的是“场景如何接入”,不是“所有场景都应该迁移到 hook”
3. 关键目录与文件
3.1 宿主侧
| 路径 | 作用 |
|---|---|
src/addons-host/types.ts |
插件清单、注册项、上下文、渲染结果等核心类型定义 |
src/addons-host/runtime/loader.ts |
扫描 addons/、读取清单、加载服务端入口、收集注册结果 |
src/addons-host/runtime/execute.ts |
执行 slot / surface / page / api |
src/addons-host/runtime/routes.ts |
路由查找与挂载路径匹配 |
src/addons-host/runtime/render.tsx |
将插件返回的渲染结果输出到页面或区块 |
src/addons-host/runtime/http.ts |
插件 API 的统一 HTTP 入口 |
src/addons-host/runtime/data.ts |
结构化插件数据存储与 schema version |
src/addons-host/runtime/config.ts |
插件配置读写 |
src/addons-host/runtime/secrets.ts |
插件敏感配置读写 |
src/addons-host/runtime/background-jobs.ts |
插件后台任务注册、分发与队列桥接 |
src/addons-host/runtime/lifecycle.ts |
插件 install / upgrade / uninstall 生命周期执行与数据库包装 |
src/addons-host/runtime/state.ts |
插件注册表状态映射读写;当前主要用于启用/禁用和最近错误,uninstalledAt 仅保留旧数据兼容 |
src/addons-host/runtime/permissions.ts |
权限标准化、敏感权限映射与 enforcement |
src/addons-host/runtime/execution-scope.ts |
插件执行作用域与外网 fetch 守卫 |
src/addons-host/hook-catalog.ts |
hook / slot 目录与合法 hook 名校验 |
src/addons-host/surface-modes.ts |
surface 的 server / client / hybrid 模式定义 |
src/addons-host/installer.ts |
从 zip 安装、覆盖安装、物理卸载 |
src/addons-host/management.ts |
同步注册表、后台管理动作、生命周期日志 |
src/addons-host/sdk/server.ts |
defineAddon() 和服务端类型导出 |
src/addons-host/sdk/client.tsx |
浏览器侧 client module 可用的 SDK |
3.2 路由侧
| 路径 | 作用 |
|---|---|
src/app/addons/[addonId]/[[...slug]]/page.tsx |
前台插件页面入口 |
src/app/admin/addons/[addonId]/[[...slug]]/page.tsx |
后台插件页面入口 |
src/app/api/addons/[addonId]/[[...slug]]/route.ts |
前台插件 API 入口 |
src/app/api/admin/addons/[addonId]/[[...slug]]/route.ts |
后台插件 API 入口 |
src/app/%5Faddons/[addonId]/[[...path]]/route.ts |
插件静态资源入口,对应 /_addons/... |
src/app/api/admin/addons/install/route.ts |
后台 zip 安装入口 |
src/app/api/admin/addons/route.ts |
后台同步/清缓存/启停/物理卸载入口 |
3.3 数据持久化
| 路径 | 作用 |
|---|---|
prisma/schema.prisma |
定义 AddonRegistry、AddonLifecycleLog、AddonConfig |
src/db/addon-registry-queries.ts |
插件注册表与生命周期日志访问 |
src/db/addon-config-queries.ts |
插件配置访问 |
4. 插件目录结构
宿主只扫描项目根目录下的 addons/:
txtaddons/ hello-widget/ addon.json dist/ server.mjs assets/ hello-client.js hello.css .runtime/ addons-secrets.json data/ hello-widget/ __meta.json orders.json .staging/ .trash/
说明:
- 每个插件必须是
addons/下的独立目录 - 目录里必须有
addon.json - 服务端入口默认是
dist/server.mjs - 浏览器侧脚本、样式、图片、音频等静态资源必须放在
assets/ .runtime、.staging、.trash由宿主维护,不是插件源码目录
当前实现里:
addons/.runtime/addons-secrets.json是敏感配置的文件态兜底addons/.runtime/data/<addonId>/存放结构化插件数据和schemaVersion- 普通插件配置当前走数据库
addon_config - 插件状态当前走数据库
addon_registry - zip 安装时先解压到
addons/.staging - 物理卸载或覆盖安装旧版本时,旧目录会被移动到
addons/.trash
5. addon.json 清单规范
5.1 最小可运行清单
json{
"id": "hello-widget",
"name": "Hello Widget",
"version": "1.0.0",
"permissions": ["page:public"]
}
如果插件还需要在安装、升级、卸载时处理自己的表结构或初始化数据,可以继续在同一个 server entry 里加 lifecycle:
jsexport default {
async setup(api) {
// ...
},
lifecycle: {
async install(context) {
await context.database.executeRaw(
`CREATE TABLE IF NOT EXISTS addon_demo_orders (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`
)
},
async upgrade(context) {
await context.database.executeRaw(
`ALTER TABLE addon_demo_orders ADD COLUMN IF NOT EXISTS status TEXT`
)
const rows = await context.database.queryRaw(
`SELECT COUNT(*)::int AS count FROM addon_demo_orders`
)
console.log("existing rows", rows[0]?.count ?? 0)
},
async uninstall(context) {
await context.database.executeRaw(
`DROP TABLE IF EXISTS addon_demo_orders`
)
}
}
}
说明:
context.database.executeRaw()/queryRaw()适合处理插件自己的表context.database.transaction()可把多步升级包进一个事务context.database.prisma可以复用宿主 Prisma Client 访问宿主已有模型,但它不能自动识别插件新建表;插件自定义表仍应优先走 raw SQL
说明:
- 从
AddonManifest类型上看,permissions仍然是可选字段 - 但对任何会在
setup(api)里调用register*(),或在执行期调用context.readConfig()/context.data.*()这类受保护能力的插件,permissions都应视为事实上的必填 - 如果缺少所需权限,插件通常不是“部分失效”,而是在加载阶段直接抛错并进入
loadError
5.2 推荐清单
json{
"id": "hello-widget",
"name": "Hello Widget",
"version": "1.0.0",
"description": "一个示例插件",
"author": "team",
"homepage": "https://example.com",
"enabled": true,
"engines": {
"core": "^1.0.0"
},
"entry": {
"server": "dist/server.mjs"
},
"permissions": [
"slot:register",
"surface:register",
"page:public",
"api:public",
"config:read",
"config:write",
"database:sql",
"background-job:register",
"background-job:enqueue",
"background-job:delete",
"provider:register"
],
"provides": {
"slots": ["home.right.top"],
"surfaces": ["search.hero"],
"pages": [""],
"adminPages": ["settings"],
"publicApis": ["ping"],
"adminApis": ["toggle"],
"backgroundJobs": ["sync-remote"],
"providers": ["demo"]
},
"dependencies": {
"addons": [],
"conflicts": []
},
"install": {
"requiresRestart": false
}
}
5.3 字段说明
| 字段 | 是否必须 | 当前行为 |
|---|---|---|
id |
是 | 必填;安装时会校验格式是否合法 |
name |
是 | 必填 |
version |
是 | 必填 |
description / author / homepage |
否 | 展示用途 |
enabled |
否 | 作为默认启用状态参与计算 |
entry.server |
否 | 服务端入口;默认 dist/server.mjs |
engines.core |
否 | 仅元数据,当前不会自动校验版本 |
permissions |
建议视为是 | manifest 类型层允许省略,但运行时会对注册动作、context 敏感能力和外网访问做真实校验;缺失通常会导致插件 loadError |
provides |
否 | 仅元数据,真正生效的是 setup(api) 里的注册 |
dependencies.addons / conflicts |
否 | 当前不会自动解析和阻止加载 |
install.requiresRestart |
否 | 当前不会自动执行重启逻辑 |
权限字段的额外说明:
- 推荐新插件只使用标准化权限名,例如
slot:register、page:public、api:admin、background-job:enqueue - 运行时仍兼容一部分历史写法:
route:public会归一化成page:public,route:admin会归一化成page:admin - 历史上写成
slot:<具体插槽名>的权限也会被归一化为slot:register;新文档不再推荐这种写法
5.4 id 约束
安装器会要求 id 满足:
- 允许字母、数字、点、短横线
- 不能包含
.. - 首尾必须是字母或数字
- 总长度 1 到 64 个字符
建议:
- 插件目录名与
id保持完全一致 - 全部使用小写,例如
hello-widget、acme.editor
注意:
- 运行时扫描阶段如果目录名与
manifest.id不一致,不会阻止加载,但会在后台显示 warning - zip 安装阶段如果
id非法,会直接拒绝安装
6. 加载流程
宿主每次加载插件时,大致执行以下步骤:
- 扫描
addons/下所有非隐藏目录 - 只处理包含
addon.json的目录 - 读取并标准化清单
- 读取数据库
addon_registry里的插件状态;如果注册表不存在,则回退到清单默认值和空状态 - 计算插件基础 URL:
/_addons/<addonId>/addons/<addonId>/admin/addons/<addonId>/api/addons/<addonId>/api/admin/addons/<addonId>
- 如果插件处于禁用状态,或命中旧数据里的
uninstalledAt兼容标记,则不继续执行入口 - 定位服务端入口,默认
dist/server.mjs - 使用动态
import()加载入口模块 - 调用默认导出对象上的
setup(api) - 在
setup(api)的注册过程中执行权限检查,并在需要时运行数据迁移 - 收集 slot / surface / page / api / provider / hook / migration 注册结果
几个关键事实:
- 运行时真正要求的入口格式只有一个:
default export是一个带setup(api)的对象 defineAddon()只是语义化封装,不是强制要求- 服务端入口加载失败时,插件会保留在列表里,但
loadError不为空,页面、API、插槽都不会生效 - 缺少所需
permissions时,安装通常仍会成功,但插件会在setup(api)或执行期第一次触发受保护能力时进入loadError - 开发环境下服务端入口会附带基于文件修改时间的版本参数重新导入,避免 Node
import()缓存过旧
7. 服务端入口怎么写
最小可运行示例:
jsexport default {
async setup(api) {
api.registerPublicPage({
key: "index",
title: "Hello Widget",
render() {
return {
html: "<div>Hello from addon</div>"
}
}
})
}
}
推荐的完整示例:
jsexport default {
async setup(api) {
api.registerSlot({
key: "home-banner",
slot: "home.right.top",
order: 10,
async render(context) {
const enabled = await context.readConfig("enabled", true)
if (!enabled) {
return null
}
return {
html: `
<section class="rounded-2xl border border-border bg-card p-4">
<h3 class="text-base font-semibold">Hello Widget</h3>
<p class="mt-2 text-sm text-muted-foreground">
当前插件标识:${context.manifest.id}
</p>
</section>
`
}
}
})
api.registerPublicPage({
key: "index",
path: "",
title: "Hello Widget",
description: "示例前台页面",
render(context) {
return {
html: `
<div>
<p>插件主页</p>
<p>API: ${context.publicApi("ping")}</p>
</div>
`
}
}
})
api.registerAdminApi({
key: "toggle",
path: "toggle",
methods: ["POST"],
async handle(context) {
const job = await context.backgroundJobs.enqueue("sync-remote", {
source: "admin"
}, {
delayMs: 5_000
})
return {
json: {
ok: true,
jobId: job.id
}
}
}
})
api.registerBackgroundJob({
key: "sync-remote",
async handle(context) {
const enabled = await context.readConfig("enabled", true)
if (!enabled) {
return
}
await context.writeConfig("lastSyncSource", context.payload?.source ?? "unknown")
}
})
api.registerProvider({
kind: "demo",
code: "hello-widget",
label: "Hello Widget Provider"
})
}
}
8. 宿主支持的注册能力
8.1 Slot
用于把内容挂到宿主预留位置:
jsapi.registerSlot({
key: "my-block",
slot: "post.sidebar.top",
order: 100,
render(context) {
return {
text: "这是一个插件区块"
}
}
})
特点:
- 同一个插槽允许多个插件同时输出
- 排序规则是
order升序,再按addonId:key排序 render()返回null/undefined时表示本次不输出- 宿主通过
<AddonSlotRenderer slot="..." props={{ ... }} />调用时,插件可从context.props读取当前点位的上下文数据 - 如果你要的是“替换宿主本来那块 UI”,不要硬塞到 slot 里,改用下面的
surface
当前宿主已经开放了大量 slot,完整清单请以这两处为准:
src/addons-host/types.ts里的ADDON_SLOT_KEYSdocs/插件 Hook 清单.md
常见示例包括:
auth.login.form.afterauth.register.form.afterauth.login.captchaauth.register.captchapost.create.captchapost.header.beforepost.author.name.afterhome.right.toplayout.head.beforelayout.body.endsettings.content.beforemessages.page.after
注意:
- 运行时不会在 JS 层强制校验 slot 名称是否合法
- 但宿主只会执行它自己调用过的 slot
- 写了未知 slot 名称不会报错,只是永远不会被渲染
Surface
用于接管宿主现有区块的默认渲染,但仍复用宿主自己的数据来源和页面结构。
最小示例:
jsapi.registerSurface({
key: "search-hero",
surface: "search.hero",
render(context) {
return {
html: `<section class="rounded-2xl border border-border bg-card p-6">插件版搜索头部</section>`
}
}
})
对客户端交互区块,优先使用 clientModule:
jsapi.registerSurface({
key: "post-tools",
surface: "post.create.tools",
clientModule: "write-tools.js"
})
特点:
- 同一个
surface同时只会命中一个插件 - 选择规则是
priority降序,再按addonId:key稳定排序 - 插件没有命中、
render()返回null/undefined、插件报错或客户端模块加载失败时,宿主会回退到默认 UI - 服务端区块可使用
render(context)返回AddonRenderResult - 客户端交互区块更适合使用
clientModule,宿主会把当前区块 props 直接传给插件组件 messages.page、settings.page属于 hybrid surface:宿主会先尝试服务端render(context),未命中时再继续走clientModule或默认 UImessages.header、messages.sidebar、messages.thread、collection.hero、collection.pending、collection.content、history.panel、settings.content、post.create.form、post.create.tools、post.create.editor、post.create.enhancements、post.create.submit、comment.author.row、comment.author.meta、comment.author.verification、comment.author.name、comment.author.badges属于 client-only surface:必须使用clientModule,单独注册render()会在加载阶段被拒绝- 帖子详情头部额外开放了
post.header、post.author.row、post.author.meta、post.author.verification、post.author.name、post.author.badges,适合做用户名、认证位、勋章位的深度定制 - 上述帖子详情 surface / slot 的
context.props会包含post、authorHref、authorNameClassName、boardHref、isFollowingPost、isRestrictedAuthor、zone、zoneBoards;其中post.author.verification额外包含verification,post.author.badges额外包含badges - 评论区还开放了
comment.author.row、comment.author.meta、comment.author.verification、comment.author.name、comment.author.badges这组 client-only surface,宿主会把entryType、entry、authorHref、authorNameClassName、isRestrictedAuthor、shouldDimRestrictedAuthor、showVerification等上下文传入context.props
命名约定:
- 大多数成对开放的
*.before/*.afterslot,都对应同名surface - 例如
search.hero.before/search.hero.after对应search.hero - 例如
topup.payment.before/topup.payment.after对应topup.payment - 例如
messages.thread.before/messages.thread.after对应messages.thread - 例如
post.create.tools.before/post.create.tools.after对应post.create.tools - 例如
post.author.name.before/post.author.name.after对应post.author.name - 例如
post.author.badges.before/post.author.badges.after对应post.author.badges
8.2 Public Page / Admin Page
注册页面的方式:
jsapi.registerPublicPage({
key: "index",
path: "",
title: "插件首页",
chrome: {
header: true,
rightSidebar: true,
},
render() {
return { html: "<div>hello</div>" }
}
})
api.registerAdminPage({
key: "settings",
path: "settings",
title: "插件设置",
render() {
return { text: "admin page" }
}
})
路由映射:
- 前台页面:
/addons/<addonId>/<path> - 后台页面:
/admin/addons/<addonId>/<path>
注意:
path是相对路径,前后/会被自动去掉path: ""或省略path时,对应插件根路径- 当前匹配规则是精确匹配
- 暂不支持
:id、[slug]、通配符之类的动态路由 - public page 还可以通过
chrome控制宿主页外壳是否显示:headerfooterleftSidebarrightSidebar
- 这些外壳选项默认全部关闭;也就是说插件前台页默认只渲染插件自己的主体,不自动带站点头部、底部和全局左右侧栏
- 如果插件声明
chrome.footer: true,宿主会在插件页内部主动渲染全局页脚;根布局对/addons/*路径默认不再自动附带 footer,避免重复
后台页额外说明:
- 后台页面会先经过宿主的管理员鉴权
- 未登录或非管理员用户不能访问后台插件页
8.3 Public API / Admin API
注册 API:
jsapi.registerPublicApi({
key: "ping",
path: "ping",
methods: ["GET"],
handle() {
return {
json: { ok: true }
}
}
})
路由映射:
- 前台 API:
/api/addons/<addonId>/<path> - 后台 API:
/api/admin/addons/<addonId>/<path>
返回值支持两种写法:
- 直接返回
Response - 返回宿主可归一化的对象:
{ json, status, headers }{ text, status, headers }{ html, status, headers }
注意:
methods默认是["GET"]- 当前 API 路由同样是精确匹配
- 后台 API 由宿主统一做管理员鉴权
- 前台 API 不做额外鉴权,插件自己决定要不要校验登录态或权限
8.4 Provider
注册 provider:
jsapi.registerProvider({
kind: "payment",
code: "hello-pay",
label: "Hello Pay",
description: "示例 Provider"
})
当前 provider 的实际状态:
- 会被加载、统计,并显示在后台详情里
- 宿主会按
kind把 provider 统一收集到 registry / capability 层 - 当前已经接线的
kind包括:payment、auth、captcha、external-auth、editor、navigation、emoji、upload - 这些接线既支持读取静态
provider.data,也支持调用provider.data.runtime上的 runtime hooks - provider runtime 的执行同样会进入 addon execution scope,继承权限检查与外网访问守卫
- 还没有宿主消费端接线的
kind仍然可以注册并展示在后台,但不会自动产生业务效果
8.5 支付网关 Provider 怎么接
如果插件要接入支付网关,当前可行方式不是注册 page / api 自己绕开,而是注册一个:
jsapi.registerProvider({
kind: "payment",
code: "hello-pay",
label: "Hello Pay",
description: "示例支付提供方",
data: {
settingsHref: "/admin/addons/hello-pay/settings",
settingsLabel: "打开插件配置",
runtime: {
listChannels() {
return [
{
channelCode: "hello-pay.web",
label: "Hello Pay 网页支付",
description: "示例网页收银台",
clientTypes: ["WEB_DESKTOP", "WEB_MOBILE"],
presentationType: "HTML_FORM",
},
]
},
async isRunnable({ context }) {
const appId = await context.readSecret("appId", "")
return Boolean(appId)
},
getDefaultNotifyPath() {
return "/api/payments/notify/hello-pay"
},
getDefaultReturnPath() {
return "/topup/result"
},
async createCheckout(input) {
return {
presentation: {
type: "HTML_FORM",
html: "<form>...</form>",
},
providerTradeNo: "demo-trade-no",
requestPayload: null,
responsePayload: null,
redirectUrl: null,
providerTraceId: null,
}
},
async queryOrder(input) {
return {
ok: true,
orderStatus: "PAID",
tradeStatus: "SUCCESS",
paidAt: new Date().toISOString(),
}
},
async handleNotification({ request }) {
return {
verified: true,
merchantOrderNo: "pay_xxx",
providerTradeNo: "trade_xxx",
tradeStatus: "SUCCESS",
orderStatus: "PAID",
payload: Object.fromEntries(new URL(request.url).searchParams.entries()),
}
},
},
},
})
当前支付 provider runtime 已接入的能力包括:
listChannels():声明插件提供的支付通道isRunnable():告知宿主当前 provider 是否可运行getDefaultNotifyPath():返回默认异步回调地址getDefaultReturnPath():返回默认前台返回地址createCheckout():创建下单结果queryOrder():主动查单handleNotification():处理第三方回调
几个关键事实:
- 宿主会把
kind === "payment"的 provider 自动并入支付网关注册表 - 后台支付网关页会显示插件 provider
- 插件回调入口统一走
/api/payments/notify/<providerCode> - 这套能力目前只对支付场景生效,不代表其他
provider.kind也会自动被宿主解析
8.6 登录 / 注册表单扩展怎么接
当前第一版 auth 扩展,支持两件事:
- 在登录 / 注册表单末尾插入插件自定义字段
- 在服务端对这些额外字段执行插件校验
8.6.1 表单注入位置
当前宿主开放了两个 auth 表单插槽:
auth.login.form.afterauth.register.form.after
它们会被渲染在宿主内置字段和验证码区块之后、提交按钮之前。
如果插件想让自己的字段跟随表单一起提交,约定如下:
- 额外字段必须放在这个 slot 里渲染
- 额外字段的表单名必须以
addon:开头 - 例如:
addon:otpCode、addon:inviteToken
示例:
jsapi.registerSlot({
key: "login-otp-field",
slot: "auth.login.form.after",
render(context) {
return {
html: `
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">二次验证码</label>
<input
name="addon:otpCode"
type="text"
class="h-11 rounded-2xl border border-border bg-card px-4 text-sm"
placeholder="输入一次性验证码"
/>
</div>
`,
}
},
})
8.6.2 服务端校验方式
如果插件要对登录 / 注册做额外校验,需要注册一个 kind: "auth" 的 provider:
jsapi.registerProvider({
kind: "auth",
code: "hello-auth",
label: "Hello Auth",
data: {
runtime: {
validateLogin({ addonFields, user }) {
const otpCode = addonFields.otpCode
if (typeof otpCode !== "string" || otpCode !== "123456") {
return {
ok: false,
message: "二次验证码错误",
}
}
},
validateRegister({ addonFields, payload }) {
const inviteToken = addonFields.inviteToken
if (typeof inviteToken !== "string" || !inviteToken.trim()) {
return "请填写扩展邀请码"
}
if (payload.email && !payload.email.endsWith("@example.com")) {
return {
ok: false,
message: "仅允许 example.com 邮箱注册扩展入口",
}
}
},
},
},
})
当前 auth provider runtime 已接入的能力包括:
validateLogin(input):登录基础凭证校验通过后执行扩展校验validateRegister(input):注册内置字段、验证码、邮箱/手机验证码校验通过后执行扩展校验
校验输入里当前可用的重点字段:
addonFields:来自表单里所有name="addon:*"的字段request:当前原始请求context:插件执行上下文,可读写 config / secret- 登录场景额外提供:
username、user - 注册场景额外提供:
payload、registerIp
8.6.3 当前 auth 扩展的边界
当前第一版只支持“加字段 + 阻断式校验”,还不支持:
- 改写宿主内置用户名 / 密码 / 邮箱 / 手机等核心字段语义
- 自动把插件自定义字段持久化到用户主表
8.6.4 追加第三方 / 聚合登录入口怎么接
如果插件不是想“加字段”,而是想在登录 / 注册表单里追加一个第三方登录按钮组,也可以继续使用:
auth.login.form.afterauth.register.form.after
这类插件通常不需要提交 addon:* 字段,而是直接渲染一组跳转链接或按钮。
推荐模式:
- 插件在 auth slot 里追加一个“第三方登录入口”
- 用户点击后,进入插件自己的 public API,例如
/api/addons/<addonId>/start - 插件在这个 API 里完成第三方授权初始化
- 第三方回调插件自己的 public API,例如
/api/addons/<addonId>/callback - 插件把标准化后的身份信息转交给宿主 external auth bridge
- 宿主继续复用现有 external auth 流程,完成:
- 自动登录
- 自动注册
- 补充用户名
- 绑定已有账户
mode=connect时绑定到当前登录账户
宿主新增了两个桥接 API:
POST /api/auth/addon-external/startPOST /api/auth/addon-external/finish
它们不是插件页面直接给最终用户访问的入口,而是插件 server API 在服务端中转时调用的内部桥接接口。
如果插件还想把“绑定某个第三方渠道”的入口显示到用户设置页,除了桥接 API 以外,还应再注册一个:
kind: "external-auth"provider
POST /api/auth/addon-external/start
作用:
- 让宿主写入 external auth flow state
- 复用现有 cookie / 签名流程
- 让插件后续可以安全回到宿主的 external auth 链路
请求体:
json{
"addonId": "zn29o-login",
"provider": "zn29o_qq",
"mode": "login"
}
可选 mode:
loginregisterconnect
插件调用时必须附带请求头:
x-addon-auth-secret
这个 secret 由插件自己保存在:
readSecret("external-auth-bridge-secret")writeSecret("external-auth-bridge-secret", value)
POST /api/auth/addon-external/finish
作用:
- 把插件自己拿到的第三方身份信息转交给宿主
- 宿主继续执行现有 external auth 逻辑
请求体:
json{
"addonId": "zn29o-login",
"mode": "login",
"identity": {
"method": "oauth",
"provider": "zn29o_qq",
"providerLabel": "智南聚合登录 · QQ",
"providerAccountId": "social_uid_xxx",
"providerUsername": "nickname",
"providerEmail": null,
"emailVerified": false,
"displayName": "昵称",
"avatarUrl": "https://example.com/avatar.jpg"
}
}
这里的 identity 结构,本质上就是宿主现有的 ExternalAuthIdentity。
桥接完成后:
- 如果第三方账户已绑定站内账户,宿主直接登录
- 如果还没绑定,但可自动注册,宿主直接创建用户并登录
- 如果需要补用户名或绑定已有账户,宿主会跳转到现有
/auth/complete - 如果
mode=connect,宿主会把这个第三方账户绑定到当前登录用户
重要约束:
- 插件自己负责和第三方平台交互
- 宿主不会替插件完成 OAuth / code 换 token / 查询用户信息
- 插件只是在“拿到标准化身份信息之后”把结果交给宿主
provider建议使用稳定的小写编码,例如zn29o_qq、acme_wechat- 同一个插件里的不同第三方渠道,建议使用不同
provider
账户设置页当前也已经支持显示和解绑这类动态 provider。
kind: "external-auth" provider
这个 provider 的用途不是做登录校验,而是告诉宿主:
- 当前插件有哪些外部登录渠道
- 这些渠道在账户设置页里该怎么展示
- 用户点击“绑定”后应该跳到哪个插件入口
推荐写法:
jsapi.registerProvider({
kind: "external-auth",
code: "acme-external-auth",
label: "Acme Login",
data: {
runtime: {
async listEntries({ context }) {
return [
{
provider: "acme_wechat",
label: "Acme · 微信",
loginUrl: context.publicApi("start") + "?mode=login&type=wechat",
registerUrl: context.publicApi("start") + "?mode=register&type=wechat",
connectUrl: context.publicApi("start") + "?mode=connect&type=wechat",
description: "通过 Acme 登录绑定微信账号",
order: 50,
},
]
},
},
},
})
每个 entry 当前支持:
provider:最终写入authAccount.provider的 provider codelabel:账户设置页展示名称loginUrl:登录页“其它登录方式”使用的入口registerUrl:注册页“快捷注册方式”使用的入口connectUrl:用户在账户设置页点击“绑定”后跳转的地址descriptionorder
宿主当前行为:
- 如果 entry 提供
loginUrl,它会并入登录页“其它登录方式” - 如果 entry 提供
registerUrl,它会并入注册页“快捷注册方式” - 如果该 provider 已经绑定,会显示“已绑定”并允许解绑
- 如果该 provider 还没绑定,但 provider entry 提供了
connectUrl,会显示“绑定”按钮 - 如果 provider 只存在绑定记录、但当前插件没有再声明
connectUrl,账户设置页仍会显示这条记录,但只允许解绑
8.7 插件验证码怎么和系统验证码共存
当前第一版 captcha 扩展,支持两件事:
- 在登录 / 注册 / 发帖页注入插件自己的验证码 UI
- 在服务端追加执行插件验证码校验
8.7.1 验证码注入位置
当前宿主开放了三个验证码插槽:
auth.login.captchaauth.register.captchapost.create.captcha
其中:
auth.login.captcha、auth.register.captcha会和宿主内置验证码区块放在同一个安全校验区域里post.create.captcha会渲染在/write新建帖子表单内部、提交按钮区域之前post.create.captcha当前只在新建帖子流程生效,不会在/write?mode=edit编辑帖子流程渲染
这意味着:
- 如果系统验证码开启,插件验证码会叠加显示和叠加校验
- 如果系统验证码关闭,但插件在这些 slot 里渲染了内容,页面仍然会显示插件验证码区块
- 如果只想使用插件验证码,把后台登录 / 注册验证码模式设为
OFF即可
这些 slot 里的插件字段,如果要跟随宿主表单一起提交,仍然使用同一套约定:
- 字段名必须以
addon:开头 - 例如:
addon:captchaCode、addon:geetest-captcha.lot_number - 登录 / 注册场景会直接从表单提取这些字段
- 发帖场景会在前端提交
/api/posts/create时把这些字段收集进 JSONaddonFields
示例:
jsapi.registerSlot({
key: "login-addon-captcha",
slot: "auth.login.captcha",
render() {
return {
html: `
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">插件验证码</label>
<input
name="addon:captchaCode"
type="text"
class="h-11 rounded-2xl border border-border bg-card px-4 text-sm"
placeholder="输入插件验证码"
/>
</div>
`,
}
},
})
8.7.2 服务端校验方式
如果插件要追加验证码校验,需要注册一个 kind: "captcha" 的 provider:
jsapi.registerProvider({
kind: "captcha",
code: "hello-captcha",
label: "Hello Captcha",
data: {
runtime: {
verifyLoginCaptcha({ addonFields }) {
if (addonFields.captchaCode !== "654321") {
return {
ok: false,
message: "插件验证码错误",
}
}
},
verifyRegisterCaptcha({ addonFields, payload }) {
if (typeof addonFields.captchaCode !== "string" || addonFields.captchaCode.length < 4) {
return "请填写插件验证码"
}
if (payload.username.startsWith("bot_")) {
return {
ok: false,
message: "该用户名不允许通过插件验证码校验",
}
}
},
verifyCreatePostCaptcha({ addonFields, payload }) {
if (typeof addonFields.captchaCode !== "string" || addonFields.captchaCode.length < 4) {
return "请先完成发帖插件验证码"
}
if (payload.postType === "LOTTERY" && payload.boardSlug === "sandbox") {
return {
ok: false,
message: "当前节点的抽奖帖不允许通过这个验证码入口提交",
}
}
},
},
},
})
当前 captcha provider runtime 已接入的能力包括:
verifyLoginCaptcha(input):在宿主内置登录验证码之后追加执行verifyRegisterCaptcha(input):在宿主内置注册验证码之后追加执行verifyCreatePostCaptcha(input):在/api/posts/create的基础参数校验通过后执行
校验输入里当前可用的重点字段:
addonFields:来自表单里所有name="addon:*"的字段request:当前原始请求context:插件执行上下文,可读写 config / secret- 登录场景额外提供:
username - 注册场景额外提供:
payload、registerIp - 发帖场景额外提供:
payload
目前包含title、content、boardSlug、postType、isAnonymous等已通过宿主基础校验的发帖字段
8.7.3 当前 captcha 扩展的边界
当前第一版 captcha 扩展是“附加层”,不是“替换层”:
- 宿主内置验证码和插件验证码可以同时开启
- 宿主会先执行内置验证码,再执行插件验证码
- 如果只想用插件验证码,就把后台登录 / 注册验证码模式改成
OFF - 发帖页当前没有宿主内置验证码,是否启用完全由插件自己决定
当前还不支持:
- 在后台选择“某一个插件 captcha provider”为唯一验证码实现
- 对多个 captcha provider 做优先级、互斥、路由规则管理
- 让宿主自动生成插件验证码 UI;当前仍需插件自己在 slot 里渲染字段
- 把同一套 captcha hook 自动复用到
POST /api/posts/update;当前只接了新建帖子流程
8.8 导航、表情和编辑器扩展
当前宿主新增了三类 provider 接线:
navigationprovider:向header-app、footer注入链接emojiprovider:注入 Markdown 短码表情,前台 Markdown 渲染、编辑器表情面板和私信表情面板都会复用editorprovider:替换post、comment、profile、admin、generic场景的编辑器
navigation provider 支持两种写法:
- 静态:
provider.data.links - 动态:
provider.data.runtime.listLinks(input)
其中每个 link 至少要提供:
placement:header-app或footerhrefname/labelicon:仅header-app建议提供,未提供时宿主回退到⭐
emoji provider 同样支持两种写法:
- 静态:
provider.data.items - 动态:
provider.data.runtime.listItems(input)
返回值使用宿主现有的 MarkdownEmojiItem[] 结构:
shortcodelabelicon
editor provider 现在支持两类能力:
- 整块替换编辑器
- 给默认
RefinedRichPostEditor追加 toolbar button
宿主会先按场景挑选 provider,命中 clientModule 时整块加载其客户端模块;未命中时仍回退到默认 RefinedRichPostEditor。toolbarItems 只作用在默认编辑器,不会自动注入到“整块替换”的自定义编辑器里。
editor provider 当前支持:
provider.data.clientModule:客户端模块路径,可写插件 assets 下的相对路径provider.data.supports/provider.data.contexts:支持的编辑器场景provider.data.runtime.getClientModule(input):动态返回客户端模块路径provider.data.runtime.getSupports(input):动态返回支持场景provider.data.toolbarItems:为默认编辑器追加 toolbar itemprovider.data.runtime.getToolbarItems(input):动态返回 toolbar item 列表
编辑器客户端模块应导出以下任一接口:
ComponentcreateComponent(sdk)
宿主会向插件组件传入:
valueonChangeplaceholderminHeightdisableduploadFoldermarkdownEmojiMapmarkdownImageUploadEnabledcontextproviderCodeproviderLabel
toolbar item 当前支持的字段:
keyclientModulesupports/contextsorderlabeltitledescription
toolbar item 客户端模块会额外收到:
itemdisabledvalueselectioneditor
其中 editor 当前提供:
focus()preserveSelection()getSelection()getValue()setValue(value)insertTemplate(template)replaceSelection(value)wrapSelection(before, after?)setHeadingLevel(level)toggleBold()toggleUnderline()toggleStrike()toggleHighlight()formatCode(type)toggleQuote()formatList(type)insertDivider()align(value)
最小示例:
jsapi.registerProvider({
kind: "editor",
code: "callout-tools",
label: "Callout 工具条扩展",
data: {
toolbarItems: [
{
key: "insert-callout",
clientModule: "client/insert-callout.js",
contexts: ["post", "comment"],
order: 120,
label: "Callout",
title: "插入提示块",
},
],
},
})
对应的 client/insert-callout.js 可以导出:
jsexport function Component({ item, disabled, editor, sdk }) {
const { AlertCircle } = sdk.icons
return (
<button
type="button"
disabled={disabled}
aria-label={item.title || item.label}
title={item.title || item.label}
className="shrink-0 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
onClick={() => {
editor.insertTemplate("> [!TIP]\\n> 在这里填写提示内容\\n")
editor.focus()
}}
>
<AlertCircle className="h-4 w-4" />
</button>
)
}
仍未支持的部分只有:
- toolbar button 分组、插入到某个内置按钮之前/之后
- 对“整块替换”的自定义编辑器强制复用宿主默认 toolbar item
8.9 上传 Provider
如果插件要接管系统图片上传链路,当前推荐注册一个 kind: "upload" 的 provider:
jsapi.registerProvider({
kind: "upload",
code: "ceoimg",
label: "CEOIMG",
description: "将系统图片上传转发到 CEOIMG 图床。",
data: {
runtime: {
async uploadFile({ context, file, preparedFile, folder }) {
const settings = await context.readConfig("settings", {})
if (!settings.enabled || folder === "attachments") {
return null
}
const formData = new FormData()
formData.append("strategy_id", "1")
formData.append(
"file",
new Blob([preparedFile.buffer], { type: preparedFile.detectedMime }),
file.name,
)
const response = await fetch("https://example.com/upload", {
method: "POST",
body: formData,
})
const result = await response.json()
return {
urlPath: result.data.links.url,
}
},
},
},
})
当前 upload provider runtime 已接入的能力包括:
uploadFile(input):尝试处理当前上传请求
上传 runtime 输入里当前可用的重点字段:
request:当前原始请求;可选actor:当前操作者摘要;用户上传和后台管理员上传都会传入file:原始FilepreparedFile.buffer:宿主完成校验、必要时加水印后的最终二进制内容preparedFile.fileHashpreparedFile.detectedMimefolder:当前上传 bucket,例如posts、avatars、iconcontext:插件运行时上下文,可读写 config / secret
当前宿主行为:
/api/upload与站点图标上传都会先尝试upload provider- provider 返回
null/undefined时,宿主继续回退到内置 local / s3 上传 - provider 返回上传结果时,宿主继续复用现有去重、入库、日志流程
- 帖子附件上传也会经过同一调度层,但插件可以按
folder或 MIME 类型选择跳过,让宿主继续使用本地 / s3 - upload provider 不需要新增专门权限名;注册期仍使用
provider:register,访问外网仍需要network:external
8.10 通用 Action Hook
宿主统一通过 api.registerActionHook() 暴露生命周期副作用点。
注册方式:
jsapi.registerActionHook({
key: "after-login-sync",
hook: "auth.login.after",
async handle(context) {
const { payload, data } = context
await data.put("login-events", {
id: `${payload.userId}:${payload.username}`,
value: payload,
})
},
})
当前宿主已开放的 action hook 包括:
auth.login.beforeauth.login.afterauth.register.beforeauth.register.afterauth.identity.bind.beforeauth.identity.bind.afterauth.identity.unbind.beforeauth.identity.unbind.afterauth.password.change.beforeauth.password.change.afterauth.password.reset.beforeauth.password.reset.afterpost.create.beforepost.create.aftercomment.create.beforecomment.create.aftermessage.send.beforemessage.send.afterpayment.paid.beforepayment.paid.afterinvite-code.purchase.beforeinvite-code.purchase.afterredeem-code.redeem.beforeredeem-code.redeem.afteruser.update.beforeuser.update.afteruser.notification-settings.update.beforeuser.notification-settings.update.afteraddon.config.changed.beforeaddon.config.changed.afterauth.logout.before/auth.logout.afterpost.update.before/post.update.afterpost.delete.before/post.delete.afterpost.status.changed.after(上架 / 下架 / 精华 / 置顶等状态变更)post.like.after/post.favorite.toggle.aftercomment.update.before/comment.update.aftercomment.delete.before/comment.delete.aftercomment.like.afteruser.follow.toggle.after(关注 / 取消关注)notification.create.before/notification.create.afterpoints.change.after(积分变更 payload 含 delta / reason / balanceAfter)upload.file.before/upload.file.after(payload 含 fileId / url / mime / size)addon.installed.after/addon.uninstalled.afteraddon.enabled.after/addon.disabled.aftersearch.query.after(payload 含 query / scope / resultsCount)
action hook 上下文当前包含:
hookpayload- 完整插件执行上下文能力
包括readConfig、writeConfig、readSecret、writeSecret、data.*
当前宿主行为:
- action hook 按
order升序执行 - 插件 action hook 执行失败时,不会回滚主业务动作
- 宿主会记录 lifecycle log,并继续执行后续插件
beforeaction hook 在宿主显式传入throwOnError: true的流程里可阻断主动作- 当前登录、注册、第三方身份绑定/解绑、密码修改/重置、发帖、评论、私信发送、邀请码购买、兑换码兑换、资料更新、通知设置写入、插件配置写入等流程已接入
beforeaction hook
8.11 通用 Waterfall Hook
除了副作用型 action hook,宿主现在也开放了数据改写型 hook:
api.registerWaterfallHook():同步串行改写api.registerAsyncWaterfallHook():异步串行改写
当前已落地的宿主 hook 包括:
同步 waterfall(registerWaterfallHook):
post.slug.value:串行改写帖子最终 slugpost.title.value:串行改写帖子标题(落库前;可做敏感词替换、自动加前缀)user.displayName.value:串行改写用户展示名(列表 / 详情渲染前)user.avatar.url.value:串行改写用户头像 URL(加 CDN 前缀 / 默认占位)search.query.normalize:串行规范化搜索关键词(大小写、同义词、繁简)seo.meta.title:串行改写 SEO<title>seo.meta.description:串行改写 SEO meta descriptionbreadcrumb.items:串行改写面包屑条目数组
异步 waterfall(registerAsyncWaterfallHook):
navigation.primary.items:主导航项数组home.sidebar.hot-topics.items:首页热点帖子列表settings.post-management.tabs:设置页“帖子管理”插件 tab 列表feed.posts.items:帖子流(首页 / 分类 / 列表页)search.results.rerank:搜索结果重排(含 query / scope 上下文)notification.dispatch.targets:通知分发目标(站内 / 邮件 / 其它通道)sitemap.entries:sitemap.xml 条目列表post.related.items:详情页相关推荐post.content.render:帖子正文渲染后 HTML(代码高亮 / LaTeX / Mermaid)
示例:
jsapi.registerAsyncWaterfallHook({
key: "prepend-navigation",
hook: "navigation.primary.items",
async transform(context) {
return [
{ label: "活动", href: "/events", activePrefix: "/events" },
...context.value,
]
},
})
settings.post-management.tabs 的返回值约定为 tab 描述数组,单项通常至少包含:
key:tab 唯一标识label:tab 文案order:排序,数值越小越靠前panel:一个与AddonRenderResult同构的对象;当前推荐使用clientModule + clientProps
示例:
jsapi.registerAsyncWaterfallHook({
key: "purchased-attachments-tab",
hook: "settings.post-management.tabs",
async transform(context) {
return [
...context.value,
{
key: "purchased-attachments",
label: "购买的附件",
order: 200,
addonId: context.manifest.id,
panel: {
clientModule: context.asset("post-management-purchased-attachments.js"),
clientProps: {
endpoint: context.publicApi("purchased-attachments-posts"),
},
},
},
]
},
})
运行规则:
- 按
order升序串行执行 - 某个 hook 返回
undefined时,保留上一个值继续向后传递 - 某个 hook 报错时,宿主会记录 lifecycle log,并默认继续后续 hook
throwOnError只在宿主显式要求时才中断主流程
完整点位请看 docs/插件 Hook 清单.md。
8.11.1 Waterfall 实战示例
下面 4 个例子覆盖最常见的"批量数据改写"场景,每个例子都给出了注册代码 + 注意事项 + 常见反模式。
例 1:给所有用户头像套一层 CDN / 占位兜底
js// server.js
api.registerWaterfallHook({
key: "avatar-cdn-wrap",
hook: "user.avatar.url.value",
order: 50,
transform(context) {
const { value, payload } = context
// payload: { userId, size? }
if (!value) {
return `https://cdn.example.com/avatar/default-${(payload.userId ?? 0) % 8}.png`
}
if (value.startsWith("/")) {
return `https://cdn.example.com${value}`
}
return value
},
})
注意:
- 该 hook 在宿主的
ensureUserDisplay()管道里执行,同一次 SSR 渲染会命中一批用户,保持 transform 轻量(纯字符串拼接即可,不要在里面发请求) - 如果需要异步查询 CDN 签名,请改用
registerAsyncWaterfallHook - 返回
undefined会让宿主保留上一个值继续传给下一个 hook
例 2:串行改写用户展示名(加 VIP 前缀 / 敏感词遮蔽)
jsapi.registerWaterfallHook({
key: "vip-badge-prefix",
hook: "user.displayName.value",
order: 100,
transform(context) {
const { value, payload } = context
// payload: { userId, role?, badges? }
if (payload.badges?.includes("vip")) {
return `⭐ ${value}`
}
return value
},
})
注意:
displayName会出现在帖子列表、详情、评论、@ 提示等几乎所有 UI 上,改写要幂等(多次 transform 不会叠加前缀)- 需要辨识"已经加过前缀"时,优先用
payload.badges判断,不要对value做startsWith("⭐")这种脆弱匹配
例 3:SEO title / description 串改
jsapi.registerWaterfallHook({
key: "seo-site-suffix",
hook: "seo.meta.title",
order: 900, // 放在最后追加站点名
transform(context) {
const { value } = context
if (!value) return "我的论坛"
if (value.endsWith(" - 我的论坛")) return value
return `${value} - 我的论坛`
},
})
api.registerWaterfallHook({
key: "seo-desc-fallback",
hook: "seo.meta.description",
transform(context) {
return context.value || "一个由论坛驱动的社区站点。"
},
})
注意:
seo.meta.*是同步 waterfall,不能await- 同一页面上多个插件都注册时按
order串行,越晚执行的越靠外包装 - 默认站点后缀类逻辑建议放到
order: 900+,避免挤占前序 transform 语义空间
例 4:扩展 sitemap.xml(异步)
jsapi.registerAsyncWaterfallHook({
key: "sitemap-extra-pages",
hook: "sitemap.entries",
order: 200,
async transform(context) {
const { value } = context
const staticEntries = [
{ loc: "/about", lastmod: "2026-01-01", changefreq: "monthly", priority: 0.5 },
{ loc: "/help", lastmod: "2026-01-01", changefreq: "monthly", priority: 0.4 },
]
// 也可以 await context.data.list(...) 动态生成
return [...value, ...staticEntries]
},
})
注意:
sitemap.entries是异步 waterfall,可以await数据查询- 宿主只要求返回的每项至少包含
loc;lastmod/changefreq/priority缺省时宿主会保留原样 loc可以是绝对 URL 也可以是/xxx形式;相对路径宿主会拼接站点域名
通用反模式(所有 waterfall 都要避免)
- ❌ 在 transform 里
throw:会被宿主吞并写 lifecycle log,但后续 hook 拿到的仍是改写前的 value,业务上往往不是你想要的 - ❌ 在 transform 里直接 mutate
context.value(数组 / 对象):虽然当前能跑,但破坏了 waterfall 的"串行值传递"语义,换个执行顺序就会出 bug。始终返回新值 - ❌ 在同步 waterfall 里调用 Promise /
fetch:返回值会被当作普通对象处理,不会被 await - ❌ 让 transform 依赖
window/document:waterfall 大多在服务端 SSR 管道里执行,碰 DOM 就会 500
8.12 Provider Registry
宿主内部现在已经把 provider 消费层统一到一个 registry/capability 层,而不是每种 provider 都各自重新扫描插件目录。
对插件作者来说,接口没有变化:
- 仍然通过
api.registerProvider()注册 provider - 仍然在
provider.data.runtime里暴露 runtime 方法
但宿主侧会统一完成这些事情:
- 按
kind收集 provider - 构造 provider context
- 进入统一 addon execution scope
- 执行权限检查
- 执行外网访问守卫
这意味着:
- 新增 provider kind 时,宿主只需要补 capability 消费逻辑
- 不再需要每个 consumer 自己手写一套 addon 扫描 / context 构造 / runtime 调用包装
8.13 宿主内容与互动 API
除了 config / secret / backgroundJobs / data,运行时上下文现在还开放了宿主内容与互动接口,适合“后台巡检型插件”:
context.database.queryRaw(sql, values?):直接查询插件自定义 SQL 表context.database.executeRaw(sql, values?):直接执行插件自定义 SQLcontext.database.transaction(task):把多步 SQL 包进一个事务context.database.prisma:在声明database:orm时复用宿主 Prisma Clientcontext.posts.create(input):以指定账号创建帖子context.posts.query(options?):按节点、分区、作者、时间、回复数、浏览数、点赞数等条件查询帖子context.posts.like(input):以指定账号确保帖子已点赞context.comments.create(input):以指定账号创建评论 / 回帖context.comments.query(options?):按帖子、作者、状态、时间、点赞数等条件查询评论context.comments.like(input):以指定账号确保评论已点赞context.messages.send(input):以指定账号发送站内私信context.notifications.create(input):创建一条系统通知context.notifications.createMany(inputs):批量创建系统通知context.follows.followUser(input):以指定账号确保已关注某个用户context.points.adjust(input):增减指定用户积分context.badges.list(options?):读取当前站点可供插件使用的勋章列表context.badges.getGrantedIds(input):读取指定用户已经持有的勋章 ID 列表context.badges.grant(input):向指定用户发放一个站点勋章context.posts.tip(input):以指定账号执行帖子打赏
典型用法就是自动顶贴 / 自动回帖机器人;如果你的插件本身还维护自定义订单、奖池、库存、报名表,也可以把 database.* 和这些宿主互动 API 组合起来:
- 用
scheduler.ensure()自我续期后台任务 - 在任务里通过
posts.query()找候选帖子 - 通过
comments.query({ postId, authorUsernames })判断某贴已经被哪些马甲账号参与过 - 选出“当前帖子还没用过的账号”和一条回复文案
- 调用
comments.create()发出回复 - 如果还需要造热度,再调用
posts.like()或comments.like() - 如果还需要联动运营动作,再调用
messages.send()、notifications.create()、follows.followUser()、points.adjust()或posts.tip()
8.13.1 posts.query() 适合解决的问题
- 只扫描指定分区 / 节点
- 只扫描最近 N 天的帖子
- 跳过回复数过多的帖子
- 优先处理回复少 / 浏览少 / 长时间没人回复的帖子
常用筛选字段包括:
boardIds/boardSlugszoneIds/zoneSlugsauthorIds/authorUsernamesstatusescreatedAfter/createdBeforepublishedAfter/publishedBeforelastCommentedAfter/lastCommentedBeforeactivityAfter/activityBeforeminCommentCount/maxCommentCountminViewCount/maxViewCountminLikeCount/maxLikeCountsortlimit/offsetincludeTotal
返回结果会包含帖子基础信息、原始内容、节点 / 分区信息、作者信息和互动统计。
8.13.2 comments.query() 适合解决的问题
- 某个帖子已经有哪些账号参与过
- 某个马甲账号是否已经在目标帖子下回复
- 最近一段时间哪些评论被点过赞
常用筛选字段包括:
postId/postIdsauthorIds/authorUsernamesstatusesparentIdcreatedAfter/createdBeforeupdatedAfter/updatedBeforeminLikeCount/maxLikeCountsortlimit/offsetincludeTotal
8.13.3 comments.create() 与 posts.like() / comments.like() 的行为约定
comments.create()不是直接裸写数据库,而是复用宿主服务链- 节点回复权限、禁言状态、回复间隔、积分门槛、审核规则仍然会生效
posts.like()/comments.like()采用 ensure-liked 语义,不会执行取消点赞- 如果目标已经点赞,返回值里的
liked仍然是true,但changed会是false
这套设计是为了让后台插件更容易实现幂等逻辑,避免任务重试时把原本点过的赞又取消掉。
8.13.4 私信、系统通知与用户关注
messages.send()适合后台客服机器人、自动欢迎私信、任务完成通知notifications.create()/createMany()适合给用户发系统通知,而不是伪造点赞 / 回复类通知follows.followUser()适合运营号、机器人号自动建立关注关系points.adjust()适合插件自己的积分奖惩、活动结算、签到补偿badges.list()/getGrantedIds()/grant()适合活动插件、成就插件、勋章联动奖励posts.tip()适合运营号 / 机器人号给帖子造支持度,复用宿主原有打赏结算
行为约定:
messages.send()复用宿主私信主链,会继续执行黑名单校验、敏感词替换、消息实时事件和message.send.before/afternotifications.create()/createMany()当前创建的是SYSTEM通知,并会继续触发未读数刷新与站外 webhook 派发follows.followUser()采用 ensure-followed 语义;如果目标已经关注,会返回changed: falsepoints.adjust()默认按你传入的delta直接结算;如果额外提供合法的scopeKey,则会先走 point-effect 规则再入账badges.list()默认只返回“启用且非隐藏”的勋章;传入includeHidden/includeDisabled后可放宽筛选badges.getGrantedIds()适合在活动开奖前排除“用户已持有勋章”的奖品,避免重复发放badges.grant()采用 ensure-granted 语义;如果目标用户已经拥有该勋章,返回值会标记alreadyGranted: trueposts.tip()会继续执行余额校验、次数限制、积分日志、分账和系统通知
9. 渲染结果格式
slot 和 page 的 render() 返回的是 AddonRenderResult 或重定向对象。
9.1 可用字段
ts{
html?: string
text?: string
clientModule?: string
clientProps?: Record<string, unknown>
containerTag?: "div" | "section" | "aside"
containerClassName?: string
stylesheets?: Array<string | { href: string; media?: string }>
scripts?: Array<string | { src: string; strategy?: "beforeInteractive" | "afterInteractive" | "lazyOnload"; type?: "module" | "text/javascript" }>
inlineScripts?: string[]
}
9.2 使用建议
- 纯静态内容优先用
html或text - 需要交互时再使用
clientModule - 样式、脚本、图片等都应该通过
context.asset("...")指向assets/目录
9.3 页面重定向
页面也可以返回:
jsreturn {
redirectTo: "/login"
}
宿主会把它当成重定向页面结果处理。
10. 浏览器侧 clientModule
如果你要渲染交互式前端,可以在服务端返回:
jsreturn {
clientModule: context.asset("hello-client.js"),
clientProps: {
title: "Hello"
}
}
10.1 clientModule 的放置位置
重要:
clientModule必须是浏览器能访问到的 URL- 最常见的做法是把编译后的浏览器脚本放进
assets/ - 然后通过
context.asset("xxx.js")生成/_addons/<addonId>/xxx.js
10.2 浏览器模块支持的导出形式
宿主支持三种形式:
- 导出
createComponent(sdk) - 导出
Component - 导出
mount(container, props, sdk),可选配对unmount
最简单的 mount 版本示例:
jsexport function mount(container, props) {
container.innerHTML = `<button type="button">Hello ${props.title ?? "Addon"}</button>`
const button = container.querySelector("button")
const onClick = () => {
button.textContent = "clicked"
}
button?.addEventListener("click", onClick)
return () => {
button?.removeEventListener("click", onClick)
container.innerHTML = ""
}
}
10.3 sdk 能力
浏览器模块会拿到一个 sdk,目前包含:
ReactcreateRoottoastcn- 一批宿主 UI 组件(基于
shadcn/ui) - 一批宿主自定义展示组件
- 一批宿主工具函数
- 一批常用
lucide-react图标 - 一个公开到
window._rhex的宿主前端全局对象
如果你的浏览器模块要长期维护,建议把它当成“宿主注入能力”,不要假设宿主之外的内部路径长期稳定。
当前已注入的 sdk.ui 常用稳定组件包括:
- 反馈与弹层:
AlertDialog*、Dialog*、DropdownMenu*、HoverCard*、Popover*、Sheet*、Tooltip* - 导航与布局:
Breadcrumb*、Collapsible*、NavigationMenu*、Pagination*、ScrollArea、Separator、Tabs* - 表单与选择:
Button、Checkbox、IconPicker、Input、InputGroup*、Textarea、Select*、Combobox*、Slider、Switch、Toggle、ToggleGroup* - 数据与展示:
Avatar*、Badge、Card*、Command*、Skeleton、Spinner、Table* - 宿主扩展 UI:
Modal、FormModal、Sidebar*、useSidebar
当前已注入的 sdk.custom 宿主自定义组件包括:
-
内容流:
ForumPostStreamView -
节点选择:
BoardSelectField -
等级与图标:
LevelIcon、LevelBadge -
用户身份:
UserAvatar、UserStatusBadge、UserVerificationBadge、UserDisplayedBadges -
VIP 展示:
AvatarVipBadge、VipLevelIcon、VipNameTooltip、VipDisplayName -
当前已注入的
sdk.utils宿主工具包括:escapeHtml、isRecord、normalizeOptionalString、loadScriptOnce
当前已注入的 sdk.icons 常用图标包括:
- 导航与方向:
ArrowRight、ArrowUpRight、ChevronDown、ChevronLeft、ChevronRight、ChevronUp - 状态与提示:
AlertCircle、CheckCircle2、CircleHelp、Info、ShieldCheck、Sparkles - 通用动作:
Check、Download、ExternalLink、Eye、EyeOff、Filter、Loader2、MoreHorizontal、Pencil、Plus、Save、Search、Trash2、Upload、X - 内容与社交:
FileText、Heart、ImageIcon、Link2、MessageCircle、MessageSquareMore、Pin、Star - 用户与站点:
Clock3、Lock、Palette、Settings2、User、UserRound、Users - 音频播放:
ListMusic、Music4、Pause、Play、SkipBack、SkipForward、Volume2、VolumeX
注意:
- 这里是宿主显式公开给插件的稳定集合
- 它不等于
src/components/ui/目录下的所有文件都会自动成为插件 API sdk.custom里的组件属于宿主站点语义组件,不是通用shadcn/uisdk.utils适合放插件资产脚本里经常重复的小工具- 后续如果宿主继续扩充公开集合,应以
src/addons-host/sdk/client.tsx为准
10.3.1 window._rhex 全局对象
除了 clientModule 里收到的 sdk 参数,宿主还会在浏览器里暴露一个全局对象:
jswindow._rhex
它的作用:
- 让插件的普通浏览器脚本也能拿到宿主公共 SDK,而不必每个文件自己重复实现工具函数
- 让不走 React
createComponent(sdk)的脚本,也能读取宿主公开的前端上下文
当前 window._rhex 里主要包括:
sdkVersiongetSdk()uicustomiconsutilstoastcnReactcreateRootsessionsite
其中:
window._rhex.session.isAuthenticated:当前用户是否已登录window._rhex.session.user:当前登录用户的公开摘要;未登录时为nullwindow._rhex.site:当前站点的公开设置快照;字段集合与宿主getSiteSettings()返回值保持一致
当前 window._rhex.session.user 公开字段包括:
idusernamenicknameavatarPathrolestatuslevelpointsvipLevelvipExpiresAt
当前 window._rhex.site 不再只暴露少量摘要字段,而是直接暴露宿主的公开站点设置快照。
这意味着插件前端脚本可以直接读取大部分“前台本来就能看到、且宿主认为可公开”的站点设置,例如:
- 站点品牌与展示:
siteName、siteSlogan、siteDescription、siteLogoPath、siteIconPath - 交互与显示模式:
pointName、postLinkDisplayMode、leftSidebarDisplayMode、homeFeedPostListDisplayMode - 页面与分页参数:
homeFeedPostPageSize、zonePostPageSize、boardPostPageSize、commentPageSize - 公开功能开关:
friendLinksEnabled、checkInEnabled、inviteCodePurchaseEnabled、tippingEnabled - 上传与附件能力:
markdownImageUploadEnabled、attachmentUploadEnabled、attachmentDownloadEnabled、attachmentAllowedExtensions - 可公开导航与文案:
footerLinks、headerAppLinks、redeemCodeHelpEnabled、redeemCodeHelpTitle、redeemCodeHelpUrl
完整字段范围请以宿主源码中的 SiteSettingsData 为准:
src/lib/site-settings.types.tssrc/lib/site-settings.ts里的getSiteSettings()
使用示例:
jsconst rhex = window._rhex
const isLoggedIn = Boolean(rhex?.session?.isAuthenticated)
const username = rhex?.session?.user?.username ?? ""
const pointName = rhex?.site?.pointName ?? "积分"
const html = rhex?.utils?.escapeHtml(username) ?? username
约束:
window._rhex只应读取宿主明确公开的字段,不要假设还有未文档化的内部结构- 这里公开的是“前端可安全暴露摘要”,不是完整用户对象
window._rhex.site虽然字段明显变多,但仍然只包含公开站点设置,不包含后台敏感配置- 不会通过
window._rhex暴露任何 secret、session token、后台敏感配置
10.4 UI 约束与 shadcn/ui
可以。当前宿主前端就是基于 shadcn/ui,插件的浏览器侧模块也允许使用宿主注入的 shadcn/ui 组件。
推荐做法:
- 不走组件模式时,前台页面默认直接输出 Tailwind utility class;优先写宿主语义 token,不优先造新的页面级 class 名
- 在
clientModule里优先通过sdk.ui使用宿主提供的通用 UI 组件 - 需要宿主站点语义时,通过
sdk.custom使用头像、等级、VIP、徽章、节点选择器等自定义组件 - 通过
sdk.toast、sdk.cn、sdk.icons复用宿主现成能力 - 前台和后台页面都优先复用宿主已提供的组件组合,保证视觉和交互风格一致
- 如果插件发现宿主其实已经有某个组件,但 SDK 还没公开,优先补 SDK 暴露,不要在插件里复制一份
- 宿主当前在
src/app/globals.css中已将addons/**/*.{ts,tsx,js,jsx,mjs,cjs,mdx,md,html}纳入 Tailwind v4 扫描;仓库内 addon 前台应直接复用这套 utility class,而不是自己再起一套 Tailwind - 只有宿主系统 UI 确实没有对应模式时,再补自定义 CSS 或轻量自定义容器
- 如果宿主页面已经给插件内容包了一层 Card / Section 壳,插件第一层不要再套一层同级 Card,避免双层边框和重复内边距
- 插件交互页建议按
model/controller/view分层组织代码,避免把数据转换、网络请求和渲染混在一起
示例:
jsexport function createComponent(sdk) {
const { React, ui, icons, toast } = sdk
const { Button, Card, CardContent, CardHeader, CardTitle } = ui
const { Play } = icons
return function DemoCard() {
return React.createElement(
Card,
null,
React.createElement(
CardHeader,
null,
React.createElement(CardTitle, null, "Hello Widget")
),
React.createElement(
CardContent,
null,
React.createElement(
Button,
{
onClick: () => toast.success("clicked")
},
React.createElement(Play, { "data-icon": "inline-start" }),
"Run"
)
)
)
}
}
不要这样做:
- 不要在插件源码里直接 import 宿主内部别名,例如
@/components/ui/button、@/lib/utils - 不要在插件源码里直接 import 宿主自定义组件路径,例如
@/components/user/user-avatar、@/components/level-icon - 不要把宿主仓库里的
shadcn/ui源码路径当成插件公共 API - 不要假设宿主未来一定会继续暴露完全相同的组件集合
原因:
- 插件是独立产物,最终通过
addons/<addonId>/dist和assets/被加载 - zip 安装后的插件运行环境里,并不存在宿主源码别名解析
- 当前真正稳定的浏览器侧 UI 接口,是宿主传给你的
sdk.ui和sdk.custom
额外约束:
- 如果插件只需要现有那批组件,优先使用
sdk.ui - 如果插件需要头像、等级、VIP、徽章等站点语义组件,优先使用
sdk.custom - 如果插件需要宿主当前未暴露的
shadcn/ui组件,不要直接引用宿主源码;应把所需实现打包进你自己的前端产物 - 如果插件需要宿主当前未暴露的站点自定义组件,也不要直接引用宿主源码;应先扩充宿主公开 SDK,或把自己的实现打包进插件产物
- 本仓库当前宿主会把 addon 源码里的 Tailwind utility class 纳入统一扫描,但这不等于允许插件再自带一套 Tailwind 构建;不要新增
tailwind.config.*、额外 PostCSS 流水线,也不要引入cdn.tailwindcss.com - 额外样式请放到
assets/*.css,并通过stylesheets显式注入,但只用于 utility class 不适合处理的局部场景
11. 插件上下文能力
宿主在执行 slot / surface / page / api 时会传入运行时上下文对象;在安装、升级、卸载 lifecycle hook 里会传入一套扩展上下文。
所有上下文都包含:
manifeststateenabledrootDirassetRootDirassetBaseUrlpublicBaseUrladminBaseUrlpublicApiBaseUrladminApiBaseUrlpermissionshasPermission(permission)assertPermission(permission, message?)getCurrentUser()getSiteSettings()getBoardSelectOptions()asset(path)publicPage(path)adminPage(path)publicApi(path)adminApi(path)readAssetText(path)readAssetJson(path)readConfig(configKey, fallback)writeConfig(configKey, value)readSecret(secretKey, fallback)writeSecret(secretKey, value)backgroundJobs.enqueue(jobKey, payload, options?)backgroundJobs.remove(jobId)scheduler.inspect({ enabled, configured, state })scheduler.ensure(currentState, { enabled, configured, jobKey, delayMs, ... })scheduler.cancel(currentState)database.queryRaw(sql, values?)database.executeRaw(sql, values?)database.transaction(task)database.prismaposts.create(input)posts.query(options?)posts.like(input)comments.create(input)comments.query(options?)comments.like(input)messages.send(input)notifications.create(input)notifications.createMany(inputs)follows.followUser(input)points.adjust(input)badges.list(options?)badges.getGrantedIds(input)badges.grant(input)posts.tip(input)data.*
补充说明:
getCurrentUser()会基于当前请求的 cookie / headers 解析宿主登录态;没有请求上下文时返回nullgetSiteSettings()返回宿主的公开站点设置快照,字段集合与前端window._rhex.site保持一致getBoardSelectOptions()返回按分区分组的节点选项,可直接喂给浏览器侧sdk.custom.BoardSelectFieldbackgroundJobs.enqueue()会返回包含jobId、attempt、maxAttempts、availableAt的任务句柄;remove()会返回实际删除位置scheduler.*适合“每天/每小时/固定延迟后执行一次”的插件级定时任务;宿主会统一复用backgroundJobs做调度与取消database.queryRaw()/executeRaw()/transaction()现在在插件运行态和 lifecycle 阶段都可用;自定义业务表推荐优先通过它们维护database.queryRaw()/executeRaw()使用 PostgreSQL 风格的$1、$2参数占位符;所有动态值都应通过第二个values数组传入,不要把用户输入直接拼进 SQL 字符串database.prisma仍然只适合访问宿主已有 Prisma 模型;插件自定义表不应依赖 Prisma schema 自动发现posts.create()复用宿主发帖主链,仍会触发状态校验、敏感词、审核、通知、等级与 action hookcomments.create()复用宿主评论主链,仍会触发状态校验、回帖权限、积分门槛、审核、通知、等级、AI 提及回复与 action hookposts.like()/comments.like()是幂等的 ensure-liked 语义:如果目标已点赞,会返回changed: false,不会像前台 UI 那样执行 toggle 取消点赞messages.send()复用宿主私信主链,仍会触发状态校验、黑名单校验、敏感词、消息事件总线与message.send.*action hooknotifications.create()/createMany()当前创建的是宿主SYSTEM类型通知,并会复用未读数刷新和站外 webhook 分发follows.followUser()是幂等的 ensure-followed 语义:如果目标已关注,会返回changed: false,不会触发取消关注points.adjust()复用宿主积分结算能力,可按需应用 point-effect 规则,并继续写入积分日志 / 审计badges.list()/getGrantedIds()/grant()复用宿主勋章体系,适合活动奖励、成就联动和后台批处理插件badges.grant()当前是 ensure-granted 语义:重复发放不会新增第二条用户勋章记录,返回值会带alreadyGrantedposts.tip()复用宿主帖子打赏结算能力,会继续执行余额校验、税率分账、积分日志和系统通知posts.query()/comments.query()是插件运行时的宿主数据读取接口,适合后台巡检、定时机器人、批处理插件;它们读取的是宿主原始业务数据,不会自动继承当前访客页面的可见性视角data.*包括ensureCollection、get、put、delete、query、cleanup、getSchemaVersion
lifecycle 上下文在上面这套基础能力之外,还额外包含:
action:install/upgrade/uninstallreadFileText(path)/readFileJson(path):读取插件根目录内任意文件,可直接读取migrations/*.sqldatabase.queryRaw(sql, values?)database.executeRaw(sql, values?)database.transaction(task)database.prisma
补充说明:
database.*只建议在 lifecycle hook 里处理安装表、升级表和清理表database.prisma复用的是宿主 Prisma Client,更适合访问宿主已有模型;插件自建表不会自动进入宿主 Prisma schema
页面上下文额外包含:
scoperoutePathrouteSegments
API 上下文额外包含:
scoperoutePathrouteSegmentsmethodrequestpathnamesearchParams
slot 上下文额外包含:
slot
surface 上下文额外包含:
surfaceprops
12. 配置与状态存储
12.1 状态
插件状态当前主要包括:
- 是否启用
- 安装时间
- 禁用时间
- 最近错误时间
- 最近错误信息
另外,注册表模型里仍保留 uninstalledAt 字段用于兼容旧数据;运行时会把这类记录视为不可用,但当前后台和 CLI 已不再提供“逻辑卸载 / 恢复”动作。
状态读取规则当前是:
- 读取数据库
addon_registry - 如果表不存在,则返回空状态,运行时回退到 manifest 默认值和本次扫描结果
说明:
- 当前没有
addons/.runtime/addons-state.json这套状态文件读写链路 - 管理页面向管理员展示的稳定状态只有
enabled/disabled
12.2 配置
普通插件配置由 readConfig / writeConfig 访问。
当前实现只走数据库 addon_config:
readConfig():查addon_config,没查到则返回调用方传入的fallbackwriteConfig():upsertaddon_config
注意:
- 当前没有
addons/.runtime/addons-config.json文件兜底 - 如果
addon_config表不存在,readConfig()会退回fallback,writeConfig()不会形成持久化
12.3 敏感配置
敏感配置由 readSecret / writeSecret 访问。
读取规则:
- 先尝试读取站点敏感状态
site_settings.sensitiveStateJson里的 addon secret 命名空间 - 数据库不可用,或没有对应值时,再回退到
addons/.runtime/addons-secrets.json
写入规则:
- 总是先写文件态 secret
- 再尽力同步到站点敏感状态;数据库不可用时保留文件态作为兜底
建议:
- API 私钥、平台公钥、第三方密钥、Token 一律放 secret
- 可公开展示或可导出的业务参数仍放普通 config
12.4 结构化插件数据
除了 config / secret,宿主现在还提供结构化插件数据存储:
context.data.ensureCollection()context.data.get()context.data.put()context.data.delete()context.data.query()context.data.cleanup()context.data.clear()context.data.getSchemaVersion()
这套数据层当前是文件态文档仓库,根目录在:
addons/.runtime/data/<addonId>/
每个 collection 是独立文件,宿主会维护:
- collection 定义
- 文档记录
- 简单索引映射
- schema version
推荐写法:
jsawait context.data.ensureCollection({
name: "orders",
indexes: [
{ name: "by-user", fields: ["userId"] },
{ name: "by-status-user", fields: ["status", "userId"] },
],
})
await context.data.put("orders", {
id: "order_001",
value: {
userId: 1,
status: "paid",
amount: 99,
},
})
const result = await context.data.query("orders", {
where: {
userId: 1,
},
sort: [
{ field: "status", direction: "asc" },
],
limit: 20,
includeTotal: true,
})
当前特点:
- 支持按
where做等值过滤 - 支持
sort - 支持
limit / offset / cursor - 支持 TTL 清理
- 支持 collection 级索引声明
12.4.1 数据迁移
插件现在还可以注册数据迁移:
jsapi.registerDataMigration({
version: 1,
async migrate(context) {
await context.data.ensureCollection({
name: "orders",
})
},
})
当前迁移规则:
- 迁移按
version升序执行 - 宿主记录每个 addon 当前
schemaVersion - 只执行尚未跑过的迁移
- 迁移失败会让 addon load 失败,并记录 lifecycle log
12.5 权限执行与审计
addon.json 里的 permissions 现在不再只是展示字段,宿主已经开始做真实 enforcement。
可以把它理解成两层:
- 注册期权限:插件在
setup(api)里调用registerSlot()、registerProvider()、registerActionHook()等注册函数时检查 - 执行期权限:插件在运行过程中调用
context.readConfig()、context.writeSecret()、context.data.*(),或 provider runtime 访问外网时检查
当前会被宿主检查的能力包括:
config:readconfig:writesecret:readsecret:writebackground-job:registerbackground-job:enqueuebackground-job:deletedatabase:sqldatabase:ormdata:readdata:writedata:deletedata:migrateslot:registersurface:registerpage:publicpage:adminapi:publicapi:adminprovider:registerhook:registerpost:createpost:querypost:likecomment:createcomment:querycomment:likemessage:sendnotification:createfollow:userpoints:adjustbadge:querybadge:grantpost:tipnetwork:externalauth:integratecaptcha:integratepayment:integrate
敏感能力额外约束:
- 登录 / 注册相关 slot 和
auth/external-authprovider 需要auth:integrate captchaprovider 和验证码 slot 需要captcha:integratepaymentprovider 需要payment:integratecontext.backgroundJobs.enqueue()需要background-job:enqueuecontext.backgroundJobs.remove()需要background-job:deletecontext.database.queryRaw()/executeRaw()需要database:sqlcontext.database.prisma需要database:ormcontext.posts.create()需要post:createcontext.posts.query()需要post:querycontext.posts.like()需要post:likecontext.comments.create()需要comment:createcontext.comments.query()需要comment:querycontext.comments.like()需要comment:likecontext.messages.send()需要message:sendcontext.notifications.create()/createMany()需要notification:createcontext.follows.followUser()需要follow:usercontext.points.adjust()需要points:adjustcontext.badges.list()/getGrantedIds()需要badge:querycontext.badges.grant()需要badge:grantcontext.posts.tip()需要post:tip- 插件执行期访问外部 HTTP(S) 资源需要
network:external - 相对地址、同源地址以及
localhost/127.0.0.1/::1默认不受network:external限制
兼容与迁移说明:
route:public/route:admin仍会被归一化为page:public/page:admin- 历史上的
slot:<slotName>仍会被归一化为slot:register - 新插件应直接声明标准化权限名,不要继续依赖旧写法
当前审计行为:
- 权限拒绝会写 lifecycle log
- 外网访问被拒绝会写 lifecycle log
- 事件执行失败会写 lifecycle log
13. 安装、启停与卸载
13.1 zip 安装要求
安装器支持两种压缩包结构:
- zip 根目录直接放
addon.json - zip 里包一层目录,目录下放
addon.json
安装时会做这些事情:
- 解压到
addons/.staging - 查找唯一的
addon.json - 解析清单
- 校验
id - 校验服务端入口存在,并从 staging 预加载 server entry
- 首次安装时,先执行新插件的
lifecycle.install - 覆盖安装时,先在 staging 执行新插件的
lifecycle.upgrade - 上一步成功后,覆盖安装才会把旧版本移动到
.trash - 把新插件移动到
addons/<addonId> - 写入状态和注册表
- 重新同步宿主
注意:
- 安装器当前会校验 zip 结构、
id和服务端入口是否存在 - 安装 / 升级 lifecycle hook 失败时,当前目录替换不会发生
- 但不会预先校验
permissions是否覆盖了setup(api)里真实用到的能力 - 所以“安装成功”不等于“插件一定能成功加载”;权限缺失通常会在同步 / 加载阶段暴露为
loadError - 后台安装表单当前额外支持
replaceExisting(升级)和enableAfterInstall两个开关
13.2 生命周期动作
当前公开的管理动作:
enable:启用插件disable:禁用插件remove:物理卸载;会先尝试执行lifecycle.uninstall,成功后再把目录移到.trashsync:重新扫描并同步注册表clear-cache:清理运行时缓存,并清掉已恢复正常插件的残留最近错误状态
说明:
- 当前后台和 CLI 都不再暴露
uninstall/reinstall - 旧注册表里的
uninstalledAt只作为兼容字段保留,运行时会把它视为不可用状态
13.3 后台管理入口
后台可直接使用:
- 页面:
/admin/addons - 接口:
/api/admin/addons - 安装接口:
/api/admin/addons/install
当前后台管理页支持的操作包括:
- 上传 zip 安装插件
- 覆盖已有插件目录
- 安装后立即启用
- 同步扫描插件目录
- 清除插件宿主缓存
- 对单个插件执行启用、禁用、物理卸载
14. 静态资源规则
静态资源通过 /_addons/<addonId>/... 暴露。
已内置的内容类型包括:
.css.js.mjs.json.mp3.wav.ogg.jpg.jpeg.png.gif.webp.svg.ico.woff.woff2.ttf
缓存策略:
.js/.mjs/.css/.json:no-cache, must-revalidate- 其他资源:默认
max-age=3600
安全规则:
- 所有资源路径都会做目录逃逸校验
- 不能通过
../访问插件目录外的文件
15. 当前开发约束与建议
15.1 强约束
- 服务端入口必须是已编译完成、可直接被 Node
import()的文件 - 浏览器模块必须是已编译完成、可直接被浏览器
import()的文件 - 不要依赖宿主会帮你编译插件源码
- 需要浏览器访问的文件必须放在
assets/ - 插件前台默认使用 Tailwind utility class 写页面结构;不管是模板字符串、原生 HTML,还是 JSX,默认都先写 utility class
- 插件前台 utility class 必须优先使用宿主
shadcn/ui语义 token,例如bg-card、bg-background、border-border、text-foreground、text-muted-foreground - 插件前端如果要用宿主 UI 或宿主自定义展示组件,稳定入口是
sdk.ui/sdk.custom,不是宿主内部源码 import - 当前仓库宿主已经在
src/app/globals.css中扫描addons/**/*.{ts,tsx,js,jsx,mjs,cjs,mdx,md,html};插件不要再新增第二套 Tailwind 配置、构建链路或https://cdn.tailwindcss.com - 插件自带样式需要自己产出并通过
stylesheets注入,但只应用在 utility class 不适合覆盖的局部能力上;不要回到整页自定义 CSS 皮肤模式
15.2 建议
- 保持插件目录名与
id一致 path一律写相对路径,不要加前导/key在单插件内保持唯一- 所有管理面操作都优先通过后台或 CLI 走,不要手动改
.runtime permissions尽量在开发早期就按最小权限集写全,不要等到功能做完再补provides、dependencies仍然建议写全,方便后台展示和后续扩展
15.3 现在最容易踩的坑
render()返回了内容,但挂错 slot,页面上永远不显示- 页面
path以为支持动态参数,结果始终 404 clientModule指向了dist/文件,浏览器无法访问- 服务端入口里用了宿主源码别名,但最终安装产物里没有正确打包
- 插件 zip 能安装,但
permissions没写全,导致setup(api)直接报权限错误 - 插件已启用,但
loadError不为空,导致所有注册项都不生效 - 覆盖安装后忘了检查
.trash,以为旧版本已经彻底删除
15.4 跨模块契约与增量修改约定(ESM / 配置 / Slot / 自检)
本节给出一组硬性约定,专门用于在已有插件上做增量功能的场景。本仓库的插件 server 入口全是 ESM(.mjs),一旦跨文件契约错位,宿主启动时会直接抛:
textSyntaxError: The requested module './xxx.mjs' does not provide an export named 'yyy'
这类错误的根因永远是「导入方和导出方对同一个名字没对齐」或「宿主仍在使用旧的模块缓存」。以下约定按出错概率排序,请按顺序遵守。
15.4.1 ESM 跨模块契约(强约束)
-
一次改动两边原子化:新增 / 重命名 / 删除一个
export时,必须在同一次编辑里把所有 import 方一起改到位;严禁只改一侧提交一次、再改另一侧提交一次的串行推进。 -
命名完全一致:ES Module 的 named import 是大小写敏感、无容错的。
loadCardPositionConfig和loadCardPositionconfig是两个不同的东西,宿主不会帮你纠正。 -
相对路径写全:
import ... from './config.mjs',必须有前导./和显式.mjs后缀。不要依赖扩展名省略,Node 的 ESM loader 默认不解析。 -
单一事实源:同一个配置 key、同一个默认值、同一个校验器只能在一个文件里声明(本仓库里是
config.mjs)。其它文件一律import这些常量与函数,不要就地再写一份字面量。 -
默认导出只用于插件入口:
dist/server.mjs用export default { setup };所有业务模块(model / controller / view)一律用 named export,禁止「默认导出一个大对象再解构」的反模式——这种写法一旦某个字段拼错,运行时才暴露。 -
禁止循环依赖:约定单向依赖链
server → controller → { config, views },config与views之间不得互相 import,controller不能被config/views反向 import。一旦出现循环,部分 named export 在求值期会是undefined,症状和「没导出」一模一样但排查起来更隐蔽。 -
模块顶部列 export 清单:每个
.mjs第一屏注释里写出自己对外暴露的名字(如// exports: DEFAULT_SHORTCUTS, loadShortcutsConfig, loadCardPositionConfig)。review 时一眼可核,也方便别的 AI / 开发者在不读全文的情况下正确 import。 -
改完用 grep 反查残留:删除或改名后执行
powershellrg "<旧名字>" site/addons/<addonId>必须返回 0 条结果,才能视为重构完成。
15.4.2 模块缓存与热加载陷阱
宿主的插件 loader 对 dist/server.mjs 及其 import 链有进程内缓存。编辑完 .mjs 文件后,光刷新浏览器不够。按以下优先级处理:
- 改动仅限
assets/*.js(浏览器侧)→ 硬刷新浏览器即可; - 改动涉及
dist/*.mjs(服务端侧)→ 重启 dev server,或在后台/admin/addons点一次「同步 / 清缓存」; - 改动涉及
addon.json的entry/provides/permissions→ 必须「同步」,否则宿主看到的仍是旧清单; - 诊断「我明明加了 export 却还是报没有」时,在
dist/server.mjs里临时console.log(Object.keys(await import('./config.mjs'))),看到的才是宿主真正用到的那一份。
15.4.3 配置项增量约定
为后续新增配置时不破坏既有安装,约束如下:
- 每个配置 key 固定在
config.mjs中以三件套出现:CONFIG_KEY_XXX常量(避免字符串散落)DEFAULT_XXX默认值(Object 默认值务必Object.freeze)loadXxxConfig(ctx)读取 + 校验 + 合法兜底函数,返回值一定合法
ctx.readConfig(key, default)的第二参数不是「装饰」,是在 host 里根本没写过这个 key 时唯一的兜底来源;新增 key 一定要传 default,否则旧安装会拿到undefined。- 合法性校验内聚:如
cardPosition只允许"top" | "bottom",白名单写在 loader 内部;controller / view 拿到的只能是「已经合法的值」,不要再次判断。 - 不要在
setup(api)阶段预读配置做缓存。配置每次 render 都要await一次,否则后台改值后必须重启插件才生效,违反文档「即时生效」的期望。 - 向后兼容:新增 key 后,旧安装(host 里没这个 key)拿到的默认值必须能让 UI 原样工作。
addon.json的permissions必须同步声明config:read(只读取)或config:read+config:write(后台也能写)。漏声明时setup(api)会直接以权限错误终止,所有注册项全部不生效。
15.4.4 Slot 声明与多点位切换
当一个功能可能落在多个 slot 点位(本插件的「卡片放顶部 / 底部」是典型案例):
addon.json的provides.slots必须列出所有候选点位。例如同时支持home.right.top和home.right.bottom,两个都要写;漏写的那个即使registerSlot成功,后台也看不到、审计也不过。- 运行期策略:两个点位都
registerSlot,在各自render(ctx)里读最新配置,不匹配当前位置就return null。不要试图「根据配置在 setup 时动态决定注册哪一个」——那样后台改配置必须重启才生效。 render()返回null= 跳过渲染,没有任何 DOM 产出;返回{ html: "" }= 渲染一个空壳节点,可能破坏父容器的 gap / 间距。按意图选其一,不要混用。- Slot
key在单插件内唯一;多点位同功能建议用home-shortcut-card-${position}这类后缀区分,方便排查日志。
15.4.5 SSR HTML 输出
- 所有进入 HTML 字符串的「配置值 / 用户输入 / 外部数据」必须先过
escapeHtml;只有你自己写死的静态片段(如 SVG 图标)可以直出。 - 不要在 SSR HTML 里引用
window/document/localStorage;这些只能放进assets/*.js里,通过clientModule由浏览器加载。 - View 层(
views/*.mjs)保持纯函数:输入 → HTML 字符串,无副作用、不读配置、不发请求。读配置、组装数据在 controller 完成。
15.4.6 增量改动自检闭环
每次完成增量功能后,按顺序跑一遍,不要省略:
node --check site/addons/<id>/dist/server.mjs,以及每个被它 import 的.mjs——先把语法 / 显式 parse error 捞掉。rg "<你新增或改名的符号>" site/addons/<id>,确认 import 方和 export 方字符完全一致、没有残留旧名字。- 重启 dev server 或在
/admin/addons执行「同步 / 清缓存」,消除模块缓存。 - 打开后台
/admin/addons/<id>,确认loadError为空、warnings为空。loadError非空时,插件所有注册项都不会生效,不要以为只是「那一项坏了」。 - 打开前台目标页,切换相关后台配置(如本插件的
cardPosition),不重启刷新页面确认立即生效;如果必须重启才生效,说明违反了 §15.4.3 最后两条。 - 涉及 slot 的改动,顺便在 DOM 里搜一次你的 slot key,确认只在期望位置出现、没在别的点位重复。
15.5 Client / Server 边界与 server-only 陷阱
从实际踩坑记录整理出的硬性边界规则。违反任何一条都会让 next build 直接失败(不是运行时报错,是编译期静态分析阶段报错)。
规则 1:出现在 client bundle 里的文件不允许 import 服务端资源
Next.js 的静态分析会沿着 import 图递归扫描。只要一个"工具文件"被某个 client component 间接引到,它内部的所有 import 都会被强制按浏览器可执行校验。
禁止在这种文件里 import:
"server-only"fs/path/crypto(node 内置)- 任何 DB / ORM 客户端(
@/lib/db、drizzle-orm/*) - 宿主侧
src/addons-host/**(含 SDK 的 server 端、provider runtime) next/headers、next/server(部分 API 在 RSC client 边界仍受限)
即使该文件里的"服务端函数"从来没被 client 调用过也不行——打包器只认静态 import 图,不认运行时是否可达。
规则 2:同源文件要按"谁调用"拆分
正确的做法是把同一个逻辑域按调用方拆成两个文件:
textsrc/lib/avatar.ts ← 纯函数 / 仅类型工具, RSC + client 都能 import
src/lib/avatar-server.ts ← 顶部 import "server-only"; DB / fs / 宿主 SDK 放这里
然后在 server.ts / route handler / RSC 里 import avatar-server,在 "use client" 组件里只 import avatar。
规则 3:判断一个文件属于哪一侧
最靠谱的判断不是看文件名,而是追溯它被哪条 import 链导入:
- 被任意
"use client"文件(或被它的 transitive import)引到 → 必须 client-safe - 只被 RSC / route handler / server.ts 引到 → 可以引 server-only
可以 rg '"use client"' src -l 找到所有 client 根;然后检查目标文件是否出现在这些根的 import 图上(IDE 的 “Find Usages” 也可以)。
规则 4:插件侧同样适用
插件 dist/server.mjs 和 assets/*.js 是完全隔离的两套 bundle:
dist/server.mjs在 Node 里跑,可以用 fs / DB / 宿主 SDKassets/*.js通过/_addons/<id>/assets/xxx.js被浏览器加载,绝不能打进任何 Node-only 代码
常见错误模式:
- ❌ 在插件的
assets/xxx.js顶部 import 了./shared/db.js,而后者引了fs - ❌ 把
addon.json里的clientModule指向dist/server.mjs(运行时会 500) - ❌ 在 server.ts 的
render()里返回了引用window的字符串,SSR 时直接报 ReferenceError
规则 5:发现污染后的排查命令
powershell# 1. 先看 next build 的第一条 error,它通常指向 bundle 根和触发文件
pnpm next build 2>&1 | Select-String -Pattern 'error|Module not found' -Context 0,3
# 2. 拿到"触发文件"后,反查它的被引用者
rg -l "from ['\"].*avatar['\"]" src
# 3. 任何一个被引用者是 "use client" 或被 "use client" 链引到,就说明污染成立
修复方式永远是把 server-only 依赖挪到独立文件,而不是在原文件里 if (typeof window === ...) 做运行时判断——静态分析阶段拦截比运行时分支早得多。
16. 最小开发流程建议
推荐按下面的顺序开发一个新插件:
- 在
addons/<addonId>/下创建目录 - 写好
addon.json,并先把首批permissions声明出来 - 先只做一个最小
dist/server.mjs - 只注册一个 public page,确认路由打通
- 再注册 slot 或 API
- 如果需要交互,再补
assets/<client>.js - 到
/admin/addons看后台状态、warning、loadError - 最后再补文档和 provider 元数据,并回头收紧到最小权限集
17. 建议的插件交付清单
一个可交付插件,至少应包含:
addon.json- 可运行的
dist/server.mjs - 如有前端交互,包含
assets/*.js - 如有样式,包含
assets/*.css - 自己的
README.md - 版本号变更记录
如果要发 zip 包,发布前至少自检:
- 是否只有一个
addon.json id是否合法- 目录名是否与
id一致 entry.server是否存在permissions是否覆盖setup(api)和运行期真实用到的能力clientModule引用的资源是否真的在assets/- 前台页、后台页、API 路径是否都能打开
18. 一句话总结
这套插件系统当前是一个“文件目录 + 清单 + 服务端注册 + 宿主路由转发”的 addon 运行时。
开发新插件时,最重要的不是把清单写得多复杂,而是保证这五件事成立:
addons/<addonId>/addon.json存在permissions与setup(api)/context实际使用的能力一致dist/server.mjs能被直接加载setup(api)里完成真实注册- 需要浏览器访问的文件都放进
assets/