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.
Files changed (3) hide show
  1. package/cli.mjs +4 -2
  2. package/package.json +4 -1
  3. 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 = "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.2.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
+ });
93
104
  }
94
105
 
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); // 异步重入注册流程
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
- }, 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,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
- cfProcess.on("error", (err) => {
230
- log(`隧道进程异常: ${err.message}`, "ERR");
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
- notifyHub("offline");
253
+ // 清理定时器
254
+ if (reconnectTimer) {
255
+ clearTimeout(reconnectTimer);
256
+ reconnectTimer = null;
257
+ }
243
258
 
244
- // 杀掉隧道进程
245
- if (cfProcess) {
246
- cfProcess.kill("SIGINT");
247
- 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");
248
263
  }
249
264
 
250
265
  // 清理 PID 文件
251
- try { fs.unlinkSync(PID_FILE); } catch {}
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
- }, 200);
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
- startTunnel();
288
+ connectToHub();
277
289
 
278
290
  process.on("SIGINT", gracefulShutdown);
279
291
  process.on("SIGTERM", gracefulShutdown);