构建一个易用的 aria2 客户端包和 CLI 应用

最近有空把之前弄了一半的 aria2 的包继续搞了搞，写一点东西，记录过程中一些有趣的点。

naria2 是一个调用 aria2 RPC 接口、封装抽象后的 aria2 下载器 JavaScript / TypeScript 客户端库，同时也是一个在 aria2c 基础上提供一些额外功能的 CLI 工具应用。

跨平台的统一安装方式

在使用 aria2 之前，我们必须先下载 aria2。这似乎是一句废话，毕竟 aria2 一般不是系统自带的东西，但作为一个 C++ 写的原生应用，显然它的下载安装不一定 trivial。

于是，我们就有一些可能的方案。比如，我作为一个客户端的包为什么要帮用户下载，你下好了，加到 PATH 里再来用吧。OK，没问题，文档里多写两句就行了，但是总归不是很优雅。

所以，我希望，使用这个库的用户不需要太多的感知到一些细节：我们会在后台启动了一个 aria2 RPC 接口服务的进程。用户只需要知道：

调用几个简单的异步函数，完成客户端的初始化；

通过封装抽象好的 API 完成 Torrent 的各种下载、监控任务；

所有逻辑处理完成后，销毁客户端。

所以，我们还是希望为不同平台的用户，提供一个方便的方式下载 aria2。

拉取并运行一个远程的安装脚本

目前相当多的跨平台应用选择这种安装方式，它们会搞个 Web 服务用于分发一段安装脚本。以 Rust 为例，复制图上这条命令，一路回车就能装好一个 Rust。

但是，从这张图上就能看出，只有 Windows 特殊，需要下一个安装包来安装。因为，这些 *nix 系统能跑的脚本，在 Windows 的 PowerShell 和 CMD 上跑起来并不容易，比如贴心的微软就帮你把 Invoke-WebRequest (iwr) 起了个 "curl" 的别名，但是用法和 curl 完全不同。

第二，维护一个分发脚本的服务简单也不简单。简单是因为，随便搞个部署静态页面的平台或者 Serverless 函数平台，分分钟就能把脚本搞上去。但是，你是不是需要买个域名，搞 SSL 证书，隔几年续费域名，国内还要考虑墙内等等，即使感谢 Cloudflare 把一些脏活累活都给你做好了，但是还是有点麻烦了。当然也有简便的方法，感谢 egoist 几年的一个项目 bina，大概就是根据你项目的 GitHub Releasse 内的标有平台的文件名，自动生成一段用于下载安装 CLI 应用到 PATH 的脚本。

发到一堆包管理器上

Rust 应用经典地域绘图。你说得对，但是 Rust 是由 Mozilla 自主研发的一款全新内存安全编程语言。编译将发生在一个被称作「卡尔构」的构建系统，在这里，被引用的指针将被授予「生命周期」，导引安全之力。你将扮演一位名为「开发者」的神秘角色在编程的搏斗中邂逅骨骼惊奇的报错，绕开它们通过编译同时，逐步发掘「Rust」的真相。

实际上，我认为，使用包管理器和抓取分发的安装脚本来运行，两者并无过多的区别。只是包管理器，第一，将 "抓取安装的东西" 和 "运行本地安装脚本" 的逻辑给做成了内部的实现，第二，帮你维护了一个包的列表和包的资源，节省了自己精力，也不容易跑路。当然包管理器能做到更多事情，比如统一管理版本、依赖等等。

除此以外，还有 nix（不太了解），tea （结合了 Web3？）等等包管理方案，本文目的也不是介绍这些东西，这两节只是顺便随便聊聊一些方案罢了。

利用 package.json 的 optionalDependencies

得益于 Node 的一些跨平台特性，于是我们考虑发到 npm 上。如果你对目前的前端工具链有所了解的话，例如 esbuild，swc 这类使用原生语言开发的应用可以通过 npm 进行分发。

以 esbuild 为例，好奇的你点开它的 npm 主页：

你会发现，它依赖了一堆东西，包括各种各样的平台。如果再点开它的 package.json，可以发现这些实际上都是 optionalDependencies：

Copy
{
"name": "esbuild",
"version": "0.19.5",
"description": "An extremely fast JavaScript and CSS bundler and minifier.",
"repository": "https://github.com/evanw/esbuild",
"scripts": {
"postinstall": "node install.js"
},
"main": "lib/main.js",
"types": "lib/main.d.ts",
"engines": {
"node": ">=12"
},
"bin": {
"esbuild": "bin/esbuild"
},
"optionalDependencies": {
"@esbuild/android-arm": "0.19.5",
"@esbuild/android-arm64": "0.19.5",
"@esbuild/android-x64": "0.19.5",
"@esbuild/darwin-arm64": "0.19.5",
"@esbuild/darwin-x64": "0.19.5",
"@esbuild/freebsd-arm64": "0.19.5",
"@esbuild/freebsd-x64": "0.19.5",
"@esbuild/linux-arm": "0.19.5",
"@esbuild/linux-arm64": "0.19.5",
"@esbuild/linux-ia32": "0.19.5",
"@esbuild/linux-loong64": "0.19.5",
"@esbuild/linux-mips64el": "0.19.5",
"@esbuild/linux-ppc64": "0.19.5",
"@esbuild/linux-riscv64": "0.19.5",
"@esbuild/linux-s390x": "0.19.5",
"@esbuild/linux-x64": "0.19.5",
"@esbuild/netbsd-x64": "0.19.5",
"@esbuild/openbsd-x64": "0.19.5",
"@esbuild/sunos-x64": "0.19.5",
"@esbuild/win32-arm64": "0.19.5",
"@esbuild/win32-ia32": "0.19.5",
"@esbuild/win32-x64": "0.19.5"
},
"license": "MIT"
}

然后，我们再随便点开一个包，可以发现每个 @esbuild/* 里其实只有一个对应平台的 esbuild 二进制文件和一个 package.json。以 @esbuild/win32-x64 为例，可以看到它的 package.json 通过 os 和 cpu 两个字段指定了这个包支持的平台。

Copy
{
"name": "@esbuild/win32-x64",
"version": "0.19.5",
"description": "The Windows 64-bit binary for esbuild, a JavaScript bundler.",
"repository": "https://github.com/evanw/esbuild",
"license": "MIT",
"preferUnplugged": true,
"engines": {
"node": ">=12"
},
"os": [
"win32"
],
"cpu": [
"x64"
]
}

安装 optionalDependencies 的逻辑大概是，会拿取当前 process.platform 和 process.arch 来判断每个包是否能够匹配它的平台，npm 只会下载对应平台的包（可以传一些参数让它强制下载别的，你可以自己试试，看看自己项目里的 swc 和 esbuild 到底用了哪个平台的版本）。

除此以外，可以看到 esbuild 的包里的 scripts 内有 postinstall 一项，它除了作为脚本可以 npm run 以外，还会 自动在 npm install 后执行（其它包管理器类似），完成其它一些初始化工作，比如它就运行一个 postinstall.js 脚本。

@naria2/node

总而言之，使用类似的配置方式，从 agalwood/Motrix 偷了几个构建好的 aria2 二进制，发到了 npm 上，具体参考：packages/binary。

感觉其它一些东西也可以用类似的方法发到 npm 上，这样以后只需要先用 Volta / fnm 搞一个 Node 下来，然后用 npm 来下载其它常用的工具。

然后，下载好了 aria2 的二进制，你还需要写一些代码，根据当前 process.platform 和 process.arch 选择对应的二进制包：

Copy
function getPackage() {
const { platform, arch } = process;

switch (platform) {
case 'win32':
if (['x64', 'ia32'].includes(arch)) {
return `@naria2/win32-${arch}`;
}
case 'darwin':
if (['x64', 'arm64'].includes(arch)) {
return `@naria2/darwin-${arch}`;
}
case 'linux':
if (['x64', 'arm64'].includes(arch)) {
return `@naria2/linux-${arch}`;
}
}

throw new Error('naria2 does not provide aria2 binary of your platform');
}

export const BINARY = getPackage();

export function getBinary() {
const pkg = require.resolve(BINARY + '/package.json');
const { platform } = process;
// Windows should end with .exe
const binary = path.join(path.dirname(pkg), platform === 'win32' ? 'aria2c.exe' : 'aria2c');
return binary;
}

最后，你只需要调用 getBinary() 就能找到当前平台下的 aria2 二进制的绝对路径。

从 Node.js 的代码中启动

经过上面的努力，我们已经把 aria2 的二进制，用类似 esbuild，swc 的方式下到了 node_modules 里。当然这还不够，继续向 "调用几个简单的异步函数，完成客户端的初始化" 努力。

maria2 是使用的一个基础的 aria2 RPC 接口客户端，它的抽象方式给了我启发。

使用 WebSocket 连接：

Copy
import { open, aria2 } from 'maria2'

const conn = await open(
new WebSocket('ws://localhost:6800/jsonrpc')

// import { createWebSocket } from 'maria2/transport'
// createWebSocket('ws://localhost:6800/jsonrpc')
)

const version = await aria2.getVersion(conn)

使用 HTTP 连接

Copy
import { open, aria2 } from 'maria2'
import { createHTTP } from 'maria2/transport'

const conn = await open(
createHTTP('http://localhost:6800/jsonrpc')
)

const version = await aria2.getVersion(conn)

我们可以类似地把从本地启动的一个 aria2 进程，连带着和它的 WebSocket 连接封装成一个东西。将原本的建立 WebSocket 连接，变成 先从 node_modules 里启动 aria2 进程，然后创建 WebSocket 连接。

Copy
export async function createChildProcess(): Promise<ChildProcessSocket> {
const child = spawn(, ['--enable-rpc']);
await new Promise((res, rej) => {
let spawn = false;

if (child.stdout) {
child.stdout.once('data', () => {
spawn = true;
res(undefined);
});
} else {
child.once('spawn', () => {
spawn = true;
res(undefined);
});
}

child.once('error', (e) => {
if (!spawn) {
rej(e);
}
});
});

return new WebSocket(`ws://127.0.0.1:6800/jsonrpc`);
}

以上示例代码，省略了亿点细节，但总之就是这么个意思。接下来就是，README.md 中描述的效果了：

Copy
npm i naria2 @naria2/node

使用时，只需要 await createClient(createChildProcess()) 一行就能完成 aria2 进程的创建和 WebSocket 连接的建立，同样 client.close() 也包含了 aria2 进程关闭的逻辑。

Copy
import { createClient } from 'naria2'
import { createChildProcess } from '@naria2/node'

// Initialize a client
const client = await createClient(createChildProcess())

// Start downloading a magnet
const torrent = await client.downloadUri('...')

// Watch torrent progress
await torrent.watchFollowedBy((torrent) => {
console.log(`Downloading ${torrent.name}`)
})

// Shutdown client
await client.shutdown()

当然可能还有一些无法忽略的细节，比如，此处这样启动的 aria2 进程可能会在脚本异常退出后，仍然保持存活，可能需要参考 一个 Node.js 命令行程序的『死亡』 完成此类的边界情况的处理。

作为 CLI 应用启动

我们既然已经有了当前平台的 aria2 二进制，而且 aria2 本来就是一个 CLI 下载工具，我们完全可以顺便用上面的方式套一层，化身一个跨平台的 CLI 下载工具。

所以，在 Windows / Mac OS / Linux 上，只要你有 Node 的环境，就能直接和其它包一样 npm i -g。

Copy
npm i -g naria2c

最初，它干的事情非常简单，拿到真实的 aria2 路径，然后用 execa 运行一下，起一个好听的名字就叫 naria2c 了。

Copy
#!/usr/bin/env node

import { execa } from 'execa'
import { getBinary } from '@naria2/node'

const binary = getBinary()

const childProcess = await execa(binary, process.argv.slice(2))

然后，我们给它再加入亿点细节。

转换 naria2c 的输出

因为做了一层的包裹，现在运行的表面上已经不是 aria2c 了，但是如果你使用 naria2c --help 仍然会看到，它提示你使用 aria2c 来运行程序，这无疑会让用户产生困惑。

于是，我们需要对 aria2c 的输出流和错误流做一些转换，将 aria2c 转换为 naria2c。

Copy
import { Transform } from 'stream'

childProcess.stdout.pipe(transformOutput()).pipe(process.stdout)
childProcess.stderr.pipe(transformOutput()).pipe(process.stderr)

function transformOutput() {
return new Transform({
transform(chunk, encoding, callback) {
let text = chunk.toString()
text = text.replace(/aria2c/g, 'naria2c')
callback(null, text)
}
});
}

但是，这可能会引入某些错误的替换，类似 Production Replacement | Vite。

于是，naria2c 的实际的选择：

我们会先看一下是否在运行是 -h, --help 或者 -v, --version，可以只过滤帮助信息和版本信息命令输出内的 aria2c；

对于标准错误流，它可能会携带帮助信息，使用上面的转换流（但是替换字符串的格式更加具体）。

看起来就像真的是自己写的一样（

透传 aria2 进程的终止信号

根据 一个 Node.js 命令行程序的『死亡』，透传了一些终止的信号给 aria2 进程。

Copy
import { onDeath } from '@breadc/death';

const cancelDeath = onDeath(async (signal) => {
const killChildProcess = () => {
childProcess.kill(signal);
return new Promise((res) => {
if (childProcess.exitCode !== null || childProcess.killed) {
res();
} else {
childProcess.once('exit', () => {
res();
});
}
});
};

await Promise.race([
killChildProcess(),
sleep(5000);
]);
});

但是由于 Windows 平台和其它平台的信号机制不太一样，这部分似乎不知道怎么模拟原本 aria2c 的终止行为。期望是，按 Ctrl-C 后，aria2 退出并输出一些总结信息，或者触发一次 aria2 的平缓退出，再按一次强制退出。

但是，反正，又不是不能用（

禁止 aria2 自动使用代理环境变量

由于机场有 BitTorrent 相关的审计规则，aria2c 会自动根据环境变量里的 http_proxy 等设置代理，很容易一不小心，忘记关代理导致触发机场的审计。

因为，我们会手动开一个 aria2c 进程，于是，我们可以在开之前做一些预处理，给它把代理相关的环境变量清掉。

首先，添加一个新的选项 -I, --ignore-proxy 用于清除代理相关的环境变量；然后，在创建进程时，传一个过滤后的 process.env 给它。

Copy
// 省略命令行参数的解析

const env = options.ignoreProxy
? {
...process.env,
HTTP_PROXY: undefined,
HTTPS_PROXY: undefined,
ALL_PROXY: undefined,
http_proxy: undefined,
https_proxy: undefined,
all_proxy: undefined,
no_proxy: undefined
}
: process.env;

添加启动 Web UI 的命令行参数

如果你只是想像 curl 等等 CLI 工具一样，只是下载一个 Torrent，那么现在的方式已经完全够用了，但是如果要操作多个 Torrent、监控它们的实时状态等等，TUI 的形式显然并不是很方便，我们还是希望能有一个 GUI 来操控 aria2。

那么，既然我们本身就在做一个 aria2 RPC 客户端的库，何不直接用它来构建另一个 aria2 的 Web 应用呢？

为此，我们给 naria2c 添加 Web UI 的相关参数 --ui，用于启动 aria2c 进程的同时，启动一个 Web UI。

Copy
naria2c --ui

目前 Web UI 只实现了一些简单的可视化和控制。

naria2c --ui 一个命令就能完成 aria2c 和 Web UI 的启动。但是，这底下还涉及一些细节：

--ui 参数不是 aria2c 支持的，需要在启动脚本中过滤提取出来；

打开 Web UI 一定伴随着启动 RPC 服务，因此，如果发现 --ui 选项开启，那么就需要同时设置 --enable-rpc；

Web UI 需要知道 RPC 服务的端口号和认证密钥，显然这个信息不需要重复指定，只需要手动解析 --rpc-secret 选项即可。

Copy
// aria2c 的参数
const args: string[] = []

// 缺失的参数
const missing = {
enableRpc: true
}

// Web UI 相关的参数
const webui = {
enable: false,
secret: undefined
}

for (const arg of process.argv.slice(2)) {
// 省略解析命令行参数
}

// 启动了 Web UI, 但是未启动 RPC, 补充参数
if (webui.enable && missing.enableRpc) {
args.push('--enable-rpc')
}

于是，我们从命令行参数中获取到了想要的信息，接下来就可以启动嵌入的 Web UI 了。

嵌入的 Web UI

紧跟着上一小节，为什么我要嵌入一个 Web UI 到 naria2c 里？

回想最开始的时候：

naria2 实际上只是一个客户端库，能跑在浏览器，Node 等等环境里；

@naria2/node 为它提供了 Node 相关的本地运行支持，例如支持本地直接打开 aria2 进程；

naria2c 是 aria2c 的跨平台 wrapper，只是一个附加产物。

那么，我们似乎应该更优先考虑，能给客户端多提供一些什么能力：启动 Web UI 完全可以归到 @naria2/node 中，作为一个包的能力给它的使用者。

除此以外，不是使用 electron 之类的技术，做一个桌面端的 GUI 应用（例如 agalwood/Motrix）。第二，做桌面端应用无法满足上面作为包的需要，因为这需要添加很多额外的打包分发成本，就像下载 aria2c 的二进制一样。

实际上，这也是我的真实需求：AnimeSpace，另一个自动的新番下载、整理解决方案。AnimeSpace 是一个命令行程序，它会自动根据订阅情况，下载动画资源的 Torrent。而目前的版本，它下载时只显示一个下载进度条的 TUI，不是非常直观。引入 @naria2/node 的这个能力，可以为它提供更丰富的下载进度 GUI。

启动 Web UI

那么如何嵌入 Web UI 到 @naria2/node？

实际上只需要将前端应用打包构建的产物，复制到这个包下的某个目录内（并对 package.json 中的 files 项进行相关的配置），然后在运行时找到前端应用构建产物的目录，在其上创建一个静态文件的 HTTP 服务器即可。

@naria2/node 对应的包结构如下图所示：

Copy
@naria2/node
├─ client/ # 前端应用的构建产物
│ ├─ assets/**
│ └─ index.html
├─ dist/** # TypeScript 源码转译的产物
├─ ...
└─ package.json

潜在的问题：

路径依赖于 TypeScript 转译构建产物的路径；

无法支持 single-executable-applications 或者内联依赖的打包方式，因为它依赖这些非代码的静态产物在文件系统中相对位置。

但是问题不大。

于是，我们可以通过 fileURLToPath(new URL('../client', import.meta.url)) 的方式来找到 Web UI 产物的路径。使用 serve-static 来启动一个本地文件的 HTTP 服务器。

最终，用户就能直接调用 await launchWebUI({ ... }) 来启动一个 Web UI。

Copy
export async function launchWebUI(options: WebUIOptions) {
const serveStatic = (await import('serve-static')).default;
const finalhandler = (await import('finalhandler')).default;
const http = await import('http');

const port = options.port ?? 6801;
const clientDir = fileURLToPath(new URL('../client', import.meta.url));
const serve = serveStatic(clientDir, { index: ['index.html'] });
const server = http.createServer(async (req, res) => {
serve(req, res, finalhandler(req, res));
});

server.listen(port);

return server;
}

export interface WebUIOptions {
port?: number;

rpc: {
port: number;

secret: string | undefined;
};
}

vite-plugin-naria2

之前从来没有写过 React，在网上云了很多，这次决定拿这个东西试一下怎么写。这里我们不需要 SSR，所以直接 Vite 就行了，外加了一堆 React 生态的东西，react-router，TanStack Query，Zustand，tailwindcss，shadcn，以及一堆 shadcn 依赖的东西。一开始看起来挺复杂的，尤其是 shadcn 弹出的一坨组件代码，看的脑阔疼，但似乎写起来还是很方便的，而且默认的功能很强大。

开始开发这个应用的第一个问题是，我们需要起一个 aria2 的服务给前端使用，于是参考之前写的插件 vite-plugin-cloudflare-functions 的思路。

直接在 Vite 插件里启动一个 aria2，然后通过虚拟模块传递连接信息给前端使用。因此就不用另外启动服务，另外配置了。

Copy
export default function Naria2(): Plugin[] {
const childProcessRuntime = {
process: undefined as ChildProcessSocket | undefined,
url: undefined as string | undefined,
secret: undefined as string | undefined
};

return [
{
name: 'vite-plugin-naria2:runtime',
apply: 'serve',
async configureServer(server) {
if (!childProcessRuntime.url) {
// 在 Vite dev 服务器里启动一个 aria2
const childProcess = await createChildProcess();
childProcessRuntime.process = childProcess;
childProcessRuntime.url = `ws://127.0.0.1:${childProcess.getOptions().listenPort}/jsonrpc`;
childProcessRuntime.secret = childProcess.getOptions().secret;
}
},
closeBundle() {
childProcessRuntime.process?.close?.();
childProcessRuntime.url = undefined;
childProcessRuntime.secret = undefined;
}
},
{
name: 'vite-plugin-naria2:build',
resolveId(id) {
if (id === '~naria2/jsonrpc') {
return '\0' + id;
}
},
load(id) {
if (id === '\0~naria2/jsonrpc') {
// 作为虚拟模块传递信息给前端
const socketCode = childProcessRuntime.url
? `new WebSocket(${JSON.stringify(childProcessRuntime.url)})`
: 'undefined';
const clientCode = `socket ? await createClient(socket, { ${
childProcessRuntime.secret
? 'secret: ' + JSON.stringify(childProcessRuntime.secret)
: ''
} }) : undefined`;

return [
`import { createClient } from 'naria2';`,
`export const socket = ${socketCode};`,
`export const client = ${clientCode};`
].join('\n');
}
}
}
];
}

打开本地资源管理器

我们还希望给这个 Web UI 加一些常用的功能，比如打开本地资源管理器？

但是，由于浏览器的安全限制，实际上这个应该不太方便直接做到。

但是，我们实际上启动了一个后端服务，直接用这个服务来打开不就行了？

Copy
export async function launchWebUI(options: WebUIOptions) {
// ...
const handler = await createWebUIHandler(options);
const server = http.createServer(async (req, res) => {
if (await handler(req, res)) {
return;
}
serve(req, res, finalhandler(req, res));
});
// ...
}

export async function createWebUIHandler(options: Pick<WebUIOptions, 'rpc'>) {
return async (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => {
if (!req.url) return false;

try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/_/open') {
return await handleWebUIOpenRequest(url, req, res);
}
return false;
} catch (error) {
return false;
}
};
}

export async function handleWebUIOpenRequest(
url: URL,
req: IncomingMessage,
res: ServerResponse<IncomingMessage>
) {
try {
const auth = req.headers.authorization;
const dir = url.searchParams.get('dir');
if (dir) {
const open = (await import('open')).default;
await open(dir).catch(() => {});
res.write(JSON.stringify({ status: 'OK', open: p }));
}

res.setHeader('Content-Type', 'application/json');
res.end();
return true;
} catch (error) {
return false;
}
}

我们给本来的静态文件 HTTP 服务，添加一个用于打开文件的接口 /_/open?dir=...。前端给这个接口发请求，实现的效果就是打开文件资源管理器。

可能存在一些安全问题，实际可以额外验证一下令牌，或者使用选项禁用此功能。

代理 aria2 RPC 端口

虽然不太需要这么做，但是或许我们可以把这个页面部署到服务器上。那么为此，我们就需要在公网上给它开 2 个端口，一个用于前端应用，一个用于 aria2 RPC 服务；或者需要额外的配置，或者使用 nginx 等等来发现 RPC 服务的位置。

和上一节一样，既然我们已经有了一个后端服务，那么我们为什么不充分利用它呢？

直接为这个服务的 /jsonrpc 路由，添加一个转发到 aria2 RPC 服务的代理。于是，部署只需要暴露应用的位置即可，连接 RPC 服务只需要访问同源的 /jsonrpc 即可。

Copy
export async function createWebUIHandler(options: Pick<WebUIOptions, 'rpc'>) {
// 创建一个代理中间件
const { createProxyMiddleware } = await import('http-proxy-middleware');
const proxyMiddleware = createProxyMiddleware({
target: `http://127.0.0.1:${options.rpc.port}`,
changeOrigin: false,
ws: true,
logLevel: 'silent'
});

return async (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => {
if (!req.url) return false;

try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === '/jsonrpc') {
// 转发请求
proxyMiddleware(req as any, res as any, () => {});
return true;
} else if (url.pathname === '/_/open') {
return await handleWebUIOpenRequest(url, req, res);
}
return false;
} catch (error) {
return false;
}
};
}

基于 http-proxy-middleware，完成 HTTP 请求的代理转发工作，它这个库还支持转发 WebSocket 请求，直接使用即可。

迷跡波