clash-balancer
v0.1.1
Published
通过编排 mihomo / Clash.Meta 实现自动代理负载均衡:客户端连固定端口,轮询分发到多个上游代理
Maintainers
Readme
clash-balancer
通过编排 mihomo / Clash.Meta 实现代理负载均衡的 Node.js 库 (TypeScript 编写,打包后同时提供 CommonJS / ESM 与类型声明)。
客户端 ──► http://127.0.0.1:<port> ──► mihomo ──► { 节点A, 节点B, 节点C … } 轮询/分发
▲
本库:生成配置 · 拉起并监督进程 · 健康检查 · 自动热重载本库不自己实现负载均衡算法(交给 mihomo 的 load-balance proxy-group),而是负责把
「一组上游代理 / 订阅」编排成「一个或多个本地端口」,并管理 mihomo 进程的生命周期。
提供两种调用方式:
| 类 | 暴露 | 适用 |
|---|---|---|
| ClashBalancer | 一个固定本地端口,流量按策略(round-robin / url-test…)分发到多个节点 | 通用代理池:一个端口,多节点自动负载均衡 |
| PinnedBalancer | 每个存活节点一个专属端口 + 一个 round-robin 主端口 | 高并发抓取:让每个出口 IP 同一时刻最多 1 个在途请求,最大化规避按 IP/子网 的 429 限流 |
前置条件
- Node.js ≥ 18(需内置
fetch)。 - 一个 mihomo / Clash.Meta 可执行文件。安装本包时会自动按当前平台从 GitHub Releases 下载
对应的 mihomo 二进制到包内
bin/,库运行时会自动发现,通常无需传binPath。- 自动下载失败也不会中断安装(只打警告);此时可手动下载放入
bin/,或调用时显式传binPath。 - 二进制不随 npm 包分发(包仅几十 KB),始终是装包时按平台现取。
- 自动下载失败也不会中断安装(只打警告);此时可手动下载放入
安装
pnpm install # 或 npm install / yarnpnpm 用户注意:pnpm v9+ 默认拦截依赖的
postinstall脚本,自动下载不会执行。 需要在你的项目里允许本包构建:pnpm approve-builds # 交互式批准,或:或在
package.json配"pnpm": { "onlyBuiltDependencies": ["clash-balancer"] }。 不想走自动下载时,直接给binPath即可,无需开启。
自动下载的环境变量(可选)
| 变量 | 作用 |
|---|---|
| MIHOMO_VERSION | 指定 mihomo 版本(如 v1.19.26);默认取 latest release |
| MIHOMO_MIRROR | GitHub 下载镜像前缀(国内加速),拼在 github.com 链接前,如 https://ghproxy.example/ |
| MIHOMO_DOWNLOAD_URL | 直接指定二进制资源 URL(优先级最高,跳过 GitHub API) |
| MIHOMO_FORCE=1 | 即使 bin/ 已有二进制也重新下载 |
| CLASH_BALANCER_SKIP_DOWNLOAD=1 | 跳过自动下载(改为手动 / binPath) |
| GITHUB_TOKEN | 提高 GitHub API 速率限制(可选) |
// ESM / TypeScript
import { ClashBalancer, PinnedBalancer } from 'clash-balancer';
// CommonJS
const { ClashBalancer, PinnedBalancer } = require('clash-balancer');快速开始
最简用法:不必提供 binPath(用 bin/ 下的二进制),订阅直接给 URL 字符串。
const lb = new ClashBalancer({ subscription: 'https://example.com/sub' });
await lb.start();
console.log(`就绪,把代理指向 http://127.0.0.1:${lb.port}`); // 默认 7890
// 客户端示例(任何支持 HTTP 代理的工具都可):
// curl -x http://127.0.0.1:7890 https://ipinfo.io/ip
await lb.stop();ClashBalancer
把一组节点/订阅聚合成一个本地端口,按策略分发。
构造选项
new ClashBalancer({
binPath, // string? mihomo 路径。省略则用 bin/ 下自带的二进制
port, // number? 客户端连接的混合端口(HTTP+SOCKS),默认 7890
controllerPort, // number? external-controller 端口,默认 9090
strategy, // 'round-robin'(默认) | 'consistent-hashing' | 'sticky-sessions' | 'url-test'
proxies, // ProxyNode[]? 静态节点(Clash 原生格式)
subscription, // string? 单订阅 URL 简写
subscriptions, // (string | Subscription)[]? 订阅列表(字符串/对象混用)
excludeFilter, // string? 剔除名称匹配该正则的节点(机场信息节点)
maxFailedTimes, // number? 节点连续失败多少次判不可用,默认 3
healthCheck: { // 主动健康检查(JS 层,周期性测各静态节点延迟)
enabled, // boolean? 默认 true
url, // string? 默认 http://www.gstatic.com/generate_204
interval, // number? ms,默认 60000
timeout, // number? ms,默认 5000
failuresToDrop, // number? 连续失败几次永久剔除,默认 3
},
restart: { maxRetries, backoffMs }, // 进程崩溃重启:默认 5 次、退避 1000ms 起
secret, // string? external-controller 鉴权,默认随机
readyTimeoutMs, // number? 就绪超时,默认 10000
workDir, // string? 运行时工作目录,默认系统临时目录
});
binPath、subscription、subscriptions不传时分别回退到:自带二进制、空、空。 至少需要一个proxies或订阅,否则start()会抛错(mihomo 不接受空组)。
方法
await lb.start(); // 生成配置 → 拉起 mihomo → 轮询就绪 → 启动健康循环
await lb.reload({ proxies?, subscriptions? }); // 热更新节点列表(PUT /configs,不重启进程)
const s = await lb.stats(); // { port, uptime, proxies: [{ name, alive, delay }] }
await lb.stop(); // 停健康循环 → 优雅关闭 mihomo → 清理临时配置事件(继承 EventEmitter)
lb.on('ready', () => {}); // 就绪
lb.on('proxy:up', (name) => {}); // 节点恢复
lb.on('proxy:down', (name) => {}); // 节点被剔除
lb.on('all:down', () => {}); // 所有节点失效(保留旧配置继续尝试)
lb.on('clash:restart',(info) => {}); // mihomo 崩溃后退避重启
lb.on('clash:fatal', (err) => {}); // 超过 maxRetries,放弃
lb.on('error', (err) => {});示例
// 静态节点 + 订阅,round-robin,剔除机场信息节点
const lb = new ClashBalancer({
strategy: 'round-robin',
proxies: [
{ name: 'hk-1', type: 'ss', server: '1.2.3.4', port: 8388, cipher: 'aes-256-gcm', password: '...' },
],
subscriptions: ['https://example.com/sub'],
excludeFilter: '剩余流量|到期|官网|过期',
healthCheck: { interval: 30000, failuresToDrop: 3 },
});
lb.on('ready', () => console.log('客户端 → http://127.0.0.1:' + lb.port));
lb.on('proxy:down', (n) => console.warn('下线', n));
await lb.start();
// 运行中热更新节点
await lb.reload({ subscriptions: ['https://example.com/sub2'] });
// 查看各节点延迟
console.log(await lb.stats());PinnedBalancer:每个出口 IP 单线程
当你高并发抓取又怕**单个出口 IP 触发限流(HTTP 429)**时:ClashBalancer 的 round-robin 是
「按 TCP 连接」轮换的,连接复用 / 节点数有限时,多个并发请求仍可能挤在同一个出口 IP 上。
PinnedBalancer 为每个存活节点单独开一个本地端口,端口的出站固定走那个节点。只要调用方
保证「每个端口最多一个并发请求」(worker-per-port),就能让每个出口 IP 同一时刻最多一个在途请求。
它内部两阶段编排:① 以 round-robin 起一遍、等订阅解析 + 健康检查、读出存活节点(按延迟排序);
② 为选中的 N 个节点各建「一个 select 组 + 一个 mixed 监听端口」,重启 mihomo 后用
external-controller 把每个组钉定到对应节点。
构造选项
new PinnedBalancer({
binPath, // string? 省略则用 bin/ 下自带的二进制
subscription, // string? 单订阅 URL 简写
subscriptions, // (string | Subscription)[]?
proxies, // ProxyNode[]? 静态节点
excludeFilter, // string? 剔除信息节点(强烈建议机场场景设置)
maxNodes, // number? 最多钉多少个节点(=并发上限/端口数),默认 24
healthUrl, // string? 健康检查 URL,默认 gstatic generate_204
healthIntervalSec, // number? 健康检查间隔秒,默认 6
maxFailedTimes, // number? 默认 3
settleMs, // number? 发现阶段等待健康检查标活的时间,默认 12000
readyTimeoutMs, // number? 单阶段就绪超时,默认 20000
workDir, // string?
log, // (msg: string) => void 进度日志回调
});属性与方法
await pb.start(); // 两阶段编排,完成后填充下列属性
pb.pins; // { port: number; node: string }[] —— 每个出口 IP 一个专属端口
pb.mixedPort; // number round-robin 主端口(供详情页/封面等少量非关键流量)
pb.controllerPort; // number external-controller 端口
pb.pid; // number? 当前 mihomo 进程 pid(用于调用方异常退出时兜底清理)
pb.controller; // Controller 可直接调 REST API
await pb.stop(); // 优雅关闭 + 清理(Windows 下含 taskkill 兜底)
pb.on('clash:restart', (info) => {});
pb.on('clash:fatal', (err) => {});消费 pins:worker-per-port
每个 worker 绑定一个 pin.port、内部串行处理任务,即可做到「每出口 IP 单线程」。
用 undici 的 ProxyAgent 把请求定向到某个端口:
import { ProxyAgent, fetch as undiciFetch } from 'undici';
const pb = new PinnedBalancer({ subscription: 'https://example.com/sub', maxNodes: 16, excludeFilter: '剩余流量|到期|官网' });
await pb.start();
const queue = [...tasks]; // 待抓取任务
await Promise.all(pb.pins.map(async (pin) => {
const dispatcher = new ProxyAgent(`http://127.0.0.1:${pin.port}`); // 钉定到该节点
for (let t; (t = queue.shift()); ) { // 该 worker 串行 → 该节点同时仅 1 请求
const res = await undiciFetch(t.url, { dispatcher });
// …处理 res…
}
await dispatcher.close();
}));
await pb.stop();⚠️ 同源坑:传给
fetch的dispatcher必须与该fetch出自同一个 undici。 用 Node 内置全局fetch搭配本包安装的ProxyAgent会报invalid onRequestStart method。 解决:统一用undici包导出的fetch(如上),或统一用 Node 内置的(但内置不导出ProxyAgent)。
策略(strategy)
| 值 | mihomo 组类型 | 行为 |
|---|---|---|
| round-robin(默认) | load-balance | 每条新连接轮换下一个存活节点 —— 分散到多 IP |
| consistent-hashing | load-balance | 同一目标域名 → 固定同一节点 |
| sticky-sessions | load-balance | 同一来源+目标 → 一段时间内固定节点 |
| url-test | url-test | 始终走最快的单一节点(非分发;全挂时会回退直连) |
库默认给 load-balance 组设了
lazy: false+max-failed-times,让 mihomo 主动健康检查、 先把死节点/信息节点剔除,否则首批连接可能轮到尚未探测的不可用节点而失败。
订阅与节点过滤
- 订阅简写:
subscription: 'https://…'等价于subscriptions: [{ name:'sub', url, interval:86400, userAgent:'clash.meta' }]。 subscriptions数组可混用 URL 字符串与完整对象;字符串自动补默认值(userAgent默认clash.meta,很多机场据此返回完整节点列表)。excludeFilter:正则,剔除机场常见的非代理"信息节点"(如剩余流量 / 套餐到期 / 官网…)—— 它们无法转发流量,round-robin 轮到或 PinnedBalancer 钉到都会失败。常用:
注意别误伤正常节点(如剩余流量|剩余|套餐|到期|过期|重置|官网|网址|订阅|续费|购买|公告|失联|Traffic|ExpireGB会命中英国 🇬🇧)。
type Subscription = {
name: string; url: string; interval: number;
userAgent?: string; // 默认 'clash.meta'
header?: Record<string, string | string[]>;
};
type ProxyNode = { name: string; type: string; server?: string; port?: number; [k: string]: unknown };让整个 Node 进程的流量都走代理
不想逐个请求传 dispatcher 时,可借助 Node 18+ 的 NODE_USE_ENV_PROXY,让进程内所有 fetch /
http / https 自动走某个端口(配合 NO_PROXY 排除数据库等不该走代理的目标):
const lb = new ClashBalancer({ subscription: 'https://example.com/sub' });
await lb.start();
const proxy = `http://127.0.0.1:${lb.port}`;
const child = spawn(process.execPath, ['your-script.js'], {
env: { ...process.env, NODE_USE_ENV_PROXY: '1', HTTP_PROXY: proxy, HTTPS_PROXY: proxy, NO_PROXY: '.your-db.com,127.0.0.1' },
stdio: 'inherit',
});注意:
NODE_USE_ENV_PROXY在进程启动时读取,故需以子进程方式注入(不能在已运行进程里改process.env)。
辅助函数 / 底层构件
import { bundledBinPath, normalizeSubscriptions, buildConfig, Controller, Supervisor, HealthChecker } from 'clash-balancer';
bundledBinPath(); // 解析 bin/ 下自带二进制的绝对路径(找不到返回 undefined)
normalizeSubscriptions(sub, subs);// 把字符串/对象混合的订阅归一化为 Subscription[]
buildConfig(opts); // 纯函数:生成 mihomo YAML 字符串(支持 pins/excludeFilter…)Controller— mihomo external-controller REST 薄封装:version()/proxies()/providers()/delay(name, {url,timeout})/select(group, name)/reloadConfig(path)。Supervisor— 进程生命周期:拉起、捕获 stdout/stderr、崩溃指数退避重启、优雅关闭。HealthChecker— 周期性测延迟、标记/剔除死节点、触发重建+热重载、发事件。
ClashBalancer / PinnedBalancer 即由以上构件组合而成;需要更细控制时可直接使用它们。
Windows / Clash Verge 注意事项
- 若本机常驻 Clash Verge(
verge-mihomo.exe),它默认占用 7890 / 9090。请给本库用别的端口 (port/controllerPort),或确保不冲突;否则会出现mihomo 就绪超时(且 stderr 为空)。 - Windows 下子进程在父进程异常退出时可能成为孤儿继续占端口。
PinnedBalancer.stop()已内置taskkill兜底;ClashBalancer的调用方建议在进程退出钩子里补杀。清理残留:taskkill /F /IM mihomo-windows-amd64-compatible.exe
开发
源码为 TypeScript(src/),用 tsup 打包到 dist/,
测试与示例用 tsx 直接运行 .ts。
pnpm build # 产出 dist/index.js(CJS)、index.mjs(ESM)、index.d.ts
pnpm typecheck # tsc --noEmit 全量类型检查
pnpm test # node --test(经 tsx 运行 test/**/*.test.ts)
pnpm example # 运行 examples/basic.ts
# 真实功能冒烟(需 bin/ 下有 mihomo 二进制):
pnpm tsx examples/smoke.ts # 全程本地(本地上游代理 + 本地目标)
pnpm tsx examples/sub-smoke.ts # 真实订阅(需自备订阅)
pnpm tsx examples/rr-local.ts # 确定性验证 round-robin 在多节点间轮换
pnpm tsx examples/envproxy-local.ts # 验证 NODE_USE_ENV_PROXY 经本库出网License
MIT
