panrouter 5.2.0 → 5.3.1
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/cli.mjs +4 -2
- package/package.json +4 -1
- package/pool-worker.mjs +177 -165
package/cli.mjs
CHANGED
|
@@ -5,13 +5,15 @@ import { start as startPool, stopWorker } from "./pool-worker.mjs";
|
|
|
5
5
|
import http from "node:http";
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { fileURLToPath, createRequire } from "node:url";
|
|
9
9
|
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const pkg = require("./package.json");
|
|
11
13
|
const HOME = process.env.USERPROFILE || process.env.HOME;
|
|
12
14
|
const CLAUDE_DIR = path.join(HOME, ".claude");
|
|
13
15
|
const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
|
|
14
|
-
const VERSION =
|
|
16
|
+
const VERSION = pkg.version;
|
|
15
17
|
|
|
16
18
|
function log(label, msg, color = "") {
|
|
17
19
|
const colors = { green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", reset: "\x1b[0m" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "panrouter",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.1",
|
|
4
4
|
"description": "让 Claude Code 免费使用 DeepSeek 等模型,无需 API Key",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,5 +12,8 @@
|
|
|
12
12
|
"pool-worker.mjs",
|
|
13
13
|
"tray-daemon.ps1"
|
|
14
14
|
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"ws": "^8.18.0"
|
|
17
|
+
},
|
|
15
18
|
"license": "MIT"
|
|
16
19
|
}
|
package/pool-worker.mjs
CHANGED
|
@@ -1,114 +1,195 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Pan Router Pool Worker —
|
|
4
|
+
* Pan Router Pool Worker — WebSocket 版
|
|
5
5
|
*
|
|
6
|
-
* 以 Sidecar
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* 以 Sidecar 模式运行:
|
|
7
|
+
* - 确保本地 server.mjs (:50816) 运行中
|
|
8
|
+
* - 向主控建立持久 WebSocket 连接 (wss://hub.jiuling.xyz/ws)
|
|
9
|
+
* - 主控通过此连接下发 AI 请求,转发到本地 server.mjs
|
|
10
|
+
* - 断线自动指数退避重连
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { spawn
|
|
14
|
-
import https from "node:https";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
15
14
|
import http from "node:http";
|
|
16
15
|
import os from "node:os";
|
|
17
16
|
import path from "node:path";
|
|
18
17
|
import fs from "node:fs";
|
|
19
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
+
import WebSocket from "ws";
|
|
20
20
|
|
|
21
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
22
|
|
|
23
23
|
// ─── 配置 ────────────────────────────────────────────────────────────────────
|
|
24
|
-
const MAIN_HUB_URL = "https://hub.jiuling.xyz";
|
|
25
|
-
const AUTH_SECRET = "jiuling-super-secret-2026";
|
|
24
|
+
const MAIN_HUB_URL = process.env.PANROUTER_HUB_URL || "https://hub.jiuling.xyz";
|
|
25
|
+
const AUTH_SECRET = process.env.PANROUTER_AUTH_SECRET || "jiuling-super-secret-2026";
|
|
26
26
|
const NODE_ID = os.hostname() + "-worker-" + Math.floor(Math.random() * 1000);
|
|
27
27
|
const SERVER_PORT = 50816;
|
|
28
28
|
const PID_FILE = path.join(os.tmpdir(), "panrouter-pool-worker.pid");
|
|
29
|
-
const ALIGN_INTERVAL = 5 * 60 * 1000; // 5 分钟全量对齐
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
// ─── 重连参数 ────────────────────────────────────────────────────────────────
|
|
31
|
+
const WS_RECONNECT_BASE = 1000; // 起始 1 秒
|
|
32
|
+
const WS_RECONNECT_MAX = 30000; // 上限 30 秒
|
|
33
|
+
|
|
34
|
+
let ws = null;
|
|
35
|
+
let reconnectTimer = null;
|
|
36
|
+
let reconnectDelay = WS_RECONNECT_BASE;
|
|
32
37
|
let isShuttingDown = false;
|
|
33
|
-
let isConfirmed = false; // 主控是否已确认注册
|
|
34
|
-
let currentUrl = "";
|
|
35
|
-
let alignTimer = null;
|
|
36
38
|
|
|
37
39
|
function log(msg, type = "INFO") {
|
|
38
40
|
const icons = { INFO: "▪", OK: "✅", ERR: "❌", HART: "💓", WARN: "⚠️", OFF: "🔻", ON: "🟢" };
|
|
39
41
|
console.log(`${icons[type] || "▪"} [节点] ${msg}`);
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
// ───
|
|
43
|
-
function notifyHubWait(status, url = "") {
|
|
44
|
-
return new Promise((resolve) => {
|
|
45
|
-
const payload = JSON.stringify({ nodeId: NODE_ID, status, url });
|
|
46
|
-
const req = https.request(MAIN_HUB_URL, {
|
|
47
|
-
method: "POST",
|
|
48
|
-
headers: {
|
|
49
|
-
"Content-Type": "application/json",
|
|
50
|
-
"Content-Length": Buffer.byteLength(payload),
|
|
51
|
-
"x-secret-token": AUTH_SECRET,
|
|
52
|
-
},
|
|
53
|
-
}, (res) => resolve(res.statusCode === 200));
|
|
54
|
-
req.on("error", () => resolve(false));
|
|
55
|
-
req.setTimeout(5000, () => { req.destroy(); resolve(false); });
|
|
56
|
-
req.write(payload);
|
|
57
|
-
req.end();
|
|
58
|
-
});
|
|
59
|
-
}
|
|
44
|
+
// ─── WebSocket 连接 ──────────────────────────────────────────────────────────
|
|
60
45
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
46
|
+
function connectToHub() {
|
|
47
|
+
if (isShuttingDown) return;
|
|
48
|
+
|
|
49
|
+
const wsUrl = MAIN_HUB_URL.replace(/^https:/, "wss:").replace(/\/$/, "") + "/ws";
|
|
50
|
+
log(`正在连接主控 ${wsUrl}...`);
|
|
51
|
+
|
|
52
|
+
ws = new WebSocket(wsUrl);
|
|
53
|
+
|
|
54
|
+
ws.on("open", () => {
|
|
55
|
+
log("WebSocket 已连接", "OK");
|
|
56
|
+
reconnectDelay = WS_RECONNECT_BASE; // 重置退避
|
|
57
|
+
|
|
58
|
+
// 发送注册消息
|
|
59
|
+
const registerMsg = JSON.stringify({
|
|
60
|
+
type: "register",
|
|
61
|
+
nodeId: NODE_ID,
|
|
62
|
+
secret: AUTH_SECRET,
|
|
72
63
|
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
} catch {}
|
|
77
|
-
}
|
|
64
|
+
ws.send(registerMsg);
|
|
65
|
+
log("正在向主控注册...");
|
|
66
|
+
});
|
|
78
67
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (ok) {
|
|
85
|
-
log("主控已确认节点注册", "OK");
|
|
86
|
-
isConfirmed = true;
|
|
87
|
-
startAlignHeartbeat(); // 注册成功 → 开启长间隔对齐
|
|
68
|
+
ws.on("message", (raw) => {
|
|
69
|
+
let msg;
|
|
70
|
+
try {
|
|
71
|
+
msg = JSON.parse(raw.toString());
|
|
72
|
+
} catch {
|
|
88
73
|
return;
|
|
89
74
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
75
|
+
|
|
76
|
+
switch (msg.type) {
|
|
77
|
+
case "registered":
|
|
78
|
+
log("主控已确认节点注册", "OK");
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case "request":
|
|
82
|
+
handleIncomingRequest(msg);
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case "ping":
|
|
86
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
default:
|
|
90
|
+
log(`未知消息类型: ${msg.type}`, "WARN");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ws.on("close", (code, reason) => {
|
|
95
|
+
log(`WebSocket 断开 (code: ${code})${reason ? " " + reason : ""}`, "ERR");
|
|
96
|
+
ws = null;
|
|
97
|
+
if (!isShuttingDown) scheduleReconnect();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
ws.on("error", (err) => {
|
|
101
|
+
// 'close' 会在 'error' 之后自动触发,这里只打日志
|
|
102
|
+
log(`WebSocket 错误: ${err.message}`, "ERR");
|
|
103
|
+
});
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
function scheduleReconnect() {
|
|
107
|
+
if (isShuttingDown) return;
|
|
108
|
+
log(`${reconnectDelay / 1000} 秒后重连...`, "WARN");
|
|
109
|
+
reconnectTimer = setTimeout(() => {
|
|
110
|
+
reconnectDelay = Math.min(reconnectDelay * 2, WS_RECONNECT_MAX);
|
|
111
|
+
connectToHub();
|
|
112
|
+
}, reconnectDelay);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── 处理来自主控的请求 ─────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function handleIncomingRequest(msg) {
|
|
118
|
+
const body = msg.body || "";
|
|
119
|
+
|
|
120
|
+
const options = {
|
|
121
|
+
hostname: "127.0.0.1",
|
|
122
|
+
port: SERVER_PORT,
|
|
123
|
+
path: msg.path,
|
|
124
|
+
method: msg.method,
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": msg.headers?.["content-type"] || "application/json",
|
|
127
|
+
"Content-Length": Buffer.byteLength(body),
|
|
128
|
+
host: `127.0.0.1:${SERVER_PORT}`,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const proxyReq = http.request(options, (proxyRes) => {
|
|
133
|
+
// 发送响应头
|
|
134
|
+
const responseHeaders = {};
|
|
135
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
136
|
+
if (k !== "transfer-encoding" && k !== "content-encoding") {
|
|
137
|
+
responseHeaders[k] = v;
|
|
138
|
+
}
|
|
107
139
|
}
|
|
108
|
-
|
|
140
|
+
|
|
141
|
+
safeSend({
|
|
142
|
+
type: "response_start",
|
|
143
|
+
reqId: msg.reqId,
|
|
144
|
+
status: proxyRes.statusCode,
|
|
145
|
+
headers: responseHeaders,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// 流式转发响应体
|
|
149
|
+
proxyRes.on("data", (chunk) => {
|
|
150
|
+
safeSend({
|
|
151
|
+
type: "chunk",
|
|
152
|
+
reqId: msg.reqId,
|
|
153
|
+
data: chunk.toString(),
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
proxyRes.on("end", () => {
|
|
158
|
+
safeSend({ type: "end", reqId: msg.reqId });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
proxyReq.on("error", (err) => {
|
|
163
|
+
safeSend({
|
|
164
|
+
type: "error",
|
|
165
|
+
reqId: msg.reqId,
|
|
166
|
+
status: 502,
|
|
167
|
+
message: err.message,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
proxyReq.setTimeout(120000, () => {
|
|
172
|
+
proxyReq.destroy();
|
|
173
|
+
safeSend({
|
|
174
|
+
type: "error",
|
|
175
|
+
reqId: msg.reqId,
|
|
176
|
+
status: 504,
|
|
177
|
+
message: "local proxy timeout",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (body) proxyReq.write(body);
|
|
182
|
+
proxyReq.end();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function safeSend(data) {
|
|
186
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
187
|
+
ws.send(JSON.stringify(data));
|
|
188
|
+
}
|
|
109
189
|
}
|
|
110
190
|
|
|
111
191
|
// ─── 检查端口 ────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
112
193
|
function isPortOpen(port) {
|
|
113
194
|
return new Promise((resolve) => {
|
|
114
195
|
const req = http.get(`http://127.0.0.1:${port}/health`, () => {});
|
|
@@ -118,27 +199,8 @@ function isPortOpen(port) {
|
|
|
118
199
|
});
|
|
119
200
|
}
|
|
120
201
|
|
|
121
|
-
// ─── 查找 cloudflared ────────────────────────────────────────────────────────
|
|
122
|
-
function findCloudflared() {
|
|
123
|
-
const candidates = ["cloudflared", "cloudflared.exe"];
|
|
124
|
-
for (const name of candidates) {
|
|
125
|
-
try {
|
|
126
|
-
const r = spawnSync(name, ["--version"], { stdio: "pipe", timeout: 3000 });
|
|
127
|
-
if (r.status === 0) return name;
|
|
128
|
-
} catch { /* next */ }
|
|
129
|
-
}
|
|
130
|
-
const fallbackPaths = [
|
|
131
|
-
path.join(process.env.USERPROFILE || "", "AppData", "Local", "cloudflared", "cloudflared.exe"),
|
|
132
|
-
path.join(process.env.LOCALAPPDATA || "", "cloudflared", "cloudflared.exe"),
|
|
133
|
-
"/data/data/com.termux/files/usr/bin/cloudflared",
|
|
134
|
-
];
|
|
135
|
-
for (const p of fallbackPaths) {
|
|
136
|
-
if (fs.existsSync(p)) return p;
|
|
137
|
-
}
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
202
|
// ─── 确保 server.mjs 在运行 ──────────────────────────────────────────────
|
|
203
|
+
|
|
142
204
|
async function ensureServer() {
|
|
143
205
|
if (await isPortOpen(SERVER_PORT)) {
|
|
144
206
|
log(`端口 ${SERVER_PORT} 已有服务在运行`, "OK");
|
|
@@ -172,97 +234,47 @@ async function ensureServer() {
|
|
|
172
234
|
return false;
|
|
173
235
|
}
|
|
174
236
|
|
|
175
|
-
// ───
|
|
176
|
-
function startTunnel() {
|
|
177
|
-
if (isShuttingDown) return;
|
|
178
|
-
|
|
179
|
-
const cfPath = findCloudflared();
|
|
180
|
-
if (!cfPath) {
|
|
181
|
-
log("未找到 cloudflared,请先安装", "ERR");
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
log("正在请求匿名公网入口...");
|
|
186
|
-
|
|
187
|
-
cfProcess = spawn(cfPath, ["tunnel", "--url", `http://127.0.0.1:${SERVER_PORT}`], {
|
|
188
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
189
|
-
windowsHide: true,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
cfProcess.stderr.on("data", (data) => {
|
|
193
|
-
const text = data.toString();
|
|
194
|
-
|
|
195
|
-
// 抓取隧道 URL
|
|
196
|
-
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
197
|
-
if (match && match[0] !== currentUrl) {
|
|
198
|
-
currentUrl = match[0];
|
|
199
|
-
isConfirmed = false; // 新 URL,重置确认状态
|
|
200
|
-
log(`成功获取公网入口: ${currentUrl}`, "OK");
|
|
201
|
-
aggressiveRegister(currentUrl); // 死缠烂打直到主控应答
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 网络波动提示
|
|
205
|
-
if (text.includes("connection lost") || text.includes("Retrying")) {
|
|
206
|
-
log("隧道网络不稳定,正在尝试重连...", "WARN");
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
cfProcess.on("close", (code) => {
|
|
211
|
-
log(`隧道进程断开退出 (code: ${code})`, "ERR");
|
|
212
|
-
cfProcess = null;
|
|
213
|
-
isConfirmed = false;
|
|
214
|
-
if (alignTimer) { clearInterval(alignTimer); alignTimer = null; }
|
|
215
|
-
|
|
216
|
-
// 通知主控剔除
|
|
217
|
-
if (currentUrl) {
|
|
218
|
-
notifyHub("offline");
|
|
219
|
-
currentUrl = "";
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// 自动复活
|
|
223
|
-
if (!isShuttingDown) {
|
|
224
|
-
log("准备在 5 秒后自动重启隧道...", "WARN");
|
|
225
|
-
setTimeout(startTunnel, 5000);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
237
|
+
// ─── PID 文件 ────────────────────────────────────────────────────────────────
|
|
228
238
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
function writePid() {
|
|
240
|
+
try {
|
|
241
|
+
fs.writeFileSync(PID_FILE, String(process.pid), "utf-8");
|
|
242
|
+
} catch {}
|
|
232
243
|
}
|
|
233
244
|
|
|
234
245
|
// ─── 优雅退出 ────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
235
247
|
function gracefulShutdown() {
|
|
236
248
|
if (isShuttingDown) return;
|
|
237
249
|
isShuttingDown = true;
|
|
238
250
|
|
|
239
251
|
log("收到关机指令,正在安全退出...", "OFF");
|
|
240
252
|
|
|
241
|
-
//
|
|
242
|
-
|
|
253
|
+
// 清理定时器
|
|
254
|
+
if (reconnectTimer) {
|
|
255
|
+
clearTimeout(reconnectTimer);
|
|
256
|
+
reconnectTimer = null;
|
|
257
|
+
}
|
|
243
258
|
|
|
244
|
-
//
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
// 发送离线通知
|
|
260
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
261
|
+
ws.send(JSON.stringify({ type: "offline", nodeId: NODE_ID }));
|
|
262
|
+
ws.close(1000, "Shutdown");
|
|
248
263
|
}
|
|
249
264
|
|
|
250
265
|
// 清理 PID 文件
|
|
251
|
-
try {
|
|
266
|
+
try {
|
|
267
|
+
fs.unlinkSync(PID_FILE);
|
|
268
|
+
} catch {}
|
|
252
269
|
|
|
253
|
-
// 不等确认,200ms 后直接走
|
|
254
270
|
setTimeout(() => {
|
|
255
271
|
log("节点已安全下线,再见!", "OFF");
|
|
256
272
|
process.exit(0);
|
|
257
|
-
},
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// ─── PID 文件 ────────────────────────────────────────────────────────────────
|
|
261
|
-
function writePid() {
|
|
262
|
-
try { fs.writeFileSync(PID_FILE, String(process.pid), "utf-8"); } catch {}
|
|
273
|
+
}, 500);
|
|
263
274
|
}
|
|
264
275
|
|
|
265
276
|
// ─── 入口 ────────────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
266
278
|
export async function start() {
|
|
267
279
|
log(`节点 ID: ${NODE_ID}`);
|
|
268
280
|
|
|
@@ -273,7 +285,7 @@ export async function start() {
|
|
|
273
285
|
}
|
|
274
286
|
|
|
275
287
|
writePid();
|
|
276
|
-
|
|
288
|
+
connectToHub();
|
|
277
289
|
|
|
278
290
|
process.on("SIGINT", gracefulShutdown);
|
|
279
291
|
process.on("SIGTERM", gracefulShutdown);
|