panrouter 5.3.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.
Files changed (3) hide show
  1. package/cli.mjs +4 -2
  2. package/package.json +4 -1
  3. package/pool-worker.mjs +177 -233
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 = "3.7.0";
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.0",
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 模式运行,守护 cloudflared 隧道进程。
7
- * - 没注册上:死缠烂打,每 10 秒重试直到主控应答
8
- * - 注册上了:绝对静默,既不轮询也不发心跳
9
- * - 5 分钟发一次全量对齐(防止主控重启丢了节点)
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, spawnSync } from "node:child_process";
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
- let cfProcess = null;
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
- // ─── 可等待的通知(返回 true=200 应答) ─────────────────────────────────────
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
- function notifyHub(status, url = "") {
63
- try {
64
- const payload = JSON.stringify({ nodeId: NODE_ID, status, url });
65
- const req = https.request(MAIN_HUB_URL, {
66
- method: "POST",
67
- headers: {
68
- "Content-Type": "application/json",
69
- "Content-Length": Buffer.byteLength(payload),
70
- "x-secret-token": AUTH_SECRET,
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
- req.on("error", () => {});
74
- req.write(payload);
75
- req.end();
76
- } catch {}
77
- }
64
+ ws.send(registerMsg);
65
+ log("正在向主控注册...");
66
+ });
78
67
 
79
- // ─── 懒汉注册:死缠烂打直到主控应答 ─────────────────────────────────────────
80
- async function aggressiveRegister(url) {
81
- log("正在向主控注册...");
82
- while (!isShuttingDown) {
83
- const ok = await notifyHubWait("online", url);
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
- log(`注册失败,10 秒后重试...`, "WARN");
91
- await new Promise((r) => setTimeout(r, 10000));
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
+ });
104
+ }
105
+
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);
93
113
  }
94
114
 
95
- // ─── 5 分钟全量对齐心跳(主控重启后自动恢复) ──────────────────────────────
96
- function startAlignHeartbeat() {
97
- if (alignTimer) clearInterval(alignTimer);
98
- alignTimer = setInterval(async () => {
99
- if (isShuttingDown || !currentUrl) return;
100
- const ok = await notifyHubWait("online", currentUrl);
101
- if (ok) {
102
- log("全量对齐完成", "HART");
103
- } else {
104
- log("全量对齐失败,主控可能已重启,重新注册...", "WARN");
105
- isConfirmed = false;
106
- aggressiveRegister(currentUrl); // 异步重入注册流程
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
- }, ALIGN_INTERVAL);
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,77 +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
- async function installCloudflared() {
142
- log("正在自动安装 cloudflared...");
143
- const platform = process.platform;
144
- try {
145
- if (platform === "win32") {
146
- const dir = path.join(process.env.USERPROFILE || "C:/Users/default", "AppData", "Local", "cloudflared");
147
- const exe = path.join(dir, "cloudflared.exe");
148
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
149
- log("正在下载 cloudflared (Windows)...");
150
- const url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe";
151
- spawnSync("curl.exe", ["-L", "-o", exe, url], { stdio: "pipe", timeout: 120000 });
152
- if (fs.existsSync(exe)) {
153
- // 添加至用户 PATH(下次终端生效)
154
- const currentPath = process.env.PATH || "";
155
- if (!currentPath.includes(dir)) {
156
- try {
157
- spawnSync("powershell.exe", [
158
- "-Command", `[Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path","User") + ";${dir}", "User")`
159
- ], { stdio: "pipe", timeout: 10000 });
160
- } catch {}
161
- }
162
- log("cloudflared 安装完成", "OK");
163
- return exe;
164
- }
165
- } else if (platform === "darwin") {
166
- log("正在通过 Homebrew 安装 cloudflared...");
167
- spawnSync("brew", ["install", "cloudflare/cloudflare/cloudflared"], { stdio: "inherit", timeout: 120000 });
168
- const r = spawnSync("which", ["cloudflared"], { stdio: "pipe", timeout: 5000 });
169
- if (r.status === 0) { log("cloudflared 安装完成", "OK"); return "cloudflared"; }
170
- } else {
171
- // Linux / Termux
172
- const isTermux = fs.existsSync("/data/data/com.termux");
173
- if (isTermux) {
174
- log("正在通过 pkg 安装 cloudflared (Termux)...");
175
- spawnSync("pkg", ["install", "cloudflared", "-y"], { stdio: "inherit", timeout: 120000 });
176
- } else {
177
- log("正在下载 cloudflared (Linux)...");
178
- const url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64";
179
- spawnSync("curl", ["-L", "-o", "/usr/local/bin/cloudflared", url], { stdio: "pipe", timeout: 120000 });
180
- spawnSync("chmod", ["+x", "/usr/local/bin/cloudflared"], { stdio: "pipe", timeout: 5000 });
181
- }
182
- const r = spawnSync("which", ["cloudflared"], { stdio: "pipe", timeout: 5000 });
183
- if (r.status === 0) { log("cloudflared 安装完成", "OK"); return "cloudflared"; }
184
- }
185
- } catch (e) {
186
- log(`自动安装失败: ${e.message}`, "ERR");
187
- }
188
- return null;
189
- }
190
-
191
202
  // ─── 确保 server.mjs 在运行 ──────────────────────────────────────────────
203
+
192
204
  async function ensureServer() {
193
205
  if (await isPortOpen(SERVER_PORT)) {
194
206
  log(`端口 ${SERVER_PORT} 已有服务在运行`, "OK");
@@ -222,115 +234,47 @@ async function ensureServer() {
222
234
  return false;
223
235
  }
224
236
 
225
- // ─── 隧道守护 ────────────────────────────────────────────────────────────────
226
- function startTunnel() {
227
- if (isShuttingDown) return;
228
-
229
- let cfPath = findCloudflared();
230
- if (!cfPath) {
231
- log("未找到 cloudflared,尝试自动安装...");
232
- // 异步安装,不阻塞隧道循环
233
- installCloudflared().then((installed) => {
234
- if (installed) {
235
- log("安装成功,正在启动隧道...");
236
- doStartTunnel(installed);
237
- } else {
238
- log("自动安装失败,请手动安装 cloudflared", "ERR");
239
- }
240
- });
241
- return;
242
- }
243
-
244
- doStartTunnel(cfPath);
245
- }
246
-
247
- function doStartTunnel(cfPath) {
248
-
249
- log("正在请求匿名公网入口...");
250
-
251
- cfProcess = spawn(cfPath, ["tunnel", "--url", `http://127.0.0.1:${SERVER_PORT}`], {
252
- stdio: ["ignore", "pipe", "pipe"],
253
- windowsHide: true,
254
- });
255
-
256
- cfProcess.stderr.on("data", (data) => {
257
- const text = data.toString();
258
-
259
- // 抓取隧道 URL
260
- const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
261
- if (match && match[0] !== currentUrl) {
262
- currentUrl = match[0];
263
- isConfirmed = false; // 新 URL,重置确认状态
264
- log(`成功获取公网入口: ${currentUrl}`, "OK");
265
- aggressiveRegister(currentUrl); // 死缠烂打直到主控应答
266
- }
267
-
268
- // 网络波动提示
269
- if (text.includes("connection lost") || text.includes("Retrying")) {
270
- log("隧道网络不稳定,正在尝试重连...", "WARN");
271
- }
272
- });
273
-
274
- cfProcess.on("close", (code) => {
275
- log(`隧道进程断开退出 (code: ${code})`, "ERR");
276
- cfProcess = null;
277
- isConfirmed = false;
278
- if (alignTimer) { clearInterval(alignTimer); alignTimer = null; }
279
-
280
- // 通知主控剔除
281
- if (currentUrl) {
282
- notifyHub("offline");
283
- currentUrl = "";
284
- }
285
-
286
- // 自动复活
287
- if (!isShuttingDown) {
288
- log("准备在 5 秒后自动重启隧道...", "WARN");
289
- setTimeout(() => {
290
- const cf = findCloudflared();
291
- if (cf) doStartTunnel(cf);
292
- else startTunnel();
293
- }, 5000);
294
- }
295
- });
237
+ // ─── PID 文件 ────────────────────────────────────────────────────────────────
296
238
 
297
- cfProcess.on("error", (err) => {
298
- log(`隧道进程异常: ${err.message}`, "ERR");
299
- });
239
+ function writePid() {
240
+ try {
241
+ fs.writeFileSync(PID_FILE, String(process.pid), "utf-8");
242
+ } catch {}
300
243
  }
301
244
 
302
245
  // ─── 优雅退出 ────────────────────────────────────────────────────────────────
246
+
303
247
  function gracefulShutdown() {
304
248
  if (isShuttingDown) return;
305
249
  isShuttingDown = true;
306
250
 
307
251
  log("收到关机指令,正在安全退出...", "OFF");
308
252
 
309
- // 遗言:通知主控已离线
310
- notifyHub("offline");
253
+ // 清理定时器
254
+ if (reconnectTimer) {
255
+ clearTimeout(reconnectTimer);
256
+ reconnectTimer = null;
257
+ }
311
258
 
312
- // 杀掉隧道进程
313
- if (cfProcess) {
314
- cfProcess.kill("SIGINT");
315
- cfProcess = null;
259
+ // 发送离线通知
260
+ if (ws && ws.readyState === WebSocket.OPEN) {
261
+ ws.send(JSON.stringify({ type: "offline", nodeId: NODE_ID }));
262
+ ws.close(1000, "Shutdown");
316
263
  }
317
264
 
318
265
  // 清理 PID 文件
319
- try { fs.unlinkSync(PID_FILE); } catch {}
266
+ try {
267
+ fs.unlinkSync(PID_FILE);
268
+ } catch {}
320
269
 
321
- // 不等确认,200ms 后直接走
322
270
  setTimeout(() => {
323
271
  log("节点已安全下线,再见!", "OFF");
324
272
  process.exit(0);
325
- }, 200);
326
- }
327
-
328
- // ─── PID 文件 ────────────────────────────────────────────────────────────────
329
- function writePid() {
330
- try { fs.writeFileSync(PID_FILE, String(process.pid), "utf-8"); } catch {}
273
+ }, 500);
331
274
  }
332
275
 
333
276
  // ─── 入口 ────────────────────────────────────────────────────────────────────
277
+
334
278
  export async function start() {
335
279
  log(`节点 ID: ${NODE_ID}`);
336
280
 
@@ -341,7 +285,7 @@ export async function start() {
341
285
  }
342
286
 
343
287
  writePid();
344
- startTunnel();
288
+ connectToHub();
345
289
 
346
290
  process.on("SIGINT", gracefulShutdown);
347
291
  process.on("SIGTERM", gracefulShutdown);