Yaak 插件导入器(Importer)开发指南:从 onImportSource 钩子到安全的文件树读取

原创2026-09-30 15:59:191,702 阅读
文章标签:开发工具接口测试

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;
    },
  },
};

要点拆解:

  1. readDir() 不传参数即从根目录("")读取直接子项,返回的是 ImportFileEntry[],每一项形如 { name, path, type: "file" | "directory" }。
  2. readTextFile(path) 以严格 UTF-8 解码文件内容;遇到二进制或非法 UTF-8 内容会直接抛错,因此读二进制请改用 readFile(path)(返回 Uint8Array)。
  3. 因为文件树 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 的导入界面无缝接纳。

结语:写一个导入插件的自检清单

对照本仓库的类型定义、运行时实现与测试套件,发布前请确认:

  1. onImportSource 对不属于本格式的 source 返回 null/undefined,绝不抛错或吞掉其他插件的输入;
  2. 文本路径处理 { type: "text", name, text },注意空文本与“名字像 ZIP 但内容不是”的文件;
  3. 目录/ZIP 路径只用 readDir / readFile / readTextFile,路径永远相对根、只用 /;
  4. 没有把 files 对象带出钩子生命周期,也没有假设目录读取是快照;
  5. 资源返回至少带 name/id/model(涉及子资源时再加 workspaceId),不依赖宿主填充 importer 字段;
  6. 只做格式识别,不臆测来源可信度——安全边界由运行时尽力提供,插件自身应把目录输入视为可信。

按此清单开发,你的导入器将与仓库内置的 cURL、Bruno、OpenAPI 等官方插件行为一致,稳定地服务于 Yaak 的导入流程。

登录后查看全文
yaak