ellm-proxy 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -11
- package/bin/ellm-proxy.js +2 -63
- package/dist/cli.js +35793 -0
- package/dist/server.js +16723 -6451
- package/dist/web-ui/assets/{DashboardView-BCwrFrE2.js → DashboardView-3c_i_3VF.js} +1 -1
- package/dist/web-ui/assets/LayoutView-BhN1f8wo.js +1 -0
- package/dist/web-ui/assets/LayoutView-CNd_MJt2.css +1 -0
- package/dist/web-ui/assets/{LoginView-Dp7NmXtf.js → LoginView-J23vsMu2.js} +1 -1
- package/dist/web-ui/assets/{LogsView-Dyy_zkgg.js → LogsView-2h2Y0XE8.js} +1 -1
- package/dist/web-ui/assets/ProcessView-Bn8Ph7rh.css +1 -0
- package/dist/web-ui/assets/ProcessView-Bs2k6aAf.js +1 -0
- package/dist/web-ui/assets/{ProvidersView-CYFs76wJ.js → ProvidersView-bE1Hv9D-.js} +1 -1
- package/dist/web-ui/assets/SettingsView-BmLmC-_v.js +1 -0
- package/dist/web-ui/assets/SettingsView-Ceyj-ZDZ.css +1 -0
- package/dist/web-ui/assets/{SetupView-DX6S_KpK.js → SetupView-Cgj7ubNz.js} +1 -1
- package/dist/web-ui/assets/{config-CRWaUFQc.js → config-nvSp8hHp.js} +1 -1
- package/dist/web-ui/assets/{index-X5PdkJlA.js → index-sPrBBnf5.js} +2 -2
- package/dist/web-ui/assets/{providers-H_9x778D.js → providers-di7L8jy_.js} +1 -1
- package/dist/web-ui/assets/useManagerApi-qPjxfB0X.js +1 -0
- package/dist/web-ui/assets/useToast-D76qyWZg.js +1 -0
- package/dist/web-ui/config.js +3 -6
- package/dist/web-ui/index.html +1 -1
- package/docs/DESIGN.md +43 -14
- package/package.json +2 -17
- package/bin/commands/autostart.js +0 -37
- package/bin/commands/config.js +0 -8
- package/bin/commands/credentials.js +0 -100
- package/bin/commands/pm2.js +0 -28
- package/bin/commands/service.js +0 -107
- package/dist/web-ui/assets/LayoutView-BUA8-IzK.css +0 -1
- package/dist/web-ui/assets/LayoutView-BePJfgia.js +0 -1
- package/dist/web-ui/assets/ProcessView-CpPpMtFg.js +0 -1
- package/dist/web-ui/assets/ProcessView-DK0TvDpR.css +0 -1
- package/dist/web-ui/assets/SettingsView-BkwyoOUb.css +0 -1
- package/dist/web-ui/assets/SettingsView-mPcaVAD2.js +0 -1
- package/dist/web-ui/assets/useManagerApi-C1_COxKg.js +0 -1
- package/dist/web-ui/assets/useToast-MtbkGJiM.js +0 -1
- package/manager/api/harness/backup.js +0 -20
- package/manager/api/harness/claude-code.js +0 -54
- package/manager/api/harness/codex.js +0 -123
- package/manager/api/harness/dsh.js +0 -175
- package/manager/api/harness/index.js +0 -118
- package/manager/api/service-settings.js +0 -98
- package/manager/api/service.js +0 -147
- package/manager/index.js +0 -182
- package/utils/autostart.js +0 -219
- package/utils/interpreter.js +0 -14
- package/utils/paths.js +0 -45
- package/utils/pm2.js +0 -130
- package/utils/port.js +0 -35
- package/utils/service.js +0 -205
- package/utils/ui.js +0 -59
- package/utils/updater.js +0 -233
package/manager/index.js
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
// ellm-proxy 管理服务入口(独立常驻脚本):随 `ellm-proxy start` 一并由 pm2 托管(name=ellm-proxy-manager);
|
|
2
|
-
// 也可手动 `node manager/index.js` 运行。本文件 = 创建 app(中间件 + api 聚合挂载)+ 启动 HTTP 服务;
|
|
3
|
-
// 各 api 域实现在 api/ 下:service.js(进程控制,命名对齐 backend/src/api/service.ts)、harness/(代理开关域)。
|
|
4
|
-
//
|
|
5
|
-
// 为什么独立于 backend:ellm-proxy 与 backend 同进程,进程自杀后管理台与转发层同时失联,
|
|
6
|
-
// 无法返回操作结果——启停必须由活在外面的本服务执行。
|
|
7
|
-
//
|
|
8
|
-
// 管理界面托管:本服务同进程托管 web-ui 构建产物(根路径 /,hash 路由),并反代 /api/* 到主服务——
|
|
9
|
-
// web-ui 全部接口(/api/* 管理台 + /status /restart /harness 等进程/开关域)同源可达,config.js 留空零配置。
|
|
10
|
-
//
|
|
11
|
-
// 接口(CORS 全放开,均返回 {ok, ...} 或 {ok:false, error}):
|
|
12
|
-
// GET / web-ui 管理界面(静态托管,产物缺失时仅告警跳过)
|
|
13
|
-
// GET /api/* 反代主服务管理台 API(端口 = ELLM_PORT env > 配置文件 port,每请求解析)
|
|
14
|
-
// GET /status ellm-proxy 运行状态(running/pid/status/uptime/网关端口)
|
|
15
|
-
// GET /processes 全进程列表(pm2 守护进程/管理服务/主服务:状态/PID/端口/内存/CPU/运行时长)
|
|
16
|
-
// POST /start | /stop | /restart pm2 启动/停止/重启 ellm-proxy(复用 utils/service.js,含探活)
|
|
17
|
-
// GET|POST /autostart 开机自启状态 / 开关(body {enabled};utils/autostart.js,opt-out 尊重用户选择)
|
|
18
|
-
// GET /update 自动更新状态(开关/上次检查/上次更新/supported)
|
|
19
|
-
// POST /update/enabled body {enabled};自动更新开关(ellm.config.json 顶层 autoUpdate)
|
|
20
|
-
// POST /update/check 手动检查更新(npm view 私有源)
|
|
21
|
-
// POST /update/apply 手动执行更新(停主服务 → npm i -g → 重启主服务 → manager 延迟自重启)
|
|
22
|
-
// GET /harness Claude Code / Codex / DeepSeek Harness 代理开关状态
|
|
23
|
-
// POST /harness/claude-code body {enabled};开 = 写 ~/.claude/settings.json(备份),关 = 还原备份
|
|
24
|
-
// POST /harness/codex body {enabled};开 = 写 ~/.codex/config.toml(备份),关 = 还原备份
|
|
25
|
-
// POST /harness/dsh body {enabled};开 = 写 ~/.dsh/settings.yaml + .credentials.yaml(备份),关 = 还原
|
|
26
|
-
//
|
|
27
|
-
// 配置来源(env 优先,argv 可覆盖,缺省兜底):
|
|
28
|
-
// 监听端口 ELLM_MANAGER_PORT(doStart 统一预算 = 主服务端口+1)> argv --port > 29771
|
|
29
|
-
// web-ui 产物 ELLM_WEBUI_DIR(可选覆盖)> 按本文件位置推导:包模式 <pkgRoot>/dist/web-ui → 仓库模式 <repoRoot>/web-ui/dist
|
|
30
|
-
// 主服务参数 与 ellm-proxy start 注入的 env 同源:ELLM_CONFIG / ELLM_DATA_DIR / ELLM_APP_DIR / ELLM_PORT /
|
|
31
|
-
// ELLM_SERVER_JS(主服务 dist/server.js 路径,restart 重启主服务时使用,保证参数不漂移)
|
|
32
|
-
import { existsSync } from "node:fs";
|
|
33
|
-
import { readFile } from "node:fs/promises";
|
|
34
|
-
import { Hono } from "hono";
|
|
35
|
-
import { cors } from "hono/cors";
|
|
36
|
-
import { proxy } from "hono/proxy";
|
|
37
|
-
import { serve } from "@hono/node-server";
|
|
38
|
-
import { serveStatic } from "@hono/node-server/serve-static";
|
|
39
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
40
|
-
import { fileURLToPath } from "node:url";
|
|
41
|
-
import mri from "mri";
|
|
42
|
-
import { absolutize } from "../utils/paths.js";
|
|
43
|
-
import { resolveGatewayBase } from "../utils/service.js";
|
|
44
|
-
import { startUpdateWatchdog } from "../utils/updater.js";
|
|
45
|
-
import { ICONS, say } from "../utils/ui.js";
|
|
46
|
-
import { createServiceApp } from "./api/service.js";
|
|
47
|
-
import { createServiceSettingsApp } from "./api/service-settings.js";
|
|
48
|
-
import { createHarnessApp } from "./api/harness/index.js";
|
|
49
|
-
|
|
50
|
-
const DEFAULT_SERVER_PORT = 29771; // 手动运行且无 env 时的兜底(默认网关 29770 + 1)
|
|
51
|
-
// 本文件在 manager/ 下,包根为上一级
|
|
52
|
-
const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
53
|
-
|
|
54
|
-
// web-ui 构建产物目录:ELLM_WEBUI_DIR 优先;缺省按本文件位置推导(包模式 dist/web-ui 与 server.js 同级,
|
|
55
|
-
// 仓库模式落到 monorepo 的 web-ui/dist)。启动时判定一次,缺失则跳过静态托管(接口不受影响)。
|
|
56
|
-
const WEBUI_DIST_DIR = process.env.ELLM_WEBUI_DIR
|
|
57
|
-
? resolve(process.cwd(), process.env.ELLM_WEBUI_DIR)
|
|
58
|
-
: ([join(pkgRoot, "dist", "web-ui"), resolve(pkgRoot, "..", "web-ui", "dist")].find((p) =>
|
|
59
|
-
existsSync(join(p, "index.html")),
|
|
60
|
-
) ?? join(pkgRoot, "dist", "web-ui"));
|
|
61
|
-
const HAS_WEBUI = existsSync(join(WEBUI_DIST_DIR, "index.html"));
|
|
62
|
-
|
|
63
|
-
/** web-ui 管理界面静态托管:/ 出 index.html(hash 路由唯一服务端入口),assets/ 补长缓存头。
|
|
64
|
-
* 须在 api 聚合与 /api 反代之后注册——serve-static 未命中才放行,不会遮蔽接口。 */
|
|
65
|
-
function registerWebUi(app) {
|
|
66
|
-
if (!HAS_WEBUI) return;
|
|
67
|
-
// serve-static 的 root 只接受相对 cwd 的路径:把绝对定位换算成当前 cwd 表达(仓库/发布包/任意 cwd 均成立)
|
|
68
|
-
const root = relative(process.cwd(), WEBUI_DIST_DIR);
|
|
69
|
-
app.get("/", async (c) => {
|
|
70
|
-
try {
|
|
71
|
-
const buf = await readFile(join(WEBUI_DIST_DIR, "index.html"));
|
|
72
|
-
return c.body(new Uint8Array(buf), 200, {
|
|
73
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
74
|
-
"Cache-Control": "no-cache",
|
|
75
|
-
});
|
|
76
|
-
} catch {
|
|
77
|
-
return c.text("Not Found", 404);
|
|
78
|
-
}
|
|
79
|
-
});
|
|
80
|
-
app.use("/*", (c, next) => {
|
|
81
|
-
if (c.req.path === "/") return next(); // 根路径由上面的 serveIndex 处理
|
|
82
|
-
return serveStatic({
|
|
83
|
-
root,
|
|
84
|
-
onFound: (_p, ctx) => {
|
|
85
|
-
if (ctx.req.path.startsWith("/assets/")) ctx.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
86
|
-
},
|
|
87
|
-
})(c, next);
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** 创建管理服务 app:CORS 全放开 + api 聚合挂载 + /api 反代主服务 + web-ui 静态托管 + 统一 404/500 */
|
|
92
|
-
function createApp({ startOpts }) {
|
|
93
|
-
const app = new Hono();
|
|
94
|
-
|
|
95
|
-
// CORS 全放开(OPTIONS 预检由中间件自动处理)
|
|
96
|
-
app.use("*", cors());
|
|
97
|
-
|
|
98
|
-
// api 聚合:进程控制(GET /status · POST /start|stop|restart)、服务设置(autostart / update 域)
|
|
99
|
-
// 与 harness 开关(GET /harness · POST /harness/claude-code|codex|dsh)
|
|
100
|
-
app.route("/", createServiceApp({ startOpts }));
|
|
101
|
-
app.route("/", createServiceSettingsApp({ startOpts }));
|
|
102
|
-
app.route("/harness", createHarnessApp({ startOpts }));
|
|
103
|
-
|
|
104
|
-
// 反代主服务管理台 API(web-ui 同源访问的 /api/*):端口与 start 口径一致(ELLM_PORT env > 配置文件),
|
|
105
|
-
// 每请求解析——主服务端口顺延漂移无感知
|
|
106
|
-
app.all("/api/*", (c) => {
|
|
107
|
-
const { base } = resolveGatewayBase();
|
|
108
|
-
const port = Number(new URL(base).port);
|
|
109
|
-
if (!port) {
|
|
110
|
-
return c.json(
|
|
111
|
-
{ ok: false, error: "主服务端口无法解析(ELLM_PORT env 与配置文件 port 均缺失),无法转发 /api/*" },
|
|
112
|
-
502,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
// 目标 URL 必须带上原始查询串:c.req.path 只有 pathname,漏拼 search 会把
|
|
116
|
-
// ?id=... / ?page=... 等查询参数全部丢掉(主服务按缺参返回 400 或默认分页)
|
|
117
|
-
const incoming = new URL(c.req.raw.url);
|
|
118
|
-
return proxy(new URL(incoming.pathname + incoming.search, base), {
|
|
119
|
-
method: c.req.method,
|
|
120
|
-
headers: c.req.raw.headers,
|
|
121
|
-
body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : c.req.raw.body,
|
|
122
|
-
duplex: "half", // body 为流时 undici 必需
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
registerWebUi(app);
|
|
127
|
-
|
|
128
|
-
app.notFound((c) =>
|
|
129
|
-
c.json(
|
|
130
|
-
{
|
|
131
|
-
ok: false,
|
|
132
|
-
error: `未知接口: ${c.req.method} ${c.req.path}(可用: GET /(管理界面), GET /api/*(反代主服务), GET /status, GET /processes, POST /start|stop|restart, GET|POST /autostart, GET /update, POST /update/enabled|check|apply, GET /harness, POST /harness/claude-code|codex|dsh)`,
|
|
133
|
-
},
|
|
134
|
-
404,
|
|
135
|
-
),
|
|
136
|
-
);
|
|
137
|
-
app.onError((err, c) => {
|
|
138
|
-
console.error(`[ellm-proxy-manager] ${c.req.method} ${c.req.path} 失败:`, err?.message ?? err);
|
|
139
|
-
return c.json({ ok: false, error: err?.message ?? String(err) }, 500);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
return app;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function main() {
|
|
146
|
-
// argv 解析(mri):仅 --port(其余主服务参数一律走 env,与 start 注入链同源)
|
|
147
|
-
const argv = mri(process.argv.slice(2));
|
|
148
|
-
const listenPort = Number(argv.port) || Number(process.env.ELLM_MANAGER_PORT) || DEFAULT_SERVER_PORT;
|
|
149
|
-
// 主服务启动参数(start/restart 时透传给 doStart):ELLM_* env 与 CLI start 注入链同源
|
|
150
|
-
const startOpts = absolutize({
|
|
151
|
-
config: process.env.ELLM_CONFIG,
|
|
152
|
-
dataDir: process.env.ELLM_DATA_DIR,
|
|
153
|
-
workingDir: process.env.ELLM_APP_DIR,
|
|
154
|
-
serverJs: process.env.ELLM_SERVER_JS || join(pkgRoot, "dist", "server.js"),
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const app = createApp({ startOpts });
|
|
158
|
-
|
|
159
|
-
const server = serve({ fetch: app.fetch, port: listenPort, hostname: "127.0.0.1" });
|
|
160
|
-
server.on("error", (err) => {
|
|
161
|
-
console.error(
|
|
162
|
-
`错误:管理服务监听 ${listenPort} 失败(${err.code ?? err.message})${err.code === "EADDRINUSE" ? ",端口被占用" : ""}`,
|
|
163
|
-
);
|
|
164
|
-
process.exit(1);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
say(`ellm-proxy 管理服务已启动: http://127.0.0.1:${listenPort}`, ICONS.rocket);
|
|
168
|
-
say(
|
|
169
|
-
HAS_WEBUI
|
|
170
|
-
? ` 管理界面: http://127.0.0.1:${listenPort}/(web-ui,/api/* 反代主服务)`
|
|
171
|
-
: ` 管理界面未托管:web-ui 构建产物缺失(${WEBUI_DIST_DIR}),仓库根执行 pnpm build:web 后重启本服务`,
|
|
172
|
-
);
|
|
173
|
-
say(
|
|
174
|
-
" 接口: GET /status /processes · POST /start /stop /restart · GET|POST /autostart · GET /update · POST /update/enabled|check|apply · GET /harness · POST /harness/claude-code /harness/codex /harness/dsh",
|
|
175
|
-
);
|
|
176
|
-
say(" CORS 已全放开(仅监听 127.0.0.1,不对外)");
|
|
177
|
-
|
|
178
|
-
// 自动更新看门狗:源码运行时内部直接返回(不轮询);开关默认开启,每小时检查私有源新版本
|
|
179
|
-
startUpdateWatchdog(startOpts);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
main();
|
package/utils/autostart.js
DELETED
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
// 开机自启核心(跨平台,登录时触发,均无需管理员权限):状态探测 / 注册 / 移除 / 首启自愈。
|
|
2
|
-
// 被 CLI(bin/commands/autostart.js)与管理服务(manager/api/service-settings.js)复用。
|
|
3
|
-
// 本模块只做平台操作并返回数据,人读文案由调用方输出;错误以 throw 抛出(文案是契约的一部分)。
|
|
4
|
-
// 自启项内容三平台一致:登录时执行 <nodePath> <包内 pm2 bin> resurrect(恢复最后一次 pm2 dump):
|
|
5
|
-
// Windows: 启动文件夹 ellm-proxy-autostart.cmd(shell:startup——schtasks onlogon 需管理员,实测被拒)
|
|
6
|
-
// macOS: LaunchAgent ~/Library/LaunchAgents/com.ellm-proxy.autostart.plist(launchctl load)
|
|
7
|
-
// Linux: systemd user unit ~/.config/systemd/user/ellm-proxy.service(systemctl --user enable)
|
|
8
|
-
// opt-out 标记(<appDir>/autostart.opt-out):用户显式关闭过自启后,首启自愈不再自动重开
|
|
9
|
-
// (尊重用户选择);enableAutostart 成功时清除,disableAutostart 成功时写入。
|
|
10
|
-
import { writeFileSync, rmSync, existsSync, mkdirSync } from "node:fs";
|
|
11
|
-
import { homedir, platform } from "node:os";
|
|
12
|
-
import { spawn } from "node:child_process";
|
|
13
|
-
import { join } from "node:path";
|
|
14
|
-
import { defaultAppDir } from "./paths.js";
|
|
15
|
-
import { resolveInterpreter } from "./interpreter.js";
|
|
16
|
-
import { pm2BinJs } from "./pm2.js";
|
|
17
|
-
|
|
18
|
-
const AUTOSTART_CMD = "ellm-proxy-autostart.cmd"; // Windows
|
|
19
|
-
const PLIST_LABEL = "com.ellm-proxy.autostart"; // macOS
|
|
20
|
-
const LINUX_UNIT = "ellm-proxy.service"; // Linux
|
|
21
|
-
const OPT_OUT_FILE = "autostart.opt-out"; // appDir 下的 opt-out 标记文件名
|
|
22
|
-
|
|
23
|
-
/** opt-out 标记路径(用户显式关闭过自启的持久标记,首启自愈据此跳过) */
|
|
24
|
-
function optOutPath() {
|
|
25
|
-
return join(defaultAppDir, OPT_OUT_FILE);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 用户启动文件夹(Windows shell:startup):登录时自动执行其中的脚本 */
|
|
29
|
-
function windowsStartupDir() {
|
|
30
|
-
const dir = process.env["APPDATA"];
|
|
31
|
-
if (!dir) throw new Error("无法定位启动文件夹(APPDATA 环境变量未设置)。");
|
|
32
|
-
return join(dir, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** spawn 执行外部命令,返回退出码;进程无法启动(ENOENT 等)返回 127 */
|
|
36
|
-
function runExit(cmd, args) {
|
|
37
|
-
return new Promise((resolvePromise) => {
|
|
38
|
-
const child = spawn(cmd, args, { stdio: "ignore", shell: false });
|
|
39
|
-
child.on("error", () => resolvePromise(127));
|
|
40
|
-
child.on("exit", (code) => resolvePromise(code ?? 1));
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 各平台自启项路径(探测用;未知平台为 null) */
|
|
45
|
-
function platformItemPath() {
|
|
46
|
-
switch (platform()) {
|
|
47
|
-
case "win32":
|
|
48
|
-
return join(windowsStartupDir(), AUTOSTART_CMD);
|
|
49
|
-
case "darwin":
|
|
50
|
-
return join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
51
|
-
case "linux":
|
|
52
|
-
return join(homedir(), ".config", "systemd", "user", LINUX_UNIT);
|
|
53
|
-
default:
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// —— 注册(各平台) ————————————————————————————————————————————————
|
|
59
|
-
|
|
60
|
-
async function windowsOn(nodePath, pm2Js) {
|
|
61
|
-
// cmd 内容全 ASCII(含中文的注释交给外部帮助输出,避免 cmd 在 GBK 代码页下乱码)
|
|
62
|
-
const body = [
|
|
63
|
-
"@echo off",
|
|
64
|
-
`rem Auto-generated by "ellm-proxy autostart on". Disable with "ellm-proxy autostart off".`,
|
|
65
|
-
`"${nodePath}" "${pm2Js}" resurrect >nul 2>&1`,
|
|
66
|
-
"",
|
|
67
|
-
].join("\r\n");
|
|
68
|
-
const itemPath = join(windowsStartupDir(), AUTOSTART_CMD);
|
|
69
|
-
writeFileSync(itemPath, body, "utf-8");
|
|
70
|
-
return itemPath;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function darwinOn(nodePath, pm2Js) {
|
|
74
|
-
const itemPath = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
75
|
-
const logPath = join(defaultAppDir, "logs", "autostart.log");
|
|
76
|
-
mkdirSync(join(defaultAppDir, "logs"), { recursive: true });
|
|
77
|
-
const body = [
|
|
78
|
-
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
79
|
-
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
80
|
-
'<plist version="1.0">',
|
|
81
|
-
"<dict>",
|
|
82
|
-
` <key>Label</key><string>${PLIST_LABEL}</string>`,
|
|
83
|
-
" <key>ProgramArguments</key>",
|
|
84
|
-
" <array>",
|
|
85
|
-
` <string>${nodePath}</string>`,
|
|
86
|
-
` <string>${pm2Js}</string>`,
|
|
87
|
-
" <string>resurrect</string>",
|
|
88
|
-
" </array>",
|
|
89
|
-
" <key>RunAtLoad</key><true/>",
|
|
90
|
-
` <key>StandardOutPath</key><string>${logPath}</string>`,
|
|
91
|
-
` <key>StandardErrorPath</key><string>${logPath}</string>`,
|
|
92
|
-
"</dict>",
|
|
93
|
-
"</plist>",
|
|
94
|
-
"",
|
|
95
|
-
].join("\n");
|
|
96
|
-
writeFileSync(itemPath, body, "utf-8");
|
|
97
|
-
// 先卸载旧定义再加载(幂等;未加载过时 unload 报错属正常)
|
|
98
|
-
await runExit("launchctl", ["unload", itemPath]);
|
|
99
|
-
const code = await runExit("launchctl", ["load", itemPath]);
|
|
100
|
-
if (code !== 0) {
|
|
101
|
-
throw new Error(`launchctl load 失败(退出码 ${code})。请手动执行: launchctl load "${itemPath}"`);
|
|
102
|
-
}
|
|
103
|
-
return itemPath;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function linuxOn(nodePath, pm2Js) {
|
|
107
|
-
const itemPath = join(homedir(), ".config", "systemd", "user", LINUX_UNIT);
|
|
108
|
-
mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
|
|
109
|
-
const body = [
|
|
110
|
-
"[Unit]",
|
|
111
|
-
"Description=ellm-proxy autostart (pm2 resurrect)",
|
|
112
|
-
"After=default.target",
|
|
113
|
-
"",
|
|
114
|
-
"[Service]",
|
|
115
|
-
"Type=oneshot",
|
|
116
|
-
`ExecStart="${nodePath}" "${pm2Js}" resurrect`,
|
|
117
|
-
"",
|
|
118
|
-
"[Install]",
|
|
119
|
-
"WantedBy=default.target",
|
|
120
|
-
"",
|
|
121
|
-
].join("\n");
|
|
122
|
-
writeFileSync(itemPath, body, "utf-8");
|
|
123
|
-
const reload = await runExit("systemctl", ["--user", "daemon-reload"]);
|
|
124
|
-
if (reload !== 0) {
|
|
125
|
-
throw new Error(
|
|
126
|
-
`systemctl --user daemon-reload 失败(退出码 ${reload})。当前环境可能无 systemd(WSL/容器),请手动启用或删除: ${itemPath}`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
const enable = await runExit("systemctl", ["--user", "enable", LINUX_UNIT]);
|
|
130
|
-
if (enable !== 0) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`systemctl --user enable 失败(退出码 ${enable})。请手动执行: systemctl --user enable ${LINUX_UNIT}`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
return itemPath;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// —— 移除(各平台) ————————————————————————————————————————————————
|
|
139
|
-
|
|
140
|
-
async function windowsOff() {
|
|
141
|
-
const itemPath = join(windowsStartupDir(), AUTOSTART_CMD);
|
|
142
|
-
if (!existsSync(itemPath)) return false;
|
|
143
|
-
rmSync(itemPath, { force: true });
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function darwinOff() {
|
|
148
|
-
const itemPath = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
|
|
149
|
-
if (!existsSync(itemPath)) return false;
|
|
150
|
-
await runExit("launchctl", ["unload", itemPath]);
|
|
151
|
-
rmSync(itemPath, { force: true });
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function linuxOff() {
|
|
156
|
-
const itemPath = join(homedir(), ".config", "systemd", "user", LINUX_UNIT);
|
|
157
|
-
if (!existsSync(itemPath)) return false;
|
|
158
|
-
await runExit("systemctl", ["--user", "disable", LINUX_UNIT]); // 容错:未启用过时报错不影响删除
|
|
159
|
-
rmSync(itemPath, { force: true });
|
|
160
|
-
await runExit("systemctl", ["--user", "daemon-reload"]);
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// —— 对外 API ——————————————————————————————————————————————————————
|
|
165
|
-
|
|
166
|
-
/** 自启状态:enabled = 平台自启项是否存在;itemPath 未知平台为 null */
|
|
167
|
-
export async function autostartStatus() {
|
|
168
|
-
const itemPath = platformItemPath();
|
|
169
|
-
return {
|
|
170
|
-
platform: platform(),
|
|
171
|
-
enabled: itemPath ? existsSync(itemPath) : false,
|
|
172
|
-
itemPath,
|
|
173
|
-
/** 用户是否显式关闭过(首启自愈据此跳过) */
|
|
174
|
-
optOut: existsSync(optOutPath()),
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** 注册自启项(幂等:重复注册覆盖旧文件;macOS 先 unload 再 load)。成功返回 { itemPath } */
|
|
179
|
-
export async function enableAutostart() {
|
|
180
|
-
const pf = platform();
|
|
181
|
-
if (pf !== "win32" && pf !== "darwin" && pf !== "linux") {
|
|
182
|
-
throw new Error(`暂不支持的平台: ${pf}。可改用 pm2 自带的 pm2 startup。`);
|
|
183
|
-
}
|
|
184
|
-
const nodePath = await resolveInterpreter();
|
|
185
|
-
const pm2Js = pm2BinJs();
|
|
186
|
-
const itemPath = await (pf === "win32" ? windowsOn : pf === "darwin" ? darwinOn : linuxOn)(nodePath, pm2Js);
|
|
187
|
-
rmSync(optOutPath(), { force: true }); // 显式开启 = 清除 opt-out,首启自愈不再被视为已关闭
|
|
188
|
-
return { itemPath };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** 移除自启项。返回 removed(此前是否存在);成功后写 opt-out 标记(首启自愈不再自动重开) */
|
|
192
|
-
export async function disableAutostart() {
|
|
193
|
-
const pf = platform();
|
|
194
|
-
if (pf !== "win32" && pf !== "darwin" && pf !== "linux") {
|
|
195
|
-
throw new Error(`暂不支持的平台: ${pf}。可改用 pm2 自带的 pm2 startup。`);
|
|
196
|
-
}
|
|
197
|
-
const removed = await (pf === "win32" ? windowsOff : pf === "darwin" ? darwinOff : linuxOff)();
|
|
198
|
-
mkdirSync(defaultAppDir, { recursive: true });
|
|
199
|
-
writeFileSync(optOutPath(), new Date().toISOString(), "utf-8");
|
|
200
|
-
return { removed };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** 首启自愈(doStart 末尾静默调用):自启项已存在或用户 opt-out 过则跳过,否则注册并提示一句。
|
|
204
|
-
* 失败不抛出(不阻塞启动,console.warn 提示手动路径)。 */
|
|
205
|
-
export async function ensureAutostartOnStart() {
|
|
206
|
-
try {
|
|
207
|
-
const status = await autostartStatus();
|
|
208
|
-
if (!status.itemPath || status.enabled) return; // 未知平台 / 已注册
|
|
209
|
-
if (status.optOut) {
|
|
210
|
-
console.log("已按此前设置保持开机自启关闭(如需开启:ellm-proxy autostart on 或管理台设置页)。");
|
|
211
|
-
return;
|
|
212
|
-
}
|
|
213
|
-
const { itemPath } = await enableAutostart();
|
|
214
|
-
console.log(`已自动注册开机自启(登录时 pm2 resurrect): ${itemPath}`);
|
|
215
|
-
console.log("禁用方式: ellm-proxy autostart off 或管理台设置页。");
|
|
216
|
-
} catch (err) {
|
|
217
|
-
console.warn(`开机自启注册失败(不影响本次启动): ${err?.message ?? err}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
package/utils/interpreter.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// 服务解释器解析:保证 dist/server.js(node:sqlite)运行在 Node >= MIN_NODE 上。
|
|
2
|
-
// CLI 自身 node 满足 → process.execPath;否则 get-node 解析(缺失时下载一次并缓存),
|
|
3
|
-
// pm2 daemon 的 node 版本无关紧要(interpreter 由本模块保证)。
|
|
4
|
-
import getNode from "get-node";
|
|
5
|
-
import { MIN_NODE, FALLBACK_NODE } from "./paths.js";
|
|
6
|
-
|
|
7
|
-
export async function resolveInterpreter() {
|
|
8
|
-
const [maj, min] = process.version.replace(/^v/, "").split(".").map(Number);
|
|
9
|
-
if (maj > 22 || (maj === 22 && min >= 13)) return process.execPath;
|
|
10
|
-
console.log(`当前 node ${process.version} 不满足 ${MIN_NODE},正在解析可用版本(缺失时自动下载一次并缓存)...`);
|
|
11
|
-
const { path, version } = await getNode(FALLBACK_NODE, { progress: true });
|
|
12
|
-
console.log(`服务将使用 node ${version}: ${path}`);
|
|
13
|
-
return path;
|
|
14
|
-
}
|
package/utils/paths.js
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// 共享常量与路径工具:包根/应用目录常量、配置查找链、首次配置自愈、选项绝对化。
|
|
2
|
-
// 供 CLI(bin/)与管理服务(manager/)两侧复用;工具一律返回数据,人读文案由调用方输出。
|
|
3
|
-
import { readFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { dirname, join, resolve } from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
|
|
8
|
-
/** 本文件在 utils/ 下,包根为上一级 */
|
|
9
|
-
export const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
-
export const defaultAppDir = join(homedir(), ".ellm-proxy");
|
|
11
|
-
export const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf-8"));
|
|
12
|
-
export const APP_NAME = "ellm-proxy"; // 主服务(dist/server.js)的 pm2 进程名
|
|
13
|
-
export const MANAGER_APP_NAME = "ellm-proxy-manager"; // 管理服务(manager/index.js)的 pm2 进程名
|
|
14
|
-
export const MIN_NODE = ">=22.13.0"; // node:sqlite 最低版(与包 engines 一致)
|
|
15
|
-
export const FALLBACK_NODE = "22"; // 兜底解释器锁定的主版本(满足 MIN_NODE,与 nve 22 口径一致)
|
|
16
|
-
|
|
17
|
-
/** 配置文件路径:--config > ELLM_CONFIG 环境变量 > ~/.ellm-proxy/ellm.config.json(与 server 端查找链一致) */
|
|
18
|
-
export function configPath(override) {
|
|
19
|
-
if (override) return resolve(override);
|
|
20
|
-
return process.env["ELLM_CONFIG"] ? resolve(process.env["ELLM_CONFIG"]) : join(defaultAppDir, "ellm.config.json");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** 首次配置自愈:不存在则从包内示例复制创建,并提示两种修改方式 */
|
|
24
|
-
export function ensureConfig(override) {
|
|
25
|
-
const config = configPath(override);
|
|
26
|
-
if (existsSync(config)) return config;
|
|
27
|
-
const example = join(pkgRoot, "ellm.config.example.json");
|
|
28
|
-
if (!existsSync(example)) {
|
|
29
|
-
throw new Error(`缺少示例配置 ${example},无法自动创建配置文件`);
|
|
30
|
-
}
|
|
31
|
-
mkdirSync(dirname(config), { recursive: true });
|
|
32
|
-
copyFileSync(example, config);
|
|
33
|
-
console.log(`已创建配置文件: ${config}`);
|
|
34
|
-
console.log("可直接编辑该文件(填入供应商 apiKey),或经管理台在线修改(实时热更新)。");
|
|
35
|
-
return config;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** 路径类选项统一转绝对路径(相对按调用方当前目录展开;CLI cwd 与 node 进程工作目录基准不同,透传相对串会错位) */
|
|
39
|
-
export function absolutize(opts) {
|
|
40
|
-
const out = { ...opts };
|
|
41
|
-
for (const key of ["config", "dataDir", "workingDir", "serverJs"]) {
|
|
42
|
-
if (out[key]) out[key] = resolve(out[key]);
|
|
43
|
-
}
|
|
44
|
-
return out;
|
|
45
|
-
}
|
package/utils/pm2.js
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
// pm2 JS API 封装:连接包裹/回调转 promise/应用查询/探活轮询/安全 dump/包内 pm2 CLI 定位/env 白名单。
|
|
2
|
-
import { rmSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import { createRequire } from "node:module";
|
|
6
|
-
import pm2 from "pm2";
|
|
7
|
-
import { APP_NAME, MANAGER_APP_NAME } from "./paths.js";
|
|
8
|
-
import { dot, endOk, endFail } from "./ui.js";
|
|
9
|
-
|
|
10
|
-
/** pm2.connect 包裹:fn 内可调 pm2.xxx(回调式),结束后自动 disconnect */
|
|
11
|
-
export function withPm2(fn) {
|
|
12
|
-
return new Promise((resolvePromise, reject) => {
|
|
13
|
-
pm2.connect((connectErr) => {
|
|
14
|
-
if (connectErr) return reject(connectErr);
|
|
15
|
-
Promise.resolve(fn(pm2)).then(
|
|
16
|
-
(value) => pm2.disconnect(() => resolvePromise(value)),
|
|
17
|
-
(err) => {
|
|
18
|
-
try {
|
|
19
|
-
pm2.disconnect();
|
|
20
|
-
} catch {
|
|
21
|
-
/* 已断开 */
|
|
22
|
-
}
|
|
23
|
-
reject(err);
|
|
24
|
-
},
|
|
25
|
-
);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** pm2 回调式方法转 promise */
|
|
31
|
-
export function pm2cb(method, ...args) {
|
|
32
|
-
return new Promise((resolvePromise, reject) => {
|
|
33
|
-
pm2[method](...args, (err, ...out) => (err ? reject(err) : resolvePromise(...out)));
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** 查询应用当前进程记录(list 中按 name 匹配;不存在返回 undefined)。
|
|
38
|
-
* name 缺省 = 主服务 ellm-proxy;管理服务传 MANAGER_APP_NAME */
|
|
39
|
-
export async function findApp(name = APP_NAME) {
|
|
40
|
-
const list = (await pm2cb("list")) || [];
|
|
41
|
-
return list.find((p) => p.name === name);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 启动探活:轮询 status 至 online(每 pollMs 一次,全程打进度点)——比固定等待更快成功
|
|
45
|
-
* (online 即返回),也能容忍慢启动(默认 15s 上限,固定 1.5s 在慢机器上会误报);errored/stopped
|
|
46
|
-
* 提前失败(脚本秒崩不必等满超时)。failHint 是失败文案的线索部分(文案是契约的一部分)。
|
|
47
|
-
* quiet = 静默轮询不打点(多服务并行等待时输出由调用方合并,避免多组进度点交错)。 */
|
|
48
|
-
export async function waitOnline(name, failHint, { timeoutMs = 15000, pollMs = 300, quiet = false } = {}) {
|
|
49
|
-
const deadline = Date.now() + timeoutMs;
|
|
50
|
-
let last = null;
|
|
51
|
-
for (;;) {
|
|
52
|
-
last = await findApp(name);
|
|
53
|
-
const status = last?.pm2_env?.status;
|
|
54
|
-
if (status === "online") {
|
|
55
|
-
if (!quiet) endOk();
|
|
56
|
-
return last;
|
|
57
|
-
}
|
|
58
|
-
if (status === "errored" || status === "stopped" || Date.now() >= deadline) {
|
|
59
|
-
if (!quiet) endFail();
|
|
60
|
-
throw new Error(`${name} 启动后状态异常(${status ?? "已退出"})。${failHint}`);
|
|
61
|
-
}
|
|
62
|
-
if (!quiet) dot();
|
|
63
|
-
await new Promise((r) => setTimeout(r, pollMs));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** 安全 dump(start/stop 成功后调用):pm2 不允许 dump 空列表会报错——此时说明全部进程已删,
|
|
68
|
-
* 清掉旧 dump.pm2 与滚动备份 dump.pm2.bak(resurrect 无 dump.pm2 时会回落 .bak),防止开机
|
|
69
|
-
* resurrect 把已停止的服务拉起来 */
|
|
70
|
-
export async function safeDump() {
|
|
71
|
-
try {
|
|
72
|
-
await pm2cb("dump");
|
|
73
|
-
} catch {
|
|
74
|
-
try {
|
|
75
|
-
rmSync(join(homedir(), ".pm2", "dump.pm2"), { force: true });
|
|
76
|
-
rmSync(join(homedir(), ".pm2", "dump.pm2.bak"), { force: true });
|
|
77
|
-
} catch {
|
|
78
|
-
/* 清理失败不影响 stop 语义 */
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** 当前包内 pm2 CLI 入口绝对路径(autostart cmd 用;resolve 兼容 npm 全局平铺与 pnpm 布局) */
|
|
84
|
-
export function pm2BinJs() {
|
|
85
|
-
const pm2PkgRoot = dirname(createRequire(import.meta.url).resolve("pm2/package.json"));
|
|
86
|
-
return join(pm2PkgRoot, "bin", "pm2");
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** 清洗 pm2 子进程透传的 env:pm2 托管的进程自身 env 带 pm_id/pm2_env/unique_id 等
|
|
90
|
-
* 内部变量与 bash 注入的 `_`,原样传回 pm2.start 会污染新进程注册——实测主服务
|
|
91
|
-
* (name=ellm-proxy)被注册成管理服务的名字,导致探活误判「已退出」。
|
|
92
|
-
* 黑名单清洗实测仍有漏网(多键组合触发),故改用白名单:
|
|
93
|
-
* 只透传主服务必需的业务键(ELLM_* 前缀,含 ELLM_PORT / ELLM_MANAGER_PORT)与 Windows node 运行所需的系统键。
|
|
94
|
-
* PM2_HOME 保留(pm2 API 连接 daemon 需要,主服务自身不用)。 */
|
|
95
|
-
const PM2_ENV_WHITELIST = new Set([
|
|
96
|
-
"PATH",
|
|
97
|
-
"SYSTEMROOT",
|
|
98
|
-
"WINDIR",
|
|
99
|
-
"SYSTEMDRIVE",
|
|
100
|
-
"COMSPEC",
|
|
101
|
-
"PATHEXT",
|
|
102
|
-
"OS",
|
|
103
|
-
"TEMP",
|
|
104
|
-
"TMP",
|
|
105
|
-
"USERPROFILE",
|
|
106
|
-
"HOMEDRIVE",
|
|
107
|
-
"HOMEPATH",
|
|
108
|
-
"APPDATA",
|
|
109
|
-
"LOCALAPPDATA",
|
|
110
|
-
"PROGRAMDATA",
|
|
111
|
-
"PROGRAMFILES",
|
|
112
|
-
"ALLUSERSPROFILE",
|
|
113
|
-
"PUBLIC",
|
|
114
|
-
"USERNAME",
|
|
115
|
-
"USERDOMAIN",
|
|
116
|
-
"COMPUTERNAME",
|
|
117
|
-
"NUMBER_OF_PROCESSORS",
|
|
118
|
-
"HTTP_PROXY",
|
|
119
|
-
"HTTPS_PROXY",
|
|
120
|
-
"NO_PROXY",
|
|
121
|
-
]);
|
|
122
|
-
|
|
123
|
-
export function sanitizePm2Env(env) {
|
|
124
|
-
const out = {};
|
|
125
|
-
for (const [key, value] of Object.entries(env)) {
|
|
126
|
-
if (key === "PM2_HOME" || key.startsWith("ELLM_")) out[key] = value;
|
|
127
|
-
else if (PM2_ENV_WHITELIST.has(key.toUpperCase())) out[key] = value;
|
|
128
|
-
}
|
|
129
|
-
return out;
|
|
130
|
-
}
|
package/utils/port.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
// 端口可用性探测:试绑 listen(全部成功=空闲,任一 EADDRINUSE 等错误=占用),供 service.js 启动前预算端口。
|
|
2
|
-
// Windows 的 wildcard/specific 共存语义(bind 0.0.0.0 与 bind 127.0.0.1 可同端口并存,实测)会让单维度
|
|
3
|
-
// 探测漏报"bind 成功但 IPv4 流量被占用者抢走"的假活,故主服务探测 0.0.0.0 + 127.0.0.1 双维度;
|
|
4
|
-
// IPv6-only 占用极罕见(漏报由 pm2 探活失败兜底)。管理服务固定监听 127.0.0.1 → 探测 ["127.0.0.1"]。
|
|
5
|
-
// 探测与实际 bind 之间存在毫秒级竞态窗口(TOCTOU),极端时被抢占 → 仍由 pm2 探活失败兜底。
|
|
6
|
-
import { createServer } from "node:net";
|
|
7
|
-
|
|
8
|
-
/** 单 host 试绑:成功=空闲(等 close 回调 socket 释放后才返回 true,避免误占探测端口) */
|
|
9
|
-
function isHostFree(port, host) {
|
|
10
|
-
return new Promise((resolve) => {
|
|
11
|
-
const srv = createServer();
|
|
12
|
-
srv.once("error", () => resolve(false)); // EADDRINUSE / EACCES 等一律视为不可用
|
|
13
|
-
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
14
|
-
srv.listen(port, host);
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** 端口是否空闲:逐个 host 试绑,全部成功才空闲(hosts 传数组多维探测,传字符串退化为单 host) */
|
|
19
|
-
export function isPortFree(port, hosts = ["0.0.0.0", "127.0.0.1"]) {
|
|
20
|
-
const list = Array.isArray(hosts) ? hosts : [hosts];
|
|
21
|
-
return (async () => {
|
|
22
|
-
for (const host of list) {
|
|
23
|
-
if (!(await isHostFree(port, host))) return false;
|
|
24
|
-
}
|
|
25
|
-
return true;
|
|
26
|
-
})();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** 从 start 起向上找第一个空闲端口(含 start;maxTry 个内全占返回 0,由调用方决定兜底行为) */
|
|
30
|
-
export async function findFreePort(start, hosts = ["0.0.0.0", "127.0.0.1"], maxTry = 100) {
|
|
31
|
-
for (let p = start; p < start + maxTry; p++) {
|
|
32
|
-
if (await isPortFree(p, hosts)) return p;
|
|
33
|
-
}
|
|
34
|
-
return 0;
|
|
35
|
-
}
|