UniRhy 插件是运行在服务端的 WebAssembly 模块。一个插件声明一个或多个异步任务,可以查询或修改音乐库、访问存储节点、发起 HTTP 请求,也可以继续创建任务。本指南以 Rust 为例完成最小插件,UniRhy 不绑定插件的编程语言,任何能生成兼容的 WebAssembly 模块的语言都可以使用。
插件拥有接近管理员的能力,并且与服务端运行在同一信任边界内。当前版本下,UniRhy 不为插件配置单独的网络白名单、调用超时或内存上限。只安装你信任且与当前 UniRhy 版本匹配的插件。
插件系统仍处于早期阶段,当前不承诺 ABI 稳定性及向前或向后兼容。
理解执行模型
所有任务都是同一种记录,并通过同一个导出函数执行。一条任务接收自己的 payload,完成工作,再返回它希望入队的后继任务;不返回后继即为叶子任务:
- 服务端对每条任务调用一次
execute(),传入{taskId, taskType, payload}。一个插件只导出一个execute(),由插件自己按taskType分发。 - 返回的后继会作为当前任务的子任务入队,与当前任务被标记为
COMPLETED处于同一个事务;随后每个子任务各自独立调度。 - 后继可以指向同一插件的另一种任务类型,也可以显式给出
namespace,把工作派给其他插件。 - 并发是「每种任务类型一份」的属性。负责展开工作的入口任务通常配置为并发
1,它产出的工作任务则使用更高的并发。 - 每次调用都会得到全新的 WASM Instance。线性内存与全局变量不在任务之间共享,需要传递的数据请放进 payload、插件数据或外部持久化存储。
- 后继在活动兄弟之间去重:若某个后继的父任务、命名空间、任务类型与 payload 都与一条
PENDING或RUNNING的兄弟任务相同,则被静默丢弃。根任务不参与这项去重。 - 任务处于
COMPLETED只表示这一次调用成功且其后继已入队,并不代表它下方的子树已经结束。
一般来说,入口任务应专注于发现工作并产出小而稳定的 payload,把网络下载与数据写入放在工作任务中。Host API 对所有任务都可用,但在展开阶段产生的外部副作用同样可能被重复执行。
准备 Rust 工程
安装 Rust 的裸 WASM target:
rustup target add wasm32-unknown-unknown
cargo new --lib artist-enricher
cd artist-enricher
将 crate 构建为动态库,并让 release 构建以体积为优化目标:
[package]
name = "artist_enricher"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
lto = true
opt-level = "z"
strip = true
panic = "abort"
codegen-units = 1
UniRhy 不提供 WASI。插件无法直接使用宿主文件系统、套接字或线程,这些能力都要通过 env 模块中的 Host API 获得。
编写 plugin.yml
包根目录必须包含 plugin.yml 与 plugin.wasm。下面的 manifest 声明了一个供用户投递的入口任务、一个只能由插件自己产出的工作任务,以及一项插件级配置:
id: com.example.unirhy.artist-enricher
name: Artist Enricher
version: 0.1.0
runtime:
type: wasm
abi: unirhy-wasm-abi-v1
tasks:
- type: SCAN_ARTISTS
name: 扫描艺术家
concurrency: 1
userSubmittable: true
form:
schema:
$schema: https://json-schema.org/draft/2020-12/schema
type: object
title: 扫描艺术家
description: 将艺术家库切分为若干补全批次
properties:
batchSize:
type: integer
title: 每批数量
minimum: 1
default: 50
required:
- batchSize
additionalProperties: false
order:
- batchSize
- type: ENRICH_ARTIST
name: 补全艺术家
concurrency: 4
form:
schema:
$schema: https://json-schema.org/draft/2020-12/schema
type: object
title: 补全单个批次
properties:
offset:
type: integer
title: 偏移量
minimum: 0
limit:
type: integer
title: 数量
minimum: 1
required:
- offset
- limit
additionalProperties: false
order:
- offset
- limit
config:
schema:
$schema: https://json-schema.org/draft/2020-12/schema
type: object
title: 元数据来源
properties:
endpoint:
type: string
title: API 地址
minLength: 1
apiKey:
type: string
title: API 密钥
writeOnly: true
minLength: 1
required:
- endpoint
- apiKey
additionalProperties: false
order:
- endpoint
- apiKey
其中的关键约束是:
id同时是任务命名空间,须为至少两段的小写反向域名;app.unirhy前缀保留给内置任务。tasks[].type必须是大写标识符,且在插件内唯一。id与tasks[].type共同构成稳定的任务身份。tasks[].name是任务在任务列表与筛选器中的显示名,可省略;省略时回退为tasks[].type。- 至少要有一个任务声明
userSubmittable: true。只有这类任务能从界面或host_task_create投递,其余任务只能作为后继出现。 tasks[].concurrency是该任务类型首次安装时的并发初始值。管理员之后可以调整,同 id 覆盖升级会保留当前值。version仅用于展示,服务端不比较版本,也不做依赖解析。form.schema使用 JSON Schema Draft 2020-12 的受支持子集。字段可以是string、integer、number、boolean标量,也可以是元素类型同质的string/integer/number数组。- 根 Schema 必须声明
type: object、properties、required与additionalProperties: false,form.order必须不重不漏地列出全部字段。 - 对可投递任务,
form会用于校验提交的参数;对其余任务,它只是声明 payload 契约,服务端不会再校验后继携带的 payload。 default只用于初始化前端表单。服务端不会注入默认值,required 字段仍须出现在提交的参数中。config声明的是插件级配置,由管理员在插件页面填写,与具体任务无关。它沿用同一套 Schema 子集,但不允许数组,且string字段可以声明writeOnly: true。writeOnly字段会加密存储,且永远不会回传给管理界面,管理端只能看到该字段是否已配置。插件自身通过host_plugin_config_get读取完整值。- 若
config中的 required 字段尚未配置齐全,插件无法被启用。
如果某个任务不需要参数,省略它的 form,UniRhy 会提供一个不接受任何字段的空表单。插件不需要配置时,整段 config 可以省略。
实现 WASM ABI
ABI v1 要求提供线性内存与以下导出:
| 导出 | 签名 | 用途 |
|---|---|---|
alloc |
(i32 size) -> i32 |
为宿主写入的数据分配内存 |
dealloc |
(i32 ptr, i32 len) |
释放跨边界传输占用的内存 |
execute |
(i32 ptr, i32 len) -> i64 |
执行一条任务并返回结果信封 |
execute() 接收一个 UTF-8 JSON 对象:
{ "taskId": 42, "taskType": "SCAN_ARTISTS", "payload": { "batchSize": 50 } }
并返回一个信封。成功时 successors 可以省略或为空,表示叶子任务;namespace 缺省即插件自身:
{
"ok": true,
"successors": [{ "taskType": "ENRICH_ARTIST", "payload": { "offset": 0, "limit": 50 } }]
}
失败时 error 必须是非空字符串,任务会以该信息记为 FAILED:
{ "ok": false, "error": "metadata source returned 503" }
返回的 i64 高 32 位是输出指针,低 32 位是字节长度:
(ptr << 32) | len
下面的最小实现把音乐库切分成若干批次,并在处理每个批次时写一条服务端日志:
use serde::Deserialize;
use serde_json::{json, Value};
use std::{mem, ptr, slice};
#[link(wasm_import_module = "env")]
extern "C" {
fn host_log(level: i32, ptr: i32, len: i32);
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Invocation {
task_id: i64,
task_type: String,
payload: Value,
}
#[no_mangle]
pub extern "C" fn alloc(size: u32) -> u32 {
let mut bytes = Vec::<u8>::with_capacity(size as usize);
let pointer = bytes.as_mut_ptr();
mem::forget(bytes);
pointer as u32
}
#[no_mangle]
pub unsafe extern "C" fn dealloc(pointer: u32, size: u32) {
if size > 0 {
drop(Vec::from_raw_parts(pointer as *mut u8, 0, size as usize));
}
}
fn return_json(value: &Value) -> u64 {
let bytes = serde_json::to_vec(value).expect("failed to serialize JSON output");
if bytes.is_empty() {
return 0;
}
let size = bytes.len() as u32;
let pointer = alloc(size);
unsafe {
ptr::copy_nonoverlapping(bytes.as_ptr(), pointer as *mut u8, bytes.len());
}
((pointer as u64) << 32) | size as u64
}
#[no_mangle]
pub unsafe extern "C" fn execute(pointer: u32, size: u32) -> u64 {
let bytes = slice::from_raw_parts(pointer as *const u8, size as usize);
let outcome = match serde_json::from_slice::<Invocation>(bytes) {
Ok(call) => dispatch(&call),
Err(err) => Err(format!("invalid invocation: {err}")),
};
let envelope = match outcome {
Ok(successors) => json!({ "ok": true, "successors": successors }),
Err(message) => json!({ "ok": false, "error": message }),
};
return_json(&envelope)
}
fn dispatch(call: &Invocation) -> Result<Vec<Value>, String> {
match call.task_type.as_str() {
"SCAN_ARTISTS" => plan_batches(&call.payload),
"ENRICH_ARTIST" => enrich(call.task_id, &call.payload).map(|()| Vec::new()),
other => Err(format!("unknown task type: {other}")),
}
}
fn plan_batches(payload: &Value) -> Result<Vec<Value>, String> {
let batch_size = payload
.get("batchSize")
.and_then(Value::as_i64)
.ok_or("batchSize is required")?;
if batch_size <= 0 {
return Err("batchSize must be positive".to_string());
}
let successors = (0..4)
.map(|index| {
json!({
"taskType": "ENRICH_ARTIST",
"payload": { "offset": index * batch_size, "limit": batch_size }
})
})
.collect();
Ok(successors)
}
fn enrich(task_id: i64, payload: &Value) -> Result<(), String> {
let message = format!("artist-enricher: taskId={task_id}, payload={payload}");
unsafe { host_log(1, message.as_ptr() as i32, message.len() as i32) };
Ok(())
}
host_log 的级别为 0 = debug、1 = info、2 = warn,其余取值按 error 记录。
execute() 内部发生 panic 或 trap 同样会让任务失败,但显式返回 {"ok": false, "error": ...} 能给出有用得多的失败原因。
调用 Host API
Host imports 位于 env 模块。除日志与二进制存储操作外,它们共用同一签名:
(i32 requestPtr, i32 requestLen) -> i64
插件把 UTF-8 的 JSON Object 写入自己的线性内存,调用 Host 函数,再按与自身 execute() 返回值完全相同的方式解包响应指针与长度。读取完响应后请调用自己的 dealloc() 释放内存。
例如一次 host_artist_search 的请求可以是:
{ "name": "Miles Davis" }
JSON 类 Host API 始终返回信封:
{ "ok": true, "data": [{ "id": 42, "displayName": "Miles Davis" }] }
{
"ok": false,
"error": { "code": "NOT_FOUND", "message": "The requested resource was not found" }
}
读取 data 前先检查 ok。常见错误码为 INVALID_ARGUMENT、NOT_FOUND、CONFLICT、RESPONSE_TOO_LARGE 与 INTERNAL。非法指针、越界内存或无法解析的请求 JSON 属于 ABI 失败,会直接中止当前 WASM 调用。
当前 Host API 覆盖以下领域:
| 领域 | 能力 |
|---|---|
| 基础设施 | 分级日志、HTTP 请求,以及把 HTTP 响应流式写入存储 |
| 音乐元数据 | 查询与修改 Artist、Work、Recording、Album |
| 媒体 | 查询、创建与删除 MediaFile 与 Asset |
| 存储 | 查询 FS/OSS 节点;列举、stat、读取、写入与删除对象 |
| 播放列表 | 查询、创建、更新、删除与调整曲目顺序 |
| 任务 | 查询任务定义、创建与管理任务、读取统计 |
| 配置与数据 | 读取插件自身配置,读写与列举插件数据 |
| 只读元数据 | 查询插件与账号 |
分页调用默认返回 100 行,pageIndex 从 0 开始,pageSize 上限 1000。完整的函数名与请求/响应结构见 Host API 参考。Host 函数目录不构成长期兼容承诺,升级时请针对目标 UniRhy 版本重新构建并验证插件。
配置与插件数据
host_plugin_config_get 返回插件自身的配置,其中包含明文的 writeOnly 字段。其他插件无法读取它,每个插件的数据都以自身 id 为界。
host_plugin_data_put、host_plugin_data_get 与 host_plugin_data_list 提供一个键值存储,用于保存需要跨任务存活的状态,例如同步游标。若某个键同时也在 config 中声明,写入时会按该字段的 Schema 校验,并在字段为 writeOnly 时加密存储;其余键则原样保存任意 JSON 值。
二进制数据与大文件
有三个操作使用特殊约定:
host_storage_object_read(requestPtr, requestLen) -> i64返回原始字节,对象不存在时返回0。调用前先 stat,并确认插件侧内存足够。host_storage_object_write(metaPtr, metaLen, dataPtr, dataLen) -> i64把原始字节写入对象,返回 JSON 信封。host_http_download_to_storage仍然接收 JSON,但服务端会把响应直接流式写入 FS/OSS,不经过插件内存。音频等大文件请使用它。
普通 host_http_request 的响应要经过 Base64 与插件内存,硬上限为 256 MiB。内容较大时优先使用流式下载。
事务、失败与幂等
通过 Host API 产生的数据库写入参与当前任务的事务。每次 JSON Host 调用还会建立一个嵌套保存点:若某次调用返回错误信封,它的数据库改动会被回滚,而插件仍可检查错误并继续执行。如果 execute() 随后失败,本次调用产生的全部数据库改动都会回滚,包括它本应入队的后继。
HTTP、文件系统与 OSS 的副作用不在 PostgreSQL 事务内。节点关闭、连接中断,或管理员重置失败任务,都可能让一条任务在外部写入已经完成之后再次执行。因此:
- 使用确定性的对象键,并在写入前检查目标;
- 用
overwrite显式表达覆盖意图; - 让后继 payload 保持确定,使重跑落入活动兄弟去重,而不是让子树翻倍;
- 让重复执行得到相同结果,或能够识别已完成的步骤;
- 不要把任务状态当作某个外部副作用恰好发生一次的证据。
普通执行错误不会自动重试,需要管理员把失败或已取消的任务重新置为 PENDING。若某次 HTTP 调用需要重试,请在单次调用内做有限次重试并自行实现退避。
构建与打包
编译模块:
cargo build --release --target wasm32-unknown-unknown
创建打包目录,并保证两个文件都位于 ZIP 根目录:
mkdir -p dist/plugin
cp plugin.yml dist/plugin/plugin.yml
cp target/wasm32-unknown-unknown/release/artist_enricher.wasm dist/plugin/plugin.wasm
(cd dist/plugin && zip -r ../artist-enricher-0.1.0.up plugin.yml plugin.wasm)
unzip -l dist/artist-enricher-0.1.0.up
.up 包就是 ZIP 文件。压缩包上限为 10 MiB,其中 plugin.wasm 条目上限为 20 MiB。
安装与验证
- 以管理员身份登录,打开 设置 → 插件,上传
.up文件。 - 若 manifest 声明了
config,先填写插件配置;required 字段必须配置完整才能启用插件。 - 上传后的插件保持禁用。启用时服务端会实例化 WASM,并校验 Host imports 与
alloc、dealloc、execute导出,其中execute的签名必须是(i32, i32) -> i64。 - 打开 任务管理,选择插件中可投递的任务,填写由 Schema 生成的表单并提交。
- 确认根任务已完成,再展开任务树查看它产出的后继。失败原因记录在各条任务上。
- 使用
host_log时带上有用的任务标识与任务类型,但绝不要记录凭据、Cookie 或完整媒体内容。
启用失败的常见原因包括:import 名称或签名不匹配、缺少必需导出、使用了 WASI、ABI 取值不正确、配置不完整,以及包内文件不在 ZIP 根目录。上传成功只能说明 manifest 与模块可以被解析,启用成功才能说明模块与当前 Host API 链接一致。
升级插件
上传同 id 的包即为就地升级,插件会重新变为禁用状态。新 manifest 必须继续声明旧版本已声明的全部任务类型(服务端会拒绝丢弃任务类型的上传),且每种任务都要能处理旧版本创建的 payload。已有的并发值会被保留,已存储的配置会按新声明调整加密表示。服务端不会在任务上记录插件版本,也不会为待执行的任务保留旧的 WASM。
如果 payload 协议无法保持兼容,请以新的插件 id 发布。发布前至少要验证:新的 execute() 能接受旧 payload、重复执行不会破坏外部数据、目标 UniRhy 版本提供了所需的全部 Host import。