最近有空把之前弄了一半的 aria2 的包继续搞了搞,写一点东西,记录过程中一些有趣的点。
naria2 是一个调用 aria2 RPC 接口、封装抽象后的 aria2 下载器 JavaScript / TypeScript 客户端库,同时也是一个在 aria2c 基础上提供一些额外功能的 CLI 工具应用。
Convenient BitTorrent Client based on the aria2 JSON-RPC
跨平台的统一安装方式#
在使用 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 进行分发。
你会发现,它依赖了一堆东西,包括各种各样的平台。如果再点开它的 package.json
,可以发现这些实际上都是 optionalDependencies
:
{
"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
两个字段指定了这个包支持的平台。
{
"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
选择对应的二进制包:
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 连接:
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 连接
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 连接。
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
中描述的效果了:
npm i naria2 @naria2/node
使用时,只需要 await createClient(createChildProcess())
一行就能完成 aria2 进程的创建和 WebSocket 连接的建立,同样 client.close()
也包含了 aria2
进程关闭的逻辑。
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
。
npm i -g naria2c
最初,它干的事情非常简单,拿到真实的 aria2
路径,然后用 execa
运行一下,起一个好听的名字就叫 naria2c
了。
#!/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
。
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
进程。
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
给它。
// 省略命令行参数的解析
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。
naria2c --ui
目前 Web UI 只实现了一些简单的可视化和控制。
naria2c --ui
一个命令就能完成 aria2c
和 Web UI 的启动。但是,这底下还涉及一些细节:
--ui
参数不是aria2c
支持的,需要在启动脚本中过滤提取出来;- 打开 Web UI 一定伴随着启动 RPC 服务,因此,如果发现
--ui
选项开启,那么就需要同时设置--enable-rpc
; - Web UI 需要知道 RPC 服务的端口号和认证密钥,显然这个信息不需要重复指定,只需要手动解析
--rpc-secret
选项即可。
// 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
对应的包结构如下图所示:
@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。
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,然后通过虚拟模块传递连接信息给前端使用。因此就不用另外启动服务,另外配置了。
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 加一些常用的功能,比如打开本地资源管理器?
但是,由于浏览器的安全限制,实际上这个应该不太方便直接做到。
但是,我们实际上启动了一个后端服务,直接用这个服务来打开不就行了?
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
即可。
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 请求,直接使用即可。