给下载器写插件:一个 manifest.json + 30 行 JavaScript,做一个 GitHub 下载加速解析器

结论先放这 :FluxDown 的插件是纯 JavaScript ,跑在嵌入引擎的 QuickJS 里。写一个插件只需要两个文件------manifest.json 声明"我管哪些网址",resolve.js 导出一个函数把网址改写成真直链。不需要 npm、不需要构建、不需要重新编译宿主,改完 .js 存盘就生效

这篇把完整的心智模型、可直接抄的代码、以及沙箱的全部边界一次讲清。


一、插件到底解决什么问题

下载器的默认行为很朴素:给它一个 URL,它就 GET 那个 URL 存下来。

问题是用户手里的 URL 往往不是文件本身,而是一个页面:

  • 一个 GitHub 文件页 github.com/owner/repo/releases/download/...(能下,但走国际线路很慢)
  • 一个网盘的分享页(HTML,里面才有真直链,而且直链会过期)
  • 一个视频播放页(真正的媒体是页面里的某个 m3u8)

下载器不可能内置全世界所有站点的解析规则。所以留了一个口子:在"任务真正发起下载"之前插一段 JS,让它把 URL 改写掉。

这就是 resolver。整个插件系统就两个平面:

平面 干什么 失败了会怎样
resolver 下载前改写 URL fail-closed :插件抛错/超时/返回非法 → 任务直接失败,绝不偷偷用原始 URL 下
hooks 任务开始/完成/出错时通知你 fire-and-forget:抛错只记日志,绝不影响任务

第一行的设计取舍值得单独说一下:为什么 resolver 失败不回退到原始链接?因为那样用户会得到一个 5 KB 的 HTML 文件,还以为下载成功了。失败要响亮,比静默给错东西强。(真需要放行时,任务详情面板上有一个「忽略插件重试」按钮作为逃生舱。)

二、最小可用插件

建一个目录,两个文件。

manifest.json

json 复制代码
{
  "identity": "me@hello-plugin",
  "name": "Hello Plugin",
  "version": "1.0.0",
  "minAppVersion": "0.1.60",
  "resolvers": [
    {
      "match": { "urls": ["*://echo.example/*"] },
      "entry": "resolve.js",
      "timeoutMs": 8000
    }
  ]
}

resolve.js

js 复制代码
globalThis.resolve = async (ctx) => {
  flux.logger.info('[hello] 收到任务', ctx.url);
  // 返回 null / undefined = 放行,按原始 URL 下载
  return { url: 'https://example.com/real-file.bin' };
};

完事。identity 是永久 ID,格式固定 ^[a-z0-9_-]+@[a-z0-9_-]+$不能有点号,因为它要当配置键的一段用)。

装上去

设置 → 扩展 → 插件,右上角确认「开发模式」开关是开的(默认就开着)。开发模式打开后,安装区会多出一个目录选择框,占位文字「选择插件目录」,tooltip 是「从目录安装(开发模式)」。选中你的目录,点「安装」。

这里有个细节值得知道:开发模式安装不拷贝任何文件 ,它只是把你的目录绝对路径写进配置键 plugin.dev.<identity>。所以------

调试循环

  • resolve.js / hooks.js存盘即生效。因为 dev 模式下每次调用都重新读文件:

    rust 复制代码
    match (&self.resolver_entry, self.dev) {
        (Some(p), true)  => tokio::fs::read_to_string(p).await.ok(),  // dev:每次调用重读
        (Some(_), false) => self.resolver_cache.clone(),              // 非 dev:加载时缓存
        (None, _) => None,
    }
  • manifest.json必须重载:在插件卡片上把开关关掉再打开就行。

日志用 flux.logger.info/warn/error 或者 console.log,都会写进 App 日志文件;任务详情面板的「日志」标签页也能看到执行痕迹。

三、resolve(ctx) 的完整契约

传进来的 ctx

字段全是 camelCase:

字段 说明
taskId 任务 ID
url 原始任务 URL(永远是用户输入的那个,不是上一次改写的结果)
cookies 任务携带的 Cookie 串(浏览器扩展接管的任务会带上)
referrer / userAgent 同上
extraHeaders 附加请求头,object
resolverItem 二段解析标识。初段恒为空字符串;非空说明这是多文件清单里某个条目的二次解析

返回什么

返回 null / undefined = 放行不改写。返回对象时的字段:

字段 说明
url 改写后的直链。scheme 必须 ∈ {http, https, ftp, magnet, ed2k},长度 ≤ 8 KB
audioUrl 可选音频直链(DASH 音视频分离场景)
fileName 可选文件名。禁止 /\.. 与控制字符
totalBytes 已知大小
extraHeaders 下载时要带的额外请求头
ephemeral true = 这是一次性签名直链,跳过 probe (probe 会作废它)。代价是失去 If-Range 一致性校验
rangeSupported true = 我担保这个服务支持 Range。与 ephemeral 正交:跳过 probe 的同时仍然按已验证 Range 规划多段并发
variants 画质/格式变体数组,非空时宿主弹选择框让用户选(≤ 50 个)
defaultVariantIndex 超时/免打扰/headless 时的默认变体
manifest 多文件清单(网盘文件夹)。与 url / variants / audioUrl 互斥

ephemeralrangeSupported 这一对是我觉得最值得抄的设计。很多签名直链(比如 googlevideo探测一次就作废 ,但它明明支持 Range。如果只有 ephemeral 一个开关,跳过 probe 的同时也就丢掉了 Range 信息,只能保守地单流下载。拆成两个正交字段之后,插件可以说:"别 probe,但你放心开多线程。"

惰性:解析结果不落库

这是另一个关键设计:任务表只存 resolver_plugin_id,从不存解析出来的直链。

于是每次 start / resume 都会重跑一次 resolve()。听起来浪费,但这是唯一正确的做法------网盘直链几小时就过期,如果把直链存进数据库,用户第二天点"继续"必然 404。

界面上对应的提示是任务组详情里那句「惰性续期:每次启动自动重新解析」。

四、宿主给你的 API

QuickJS 里只有两个全局对象:fluxconsole没有 require、没有 fs、没有 setTimeout、没有 DOM ,也不是 ES Module(用不了 import/export,入口必须挂 globalThis)。

API 用途 限额
flux.fetch(opts) HTTP 请求 仅 http/https;响应体 8 MB 截断;单请求 10 s;全局并发 8;重定向 ≤ 30 跳
flux.storage.get/set 跨调用持久化 单值 ≤ 64 KB,单插件 ≤ 100 键
flux.fs.writeFile/readFile/remove/list 插件工作区文件 单文件 8 MB,工作区总量 64 MB,最多 100 个文件,扁平目录
flux.settings.<key> 读设置项(类型化,number 就是 number) ---
flux.info {identity, version, appVersion} ---
flux.logger.* / console.* 写日志 单条 4 KB 截断
flux.task.requestRetry({delayMs}) 请求重试 仅 onError 有效
flux.task.recordArtifact(name) 登记衍生产物(删任务时连带删) 仅 onDone 有效
flux.ffmpeg / flux.ffprobe 转码/探针 permissions: ["ffmpeg"]仅 onDone
flux.ytdlp 调 yt-dlp permissions: ["ytdlp"],resolve + 全部 hook 可用

没授权的能力不是报错,而是根本不存在 ------flux.ffmpeg 会是 undefined。所以要写 if (flux.ffmpeg) { ... }

沙箱边界(我觉得这部分比 API 本身有意思)

  • flux.fetch 有 SSRF 守卫 ,而且是三处校验:URL 字面量、DNS 解析结果、每一跳重定向 。判定函数只放行"全局可路由的单播地址"------8.8.8.8 放行,127.0.0.1169.254.169.254 拒绝。
  • ffmpeg 参数被封死 :拒绝任何 URL scheme(http://file:concat:crypto:)、绝对路径、盘符、..。结论是 ffmpeg 在这个沙箱里没有网络出口,也够不到工作区之外的路径
  • yt-dlp 反过来 :放行 URL(联网是它的本职),但拉黑 13 个危险开关--------exec--downloader--config-location--plugin-dirs--cookies-from-browser--batch-file 等,并且强制前置注入 --ignore-config

两个外部二进制、两套完全不对称的策略,因为它们的威胁模型不一样:ffmpeg 只需要处理本地产物,yt-dlp 天然要联网。权限设计不能一刀切。

五、声明式设置表单

不用写任何 UI 代码。在 manifest 里声明字段,桌面端和 Web 端各自自动生成表单:

json 复制代码
"settings": [
  {
    "key": "preferred",
    "title": "加速源",
    "description": "自动 = 每次下载前并行测速,选最快且可用的源",
    "type": "string",
    "widget": "select",
    "options": [
      { "value": "auto",    "label": "自动(测速优选)" },
      { "value": "direct",  "label": "直连(禁用加速)" }
    ],
    "default": "auto"
  },
  {
    "key": "verbose",
    "title": "详细日志",
    "type": "boolean",
    "widget": "toggle",
    "default": "false"
  }
]

脚本里直接 flux.settings.preferred / flux.settings.verbose 读,类型是对的(boolean 就是 boolean,不用自己 parse)。

type × widget 是一张闭合矩阵,越界直接拒绝安装,不做静默降级:

widget 允许的 type
text / password / textarea / folder / select string
toggle boolean
number number

几个容易踩的点:

  • default 一律是字符串 。number 写 "3",boolean 写 "true" / "false"
  • pattern 用的是 JS RegExp 语法,不是 Rust regex(这是一处刻意的偏离,因为写插件的人心智模型是 JS)。
  • 顶层 manifest 是 deny_unknown_fields 的:字段名写错不会被忽略,整份 manifest 校验失败。这是好事,省得你调半天发现是拼错了。
  • 还有一个 helperScript 字段:可以附一段"到目标站点 Console 里跑一下拿到你的 token"的脚本,宿主会在字段旁渲染一个复制按钮。宿主只复制,绝不执行

六、一个真实例子:GitHub 下载加速

前面都是玩具,来看一个真跑在市场里的插件的 manifest(fluxdown@github-accel):

json 复制代码
{
  "identity": "fluxdown@github-accel",
  "name": "GitHub 加速",
  "version": "1.0.0",
  "minAppVersion": "0.1.60",
  "resolvers": [
    {
      "match": {
        "urls": [
          "*://github.com/*",
          "*://raw.githubusercontent.com/*",
          "*://gist.githubusercontent.com/*"
        ]
      },
      "entry": "resolve.js",
      "timeoutMs": 25000
    }
  ]
}

它的 resolve.js 干的事:对本次要下的这个文件,在若干加速源上并行发 Range 采样请求(64 KB) ------一次探测同时验证「可用性」和「速度」,选最快的那个改写直链;全部不可用就返回 null 回退直连 。探测到 206 就顺手回填 rangeSupported: true,让引擎直接开多线程。

思路值得抄的地方:加速失败绝不能阻断下载。这类插件的正确姿势是"锦上添花",而不是"我挂了你也别想下"。

match.urls 的通配规则很简单:唯一通配符是 * ,没有正则、没有 **。多个插件同时命中同一个 URL 时,identity 字典序最小的胜出(一个刻意选择的、可预测的确定性规则,而不是加载顺序)。

七、跑挂了会怎样

写插件一定会写出死循环和内存泄漏,所以宿主有三重预算:

平面 超时 内存
resolve 10 s(manifest 里 timeoutMs 可下调,但 30 s 硬顶 64 MB
hook(普通) 5 s 32 MB
hook(授权 ffmpeg/ytdlp) 1830 s 墙钟(CPU 中断预算仍 30 s) 32 MB

注意最后一行的墙钟与 CPU 预算解耦 :hook 里 await 等 ffmpeg 子进程时不烧 CPU、不触发中断,所以可以给很长的墙钟;但纯 JS 死循环仍然 30 秒内被掐。

再上面还有熔断:连续 3 次超时或内存超限 → 插件被自动禁用,弹 toast「插件「XX」已因连续失败被自动禁用,可在「设置 → 扩展 → 插件」中重新启用」,卡片上挂「已自动禁用」徽章(和手动关闭的「已禁用」区分开)。升级安装会自动解熔断。

还有几条会让你 debug 到怀疑人生的事实,提前说:

  1. 每次调用都是全新的 QuickJS 上下文 。模块级 let cache = {} 永远是空的,跨调用要存东西只能 flux.storage
  2. 不是 ES Moduleimport/export 用不了,入口必须是 globalThis.resolve = ...(顶层 function resolve(ctx){} 声明也行,它天然是全局函数)。
  3. 移动端没有插件系统plugins 是个 Cargo feature,desktop / server 开启,Android / iOS 关闭。
  4. 同时声明 resolver 又订阅 onMetaProbed 是个死订阅------带 resolver 的任务会跳过元数据探测,这个钩子对它自己的任务永远不触发。宿主加载时会给你记一条 warn。

八、分发

打包就是把插件目录打成 zip,扩展名改成 .fxplug.zip 也认)。manifest.json 必须在 zip 根,或者在唯一一层包裹目录里(会自动剥壳)。上限 50 MB / 200 个条目,解压做了双重 zip-slip 防护。

市场那边是零后端 设计:索引就是一个 Git 仓库里的 index.json,客户端拉下来、按 sequence 做防回滚高水位校验,下载插件包时用 content_hash(sha256)钉住内容、多镜像 failover。

这里必须诚实说一句 :市场 v1 没有作者签名sigScheme 是个预留字段,现在恒为 "none",代码里没有任何验签逻辑。信任基座只有三条:内容寻址(sha256 对得上)、TLS、以及 Git 历史本身的 Merkle 链。签名在路线图上,但今天还不能说"已签名"。

九、常见问题

Q:github 下载加速插件的原理是什么? A:在下载真正发起之前,把 github.com/... 这个 URL 改写成某个加速源上的等价地址。关键是改写前要先验证 :对多个候选源并行发一个 64 KB 的 Range 采样请求,一次探测同时拿到「可用性」和「速度」,选最快的;全部不可用就返回 null 走 GitHub 直连。加速失败绝不能阻断下载。

Q:写插件需要会 Rust 吗? A:不需要。插件是纯 JavaScript,跑在引擎内嵌的 QuickJS 里。宿主只注入 fluxconsole 两个全局对象,没有 Node 的 require/fs,也不是 ES Module。

Q:改了插件代码要重启软件吗? A:改 .js 不用,存盘即生效 (开发模式下每次调用重新读文件)。改 manifest.json 需要在插件卡片上把开关关掉再打开。

Q:插件写崩了会不会把下载器搞挂? A:不会。插件跑在独立的线程池上,有内存上限、CPU 中断预算和墙钟超时三重约束;连续 3 次超时或内存超限会被自动禁用并弹提示。resolver 失败只会让该任务失败,任务详情面板上有「忽略插件重试」按钮可以跳过插件重下。

Q:手机端能用插件吗? A:不能。plugins 是一个 Cargo feature,桌面端和服务端开启,Android / iOS 构建关闭。

十、参考来源

  • 插件系统实现:native/engine/src/plugin/manifest.rs 校验器、quickjs.rs 运行时、bridge.rs 全部限额与沙箱边界、manager.rs 生命周期与熔断)
  • 宿主注入的全部 JS 全局对象:native/engine/src/plugin/quickjs.rsFLUX_PRELUDE
  • 可直接跑的官方示例:examples/plugins/echo-rewriter/examples/plugins/manifest-playground/
  • 真实上架插件:fluxdown@github-accel(GitHub 加速)、fluxdown@ytdlp

十一、小结

  • 插件 = manifest.json(声明管哪些 URL)+ 一个导出 globalThis.resolve.js
  • 开发模式安装本地目录,改 JS 存盘即生效;改 manifest 关开一次开关。
  • resolver fail-closed ,hook fire-and-forget------两个平面的失败语义刻意相反。
  • 解析结果不落库,每次启动重解析,为的是应对会过期的直链。
  • 沙箱:SSRF 三处校验、ffmpeg 全封网、yt-dlp 放网但拉黑 13 个开关、三重预算 + 熔断。

  • 官方示例插件(可直接跑):仓库 examples/plugins/echo-rewriter/(resolver + 3 个钩子 + 7 种设置控件)与 examples/plugins/manifest-playground/(多文件清单)
  • 插件开发文档:fluxdown.zerx.dev
  • 仓库:github.com/zerx-lab/Fl...

想给哪个站点写解析器?评论区说,我可以帮看 URL 匹配规则怎么写。

相关推荐
Ivanqhz3 小时前
Rust #[derive(Serialize)]浅析
开发语言·后端·rust
Ivanqhz3 小时前
Rust parse() 浅析
开发语言·后端·rust
程序员爱钓鱼3 小时前
Rust Result 详解:可靠的错误处理机制
前端·后端·rust
k4m7v2pz18 小时前
rvs(rust-verb-shell):一款面向人类和 AI Agent 的结构化 Shell
rust·shell·基建·rust-verb-shell·verb-noun
CappuccinoRose1 天前
Rust学习文档(二)
开发语言·后端·学习·rust
Ivanqhz1 天前
Rust 自引用结构(Self-Referential Structure)
rust
Yeauty1 天前
你那条 ffmpeg 命令,一键翻成 Rust builder 代码
开发语言·rust·ffmpeg
程序员爱钓鱼1 天前
Rust Option 详解:安全处理“可能存在,也可能不存在”的值
前端·后端·rust
带娃的IT创业者2 天前
重新定义前端构建速度:深度解析 SWC 如何用 Rust 颠覆 JavaScript 工具链
前端·javascript·rust·前端构建·swc