Yaak 插件导入器(Importer)开发指南:从 onImportSource 钩子到安全的文件树读取
Yaak 插件导入器(Importer)开发指南:从 onImportSource 钩子到安全的文件树读取
本文围绕 Yaak 桌面 API 客户端(基于 Tauri、Rust 与 ReactJS 构建,支持 REST、GraphQL、SSE、WebSocket 与 gRPC)的插件导入器(Importer)机制展开。你将掌握实现 onImportSource 钩子、区分文本与目录两种输入源、使用只读文件树 API、理解路径安全约束与资源上限,并最终写出可被 Yaak 原生识别的导入插件。文中所有结论均以本仓库的官方类型定义、运行时实现及测试用例为事实依据。
一、导入器在 Yaak 插件体系中的定位
Yaak 的插件运行在独立的 Node.js 运行时中,通过 plugin-runtime-types 包提供的 TypeScript 类型定义与主程序通信。导入器(Importer)是插件定义中的一类能力,负责把外部格式(cURL 命令、OpenAPI 文档、Bruno 集合、Postman 导出、Insomnia 数据等)转换为 Yaak 的内部资源模型。
导入器的类型定义集中在 ImporterPlugin.ts,核心结构如下:
export type ImporterPlugin = {
name: string;
description?: string;
/** 当 source 不是本插件格式时返回 null。定义后优先于 onImport。 */
onImportSource?: ImportSourceHandler;
/** @deprecated 请使用 onImportSource。只接收单个文本文档。 */
onImport?: ImportTextHandler;
};
本仓库自带的官方导入插件有 importer-curl、importer-openapi、importer-bruno、importer-postman、importer-insomnia、importer-yaak 等,它们都基于下面要讲的同一套钩子与文件树约定,是学习该 API 的最佳范本。
二、核心钩子:onImportSource
实现导入器只需实现一个钩子:
async onImportSource(ctx, { source }) { ... }
source 是导入入口传给插件的输入描述,只有两种形态:
{ type: "text", name, text }:一个 UTF-8 文档,或一段粘贴进来的文本(此时name固定为input)。例如用户粘贴一段 cURL 命令,或拖入一个.json/.yaml文件。{ type: "directory", files }:一个目录或一个 ZIP 归档,被读取为一棵统一的文件树。例如用户选择整个 Bruno 集合目录或一个 Postman 环境 ZIP。
对应到 gen_events.ts 中的底层类型:
export type ImportFileSource =
| { type: "file", name: string, base64: string }
| { type: "directory", path: string };
export type ImportRequest = {
content: string, // 为纯文本导入器与旧版运行时保留
source?: ImportFileSource,
};
返回约定:null 表示“不是我的格式”
当 source 不属于本插件的格式时,必须返回 null 或 undefined,运行时据此继续尝试下一个导入器。每个 source 只会采纳一个导入器的结果——宿主按顺序尝试各插件,第一个返回非空结果者胜出,之后的导入器不再被调用。这正是 cURL 插件在开头用 if (source.type !== "text") return null; 挡掉目录、Bruno 插件用 YAML.parse 失败返回 null 来识别格式的原因。
返回的完整类型是 ImportPluginResponse:
export type ImportPluginResponse =
| null
| (Omit<ImportResponse, "importer" | "resources"> & {
resources: PartialImportResources;
});
importer 字段由宿主根据插件自身 name 自动填充,插件无需(也不应)自报名字;resources 则要求至少包含 name、id、model 三个根字段(见下文“返回数据”一节)。
三、最小可运行示例(带逐段注释)
以下代码完整实现了“读取目录或 ZIP 中的 example.json 并转换”的导入器,可直接作为新插件骨架:
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
importer: {
name: "Example",
async onImportSource(ctx, { source }) {
// 分支一:粘贴文本或单个文档,直接按字符串处理
if (source.type === "text") return convertDocument(source.text);
// 分支二:目录或 ZIP,通过 files 只读文件树访问
for (const entry of await source.files.readDir()) {
if (entry.name === "example.json") {
return convertDocument(await source.files.readTextFile(entry.path));
}
}
// 不是本插件的格式,交还给运行时尝试下一个导入器
return null;
},
},
};
要点拆解:
readDir()不传参数即从根目录("")读取直接子项,返回的是ImportFileEntry[],每一项形如{ name, path, type: "file" | "directory" }。readTextFile(path)以严格 UTF-8 解码文件内容;遇到二进制或非法 UTF-8 内容会直接抛错,因此读二进制请改用readFile(path)(返回Uint8Array)。- 因为文件树 API 全部是异步的,钩子本身声明为
async,配合for...of即可顺序遍历。
四、旧版 onImport 钩子:仍可用,但已弃用
早期导入器使用 onImport(ctx, { text }),它只接收单个文本文档。新钩子 onImportSource 定义后,onImport 会被完全忽略;反之,只有旧钩子的插件也能继续工作,但输入范围受限。
运行时对两种钩子的调度逻辑在 importFiles.ts 的 runImporter 中清晰可见:
- 只有旧钩子、且输入是目录:直接返回
null,连文件树都不打开; - 只有旧钩子、且输入是带 ZIP 签名的文件:同样返回
null(避免为每个旧插件重复打开归档); - 只有旧钩子、输入是文本(含空文本文件):以
{ text }形式调用; - 新钩子存在时:按
isText决定是给{ type: "text" }还是{ type: "directory", files },二进制输入(如\xFF开头的文件)不会进入任何钩子。
测试用例 专门验证了“旧钩子跳过目录、ZIP、二进制、不存在的目录路径,但会收到空文本”这一整套行为,可作为迁移旧插件时的行为基准。
五、文件树 API 详解
目录与 ZIP 被统一抽象为 ImportFiles(ImporterPlugin.ts):
export interface ImportFiles {
readonly name: string;
readonly kind: "zip" | "directory";
readDir(path?: string): Promise<ImportFileEntry[]>;
readFile(path: string): Promise<Uint8Array>;
readTextFile(path: string): Promise<string>; // 严格 UTF-8,二进制/非法编码抛错
}
逐个说明:
files.kind:"directory"或"zip",标识树的数据来源;files.name是用户选中内容的 basename(例如所选目录名或 ZIP 文件名)。readDir(path = ""):列出指定路径下的直接子项,不递归。根路径是""。返回项按path字典序排序(见 importFiles.ts),且目录条目即使为空也会保留——测试中用readDir("collection/empty")返回空数组证明了隐式/空目录的存在(importFiles.test.ts)。readFile(path):返回原始字节Uint8Array,适合非文本资源(如示例中的binary.bin)。readTextFile(path):等价于用new TextDecoder("utf-8", { fatal: true })严格解码readFile的结果,非法 UTF-8 或二进制内容直接抛错,不做容错替换。
ImportFileEntry 的结构为:
export interface ImportFileEntry {
name: string; // 条目 basename
path: string; // 相对根的完整路径(/ 分隔)
type: "file" | "directory";
}
六、路径与安全约束:插件看到的树是受限的
为了保证导入安全性,运行时对路径做了一系列强制约束(实现于 importFiles.ts 的 relativePath / resolve):
- 一律使用
/分隔,路径相对根目录;插件无需也不应拼接绝对路径。 - 拒绝绝对路径:以
/开头、匹配 Windows 盘符(C:等)的路径直接抛错。 - 拒绝
..:任何路径段为..都会抛出Import paths cannot leave the selected source,杜绝越界读取。 - 拒绝反斜杠
\与 NUL 字节:防止路径解析歧义。 - 符号链接:在目录列表中被跳过(
opendir后只保留isFile()/isDirectory()的真实条目),在读取时被显式拒绝(Import sources do not follow symbolic links),含中间路径组件的符号链接同样在逐段校验时拦截。 - 不解压到磁盘:ZIP 始终在内存中以惰性方式读取,任何条目都不会被解包落地。
测试用例 覆盖了 ../outside、/absolute、C:/outside、nested/../../outside、nested\file 五种非法输入以及目录内符号链接 escape 的拒绝场景。
需要特别警惕的两点
- 目录读取不是快照:运行时对目录的路径校验与后续
open/opendir并非原子操作,校验后、读取前若有人把已通过校验的路径替换为符号链接,仍可能把读取重定向到根目录之外。因此官方文档明确提示:请把目录导入视为可信输入,不要依赖这套校验提供强隔离。 - 只读且仅限钩子生命周期:文件树是只读视图,且只在
onImportSource执行期间有效——无论插件是正常返回还是抛出异常,finally中都会调用session.close()关闭句柄(importFiles.ts),之后任何readDir/readFile都会抛Import source is closed(测试见 importFiles.test.ts)。不要把files对象保存下来留作异步任务后续使用。
七、资源限制:四条硬上限
运行时在常量处定义了四条硬性限制(importFiles.ts):
| 限制项 | 数值 | 触发场景 |
|---|---|---|
| 单个 source 文件/下载 | 64 MiB | 文本、单文件、ZIP 归档的原始字节总量 |
| 单次文件读取 | 32 MiB | readFile / readTextFile 读取单个条目的解压后大小 |
| 条目总数 | 10,000 | 目录条目数或 ZIP 条目数(含隐式父目录路径) |
| 声明解压总量 | 256 MiB | ZIP 各条目 uncompressedSize 的累计和 |
超出即抛错(如 Import file exceeds the 32 MiB read limit、Import directory contains too many entries),且解压是惰性的——只有真正 readFile 时才解压对应条目,readDir 本身不会触发全量解压(测试见 importFiles.test.ts)。
另有两点补充:
- 加密 ZIP 不受支持:条目检测到加密标记(
entry.isEncrypted())直接抛错(importFiles.ts)。 - ZIP 符号链接条目被拒绝:通过外部属性位(
0xa000)识别并抛错(importFiles.ts),配套的恶意 ZIP 测试样本见测试中的import-symlink.zip等用例。
八、ZIP 的两个特殊行为
1. 按签名识别,而非扩展名
一个文件是否被当作 ZIP,取决于其魔数签名(PK\x03\x04 / PK\x05\x06 / PK\x07\x08,见 isZip 实现),与文件名无关。因此:
- 名为
api.zip但内容是 OpenAPI JSON 的“假 ZIP”,会被当作纯文本派发给插件; - 名为
collection.yaml但内容是真 ZIP 的“假 YAML”,会被当作 ZIP 归档处理。
测试 misleading ZIP suffix 与 signature precedence 分别验证了这两个方向。顺带一提,runImporter 对旧钩子插件也会做同样的签名预检,避免旧文本插件被误喂真 ZIP。
2. 压缩文件夹的“单根展开”
用压缩工具打包一个文件夹时,归档内所有内容都处于该文件夹名下。运行时通过 unwrapSingleRoot(importFiles.ts)自动把这一层“根文件夹”剥掉:只要归档顶层只有一个目录(且排除 __MACOSX 与点开头的元数据目录),插件看到的树就和直接选择该文件夹时完全一致。
测试 wrapped.zip 验证:压缩了 Demo 文件夹的归档被展开后,readDir() 直接返回 nested 与 root.txt,而 readDir("Demo") 反而报 not found。
九、返回数据:从 ImportSource 到 ImportResponse
导入器最终要把解析结果返回为资源集合。PartialImportResources(ImporterPlugin.ts)允许六类资源,且每类只要求少量必填字段:
| 资源类型 | 必填字段(其余可省略) |
|---|---|
workspaces |
name、id、model |
environments |
上述 + workspaceId |
folders |
上述 + workspaceId |
httpRequests |
上述 + workspaceId |
grpcRequests |
上述 + workspaceId |
websocketRequests |
上述 + workspaceId |
完整的目标模型在 gen_events.ts 的 ImportResources 中定义;宿主会在导入时补全 importer 字段(取插件自身 name)。此外 ImportResponse 还支持可选的 sourceKeys 字段,用于把“来源文档中的标识符”与“生成的资源 ID”建立映射,从而在重复解析同一来源时保持稳定——必须取自文档自身携带的标识,绝不能取自用户可在 Yaak 内重命名的字段,其余情况由宿主自动推导。
以真实插件为例,importer-curl 的返回值长这样:
return {
resources: {
httpRequests: requests,
workspaces: [workspace],
},
};
其中 workspace 用 generateId("workspace") 生成占位 ID、name 取 "Curl Import",运行时随后会把这类 GENERATE_ID:: 占位符替换为真实 ID。
十、真实插件源码对照
文本型导入:importer-curl
cURL 命令天然是单文档,因此插件只处理 text 分支(index.ts):
onImportSource(_ctx: Context, { source }: { source: ImportSource }) {
if (source.type !== "text") return null;
return convertCurl(source.text) as any;
}
convertCurl 的第一行 if (!rawData.match(/^\s*curl /)) return null; 就是“非本格式返回 null”的教科书写法。它对文本的解析相当深入:支持反斜杠续行、分号/换行分隔多条命令、引号状态机(单引号/双引号/$'...')、短旗标簇展开(如 -XPOST → -X POST)以及大量 cURL 旗标到 Yaak 请求模型的映射。
文本 + 目录双路径:importer-bruno
Bruno 插件同时展示了两条路径的典型用法(index.ts):
async onImportSource(_ctx, { source }) {
if (source.type === "text") {
return isBruRequestFile(source.name)
? convertBruno(YAML.stringify(singleBruRequest(source.text, source.name)))
: convertBruno(source.text);
}
const root = await readBrunoCollection(source.files);
return root ? convertBruno(YAML.stringify(root)) : null;
}
- 文本路径:按
source.name判断是否为单个.bru请求文件,从而决定包装方式; - 目录路径:
readBrunoCollection(source.files)内部正是用readDir+readTextFile递归遍历集合目录/归档,找不到有效集合时返回null。
其他官方导入器
importer-openapi、importer-postman、importer-insomnia、importer-yaak、importer-postman-environment 全部遵循同一套 API。它们共同验证了一个事实:无论输入是粘贴文本、单个文档、目录还是 ZIP,只要正确区分 source.type 并按返回约定返回 null/资源对象,就能被 Yaak 的导入界面无缝接纳。
结语:写一个导入插件的自检清单
onImportSource对不属于本格式的 source 返回null/undefined,绝不抛错或吞掉其他插件的输入;- 文本路径处理
{ type: "text", name, text },注意空文本与“名字像 ZIP 但内容不是”的文件; - 目录/ZIP 路径只用
readDir/readFile/readTextFile,路径永远相对根、只用/; - 没有把
files对象带出钩子生命周期,也没有假设目录读取是快照; - 资源返回至少带
name/id/model(涉及子资源时再加workspaceId),不依赖宿主填充importer字段; - 只做格式识别,不臆测来源可信度——安全边界由运行时尽力提供,插件自身应把目录输入视为可信。
按此清单开发,你的导入器将与仓库内置的 cURL、Bruno、OpenAPI 等官方插件行为一致,稳定地服务于 Yaak 的导入流程。