插件系统与部署
专栏:new-api 源码拆解 · 第 11 / 12 篇:::info 学习目标 完成本篇后你能够:读懂一个任务插件(plugin.js)的完整结构并说出每个 meta 字段的消费方;描述 Go 宿主运行不可信 JS 的安全设计;完成单机部署与多机部署的关键配置。 前置:第 2、6、7 篇完成。预计时长:60 分钟。 :::
:::note 本章术语速查(新手建议先读)
- jsplugin / sobek:new-api 的 JS 插件系统与它的 JavaScript 引擎(可在 Go 程序里安全执行 JS 代码)。
- 沙箱(Sandbox):限制插件只能做规定动作,超时、超资源自动掐断。
- 声明式(Declarative):只写”要什么”(配置),不写”怎么实现”(代码)——插件的转发路由就是声明式的。
- //go:Go 编译指令,把前端和插件文件打包进二进制。
- NODE_TYPE=slave:多机部署时从节点的标记——它共享主节点的数据库与配置。 :::
new-api 有两类可扩展机制:JS 插件(jsplugin,把不可信的第三方接入逻辑装进沙箱)与配置化扩展(渠道配置、param_override、高级自定义渠道)。前者技术含量最高——它解决的问题很尖锐:让社区贡献上游适配器,又不让适配器代码威胁宿主。
jsplugin:Go 宿主里的 JS 沙箱
引擎是 grafana/sobek(goja 的社区延续版,纯 Go 的 ES 实现),资源从第一天就被框住:
// pkg/jsplugin/engine.go:13-19(节选)
const (
DefaultCallTimeout = 5 * time.Second // 单次调用超时
DefaultConcurrency = 8 // 并发上限
)
插件形态极简:一个 plugin.js 文件,导出 meta(声明式元信息)+ 若干 hook 函数(如 convert(ctx) 负责协议转换)。看内置的阿里云视频插件:
// plugins/tasks/alibaba/plugin.js:1-44(节选)
export const meta = {
apiVersion: 1,
key: "alibaba",
name: "Alibaba Bailian",
channelTypes: [17], // 绑定到的渠道类型
models: ["wan2.7-i2v", "wan2.7-t2v", ...],
usageSchema: { // 用量字段声明(计费用)
seconds: { type: "number", unit: "second" },
resolution: { enum: ["480P", "720P", "1080P"] },
},
routes: [ // 声明式上游路由
{ method: "POST", path: ".../video-synthesis",
type: "submit", decode: "createVideoTask", render: "taskCreated" },
{ method: "GET", path: ".../tasks/:task_id",
type: "query", render: "taskStatus" },
],
protocols: [{ name: "openai_responses", supports: ["stream", "sync", "background"] }, "openai_video"],
};
这就是一个完整的阿里云视频生成渠道适配器——路由、解码、渲染全部声明式,不需要写 Go 代码。内置十个任务插件(alibaba/doubao/google/hailuo/jimeng/kling/sora/sunoapi/vertex-ai/vidu,覆盖视频与音乐生成)经 //go:embed 编译期嵌入(plugins/embed.go),init 时逐个注册进 jsplugin.DefaultRegistry。插件还能脱离宿主独立调试:./new-api plugin ...(main.go 转发到 jsplugin.RunCLI)。
信任边界:宿主拥有的状态机与字节级限制
插件渠道(类型 61 = ChannelTypeTaskPlugin)向宿主输出 Responses 风格语义事件,但Responses 状态机归宿主所有(PluginResponsesResponse facade:id/status/usage 由宿主填充——插件无法伪造身份)——对应第 6 篇的 Adaptor 语义,只是执行体换成了 JS。不可信插件的输出被 PluginProtocolLimits 逐字节限制:
MaxEventsPerTick = 16 每次 tick 最多 16 个事件
MaxEventBytes = 32KB 单事件上限
MaxStateBytes = 16KB 状态上限
异常封装为 HookError(清洗后 ≤512 字符)——错误信息本身也是不可信输入。这套「能力沙箱(sobek 超时/并发)+ 协议限流(字节/条数)+ 身份宿主化(状态机不外放)」的三层设计,是所有”宿主跑不可信扩展”场景的通用范本。
部署:单二进制与多机
单二进制是 new-api 部署上的最大卖点。Dockerfile 三阶段:
FROM oven/bun:1.4.0 AS builder # bun 构建前端 dist
FROM golang:1.26.1-alpine AS builder2 # CGO_ENABLED=0 静态编译
ENV GOEXPERIMENT=greenteagc
RUN go build -ldflags "-s -w -X '...common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim # 运行:EXPOSE 3000,WORKDIR /data
//go:embed web/dist 把前端塞进二进制——一个文件就是完整产品(SQLite 模式甚至不需要外部数据库)。
多机关键配置:数据面共享(SQL_DSN + REDIS_CONN_STRING);从节点设 NODE_TYPE=slave(不做 option 迁移)与 NODE_NAME;SYNC_FREQUENCY 是多机配置热更新的节拍;BATCH_UPDATE_ENABLED 各节点独立落库(计数最终一致);节点经 StartSystemInstanceReporter 上报存活到系统信息页。日志量大后把 LOG_SQL_DSN 指到独立库(甚至 ClickHouse + TTL)。
与其他模块的联系
- → relay(第 6 篇):TaskPlugin 渠道的 JS 插件实现 Adaptor/TaskAdaptor 语义(提交/查询任务、用量声明),宿主的插件协议限流器(
plugin_protocol_limiter)把不可信输出挡在协议层; - → 渠道(第 5 篇):插件注册为渠道类型 61,走统一的渠道管理与测试;
- → 部署(本篇):
//go:embed让”前端 + 内置插件”都成为二进制的一部分——升级 = 换二进制。
常见踩坑
- 插件 hook 写了死循环——sobek 有 5 秒超时兜底,但超时的调用等于功能不可用;转换逻辑保持纯函数;
- Docker 没挂载 /data——SQLite 数据与日志在容器里,重启即失;
- 多机只共享 DB 不开 Redis——额度预扣退化 DB 条件更新、限流退化为单节点内存(第 10 篇);
- MASTER 节点没指定——多机全默认时 option 迁移行为不确定,明确
NODE_TYPE; - 插件输出超大对象——被 16KB/32KB 限制截断,声明
usageSchema按规范输出用量。
随堂练习(带验收标准)
- 起一个 docker compose 实例(Postgres + Redis),创建渠道与 token 并完成一次真实调用。验收:
docker compose ps两容器健康,日志有消费记录; - 读
plugins/tasks/kling/plugin.js全文,对照第 6 篇的 Adaptor 接口,列出声明式插件覆盖了哪些 Adaptor 职责; - 多机实验:同一 compose 网络起两个 new-api 容器(一主一从 + 共享 Redis/PG),从 master 改一个倍率。验收:从节点在
SYNC_FREQUENCY内生效; - 进阶:在 plugin.js 里故意返回超大事件,观察
PluginProtocolLimits的截断行为。