跳转至

WASM 插件开发指南

最后更新: 2026-05-20
适用版本: ABI v2


概述

Remilia 支持通过 WebAssembly (WASM) 运行第三方插件,实现语言无关的插件扩展。 WASM 插件在 wazero 沙箱中运行,与宿主进程内存隔离,通过明确定义的 ABI 进行通信。

架构示意

┌───────────────────────────────────────────────────┐
│                    Host (Go)                        │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────┐  │
│  │  Manager  │  │  Bridge  │  │ HostFuncRegistry │  │
│  │ (生命周期) │  │ (事件桥接) │  │ (宿主函数)       │  │
│  └─────┬────┘  └────┬─────┘  └────────┬─────────┘  │
│        │            │                  │            │
│  ┌─────▼────────────▼──────────────────▼──────────┐ │
│  │                Runtime (wazero)                 │ │
│  │   ┌─────────────────────────────────────────┐   │ │
│  │   │           WASM Sandbox                   │   │ │
│  │   │  ┌──────────────┐  ┌──────────────────┐  │   │ │
│  │   │  │ plugin_init   │  │ plugin_handle    │  │   │ │
│  │   │  │ plugin_abi_ver│  │ malloc (可选)    │  │   │ │
│  │   │  └──────────────┘  └──────────────────┘  │   │ │
│  │   └─────────────────────────────────────────┘   │ │
│  └─────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘

ABI 合约

WASM 模块必须导出

导出函数 签名 说明
plugin_init () → i32 初始化。返回 0 表示成功,非零表示错误
plugin_handle (ptr: i32, len: i32) → (ptr: i32, len: i32) 处理事件。优先多值返回,回退 i64 编码。返回 (0,0) 表示无回复

WASM 模块可选导出

导出函数 签名 说明
malloc (size: i32) → i32 宿主分配事件写入内存。未导出时使用固定通信区
plugin_abi_version () → i32 声明插件兼容的 ABI 版本,当前为 2
plugin_metadata () → (ptr: i32, len: i32) TLV 编码的插件元数据

宿主模块导入

所有宿主函数通过模块 remilia_host 导入:

导入函数 签名 说明
log (ptr: i32, len: i32) → i64 日志输出
get_config (ptr: i32, len: i32) → i64 读取插件配置(TLV 参数 k=key,TLV 返回值 v=value)
storage_get (ptr: i32, len: i32) → i64 读取持久化存储(TLV 参数 k=key,TLV 返回值 v=value)
storage_set (ptr: i32, len: i32) → i64 写入持久化存储(TLV 参数 k=key, v=value)
__host_abi_version (ptr: i32, len: i32) → i64 查询宿主 ABI 版本
__host_functions (ptr: i32, len: i32) → i64 查询可用宿主函数列表(TLV 多键 f=function_name)

i64 返回值编码

当 WASM 运行时不支持多值返回时,使用 i64 编码:

i64 = (len << 32) | ptr
低32位 = ptr,高32位 = len


TLV 序列化格式

所有宿主与插件之间的数据交换使用 TLV(Type-Length-Value)格式:

[ key_len:ULEB128 ][ key:bytes ][ val_len:ULEB128 ][ val:bytes ]
[ key_len:ULEB128 ][ key:bytes ][ val_len:ULEB128 ][ val:bytes ]
...

键名约定

用途 出现位置
c content(消息内容) 事件 → 插件
s sender_id 事件 → 插件
p platform 事件 → 插件
i chat_id 事件 → 插件
t chat_type (group/private) 事件 → 插件
e event_id 事件 → 插件
r reply(回复文本) 插件 → 宿主
k key(存储/配置键名) 宿主函数参数
v value(存储/配置值) 宿主函数参数/返回值
E error 插件 → 宿主

开发 WASM 插件(TinyGo)

环境要求

  • TinyGo 0.31+(推荐 0.34)
  • Go 1.23.x(TinyGo 0.34 兼容版本)
  • binaryenwasm-opt 工具)

最小示例

// main.go
package main

import "unsafe"

// 响应缓冲区(静态分配,避免 GC 回收)
var respBuf [4096]byte

// ── 宿主函数导入 ──────────────────────────────────────────────────────────

//go:wasmimport remilia_host log
func hostLog(ptr uint32, len uint32) uint64

// ── ABI 导出 ──────────────────────────────────────────────────────────────

//go:export plugin_abi_version
func abiVersion() int32 { return 2 }

//go:export plugin_init
func pluginInit() int32 { return 0 }

//go:export plugin_handle
func pluginHandle(ptr uint32, length uint32) uint64 {
    if length == 0 {
        return 0
    }

    // 读取 TLV 事件
    input := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length)
    content := tlvRead(input, "c")

    // 构建 TLV 响应
    reply := "收到: " + content
    respBuf[0] = 1          // key_len
    respBuf[1] = 'r'        // key: "r"
    respBuf[2] = byte(len(reply))
    copy(respBuf[3:], reply)
    respLen := uint32(3 + len(reply))

    respPtr := uint32(uintptr(unsafe.Pointer(&respBuf[0])))
    return uint64(respLen)<<32 | uint64(respPtr)
}

// ── TLV 读取 ─────────────────────────────────────────────────────────────

func tlvRead(data []byte, key string) string {
    i := 0
    for i < len(data) {
        kLen, n := decodeULEB128(data[i:])
        if n == 0 { break }
        i += n
        if i+int(kLen) > len(data) { break }
        k := string(data[i : i+int(kLen)])
        i += int(kLen)

        vLen, n := decodeULEB128(data[i:])
        if n == 0 { break }
        i += n
        if i+int(vLen) > len(data) { break }
        v := string(data[i : i+int(vLen)])
        i += int(vLen)

        if k == key { return v }
    }
    return ""
}

func decodeULEB128(data []byte) (uint32, int) {
    var v uint32
    for i := 0; i < len(data); i++ {
        c := data[i]
        v |= uint32(c&0x7f) << (7 * i)
        if c&0x80 == 0 { return v, i + 1 }
    }
    return 0, 0
}

func main() {}

构建

# 编译为 WASM
tinygo build -o plugin.wasm -target=wasi .

# 查看导出的函数
wasm-opt --print plugin.wasm | grep "export"

加载到 Remilia

package main

import (
    "context"
    "os"

    "github.com/KomeiDiSanXian/remilia/plugin/wasm"
)

func main() {
    // 创建管理器
    mgr := wasm.NewManager(eng, nil) // eng = *engine.Engine

    // 注册 WASM 插件
    desc := &wasm.Descriptor{
        Name:    "myplugin",
        Version: "1.0.0",
        Path:    "./plugin.wasm",
        Commands: []wasm.CommandDef{
            {Command: "/hello"},
        },
        // 可选:自定义资源限制
        ResourceLimit: &wasm.ResourceLimit{
            MaxCallPerSec: 500,
            CallTimeout:   15 * time.Second,
        },
    }

    inst, err := mgr.Register(context.Background(), desc)
    if err != nil {
        panic(err)
    }

    // 查看运行时信息
    println(inst.Module.CallCount()) // 调用次数
    println(inst.Module.Uptime())    // 运行时间
    println(inst.Module.ABICompatible()) // ABI 兼容性
}

使用 go:embed 内嵌 WASM

package main

import (
    _ "embed"
    "github.com/KomeiDiSanXian/remilia/plugin/wasm"
)

//go:embed plugin.wasm
var pluginWasm []byte

func loadWasmPlugin(mgr *wasm.Manager) {
    desc := &wasm.Descriptor{
        Name: "myplugin",
        Commands: []wasm.CommandDef{
            {Command: "/hello"},
        },
    }
    mgr.Instantiate(context.Background(), desc, pluginWasm)
}

开发 WASM 插件(其他语言)

Rust

// Cargo.toml: [lib] crate-type = ["cdylib"]
// 构建: cargo build --target wasm32-wasi --release

extern "C" {
    fn log(ptr: u32, len: u32) -> u64;
}

#[no_mangle]
pub extern "C" fn plugin_abi_version() -> i32 { 2 }

#[no_mangle]
pub extern "C" fn plugin_init() -> i32 { 0 }

#[no_mangle]
pub extern "C" fn plugin_handle(ptr: u32, len: u32) -> u64 {
    // TLV 读取 + 处理
    0
}

C

__attribute__((import_module("remilia_host"), import_name("log")))
uint64_t log(uint32_t ptr, uint32_t len);

__attribute__((export_name("plugin_init")))
int32_t plugin_init(void) { return 0; }

__attribute__((export_name("plugin_handle")))
uint64_t plugin_handle(uint32_t ptr, uint32_t len) {
    // TLV 读取 + 处理
    return 0;
}

安全模型

安全机制 说明 默认值
内存隔离 wazero 沙箱隔离,插件无法访问宿主内存
宿主函数白名单 插件只能调用 remilia_host 模块中注册的函数
WASI 隔离 文件系统、网络等需显式配置,默认不可用
调用限流 令牌桶限流,防止 DoS 1000 次/秒
执行超时 plugin_handle 超时自动终止 30 秒
初始化超时 plugin_init / _start 超时自动终止 10 秒
响应大小上限 单次回复最大字节数 1 MB
WASM 文件大小上限 拒绝过大的 .wasm 文件 50 MB
导入数量上限 防止恶意模块声明大量导入 100

通过 Descriptor.ResourceLimit 可逐字段覆盖:

desc := &wasm.Descriptor{
    Name: "myplugin",
    ResourceLimit: &wasm.ResourceLimit{
        MaxCallPerSec:    100,     // 降低限流
        CallTimeout:      5 * time.Second,
        ResponseSizeMax:  64 * 1024, // 64KB
        ImportsMax:       20,
    },
}

完整示例

查看 examples/showcase 获取完整的 WASM 插件演示:

  • TinyGo 插件源码: examples/showcase/wasm/main.go
  • 宿主集成代码: examples/showcase/wasm_demo.go
  • 支持的命令: /wasmhello /wasmping /wasmcount /wasmecho /wasmstore /wasmhost /wasm

常见问题

Q: 为什么不用 Go 标准库编译 WASM?

Go 的 GOOS=wasip1 GOARCH=wasm 编译的模块在 _start 后会调用 proc_exit(0) 关闭模块, 无法保持活跃用于多次调用。因此 WASM 插件必须使用 TinyGo 编译。

Q: 为什么用 TLV 不用 JSON?

TLV 无 GC 分配、无需标准库、极简实现(约 20 行代码/语言), 插件二进制大小从 ~920KB 降到 ~172KB。

Q: 插件可以访问网络吗?

默认不可以。WASI 的网络功能默认不开放。需要走宿主提供的 http_request 等宿主函数。

Q: 如何调试 WASM 插件?

通过导入的 log 宿主函数输出日志,日志格式为 [wasm/level] message