OneKuma

OneKuma's Blog

One Lonely Kuma.
github
bilibili
twitter

構建一個易用的 aria2 客戶端包和 CLI 應用

最近有空把之前弄了一半的 aria2 的包繼續搞了搞,寫一點東西,記錄過程中一些有趣的點。

naria2 是一個調用 aria2 RPC 接口、封裝抽象後的 aria2 下載器 JavaScript / TypeScript 客戶端庫,同時也是一個在 aria2c 基礎上提供一些額外功能的 CLI 工具應用

naria2c version

naria2 ui

跨平台的統一安裝方式#

在使用 aria2 之前,我們必須先下載 aria2。這似乎是一句廢話,畢竟 aria2 一般不是系統自帶的東西,但作為一個 C++ 寫的原生應用,顯然它的下載安裝不一定 trivial。

於是,我們就有一些可能的方案。比如,我作為一個客戶端的包為什麼要幫用戶下載,你下好了,加到 PATH 裡再來用吧。OK,沒問題,文檔裡多寫兩句就行了,但是總歸不是很優雅。

所以,我希望,使用這個庫的用戶不需要太多的感知到一些細節:我們會在後台啟動了一個 aria2 RPC 接口服務的進程。用戶只需要知道

  1. 調用幾個簡單的異步函數,完成客戶端的初始化;
  2. 通過封裝抽象好的 API 完成 Torrent 的各種下載、監控任務;
  3. 所有邏輯處理完成後,銷毀客戶端。

所以,我們還是希望為不同平台的用戶,提供一個方便的方式下載 aria2。

拉取並運行一個遠程的安裝腳本#

目前相當多的跨平台應用選擇這種安裝方式,它們會搞個 Web 服務用於分發一段安裝腳本。以 Rust 為例,複製圖上這條命令,一路回車就能裝好一個 Rust。

image

但是,從這張圖上就能看出,只有 Windows 特殊,需要下個安裝包來安裝。因為,這些 *nix 系統能跑的腳本,在 Windows 的 PowerShell 和 CMD 上跑起來並不容易,比如貼心的微軟就幫你把 Invoke-WebRequest (iwr) 起了個 "curl" 的別名,但是用法和 curl 完全不同。

第二,維護一個分發腳本的服務簡單也不簡單。簡單是因為,隨便搞個部署靜態頁面的平台或者 Serverless 函數平台,分分鐘就能把腳本搞上去。但是,你是不是需要買個域名,搞 SSL 證書,隔幾年續費域名,國內還要考慮牆內等等,即使感謝 Cloudflare 把一些髒活累活都給你做好了,但是還是有點麻煩了。當然也有簡便的方法,感謝 egoist 幾年的一個項目 bina,大概就是根據你項目的 GitHub Releasse 內的標有平台的文件名,自動生成一段用於下载安装 CLI 應用到 PATH 的腳本

image

發到一堆包管理器上#

Rust 應用經典地域繪圖。你說得對,但是 Rust 是由 Mozilla 自主研發的一款全新內存安全編程語言。編譯將發生在一個被稱作「卡爾構」的構建系統,在這裡,被引用的指針將被授予「生命周期」,導引安全之力。你將扮演一位名為「開發者」的神秘角色在編程的搏鬥中邂逅骨骼驚奇的報錯,繞開它們通過編譯同時,逐步發掘「Rust」的真相。

image

實際上,我認為,使用包管理器和抓取分發的安裝腳本來運行,兩者並無過多的區別。只是包管理器,第一,將 "抓取安裝的東西" 和 "運行本地安裝腳本" 的邏輯給做成了內部的實現,第二,幫你維護了一個包的列表和包的資源,節省了自己精力,也不容易跑路。當然包管理器能做到更多事情,比如統一管理版本、依賴等等。

除此以外,還有 nix(不太了解),tea (結合了 Web3?)等等包管理方案,本文目的也不是介紹這些東西,這兩節只是順便隨便聊聊一些方案罷了。

利用 package.json 的 optionalDependencies#

得益於 Node 的一些跨平台特性,於是我們考慮發到 npm 上。如果你對目前的前端工具鏈有所了解的話,例如 esbuildswc 這類使用原生語言開發的應用可以通過 npm 進行分發。

esbuild 為例,好奇的你點開它的 npm 主頁:

image

你會發現,它依賴了一堆東西,包括各種各樣的平台。如果再點開它的 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 通過 oscpu 兩個字段指定了這個包支持的平台

{
  "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.platformprocess.arch 來判斷每個包是否能夠匹配它的平台,npm 只會下載對應平台的包(可以傳一些參數讓它強制下載別的,你可以自己試試,看看自己項裡的 swcesbuild 到底用了哪個平台的版本)。

除此以外,可以看到 esbuild 的包裡的 scripts 內有 postinstall 一項,它除了作為腳本可以 npm run 以外,還會 自動在 npm install 後執行(其它包管理器類似),完成其它一些初始化工作,比如它就運行一個 postinstall.js 腳本。

@naria2/node#

總而言之,使用類似的配置方式,從 agalwood/Motrix 偷了幾個構建好的 aria2 二進制,發到了 npm 上,具體參考:packages/binary

感覺其它一些東西也可以用類似的方法發到 npm 上,這樣以後只需要先用 Volta / fnm 搞一個 Node 下來,然後用 npm 來下載其它常用的工具。

然後,下載好了 aria2 的二進制,你還需要寫一些代碼,根據當前 process.platformprocess.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 的二進制,用類似 esbuildswc 的方式下到了 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 的實際的選擇:

  1. 我們會先看一下是否在運行是 -h, --help 或者 -v, --version,可以只過濾幫助信息和版本信息命令輸出內的 aria2c
  2. 對於標準錯誤流,它可能會攜帶幫助信息,使用上面的轉換流(但是替換字符串的格式更加具體)。

image

image

看起來就像真的是自己寫的一樣(

透傳 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

目前 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 進程;
  • naria2caria2c 的跨平台 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-routerTanStack QueryZustandtailwindcssshadcn,以及一堆 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 請求,直接使用即可。

迷跡波#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。