/** * server.ts — local HTTP server for models-config. * * Listens on 127.0.0.1 only. Picks a free port dynamically (starting from 17890). * Generates a random token per server start; all /api/* routes require X-Config-Token. * Serves the single-page frontend from ./public/index.html. */ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; import { createConnection } from "node:net"; import { readFileSync, existsSync, writeFileSync, mkdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { randomBytes } from "node:crypto"; import { loadConfig, saveConfig, validateConfig, getMeta, fetchAvailableModels, type ModelsConfig, type FetchParams, } from "./config-core.js"; import { loadRrConfig, saveRrConfig, validateRrConfig, listPresets as rrListPresets, readPreset as rrReadPreset, savePreset as rrSavePreset, activatePreset as rrActivatePreset, deletePreset as rrDeletePreset, listModelOptions as rrListModelOptions, getRoundrobinDir as rrGetRoundrobinDir, type RrConfig, } from "./rr-config.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = join(__dirname, "..", "public"); export interface ServerHandle { port: number; token: string; url: string; close(): Promise; } /** Find a free TCP port starting from `start`. Returns first free port. */ async function findFreePort(start: number): Promise { const { createServer: netServer } = await import("node:net"); return new Promise((resolve, reject) => { let port = start; const tryPort = () => { const srv = netServer(); srv.unref(); srv.once("error", (e: NodeJS.ErrnoException) => { if (e.code === "EADDRINUSE") { port += 1; if (port > 65535) { reject(new Error("no free port found")); return; } srv.close(() => tryPort()); } else { reject(e); } }); srv.listen(port, "127.0.0.1", () => { srv.close(() => resolve(port)); }); }; tryPort(); }); } /** 固定端口:所有 pi CLI 共用同一个面板 server,避免每开一个终端就递增端口。 */ export const PANEL_PORT = 17890; /** * 固定 token:存到 ~/.pi/agent/roundrobin/.panel-token,生成一次后不变。 * 这样多 CLI 共用同一端口+token,第二个 CLI 检测到端口已占用即可直接打开已有面板。 * 本地 127.0.0.1 only;同机其他进程理论上可访问,用户已接受此取舍。 */ function panelTokenPath(): string { return join(rrGetRoundrobinDir(), ".panel-token"); } export function getOrCreatePanelToken(): string { const p = panelTokenPath(); try { if (existsSync(p)) { const t = readFileSync(p, "utf-8").trim(); if (t) return t; } } catch { // ignore read error, regenerate } const t = randomBytes(24).toString("hex"); try { mkdirSync(rrGetRoundrobinDir(), { recursive: true }); writeFileSync(p, t + "\n", "utf-8"); } catch { // ignore write error } return t; } /** 检测端口是否已有 server 在监听(用于多 CLI 复用判断)。 */ export function isPortUp(port: number): Promise { return new Promise((resolve) => { const sock = createConnection(port, "127.0.0.1", () => { sock.destroy(); resolve(true); }); sock.once("error", () => resolve(false)); // 防止长时间挂起 setTimeout(() => { sock.destroy(); resolve(false); }, 500); }); } // --- helpers --- function send(res: ServerResponse, status: number, body: unknown, headers: Record = {}) { const json = JSON.stringify(body); res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers }); res.end(json); } function ok(res: ServerResponse, data: unknown) { send(res, 200, { ok: true, data }); } function fail(res: ServerResponse, status: number, message: string, code = "ERROR") { send(res, status, { ok: false, error: { message, code } }); } function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let size = 0; const MAX = 16 * 1024 * 1024; // 16MB guard req.on("data", (c: Buffer) => { size += c.length; if (size > MAX) { reject(new Error("body too large")); req.destroy(); return; } chunks.push(c); }); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); req.on("error", reject); }); } function checkToken(req: IncomingMessage, token: string): boolean { const h = req.headers["x-config-token"]; return typeof h === "string" && h === token; } // --- route handlers --- async function routeGetConfig(res: ServerResponse) { try { const config = loadConfig(); ok(res, config); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "LOAD_FAILED"); } } async function routePutConfig(res: ServerResponse, body: string) { let parsed: ModelsConfig; try { parsed = JSON.parse(body) as ModelsConfig; } catch (e) { fail(res, 400, `JSON 解析失败: ${e instanceof Error ? e.message : e}`, "BAD_JSON"); return; } try { validateConfig(parsed); } catch (e) { fail(res, 400, e instanceof Error ? e.message : String(e), "VALIDATION"); return; } try { const backup = saveConfig(parsed); const bytes = JSON.stringify(parsed, null, 2).length; ok(res, { backup, bytes }); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "SAVE_FAILED"); } } function routeMeta(res: ServerResponse) { ok(res, getMeta()); } async function routeFetchModels(res: ServerResponse, body: string) { let params: FetchParams; try { params = JSON.parse(body) as FetchParams; } catch (e) { fail(res, 400, `JSON 解析失败: ${e instanceof Error ? e.message : e}`, "BAD_JSON"); return; } if (!params.baseUrl) { fail(res, 400, "缺少 baseUrl", "MISSING_BASEURL"); return; } try { const result = await fetchAvailableModels(params); ok(res, result); } catch (e) { fail(res, 502, e instanceof Error ? e.message : String(e), "FETCH_FAILED"); } } // ===== roundrobin route handlers ===== function routeRrGetConfig(res: ServerResponse) { try { ok(res, loadRrConfig()); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "LOAD_FAILED"); } } async function routeRrPutConfig(res: ServerResponse, body: string, onRrReload?: () => void) { let parsed: RrConfig; try { parsed = JSON.parse(body) as RrConfig; } catch (e) { fail(res, 400, `JSON 解析失败: ${e instanceof Error ? e.message : e}`, "BAD_JSON"); return; } try { validateRrConfig(parsed); } catch (e) { fail(res, 400, e instanceof Error ? e.message : String(e), "VALIDATION"); return; } try { const backup = saveRrConfig(parsed); ok(res, { backup }); onRrReload?.(); // 触发 model-roundrobin 热重载 } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "SAVE_FAILED"); } } function routeRrListPresets(res: ServerResponse) { try { const names = rrListPresets(); const presets = names.map((n) => { try { return { name: n, config: rrReadPreset(n) }; } catch { return { name: n, config: null }; } }); ok(res, presets); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "LOAD_FAILED"); } } async function routeRrSavePreset(res: ServerResponse, body: string, onRrReload?: () => void) { let parsed: { name: string; config: RrConfig }; try { parsed = JSON.parse(body) as { name: string; config: RrConfig }; } catch (e) { fail(res, 400, `JSON 解析失败: ${e instanceof Error ? e.message : e}`, "BAD_JSON"); return; } try { rrSavePreset(parsed.name, parsed.config); ok(res, { name: parsed.name }); onRrReload?.(); } catch (e) { fail(res, 400, e instanceof Error ? e.message : String(e), "SAVE_FAILED"); } } function routeRrActivatePreset(res: ServerResponse, name: string, onRrReload?: () => void) { try { rrActivatePreset(name); ok(res, { name }); onRrReload?.(); // 触发 model-roundrobin 热重载 } catch (e) { fail(res, 400, e instanceof Error ? e.message : String(e), "ACTIVATE_FAILED"); } } function routeRrDeletePreset(res: ServerResponse, name: string, onRrReload?: () => void) { try { rrDeletePreset(name); ok(res, { name }); onRrReload?.(); } catch (e) { fail(res, 400, e instanceof Error ? e.message : String(e), "DELETE_FAILED"); } } function routeRrModels(res: ServerResponse) { try { ok(res, rrListModelOptions()); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "LOAD_FAILED"); } } function routeRrHealth(res: ServerResponse) { const fn = (globalThis as unknown as Record).__roundrobinGetHealth; if (typeof fn !== "function") { ok(res, { enabled: false, candidates: [], note: "roundrobin 扩展未运行或未暴露健康数据" }); return; } try { ok(res, (fn as () => unknown)()); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "HEALTH_FAILED"); } } // Provider 全量健康统计 (所有 provider/model 的成功率 + TTFT + 总延迟) function routeHealth(res: ServerResponse) { const fn = (globalThis as unknown as Record).__providerHealthGet; if (typeof fn !== "function") { ok(res, { providers: {}, note: "provider 健康统计未运行" }); return; } try { ok(res, (fn as () => unknown)()); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "HEALTH_FAILED"); } } function routeHealthReset(res: ServerResponse) { const fn = (globalThis as unknown as Record).__providerHealthReset; if (typeof fn !== "function") { fail(res, 500, "provider 健康统计未运行", "HEALTH_FAILED"); return; } try { (fn as () => void)(); ok(res, {}); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "HEALTH_FAILED"); } } // 模型可用性测试: 发一个极小真对话请求验证模型可用 async function routeTestModel(res: ServerResponse, body: string) { let params: { provider?: string; model?: string; prompt?: string }; try { params = JSON.parse(body) as { provider?: string; model?: string; prompt?: string }; } catch (e) { fail(res, 400, `JSON 解析失败: ${e instanceof Error ? e.message : e}`, "BAD_JSON"); return; } if (!params.provider || !params.model) { fail(res, 400, "缺少 provider 或 model", "MISSING_PARAMS"); return; } const fn = (globalThis as unknown as Record).__testModel; if (typeof fn !== "function") { fail(res, 500, "模型测试未运行(需 roundrobin 扩展)", "TEST_UNAVAILABLE"); return; } try { const result = await (fn as (p: string, m: string, prompt: string) => Promise)(params.provider, params.model, params.prompt || "请用一句话介绍你自己"); ok(res, result); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "TEST_FAILED"); } } // 主动测速排序: 绕过节流立即重测指定组(或全部 enabled+speedTest.enabled 组) async function routeRrManualSpeedTest(res: ServerResponse, body: string) { let params: { name?: string }; try { params = body ? (JSON.parse(body) as { name?: string }) : {}; } catch (e) { fail(res, 400, `JSON 解析失败: ${e instanceof Error ? e.message : e}`, "BAD_JSON"); return; } const fn = (globalThis as unknown as Record).__rrManualSpeedTest; if (typeof fn !== "function") { fail(res, 500, "roundrobin 扩展未运行", "EXT_UNAVAILABLE"); return; } try { const results = await (fn as (groupName?: string) => Promise)(params.name); ok(res, results); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "SPEEDTEST_FAILED"); } } function serveStatic(res: ServerResponse, path: string, contentType: string) { const full = join(PUBLIC_DIR, path); if (!existsSync(full)) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("not found"); return; } res.writeHead(200, { "Content-Type": contentType }); res.end(readFileSync(full)); } // --- main server --- export async function startServer(onShutdown?: () => void, onRrReload?: () => void): Promise { const port = PANEL_PORT; const token = getOrCreatePanelToken(); // 空闲超时: 5 分钟无请求自动关闭 server, 释放端口(不用不占资源)。 // 面板前端每 3s 轮询, 开着就不会超时; 关掉浏览器 5 分钟后自动停。 const IDLE_TIMEOUT_MS = 5 * 60 * 1000; let idleTimer: ReturnType | null = null; const resetIdle = () => { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { try { (srv as Server & { closeAllConnections?: () => void }).closeAllConnections?.(); } catch {} srv.close(); }, IDLE_TIMEOUT_MS); idleTimer.unref?.(); }; const srv: Server = createServer(async (req, res) => { resetIdle(); const url = req.url ?? "/"; const path = url.split("?")[0]; // CORS — local only, permissive for localhost res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Config-Token"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } try { // public routes if (path === "/" || path === "/index.html") { serveStatic(res, "index.html", "text/html; charset=utf-8"); return; } if (path === "/favicon.ico") { res.writeHead(204); res.end(); return; } // /api/shutdown — beacon-friendly (token in body; no header needed for sendBeacon) if (path === "/api/shutdown" && req.method === "POST") { const body = await readBody(req); let t = ""; try { t = (JSON.parse(body) as { token?: string }).token ?? ""; } catch {} if (t !== token) { fail(res, 401, "invalid token", "UNAUTHORIZED"); return; } res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ok: true, data: {} }), () => { try { (srv as Server & { closeAllConnections?: () => void }).closeAllConnections?.(); } catch {} srv.close(); }); return; } // API routes — require token if (path.startsWith("/api/")) { if (!checkToken(req, token)) { fail(res, 401, "invalid or missing token", "UNAUTHORIZED"); return; } if (path === "/api/config" && req.method === "GET") return routeGetConfig(res); if (path === "/api/config" && req.method === "PUT") { const body = await readBody(req); return routePutConfig(res, body); } if (path === "/api/meta" && req.method === "GET") return routeMeta(res); if (path === "/api/fetch-models" && req.method === "POST") { const body = await readBody(req); return routeFetchModels(res, body); } // roundrobin routes if (path === "/api/rr/config" && req.method === "GET") return routeRrGetConfig(res); if (path === "/api/rr/config" && req.method === "PUT") { const b = await readBody(req); return routeRrPutConfig(res, b, onRrReload); } if (path === "/api/rr/presets" && req.method === "GET") return routeRrListPresets(res); if (path === "/api/rr/presets" && req.method === "POST") { const b = await readBody(req); return routeRrSavePreset(res, b, onRrReload); } if (path === "/api/rr/models" && req.method === "GET") return routeRrModels(res); if (path === "/api/rr/health" && req.method === "GET") return routeRrHealth(res); if (path === "/api/rr/manual-speedtest" && req.method === "POST") { const b = await readBody(req); return routeRrManualSpeedTest(res, b); } if (path === "/api/health" && req.method === "GET") return routeHealth(res); if (path === "/api/health/reset" && req.method === "POST") return routeHealthReset(res); if (path === "/api/test-model" && req.method === "POST") { const b = await readBody(req); return routeTestModel(res, b); } if (path.startsWith("/api/rr/presets/") && path.endsWith("/activate") && req.method === "POST") { const name = path.slice("/api/rr/presets/".length, -"/activate".length); return routeRrActivatePreset(res, decodeURIComponent(name), onRrReload); } if (path.startsWith("/api/rr/presets/") && req.method === "DELETE") { const name = path.slice("/api/rr/presets/".length); return routeRrDeletePreset(res, decodeURIComponent(name), onRrReload); } fail(res, 404, `unknown route ${path}`, "NOT_FOUND"); return; } fail(res, 404, `not found: ${path}`, "NOT_FOUND"); } catch (e) { fail(res, 500, e instanceof Error ? e.message : String(e), "INTERNAL"); } }); await new Promise((resolve, reject) => { const onError = (e: Error) => reject(e); srv.once("error", onError); srv.listen(port, "127.0.0.1", () => { srv.off("error", onError); resolve(); }); }); srv.on("close", () => { if (idleTimer) clearTimeout(idleTimer); onShutdown?.(); }); return { port, token, url: `http://127.0.0.1:${port}/?token=${token}`, close: () => new Promise((resolve) => { if (idleTimer) clearTimeout(idleTimer); srv.close(() => resolve()); }), }; }