最近有空把之前弄了一半的 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 進行分發。
你會發現,它依賴了一堆東西,包括各種各樣的平台。如果再點開它的 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 請求,直接使用即可。