@mbws/plugin-sdk
v0.3.19
Published
Moye 插件开发 SDK——panel/viewer 与宿主的类型定义与运行时端口适配
Readme
@mbws/plugin-sdk
MBWS 数据加工插件协议 SDK——为插件面板提供 createMbws() 与宿主完成握手,返回带
data / runtime / storage / ui / host / caps(授予时)/ panel / contract
命名空间的客户端对象。
通常你不需要手动安装本包:用 pnpm create @mbws/plugin 脚手架生成的工程已自带依赖;
手动集成时再 pnpm add @mbws/plugin-sdk。
安装
pnpm add @mbws/plugin-sdk要求 Node ≥18(或现代浏览器环境);包内零运行时依赖(类型与运行时均为自带实现)。
最小面板示例
import { createMbws } from "@mbws/plugin-sdk";
const mbws = await createMbws();
// 读全部输入(键集见关联任务的契约 inputs;媒体字段值是引用句柄 MbwsRef,非文件名)
const inputs = await mbws.data.inputs();
// 面板内容变化后按内容自适应高度,宿主会同步调整 iframe 高度
mbws.panel.resize(document.body.scrollHeight);
// 计算完成,写回输出(outputs 的 key 集须与契约 outputs 对齐,越界会被拒)
await mbws.data.apply({ result: transform(value) });注意 createMbws() 全局只能调用一次(与 VS Code acquireVsCodeApi 同心智),二次调用
会抛 MbwsInvokeError(E_PROTOCOL_VIOLATION)。
API 速查
握手成功后返回 MbwsClient。除 panel / contract 恒存在外,其余命名空间按
manifest 权限授予情况存在——未声明/未授予的命名空间在对象上为 undefined,使用前
建议判空。
| 命名空间.方法 | 签名 | 用途 |
| --- | --- | --- |
| data.inputs | inputs(): Promise<Record<string, unknown>> | 全部输入 slot 值(媒体字段是引用句柄,非展示名) |
| data.outputs | outputs(): Promise<Partial<Record<string, unknown>>> | 上次产出反演(增量编辑;缺键 = 从头开始) |
| data.blob | blob(key: string): Promise<{ url: string; mime?: string }> | 拿文件类输入的签名访问 URL |
| data.apply | apply(outputs: Record<string, unknown>): Promise<void> | 草稿写回(经宿主 write gate 校验;幂等、不关面板) |
| data.commit | commit(outputs: Record<string, unknown>): Promise<void> | 立即落库(同一 write gate;宿主直接 upsert + 立即反显,不等用户提交表单) |
| runtime.progress | progress(ratio?: number, message?: string): Promise<void> | 上报进度(0~1)与状态文案 |
| runtime.log | log(...args: unknown[]): Promise<void> | 写运行日志(宿主侧可查看) |
| runtime.signal | signal(): AbortSignal | 取消信号——用户取消任务时 abort |
| storage.get | get(key: string): Promise<unknown> | 读插件专属配额 KV |
| storage.set | set(key: string, value: unknown): Promise<void> | 写插件专属配额 KV(配额超限抛错) |
| ui.confirm | confirm(opts: { title?: string; message: string }): Promise<boolean> | 宿主渲染的确认弹窗 |
| ui.toast | toast(message: string, opts?: { type?: "info" \| "success" \| "error" }): Promise<void> | 宿主渲染的轻提示 |
| panel.resize | resize(height: number): void | 上报面板期望高度 |
| panel.autoResize | autoResize(target?: HTMLElement \| (() => HTMLElement)): () => void | 高度自适应样板收敛:ResizeObserver 盯目标(缺省 document.body)变化即上报 + 订阅即报一次;返回停止函数(0.3.10+) |
| panel.onBeforeClose | onBeforeClose(cb: () => boolean \| { dirty: boolean; message?: string } \| Promise<同左> \| undefined): void | 注册关闭前询问回调(promise 语义,见下;对象形态可自定义确认文案) |
| panel.replyBeforeClose | replyBeforeClose(dirty: boolean, message?: string): void | 应答关闭询问(dirty=true 宿主先弹确认,message 自定义文案);旧式回调用,promise 写法不必调 |
| panel.close | close(): Promise<void> | 插件主动请求关闭面板(fire-and-forget,宿主仍走一次关闭询问) |
| host.capabilities | capabilities(): Promise<{ sdk: string; methods: string[] }> | 一次性宿主能力面探测(0.3.10+;集中降级,替代散落 typeof 探测) |
| host.can | can(method: string): Promise<boolean> | 探测某 invoke 方法是否可用(capabilities 的 memo 包装;0.3.11+) |
| viewer.inputs | inputs(): Promise<Partial<Record<string, unknown>>> | viewer 容器内拉该记录输入 slot 值集(原始数据对照;0.3.15+) |
| viewer.outputs | outputs(): Promise<Partial<Record<string, unknown>>> | viewer 容器内拉本插件产出 slot 值集(0.3.15+) |
| viewer.onChange | onChange(cb: (outputs: Partial<Record<string, unknown>>) => void): () => void | 订阅运行期产出变更(推送新 outputs 对象;订阅不进 methods,探测走 typeof;0.3.15+) |
| caps.exec.spawn | spawn(req: { attachment; args; timeoutMs?; progress? }): Promise<ExecJob> | 派发附件执行(调度权归宿主);progress: { totalSeconds } 声明编码进度分母,宿主按 out_time_us 行折算自持反显(见下) |
| caps.exec.attachJob | attachJob(jobId: string): Promise<ExecJob \| null> | 重挂后台 job 续看进度(null = 已结束/不存在,走 jobResult 兜底;0.3.10+) |
| contract.get | get(opts?: FetchContractOptions): Promise<PluginContract> | 拉关联任务的最新数据契约 |
| contract.allows | allows(outputKey: string, fieldPath: string): Promise<boolean> | 写回前探问:字段是否在契约 outputs schema 允许范围(内部缓存契约) |
contract.get 不经宿主端口,直接走 HTTP 按插件编号拉取:返回的
PluginContract 含 inputs / outputs(key → JSON Schema,data.inputs / data.apply
的对齐基准)、title、status、updatedAt 等。FetchContractOptions 可传
pluginCode(缺省从工程 manifest.json 读)、baseUrl、devKey、signal。
关闭权协议
弹窗外壳(chrome)永远归宿主:X / Esc / 遮罩 / panel.close() 四条关闭路径同源,
都走一次「关闭询问」——宿主发 panel:before-close,插件以 promise 语义应答:
mbws.panel.onBeforeClose(async () => {
// 感知关闭意图:暂停任务 / 保存草稿 / 弹自己的确认 UI
return dirtyRef.current; // true = 有未保存(宿主弹「确认放弃」),false = 放行
});
// 进阶:dirty 时自定义确认弹窗文案(≤200 字符;旧宿主忽略该字段走默认文案)
mbws.panel.onBeforeClose(async () => ({
dirty: encoding,
message: "当前段落将丢失,已完成段落已保存",
}));- 返回
boolean/Promise<boolean>时 SDK 自动应答;返回undefined保持旧式 (稍后显式调replyBeforeClose); - 宿主等待
PANEL_BEFORE_CLOSE_TIMEOUT_MS(500ms,SDK 导出常量)超时按放行关, 防插件锁死面板; - 插件无硬否决:dirty 应答后用户确认放弃仍会关(关闭权最终在用户);
panel.close()供面板跑完收尾(加工+写回+上传全部完成)后自行请求关闭, 宿主收到后同样先走一次关闭询问再关;data.apply是纯写回(幂等、可多次增量编辑),不会自动关面板;面板关闭也不 终止宿主侧任务与上传队列。
caps 产物登记(reserve → register 两段式)
长任务(视频编码等)跑几分钟才发现目标文件夹已有同名文件太浪费——覆盖预检放在 spawn 之前:
// ① spawn 前:预占产物名(冲突缺省 auto-rename,返回最终可用名)+ 占位符
const { filename, placeholder } = await mbws.caps.exec.reserveOutput({
filename: "cut1_JJD巨人S4.mp4",
dir: outputDir, // 缺省 = job 沙箱 out/;绝对路径须在本会话 pick 范围内
});
// 用返回的 filename 组装 spawn 的输出路径(冲突时已是 `名 (1).ext`)
// ①' 占位符先行(0.3.10+):预占即发——编码开始前就把字段值 commit 成占位符,
// 字段从点击瞬间有值、面板中途关闭零丢失(旧宿主不返回 placeholder,探测降级)
await mbws.data.apply({ segments: [{ clip: placeholder }] });
// ② 产出后:登记(与 ① 返回同一占位值——同一路径主键)
const registered = await mbws.caps.exec.registerOutput({
jobId,
dirname: outputDir, // 所在文件夹(绝对 = pick 范围内 reference;相对 = 沙箱内 move)
filename, // 纯文件名,完整路径由宿主 join(dirname, filename) 拼出
});
// registered.placeholder === placeholder(面板关闭后宿主对账自动转正,见下)- 占位符先行协议(0.3.10):
reserveOutput返回placeholder,编码完成前mediaUrl(placeholder)抛E_NOT_READY(data 带当前 status)——据此渲染「编码中」 占位卡;编码完成后宿主自动转正并可预览/上传。字段值全程不变(占位符稳定); - 面板中途关闭:宿主对账三触发源(registerOutput / job 终态 / 兜底扫描)任一到达
即转正 reserved 条目并广播 onUpload 事件——重开面板
caps.uploads.list()自动跟上;
编码进度声明化(progress)+ 任务调度权契约
const job = await mbws.caps.exec.spawn({
attachment: "ffmpeg",
// 宿主不注入参数:进度行需插件自己在 args 里开(-progress pipe:1 -nostats)
args: ["-progress", "pipe:1", "-nostats", "-i", "inputs/0.mp4", /* … */],
progress: { totalSeconds: 125 }, // 声明分母(预期产出总时长,秒)
});- 声明后宿主自行解析 stdout 的标准进度行(
out_time_us=)折算 ratio 并持有: job → reserved 上传条目 → 上传中心「编码中 42%」全链宿主自持——面板关闭后百分比 照常推进,编码完成自动转正上传。转码/压图等任何给得出「分母 + 标准进度行」的 工具即复用;上传条目经caps.uploads.list()可读encodingProgress(dev-mock 不产生)。 - 面板开着时插件用同一条
onLine流画自己的进度条,与宿主互不干扰;不声明progress的 spawn 完全不受影响(非强制协议)。 - 任务调度权归宿主:spawn 是「派发任务」而非「请求执行」——队列的执行方式
(并发池 / 优先级 / 暂停恢复)由宿主决定,插件无感;宿主未来引入并发调度不需要
任何插件改代码。宿主保证的不变量:行流与 exit 事件的顺序性(attachJob 回放/续流)
与
jobResult兜底语义。 reserveOutput的onConflict: "reject"改为返回{ filename, conflict: true }由插件自行处置;预占会在上传中心立一条reserved条目(编码中可见),register 到达转正;- 同内容重复(宿主按 size + 头尾指纹判重)登记时宿主会向用户弹确认——跳过则 register 返回既有占位值,插件无感知;
- 旧版
path参数(0.3.8 及以前)已被明确拒绝(E_API_CHANGED),按dirname + filename重构建。
viewer 反显数据面
viewer.html(只读反显,manifest components.viewer 声明)由宿主挂载进字段反显位。
宿主 viewer 容器与 panel 同构:mbws:init + MessageChannel 端口握手——createMbws()
在 viewer 内立即拿到真客户端,数据由插件主动拉取(0.3.15 起与 panel 的 data 面
对齐但只读):
import { createMbws } from "@mbws/plugin-sdk";
const mbws = await createMbws();
// 产出为主(Partial——顶层键可能未产出,用前判断有无);原始数据对照按需再取
const outputs = await mbws.viewer.outputs();
const inputs = await mbws.viewer.inputs();
if (outputs.b !== undefined) {
// 判断后拿到精确类型(如 string),直接用不需要再 cast
}
// 运行期产出变更(宿主发 mbws:event "viewer",payload = 新 outputs 对象):订阅制
const stop = mbws.viewer.onChange((next) => render(next));Partial 判断约定:viewer.inputs() / viewer.outputs() 整体 Partial——顶层键
可能缺席(未产出/未采集),判断留在第一层(TS 强制「有/没有」),深层结构由插件自管;
漏判断强行使用会运行时抛 TypeError——建议模板同款 ErrorBoundary 兜底反显 JSON,
不会整面白屏。
探测口径:invoke 方法(进 host.capabilities().methods 的,如 viewer.outputs)
统一用 await mbws.host.can("viewer.outputs");订阅型成员(不进 methods 的,如
onChange)用 typeof mbws.viewer?.onChange === "function"——两类口径不要混用。
viewer 容器 grants 还含 caps(有桥时)——caps.exec.mediaUrl(local:…) 可直接播
未上传完的本地文件(编码中/上传中也可播)。宿主加载失败/超时会回退内置反显组件,
插件无需处理降级。
dev 夹具(0.3.16+):dev 直开 viewer.html 时 viewer.outputs() 读工程内
samples/outputs.json(内容原样返回,写什么得什么);面板 data.apply / data.commit
在 dev 下会写回该文件(deep-merge 落盘)——不开宿主页即可端到端联调反显样式。
脚手架(create-plugin 0.1.10 起)按契约 outputs 键集生成占位。
运行形态
插件代码零环境分支——createMbws() 内部自动分辨:
- 生产(宿主 sandbox iframe 内):等待宿主经
window.postMessage递来的MessagePort,完成 init 握手后按授权构造命名空间。此后所有调用都走端口消息。 - 开发(本地
pnpm dev/ 顶窗口 / 端口等待超时):自动降级为 dev mock——data.inputs/viewer.inputs读工程内samples/inputs.json、viewer.outputs/data.outputs读samples/outputs.json(均原样返回,缺省回落 per-key 合并);data.apply/data.commit在控制台打印的同时写回samples/outputs.json; 其余命名空间基本 no-op。contract.get仍真实走 HTTP 拉线上契约。
握手等待端口默认 3000ms(createMbws({ timeoutMs }) 可调),单次调用默认 30s 超时
(invokeTimeoutMs 可调)。
dev 面模拟:MBWS_DEV_HOST_FACE=<ver> 环境变量让 dev mock 按
METHOD_INTRODUCED_AT 版本表摘掉新方法(对象属性真删,typeof 探测与
host.capabilities() 一致生效)——降级分支在浏览器 dev 即可测试,如
MBWS_DEV_HOST_FACE=0.3.9 pnpm dev。
dev 故障模拟:MBWS_DEV_SIMULATE=<token>[,<token>...] 往 happy path 里注入指定
故障,插件的降级/重试分支浏览器 dev 即可覆盖(语义与宿主对齐):
| token | 注入的故障 |
| --- | --- |
| reserve-conflict | reserveOutput 同名再占视为冲突:auto-rename 按 名 (1).ext 改名返回;onConflict: "reject" 返回 conflict: true 不预占 |
| upload-fail | 模拟上传时序终态从 done 改 failed(error 标注模拟来源) |
| attachment-fail | ensureAttachment 抛 E_ATTACHMENT_DOWNLOAD_FAILED + failed 事件 |
| spawn-fail | spawn 发一行 stderr 后按 exit 1 失败收尾(不打 dev 执行端点) |
如 MBWS_DEV_SIMULATE=upload-fail,reserve-conflict pnpm dev。
状态词表(uploads × attachments 对照)
两套状态机字面相近、语义不同,对照如下(插件 UI 按它选文案/图标):
caps.uploads(产物上传,UploadStatusPayload.status;事件 CapsUploadEvent.phase 同词)
| status | 含义 | 触发时机 |
| --- | --- | --- |
| reserved | 已预占未落盘(编码中,mediaUrl 抛 E_NOT_READY) | reserveOutput 即立;上传中心可见 |
| registered | 已登记待入队 | registerOutput(或 reserved 自动转正) |
| pending | 已入队待传 | 上传队列调度中 |
| uploading | 上传中(progress/speedBytesPerSec/etaSec 事件派生) | 字节推进时 |
| done | 终态·已上传(objectKey 下发) | 上传完成 |
| failed | 终态·失败(error 下发) | 上传失败 / 任务失败无产出 |
| missing | 终态·文件缺失(预占超时无产出等) | 兜底回收 |
caps.exec 附件(处理引擎下载,AttachmentState.status;事件 CapsAttachmentEvent.phase)
| phase | 含义 | 触发时机 |
| --- | --- | --- |
| missing | 尚未下载(快照起点,非错误) | 首屏快照 |
| downloading | 下载中(ratio/receivedBytes/totalBytes) | 首次使用附件 |
| verifying | 校验 hash | 下载完成后 |
| ready | 终态·就绪(同 hash 只下一次,后续零下载) | 校验通过 |
| failed | 终态·失败(message 下发) | 下载/校验失败 |
错误处理
调用被拒时抛 MbwsInvokeError(Error 子类),error.code 为以下错误码之一:
| 错误码 | 含义 |
| --- | --- |
| E_INVALID_ARGS | 调用参数不符合方法签名 |
| E_API_CHANGED | 调用使用了已废弃的旧方法签名(data 带 method/oldFields/newFields/since——按新字段重构建,不是修值) |
| E_NOT_READY | 引用的内容未就绪(data 带 { status };典型:mediaUrl 对编码中的产物) |
| E_UNKNOWN_METHOD | 调用了命名空间下不存在的方法 |
| E_PERMISSION_DENIED | manifest 未声明该权限,或宿主未授予 |
| E_QUOTA_EXCEEDED | 配额超限(AI 次数/费用、指令预算、wall-clock 超时等) |
| E_WRITE_GATE_REJECTED | apply 写回被拒(key 越界 / outputSchema 校验失败) |
| E_CAPABILITY_UNAVAILABLE | 依赖的能力包未安装或版本不满足 |
| E_ATTACHMENT_DOWNLOAD_FAILED | 附件下载/校验/解包失败 |
| E_ATTACHMENT_NOT_READY | spawn 时附件未就绪(data 带当前 AttachmentState) |
| E_JOB_FAILED | 附件子进程非零退出(data 带 exitCode / stderrTail) |
| E_TIMEOUT | 调用超时 |
| E_CANCELLED | 任务被取消后的在途调用收尾 |
| E_PROTOCOL_VIOLATION | 消息违反协议(含 createMbws() 二次调用) |
import { createMbws, MbwsInvokeError } from "@mbws/plugin-sdk";
const mbws = await createMbws();
try {
await mbws.data.apply({ result: 1 });
} catch (error) {
if (error instanceof MbwsInvokeError && error.code === "E_WRITE_GATE_REJECTED") {
// error.data.issues: [{ field, reason, allowedFields?, hint }]——结构化定位,
// 如 { field: "outputs.segments/0/sourceRanges", reason: "additional",
// allowedFields: ["clip", "duration"], hint: "..." }
}
}提交与上架流程
工程内不带文档(pnpm create @mbws/plugin 生成的骨架保持最小),流程统一查本包页:
- 认领任务的认领页打开「git 凭证」弹窗,拿 remote 地址 + username/token;
- 本地
git init && git add -A && git commit -m init,git remote add origin <remote 地址> && git push -u origin HEAD:main(push 只上传源码); - 开发完成在认领页点「提交审查」——锚定那一刻的 main HEAD 进代码审查;
- 审查通过平台自动构建,构建成功进样例页试用验收;驳回改完再 push + 提交审查。
另请参阅
- 工程脚手架:
@mbws/create-plugin(pnpm create @mbws/plugin <目录>一条命令生成带本 SDK 的完整插件工程)
