svamp-cli 0.2.224 → 0.2.225

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.
@@ -1,681 +0,0 @@
1
- import { spawn, execSync } from 'child_process';
2
- import { createServer } from 'net';
3
- import { mkdirSync, writeFileSync, unlinkSync, existsSync, chmodSync, readFileSync } from 'fs';
4
- import { join } from 'path';
5
- import { homedir, platform, arch } from 'os';
6
- import { randomUUID, createHash } from 'crypto';
7
- import { h as getFrpsSubdomainHost, i as getFrpsServerPort, j as getFrpsServerAddr } from './run-C-9ZtEi4.mjs';
8
- import 'fs/promises';
9
- import 'url';
10
- import 'node:crypto';
11
- import 'node:fs';
12
- import 'node:child_process';
13
- import 'util';
14
- import 'node:path';
15
- import 'node:events';
16
- import 'node:os';
17
- import '@agentclientprotocol/sdk';
18
- import '@modelcontextprotocol/sdk/client/index.js';
19
- import '@modelcontextprotocol/sdk/client/stdio.js';
20
- import '@modelcontextprotocol/sdk/types.js';
21
- import 'zod';
22
- import 'node:fs/promises';
23
- import 'node:util';
24
-
25
- function getMachineFingerprint() {
26
- const idFile = join(homedir(), ".svamp", "machine-id");
27
- try {
28
- const existing = readFileSync(idFile, "utf-8").trim();
29
- if (existing) return existing;
30
- } catch {
31
- }
32
- const id = randomUUID();
33
- try {
34
- mkdirSync(join(homedir(), ".svamp"), { recursive: true });
35
- writeFileSync(idFile, id + "\n");
36
- } catch {
37
- }
38
- return id;
39
- }
40
- const FRP_VERSION = "0.68.0";
41
- const BIN_DIR = join(homedir(), ".svamp", "bin");
42
- const FRPC_BIN = join(BIN_DIR, platform() === "win32" ? "frpc.exe" : "frpc");
43
- function isInCluster() {
44
- return !!(process.env.KUBERNETES_SERVICE_HOST || process.env.SANDBOX_ID);
45
- }
46
- const FRPS_CLUSTER_ADDR = "frps.hypha.svc.cluster.local";
47
- const DEFAULT_FRPS_PORT = isInCluster() ? 7e3 : 443;
48
- const FRPS_PUBLIC_TOKEN = "hypha-frps-public";
49
- function getFrpcDownloadUrl() {
50
- const os = platform();
51
- const a = arch();
52
- let osStr;
53
- let archStr;
54
- switch (os) {
55
- case "darwin":
56
- osStr = "darwin";
57
- break;
58
- case "linux":
59
- osStr = "linux";
60
- break;
61
- case "win32":
62
- osStr = "windows";
63
- break;
64
- default:
65
- throw new Error(`Unsupported platform: ${os}`);
66
- }
67
- switch (a) {
68
- case "x64":
69
- archStr = "amd64";
70
- break;
71
- case "arm64":
72
- archStr = "arm64";
73
- break;
74
- case "arm":
75
- archStr = "arm";
76
- break;
77
- default:
78
- throw new Error(`Unsupported architecture: ${a}`);
79
- }
80
- const ext = os === "win32" ? "zip" : "tar.gz";
81
- const filename = `frp_${FRP_VERSION}_${osStr}_${archStr}`;
82
- return `https://github.com/fatedier/frp/releases/download/v${FRP_VERSION}/${filename}.${ext}`;
83
- }
84
- async function ensureFrpc(log) {
85
- if (existsSync(FRPC_BIN)) {
86
- try {
87
- const out = execSync(`"${FRPC_BIN}" --version`, { stdio: "pipe", timeout: 5e3 }).toString().trim();
88
- if (out === FRP_VERSION) return FRPC_BIN;
89
- (log || console.log)(`frpc version mismatch: ${out} \u2260 ${FRP_VERSION}, re-downloading...`);
90
- } catch {
91
- (log || console.log)("frpc binary broken or invalid signature, re-downloading...");
92
- }
93
- try {
94
- unlinkSync(FRPC_BIN);
95
- } catch {
96
- }
97
- }
98
- const logger = log || console.log;
99
- mkdirSync(BIN_DIR, { recursive: true });
100
- const url = getFrpcDownloadUrl();
101
- logger(`Downloading frpc ${FRP_VERSION} from ${url}...`);
102
- const response = await fetch(url);
103
- if (!response.ok) {
104
- throw new Error(`Failed to download frpc: ${response.status} ${response.statusText}`);
105
- }
106
- const buffer = Buffer.from(await response.arrayBuffer());
107
- if (platform() === "win32") {
108
- throw new Error("Windows ZIP extraction not implemented \u2014 please install frpc manually");
109
- }
110
- const tmpTar = join(BIN_DIR, `frpc-${FRP_VERSION}.tar.gz`);
111
- writeFileSync(tmpTar, buffer);
112
- try {
113
- const dirName = `frp_${FRP_VERSION}_${platform() === "darwin" ? "darwin" : "linux"}_${arch() === "x64" ? "amd64" : arch()}`;
114
- execSync(
115
- `tar -xzf "${tmpTar}" -C "${BIN_DIR}" --strip-components=1 "${dirName}/frpc"`,
116
- { stdio: "pipe" }
117
- );
118
- chmodSync(FRPC_BIN, 493);
119
- logger(`frpc installed at ${FRPC_BIN}`);
120
- } finally {
121
- try {
122
- unlinkSync(tmpTar);
123
- } catch {
124
- }
125
- }
126
- return FRPC_BIN;
127
- }
128
- function generateFrpcConfig(config, proxies, admin) {
129
- const useWSS = config.serverPort === 443;
130
- const lines = [
131
- "# Auto-generated by svamp \u2014 do not edit",
132
- `serverAddr = "${config.serverAddr}"`,
133
- `serverPort = ${config.serverPort}`,
134
- "",
135
- 'auth.method = "token"',
136
- `auth.token = "${config.authToken}"`,
137
- "",
138
- "# Hypha JWT token for server-side authentication",
139
- ...config.hyphaToken ? [`metadatas.token = "${config.hyphaToken}"`] : [],
140
- "",
141
- "# Transport",
142
- ...useWSS ? ['transport.protocol = "wss"'] : [],
143
- // 15s/45s heartbeat — 2x faster failure detection than the frpc default
144
- // (30s/90s). With matching server-side frps 0.68.x, dead-peer detection
145
- // settles in well under a minute, so the daemon-side recreate threshold
146
- // can be tightened too.
147
- "transport.heartbeatInterval = 15",
148
- "transport.heartbeatTimeout = 45",
149
- "transport.poolCount = 5",
150
- "",
151
- "# Don't exit on login failure \u2014 let frpc keep retrying",
152
- "loginFailExit = false",
153
- "",
154
- 'log.to = "console"',
155
- 'log.level = "info"',
156
- ""
157
- ];
158
- if (admin) {
159
- lines.push("# Local admin/status API (loopback only \u2014 polled by the svamp daemon)");
160
- lines.push('webServer.addr = "127.0.0.1"');
161
- lines.push(`webServer.port = ${admin.port}`);
162
- lines.push(`webServer.user = "${admin.user}"`);
163
- lines.push(`webServer.password = "${admin.password}"`);
164
- lines.push("");
165
- }
166
- for (const proxy of proxies) {
167
- lines.push(`[[proxies]]`);
168
- lines.push(`name = "${proxy.name}"`);
169
- lines.push(`type = "${proxy.type}"`);
170
- lines.push(`localIP = "${proxy.localIP || "127.0.0.1"}"`);
171
- lines.push(`localPort = ${proxy.localPort}`);
172
- if (proxy.subdomain) {
173
- lines.push(`subdomain = "${proxy.subdomain}"`);
174
- }
175
- if (proxy.customDomains && proxy.customDomains.length > 0) {
176
- lines.push(`customDomains = [${proxy.customDomains.map((d) => `"${d}"`).join(", ")}]`);
177
- }
178
- if (proxy.group) {
179
- lines.push(`loadBalancer.group = "${proxy.group}"`);
180
- if (proxy.groupKey) {
181
- lines.push(`loadBalancer.groupKey = "${proxy.groupKey}"`);
182
- }
183
- }
184
- if (proxy.healthCheckType) {
185
- lines.push(`healthCheck.type = "${proxy.healthCheckType}"`);
186
- if (proxy.healthCheckType === "http" && proxy.healthCheckPath) {
187
- lines.push(`healthCheck.path = "${proxy.healthCheckPath}"`);
188
- }
189
- lines.push(`healthCheck.intervalSeconds = ${proxy.healthCheckInterval || 10}`);
190
- lines.push(`healthCheck.timeoutSeconds = ${proxy.healthCheckTimeout || 3}`);
191
- lines.push(`healthCheck.maxFailed = ${proxy.healthCheckMaxFailed || 3}`);
192
- }
193
- if (proxy.type === "http") {
194
- lines.push("transport.useEncryption = false");
195
- lines.push("transport.useCompression = false");
196
- }
197
- lines.push("");
198
- }
199
- return lines.join("\n");
200
- }
201
- function getFreePort() {
202
- return new Promise((resolve, reject) => {
203
- const srv = createServer();
204
- srv.unref();
205
- srv.on("error", reject);
206
- srv.listen(0, "127.0.0.1", () => {
207
- const addr = srv.address();
208
- const port = typeof addr === "object" && addr ? addr.port : 0;
209
- srv.close(() => port ? resolve(port) : reject(new Error("no port")));
210
- });
211
- });
212
- }
213
- function parseFrpcStatus(payload, proxyNames) {
214
- const byName = /* @__PURE__ */ new Map();
215
- if (payload && typeof payload === "object") {
216
- for (const group of Object.values(payload)) {
217
- if (!Array.isArray(group)) continue;
218
- for (const p of group) {
219
- if (p && typeof p.name === "string") {
220
- byName.set(p.name, { status: String(p.status ?? ""), err: p.err ? String(p.err) : void 0 });
221
- }
222
- }
223
- }
224
- }
225
- const missing = [];
226
- const failed = [];
227
- for (const name of proxyNames) {
228
- const entry = byName.get(name);
229
- if (!entry) {
230
- missing.push(name);
231
- continue;
232
- }
233
- if (entry.status !== "running") failed.push({ name, status: entry.status, err: entry.err });
234
- }
235
- return { allRunning: missing.length === 0 && failed.length === 0, missing, failed };
236
- }
237
- class FrpcTunnel {
238
- process = null;
239
- _connected = false;
240
- _destroyed = false;
241
- configPath;
242
- options;
243
- serverConfig;
244
- log;
245
- logBuffer = [];
246
- maxLogLines = 200;
247
- proxies = [];
248
- // Health tracking
249
- _consecutiveErrors = 0;
250
- _lastConnectedAt = 0;
251
- _lastErrorAt = 0;
252
- _firstErrorAt = 0;
253
- _restartAttempts = 0;
254
- // End-to-end probe state (set when options.probeUrl is provided).
255
- _probeTimer = null;
256
- _lastProbeOkAt = 0;
257
- _lastProbeFailAt = 0;
258
- _probeOk = false;
259
- // frpc admin/status API state (set when options.adminStatus is true).
260
- _adminPort = 0;
261
- _adminUser = "svamp";
262
- _adminPassword = randomUUID();
263
- _statusTimer = null;
264
- constructor(options) {
265
- this.options = options;
266
- this.log = options.log || ((msg) => console.log(`[FRPC] ${msg}`));
267
- const hyphaToken = options.serverConfig?.hyphaToken || process.env.HYPHA_TOKEN || "";
268
- this.serverConfig = {
269
- serverAddr: options.serverConfig?.serverAddr || process.env.FRPS_SERVER_ADDR || (isInCluster() ? FRPS_CLUSTER_ADDR : getFrpsServerAddr()),
270
- serverPort: options.serverConfig?.serverPort || getFrpsServerPort() || DEFAULT_FRPS_PORT,
271
- authToken: options.serverConfig?.authToken || process.env.FRPS_AUTH_TOKEN || FRPS_PUBLIC_TOKEN,
272
- hyphaToken,
273
- subDomainHost: options.serverConfig?.subDomainHost || process.env.FRPS_SUBDOMAIN_HOST || getFrpsSubdomainHost()
274
- };
275
- if (!this.serverConfig.hyphaToken) {
276
- throw new Error(
277
- 'Hypha token required for frpc authentication. Run "svamp login" or set HYPHA_TOKEN.'
278
- );
279
- }
280
- const machineFingerprint = getMachineFingerprint();
281
- const machineHash = createHash("sha256").update(machineFingerprint).digest("hex").slice(0, 6);
282
- this.proxies = options.ports.map((port) => {
283
- const standaloneHash = createHash("sha256").update(`${options.name}|${machineFingerprint}`).digest("hex").slice(0, 8);
284
- const subdomain = options.subdomains?.get(port) || (options.group ? `${options.group}-${createHash("sha256").update(options.group).digest("hex").slice(0, 8)}` : `${options.name}-${standaloneHash}`);
285
- const proxyName = options.group ? `${options.name}-${port}-${machineHash}` : `${options.name}-${port}-${machineHash}`;
286
- return {
287
- name: proxyName,
288
- type: "http",
289
- localIP: options.localHost || "127.0.0.1",
290
- localPort: port,
291
- subdomain,
292
- group: options.group,
293
- groupKey: options.groupKey,
294
- healthCheckType: options.healthCheckType,
295
- healthCheckPath: options.healthCheckPath,
296
- healthCheckInterval: options.healthCheckInterval
297
- };
298
- });
299
- const configDir = join(homedir(), ".svamp", "frpc");
300
- mkdirSync(configDir, { recursive: true });
301
- this.configPath = join(configDir, `${options.name}.toml`);
302
- }
303
- /** Connect to frps by starting the frpc process. */
304
- async connect() {
305
- if (this._destroyed) return;
306
- if (this.process) return;
307
- const frpcPath = await ensureFrpc(this.log);
308
- let admin;
309
- if (this.options.adminStatus) {
310
- if (!this._adminPort) {
311
- try {
312
- this._adminPort = await getFreePort();
313
- } catch (err) {
314
- this.log(`admin port alloc failed: ${err?.message ?? err}`);
315
- }
316
- }
317
- if (this._adminPort) admin = { port: this._adminPort, user: this._adminUser, password: this._adminPassword };
318
- }
319
- const configContent = generateFrpcConfig(this.serverConfig, this.proxies, admin);
320
- writeFileSync(this.configPath, configContent);
321
- this.log(`Config written to ${this.configPath}`);
322
- return new Promise((resolve, reject) => {
323
- const child = spawn(frpcPath, ["-c", this.configPath], {
324
- stdio: ["ignore", "pipe", "pipe"],
325
- env: { ...process.env }
326
- });
327
- this.process = child;
328
- let resolved = false;
329
- const onOutput = (data) => {
330
- const text = data.toString().trim();
331
- if (!text) return;
332
- for (const line of text.split("\n")) {
333
- this.logBuffer.push(line);
334
- if (this.logBuffer.length > this.maxLogLines) {
335
- this.logBuffer.shift();
336
- }
337
- }
338
- if (text.includes("start proxy success") || text.includes("login to server success")) {
339
- if (!this._connected) {
340
- this._connected = true;
341
- this._lastConnectedAt = Date.now();
342
- this._restartAttempts = 0;
343
- this._consecutiveErrors = 0;
344
- this._firstErrorAt = 0;
345
- this.options.onConnect?.();
346
- const probeConfigured = !!(this.options.probeUrl || this.options.probePath);
347
- if (!resolved && !probeConfigured) {
348
- resolved = true;
349
- resolve();
350
- }
351
- }
352
- }
353
- if (text.includes("[E]") || text.includes("error")) {
354
- this._consecutiveErrors++;
355
- this._lastErrorAt = Date.now();
356
- if (this._firstErrorAt === 0) this._firstErrorAt = Date.now();
357
- this.options.onError?.(new Error(text));
358
- }
359
- };
360
- child.stdout?.on("data", onOutput);
361
- child.stderr?.on("data", onOutput);
362
- child.on("error", (err) => {
363
- this.options.onError?.(err);
364
- if (!resolved) {
365
- resolved = true;
366
- reject(err);
367
- }
368
- });
369
- child.on("exit", (code) => {
370
- this._connected = false;
371
- this.process = null;
372
- this.options.onDisconnect?.();
373
- if (!resolved) {
374
- resolved = true;
375
- reject(new Error(`frpc exited with code ${code}`));
376
- }
377
- if (!this._destroyed && code !== 0) {
378
- this._restartAttempts++;
379
- const delay = Math.min(3e3 * Math.pow(2, this._restartAttempts - 1), 6e4);
380
- this.log(`frpc exited with code ${code}, restarting in ${Math.round(delay / 1e3)}s (attempt ${this._restartAttempts})...`);
381
- setTimeout(() => {
382
- if (!this._destroyed) {
383
- this.connect().catch((err) => {
384
- this.options.onError?.(err);
385
- });
386
- }
387
- }, delay);
388
- }
389
- });
390
- if (this.options.probeUrl || this.options.probePath) {
391
- this.startProbeLoop({
392
- onFirstOk: () => {
393
- if (!resolved) {
394
- resolved = true;
395
- resolve();
396
- }
397
- }
398
- });
399
- } else if (this.options.adminStatus && this._adminPort) {
400
- this.startAdminStatusLoop();
401
- }
402
- setTimeout(() => {
403
- if (!resolved) {
404
- resolved = true;
405
- this.log("frpc connection timeout \u2014 continuing (may still connect)");
406
- resolve();
407
- }
408
- }, 3e4);
409
- });
410
- }
411
- /** Resolve the effective probe URL, honoring probeUrl > probePath. */
412
- resolveProbeUrl() {
413
- if (this.options.probeUrl) return this.options.probeUrl;
414
- if (this.options.probePath) {
415
- const base = this.getUrls().get(this.options.ports[0]);
416
- if (!base) return null;
417
- const sep = this.options.probePath.startsWith("/") ? "" : "/";
418
- return `${base}${sep}${this.options.probePath}`;
419
- }
420
- return null;
421
- }
422
- /**
423
- * Start an end-to-end probe loop against the resolved probe URL.
424
- * Updates _probeOk + timestamps and fires onProbeFail on transitions.
425
- * Called from connect(); cleaned up in destroy().
426
- */
427
- startProbeLoop(opts = {}) {
428
- const probeUrl = this.resolveProbeUrl();
429
- if (!probeUrl) return;
430
- this.stopProbeLoop();
431
- const intervalMs = this.options.probeIntervalMs ?? 3e4;
432
- let firstOkFired = false;
433
- let inFlight = false;
434
- const runProbe = async () => {
435
- if (this._destroyed || inFlight) return;
436
- inFlight = true;
437
- try {
438
- const ctrl = new AbortController();
439
- const timer = setTimeout(() => ctrl.abort(), 1e4);
440
- let resp;
441
- try {
442
- resp = await fetch(probeUrl, {
443
- method: "GET",
444
- signal: ctrl.signal,
445
- // Don't follow redirects — we just want any 2xx/3xx.
446
- redirect: "manual",
447
- headers: { "User-Agent": "svamp-frpc-probe/1" }
448
- });
449
- } finally {
450
- clearTimeout(timer);
451
- }
452
- if (resp.status < 400) {
453
- const wasFailing = !this._probeOk;
454
- this._probeOk = true;
455
- this._lastProbeOkAt = Date.now();
456
- if (wasFailing) {
457
- this.log(`probe ok: ${probeUrl} (${resp.status})`);
458
- }
459
- if (!firstOkFired && opts.onFirstOk) {
460
- firstOkFired = true;
461
- opts.onFirstOk();
462
- }
463
- } else {
464
- throw new Error(`probe got ${resp.status}`);
465
- }
466
- } catch (err) {
467
- const wasOk = this._probeOk;
468
- this._probeOk = false;
469
- this._lastProbeFailAt = Date.now();
470
- if (wasOk) {
471
- this.log(`probe failed: ${err?.message || err}`);
472
- this.options.onProbeFail?.(err instanceof Error ? err : new Error(String(err)));
473
- }
474
- } finally {
475
- inFlight = false;
476
- }
477
- };
478
- const initialDelays = [500, 2e3, 5e3, 1e4];
479
- let burstIdx = 0;
480
- const burstTimer = setInterval(() => {
481
- if (this._destroyed || firstOkFired || burstIdx >= initialDelays.length) {
482
- clearInterval(burstTimer);
483
- return;
484
- }
485
- burstIdx++;
486
- void runProbe();
487
- }, 1500);
488
- if (intervalMs > 0) {
489
- this._probeTimer = setInterval(runProbe, intervalMs);
490
- }
491
- void runProbe();
492
- }
493
- stopProbeLoop() {
494
- if (this._probeTimer) {
495
- clearInterval(this._probeTimer);
496
- this._probeTimer = null;
497
- }
498
- }
499
- /**
500
- * Poll frpc's loopback admin API (`/api/status`) to track whether this
501
- * tunnel's proxies are registered ("running"). Feeds the same `_probeOk` /
502
- * staleness fields the daemon health loop watches, so a ghosted/stuck proxy
503
- * is detected and recreated even with no HTTP health endpoint on the backend.
504
- *
505
- * Conservative semantics (critical infra — must not churn healthy tunnels):
506
- * - all proxies "running" → ok (refresh lastProbeOkAt)
507
- * - a proxy present but not running, or missing after a grace period
508
- * → fail (sets lastProbeFailAt; daemon recreates after staleness)
509
- * - admin API unreachable → INCONCLUSIVE: leave _probeOk untouched
510
- * (a transient blip can't flip ok→fail)
511
- */
512
- startAdminStatusLoop() {
513
- this.stopAdminStatusLoop();
514
- if (!this._adminPort) return;
515
- const proxyNames = this.proxies.map((p) => p.name);
516
- const intervalMs = this.options.probeIntervalMs ?? 3e4;
517
- const startedAt = Date.now();
518
- const auth = "Basic " + Buffer.from(`${this._adminUser}:${this._adminPassword}`).toString("base64");
519
- this._probeOk = true;
520
- this._lastProbeOkAt = Date.now();
521
- let inFlight = false;
522
- const poll = async () => {
523
- if (this._destroyed || inFlight || !this.process) return;
524
- inFlight = true;
525
- try {
526
- const ctrl = new AbortController();
527
- const timer = setTimeout(() => ctrl.abort(), 5e3);
528
- let payload;
529
- try {
530
- const resp = await fetch(`http://127.0.0.1:${this._adminPort}/api/status`, {
531
- headers: { Authorization: auth },
532
- signal: ctrl.signal
533
- });
534
- if (!resp.ok) throw new Error(`admin status ${resp.status}`);
535
- payload = await resp.json();
536
- } finally {
537
- clearTimeout(timer);
538
- }
539
- const { allRunning, missing, failed } = parseFrpcStatus(payload, proxyNames);
540
- const graceElapsed = Date.now() - startedAt > 2e4;
541
- if (allRunning || !failed.length && !graceElapsed) {
542
- const wasFailing = !this._probeOk;
543
- this._probeOk = true;
544
- this._lastProbeOkAt = Date.now();
545
- if (wasFailing) this.log(`admin status ok: all proxies running`);
546
- } else {
547
- const wasOk = this._probeOk;
548
- this._probeOk = false;
549
- this._lastProbeFailAt = Date.now();
550
- if (wasOk) {
551
- const detail = [
552
- ...failed.map((f) => `${f.name}=${f.status}${f.err ? ` (${f.err})` : ""}`),
553
- ...missing.map((m) => `${m}=missing`)
554
- ].join(", ");
555
- this.log(`admin status fail: ${detail}`);
556
- this.options.onProbeFail?.(new Error(`frpc proxy not running: ${detail}`));
557
- }
558
- }
559
- } catch {
560
- } finally {
561
- inFlight = false;
562
- }
563
- };
564
- if (intervalMs > 0) this._statusTimer = setInterval(poll, intervalMs);
565
- setTimeout(() => void poll(), 3e3);
566
- }
567
- stopAdminStatusLoop() {
568
- if (this._statusTimer) {
569
- clearInterval(this._statusTimer);
570
- this._statusTimer = null;
571
- }
572
- }
573
- /** Disconnect and stop the frpc process. */
574
- destroy() {
575
- this._destroyed = true;
576
- this._connected = false;
577
- this.stopProbeLoop();
578
- this.stopAdminStatusLoop();
579
- if (this.process) {
580
- this.process.kill("SIGTERM");
581
- const p = this.process;
582
- setTimeout(() => {
583
- try {
584
- p.kill("SIGKILL");
585
- } catch {
586
- }
587
- }, 5e3);
588
- this.process = null;
589
- }
590
- try {
591
- unlinkSync(this.configPath);
592
- } catch {
593
- }
594
- }
595
- /** Whether the frpc tunnel is connected. */
596
- get isConnected() {
597
- return this._connected && this.process !== null;
598
- }
599
- /** Snapshot of tunnel health for monitoring. */
600
- get status() {
601
- return {
602
- connected: this.isConnected,
603
- pid: this.process?.pid ?? null,
604
- name: this.options.name,
605
- ports: this.options.ports,
606
- serverAddr: this.serverConfig.serverAddr,
607
- recentLogs: this.logBuffer.slice(-10),
608
- consecutiveErrors: this._consecutiveErrors,
609
- lastConnectedAt: this._lastConnectedAt,
610
- lastErrorAt: this._lastErrorAt,
611
- firstErrorAt: this._firstErrorAt,
612
- restartAttempts: this._restartAttempts,
613
- failingDurationMs: this._firstErrorAt > 0 ? Date.now() - this._firstErrorAt : 0,
614
- probe: this.resolveProbeUrl() || this.options.adminStatus && this._adminPort ? {
615
- url: this.resolveProbeUrl() || `frpc-admin:${this._adminPort}/api/status`,
616
- ok: this._probeOk,
617
- lastOkAt: this._lastProbeOkAt,
618
- lastFailAt: this._lastProbeFailAt,
619
- stalenessMs: this._lastProbeOkAt > 0 ? Date.now() - this._lastProbeOkAt : 0
620
- } : void 0
621
- };
622
- }
623
- /** Update the Hypha token. Rewrites config; takes effect on next frpc restart. */
624
- updateToken(newToken) {
625
- this.serverConfig.hyphaToken = newToken;
626
- const admin = this.options.adminStatus && this._adminPort ? { port: this._adminPort, user: this._adminUser, password: this._adminPassword } : void 0;
627
- const configContent = generateFrpcConfig(this.serverConfig, this.proxies, admin);
628
- writeFileSync(this.configPath, configContent);
629
- this.log("Config updated with fresh token");
630
- }
631
- /** Get the public URLs for each port. */
632
- getUrls() {
633
- const urls = /* @__PURE__ */ new Map();
634
- const base = this.serverConfig.subDomainHost || "svc.hypha.amun.ai";
635
- for (const proxy of this.proxies) {
636
- const subdomain = proxy.subdomain || proxy.name;
637
- urls.set(proxy.localPort, `https://${subdomain}.${base}`);
638
- }
639
- return urls;
640
- }
641
- }
642
- async function runFrpcTunnel(name, ports, serverConfig, tunnelOptions) {
643
- const portList = ports.join(", ");
644
- const tunnel = new FrpcTunnel({
645
- name,
646
- ports,
647
- serverConfig,
648
- ...tunnelOptions,
649
- onConnect: () => {
650
- console.log(`Tunnel connected: ${name} \u2192 localhost:[${portList}]`);
651
- const urls = tunnel.getUrls();
652
- for (const [port, url] of urls) {
653
- console.log(` ${port} \u2192 ${url}`);
654
- }
655
- },
656
- onDisconnect: () => {
657
- console.log("Tunnel disconnected, frpc will auto-reconnect...");
658
- },
659
- onError: (err) => {
660
- console.error(`Tunnel error: ${err.message}`);
661
- }
662
- });
663
- const cleanup = () => {
664
- console.log("\nTunnel shutting down...");
665
- tunnel.destroy();
666
- process.exit(0);
667
- };
668
- process.on("SIGINT", cleanup);
669
- process.on("SIGTERM", cleanup);
670
- try {
671
- await tunnel.connect();
672
- console.log(`Tunnel is active. Press Ctrl+C to stop.`);
673
- await new Promise(() => {
674
- });
675
- } catch (err) {
676
- console.error(`Failed to establish tunnel: ${err.message}`);
677
- process.exit(1);
678
- }
679
- }
680
-
681
- export { FrpcTunnel, ensureFrpc, generateFrpcConfig, getFreePort, parseFrpcStatus, runFrpcTunnel };