pinggy 0.5.2 → 0.5.5

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/README.md CHANGED
@@ -324,6 +324,7 @@ Extended options provide advanced controls. Specify them as positional values li
324
324
  - x:reverseproxy Disable built-in reverse-proxy header injection.
325
325
  - x:xff Add X-Forwarded-For.
326
326
  - x:fullurl | x:fullrequesturl Include original request URL.
327
+ - x:haproxy | x:haproxy:v1 | x:haproxy:v2 Send HAProxy PROXY protocol header to the local server (default v1).
327
328
  - w:<cidr>[,<cidr>...] Whitelist IPs (IPv4 CIDR).
328
329
  - k:<token> Set Bearer token(s) for auth (repeatable).
329
330
  - b:<user:pass> Add Basic Auth credentials (repeatable).
@@ -338,6 +339,9 @@ Examples:
338
339
  - Allow only a local subnet:
339
340
  pinggy w:192.168.1.0/24 -l 8080
340
341
 
342
+ - Send the real client IP to a PROXY-protocol-aware backend:
343
+ pinggy x:haproxy:v2 -l 8080
344
+
341
345
  To generate advanced CLI arguments, use [Configure from Pinggy.io](https://pinggy.io/)
342
346
 
343
347
 
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  TunnelAlreadyRunningError,
3
3
  TunnelManager
4
- } from "./chunk-DLNUDW6G.js";
4
+ } from "./chunk-PQSLSIDA.js";
5
5
  import "./chunk-UB26QJ4T.js";
6
- import "./chunk-7G6SJEEA.js";
6
+ import "./chunk-P7WNBN7F.js";
7
7
  import "./chunk-GBYF2H4H.js";
8
8
  export {
9
9
  TunnelAlreadyRunningError,
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  TunnelManager
3
- } from "./chunk-DLNUDW6G.js";
3
+ } from "./chunk-PQSLSIDA.js";
4
4
  import "./chunk-UB26QJ4T.js";
5
5
  import {
6
6
  logger
7
- } from "./chunk-7G6SJEEA.js";
7
+ } from "./chunk-P7WNBN7F.js";
8
8
  import "./chunk-GBYF2H4H.js";
9
9
 
10
10
  // src/tui/blessed/TunnelTui.ts
@@ -150,10 +150,21 @@ var asciiArtPinggyLogo = `
150
150
  \u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2554\u255D
151
151
  \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2551
152
152
  \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D `;
153
+ var asciiArtPinggyLogoCompact = "P I N G G Y";
154
+ var asciiArtPinggyLogoMedium = [
155
+ " ___ ___ _ _ ___ _____ __",
156
+ "| _ \\_ _| \\| |/ __|/ __\\ \\ / /",
157
+ "| _/| || .` | (_ | (_ |\\ V /",
158
+ "|_| |___|_|\\_|\\___|\\___| |_|"
159
+ ].join("\n");
153
160
 
154
161
  // src/tui/blessed/components/UIComponents.ts
155
162
  var MIN_WIDTH_WARNING = 60;
156
163
  var SIMPLE_LAYOUT_THRESHOLD = 80;
164
+ var MEDIUM_LOGO_THRESHOLD = 100;
165
+ var FULL_LOGO_THRESHOLD = 120;
166
+ var MEDIUM_LOGO_HEIGHT_THRESHOLD = 26;
167
+ var FULL_LOGO_HEIGHT_THRESHOLD = 34;
157
168
  function colorizeGradient(text) {
158
169
  const colors = ["red", "yellow", "green", "cyan", "blue", "magenta"];
159
170
  const lines = text.split("\n");
@@ -162,6 +173,54 @@ function colorizeGradient(text) {
162
173
  return `{${color}-fg}${line}{/${color}-fg}`;
163
174
  }).join("\n");
164
175
  }
176
+ function colorizeGradientHorizontal(text) {
177
+ const colors = ["red", "yellow", "green", "cyan", "blue", "magenta"];
178
+ let colorIndex = 0;
179
+ return [...text].map((char) => {
180
+ if (char === " ") return char;
181
+ const color = colors[colorIndex % colors.length];
182
+ colorIndex++;
183
+ return `{${color}-fg}${char}{/${color}-fg}`;
184
+ }).join("");
185
+ }
186
+ function selectLogo(width, height) {
187
+ const widthTier = width >= FULL_LOGO_THRESHOLD ? 2 : width >= MEDIUM_LOGO_THRESHOLD ? 1 : 0;
188
+ const heightTier = height >= FULL_LOGO_HEIGHT_THRESHOLD ? 2 : height >= MEDIUM_LOGO_HEIGHT_THRESHOLD ? 1 : 0;
189
+ const tier = Math.min(widthTier, heightTier);
190
+ if (tier === 2) {
191
+ return { content: colorizeGradient(asciiArtPinggyLogo), boxHeight: 7 };
192
+ }
193
+ if (tier === 1) {
194
+ return { content: colorizeGradient(asciiArtPinggyLogoMedium), boxHeight: 5 };
195
+ }
196
+ return {
197
+ content: `{bold}${colorizeGradientHorizontal(asciiArtPinggyLogoCompact)}{/bold}`,
198
+ boxHeight: 1
199
+ };
200
+ }
201
+ function wrapText(text, width) {
202
+ const lines = [];
203
+ let current = "";
204
+ for (const word of text.split(/\s+/).filter(Boolean)) {
205
+ const candidate = current ? `${current} ${word}` : word;
206
+ if (candidate.length <= width) {
207
+ current = candidate;
208
+ continue;
209
+ }
210
+ if (current) {
211
+ lines.push(current);
212
+ current = "";
213
+ }
214
+ let rest = word;
215
+ while (rest.length > width) {
216
+ lines.push(rest.slice(0, width));
217
+ rest = rest.slice(width);
218
+ }
219
+ current = rest;
220
+ }
221
+ if (current) lines.push(current);
222
+ return lines.length ? lines : [""];
223
+ }
165
224
  function createWarningUI(screen) {
166
225
  return blessed.box({
167
226
  parent: screen,
@@ -188,21 +247,26 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
188
247
  height: "100%",
189
248
  padding: 1
190
249
  });
250
+ const { content: logoContent, boxHeight: logoHeight } = selectLogo(
251
+ screen.width,
252
+ screen.height
253
+ );
191
254
  const logoBox = blessed.box({
192
255
  parent: mainContainer,
193
256
  top: 0,
194
257
  left: 0,
195
258
  width: "100%",
196
- height: 7,
197
- content: colorizeGradient(asciiArtPinggyLogo),
259
+ height: logoHeight,
260
+ content: logoContent,
198
261
  tags: true
199
262
  });
263
+ const contentTop = logoHeight + 1;
200
264
  const contentBox = blessed.box({
201
265
  parent: mainContainer,
202
- top: 8,
266
+ top: contentTop,
203
267
  left: 0,
204
268
  width: "100%-2",
205
- height: "100%-10",
269
+ height: `100%-${contentTop + 2}`,
206
270
  padding: 0,
207
271
  border: {
208
272
  type: "line"
@@ -215,20 +279,24 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
215
279
  });
216
280
  let greetHeight = 0;
217
281
  if (greet) {
218
- const greetBox = blessed.box({
282
+ const contentInnerWidth = screen.width - 6;
283
+ const greetWidth = Math.max(20, Math.floor(contentInnerWidth * 0.6));
284
+ const greetLines = wrapText(greet, greetWidth);
285
+ const greetBoxHeight = greetLines.length + 1;
286
+ blessed.box({
219
287
  parent: contentBox,
220
288
  top: 0,
221
289
  left: "center",
222
- width: "60%",
223
- height: 4,
224
- content: `{bold}${greet}{/bold}`,
290
+ width: greetWidth,
291
+ height: greetBoxHeight,
292
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
225
293
  tags: true,
226
294
  align: "center",
227
295
  style: {
228
296
  fg: "green"
229
297
  }
230
298
  });
231
- greetHeight = 4;
299
+ greetHeight = greetBoxHeight;
232
300
  }
233
301
  const upperSectionTop = greetHeight > 0 ? greetHeight : 0;
234
302
  const upperSection = blessed.box({
@@ -322,21 +390,22 @@ function createSimpleUI(screen, urls, greet) {
322
390
  });
323
391
  let currentTop = 0;
324
392
  if (greet) {
393
+ const greetWidth = Math.max(20, Math.floor((screen.width - 2) * 0.9));
394
+ const greetLines = wrapText(greet, greetWidth);
325
395
  blessed.box({
326
396
  parent: mainContainer,
327
397
  top: currentTop,
328
398
  left: "center",
329
- width: "90%",
330
- height: "shrink",
331
- content: `{bold}${greet}{/bold}`,
399
+ width: greetWidth,
400
+ height: greetLines.length,
401
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
332
402
  tags: true,
333
403
  align: "center",
334
404
  style: {
335
405
  fg: "green"
336
406
  }
337
407
  });
338
- const lines = Math.ceil(greet.length / (screen.width * 0.9));
339
- currentTop += Math.max(lines, 1) + 1;
408
+ currentTop += greetLines.length + 1;
340
409
  }
341
410
  const urlsBox = blessed.box({
342
411
  parent: mainContainer,
@@ -10,14 +10,16 @@ import {
10
10
  startDaemon,
11
11
  startRemoteManagement,
12
12
  stopDaemon
13
- } from "./chunk-C3ZMLIC4.js";
13
+ } from "./chunk-UX64XCC2.js";
14
14
  import {
15
15
  readDaemonConfig
16
16
  } from "./chunk-MT44NAXX.js";
17
17
  import {
18
+ DaemonHost,
18
19
  Route,
19
- SessionMode
20
- } from "./chunk-BFARGPGP.js";
20
+ SessionMode,
21
+ ShutdownStatus
22
+ } from "./chunk-YTJEV27N.js";
21
23
  import {
22
24
  ConfigVerb,
23
25
  SUBCOMMANDS,
@@ -34,7 +36,7 @@ import {
34
36
  updateConfigAutoStart,
35
37
  updateTunnelConfig,
36
38
  validateName
37
- } from "./chunk-WM7I57AA.js";
39
+ } from "./chunk-YWXADADB.js";
38
40
  import {
39
41
  TunnelManager,
40
42
  detachAllTunnelLoggers,
@@ -46,14 +48,14 @@ import {
46
48
  isValidPort,
47
49
  printer_default,
48
50
  setTunnelLoggingEnabled
49
- } from "./chunk-DLNUDW6G.js";
51
+ } from "./chunk-PQSLSIDA.js";
50
52
  import {
51
53
  configureLogger,
52
54
  enablePackageLogging,
53
55
  getLogLevel,
54
56
  logger,
55
57
  setLogLevel
56
- } from "./chunk-7G6SJEEA.js";
58
+ } from "./chunk-P7WNBN7F.js";
57
59
  import {
58
60
  ensurePinggyConfigDir,
59
61
  ensurePinggyLogDir,
@@ -135,6 +137,7 @@ function printHelpMessage() {
135
137
  console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
136
138
  console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
137
139
  console.log(" x:passpreflight Pass CORS preflight requests unchanged");
140
+ console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol (default v1)");
138
141
  console.log(" a:Key:Val Add header");
139
142
  console.log(" u:Key:Val Update header");
140
143
  console.log(" r:Key Remove header");
@@ -272,6 +275,7 @@ var defaultOptions = {
272
275
  };
273
276
 
274
277
  // src/cli/extendedOptions.ts
278
+ import { HaProxyVersion } from "@pinggy/pinggy";
275
279
  import { isIP } from "net";
276
280
  function parseExtendedOptions(options, config, localServerTls) {
277
281
  if (!options) return localServerTls;
@@ -298,10 +302,20 @@ function parseExtendedOptions(options, config, localServerTls) {
298
302
  case "fullrequesturl":
299
303
  config.originalRequestUrl = true;
300
304
  break;
305
+ case "haproxy":
306
+ case "haproxy:v1":
307
+ config.haProxy = HaProxyVersion.V1;
308
+ break;
309
+ case "haproxy:v2":
310
+ config.haProxy = HaProxyVersion.V2;
311
+ break;
301
312
  default: {
302
313
  if (value && (value.startsWith("localServerTls") || value.startsWith("localservertls"))) {
303
314
  const parts = value.split(/:(.+)/);
304
315
  localServerTls = parts[1] ? parts[1] : "";
316
+ } else if (value && value.startsWith("haproxy:")) {
317
+ printer_default.warn(`Invalid HAProxy version in "${opt}". Use x:haproxy, x:haproxy:v1 or x:haproxy:v2`);
318
+ logger.warn(`Warning: Invalid HAProxy version in "${opt}". Use x:haproxy, x:haproxy:v1 or x:haproxy:v2`);
305
319
  } else {
306
320
  printer_default.warn(`Unknown extended option "${value}"`);
307
321
  logger.warn(`Warning: Unknown extended option "${value}"`);
@@ -1085,7 +1099,7 @@ async function connectTui(opts) {
1085
1099
  const exitMessage = opts.exitMessage || "Stopping tunnel...";
1086
1100
  if (!noTui && process.stdin.isTTY) {
1087
1101
  try {
1088
- const { TunnelTui } = await import("./TunnelTui-QZEWWH2H.js");
1102
+ const { TunnelTui } = await import("./TunnelTui-S35BNVPO.js");
1089
1103
  const tui = new TunnelTui({
1090
1104
  urls,
1091
1105
  greet,
@@ -1308,7 +1322,7 @@ async function startBackgroundTunnels(configs, values, positionals) {
1308
1322
  client.close();
1309
1323
  }
1310
1324
  async function startAutoStartTunnels() {
1311
- const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-HP3NXPH4.js");
1325
+ const { getAutoStartConfigs: getAutoStartConfigs2 } = await import("./configStore-2AZRILVC.js");
1312
1326
  const configs = getAutoStartConfigs2();
1313
1327
  if (configs.length === 0) {
1314
1328
  printer_default.warn("No configs marked for auto-start. Use: pinggy config auto <name>");
@@ -1777,6 +1791,8 @@ async function handleDaemonStop() {
1777
1791
  const result = await stopDaemon();
1778
1792
  if (result.ok) {
1779
1793
  printer_default.success("Daemon stopped.");
1794
+ } else if (result.reason === DaemonHost.APP) {
1795
+ printer_default.print(pico2.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
1780
1796
  } else {
1781
1797
  printer_default.error(result.error);
1782
1798
  }
@@ -1797,6 +1813,7 @@ function handleDaemonStatus() {
1797
1813
  printer_default.print(` Port: ${info.port}`);
1798
1814
  printer_default.print(` Started: ${info.startedAt}`);
1799
1815
  printer_default.print(` Uptime: ${uptimeStr}`);
1816
+ printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
1800
1817
  }
1801
1818
 
1802
1819
  // src/cli/subcommand/handlers/psCommand.ts
@@ -2630,7 +2647,7 @@ var IPCServer = class {
2630
2647
  },
2631
2648
  [Route.RestartTunnel]: async (req) => {
2632
2649
  if (!req.tunnelid) throw new Error("Missing 'tunnelid' field");
2633
- return await this.ops.handleRestart(req.tunnelid);
2650
+ return await this.ops.handleRestart(req.tunnelid, req.noWait);
2634
2651
  },
2635
2652
  [Route.UpdateConfig]: async (req) => {
2636
2653
  if (!req.config) throw new Error("Missing 'config' field");
@@ -2697,6 +2714,10 @@ var IPCServer = class {
2697
2714
  },
2698
2715
  [Route.Shutdown]: () => {
2699
2716
  logger.info("Daemon shutdown requested via IPC");
2717
+ if (getDaemonHost() === DaemonHost.APP) {
2718
+ logger.info("Shutdown refused: daemon is hosted in-process by the Pinggy app");
2719
+ return { status: ShutdownStatus.RefusedInApp, errors: [] };
2720
+ }
2700
2721
  const errors = [];
2701
2722
  const step = (label, fn) => {
2702
2723
  try {
@@ -2711,7 +2732,7 @@ var IPCServer = class {
2711
2732
  step("clearDaemonState", clearDaemonState);
2712
2733
  step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
2713
2734
  setTimeout(() => process.exit(0), 200);
2714
- return { status: "shutting_down", errors };
2735
+ return { status: ShutdownStatus.ShuttingDown, errors };
2715
2736
  }
2716
2737
  };
2717
2738
  }
@@ -3154,6 +3175,10 @@ var SessionTracker = class {
3154
3175
 
3155
3176
  // src/daemon/lifecycle/daemonChild.ts
3156
3177
  var daemonState = { tunnels: [], lastUpdated: "" };
3178
+ var daemonHost = DaemonHost.CLI;
3179
+ function getDaemonHost() {
3180
+ return daemonHost;
3181
+ }
3157
3182
  var sessionTrackerRef = null;
3158
3183
  function setDaemonSessionTracker(st) {
3159
3184
  sessionTrackerRef = st;
@@ -3283,6 +3308,7 @@ async function restoreCrashedTunnels(manager) {
3283
3308
  async function runDaemonChild(opts = {}) {
3284
3309
  const installSignalHandlers = opts.installSignalHandlers ?? true;
3285
3310
  const exitOnFailure = opts.exitOnFailure ?? true;
3311
+ daemonHost = opts.host ?? DaemonHost.CLI;
3286
3312
  ensurePinggyConfigDir();
3287
3313
  const persistedLevel = readDaemonConfig()?.logLevel;
3288
3314
  const envLevel = process.env.PINGGY_LOG_LEVEL;
@@ -3358,7 +3384,8 @@ async function runDaemonChild(opts = {}) {
3358
3384
  const info = {
3359
3385
  pid: process.pid,
3360
3386
  port,
3361
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
3387
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3388
+ host: daemonHost
3362
3389
  };
3363
3390
  writeDaemonInfo(info);
3364
3391
  logger.info("Daemon info written", info);
@@ -3399,7 +3426,7 @@ async function main() {
3399
3426
  const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
3400
3427
  configureLogger(values);
3401
3428
  if (values["_daemon-child"]) {
3402
- const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-6QA4AVAN.js");
3429
+ const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-ZACX7V6J.js");
3403
3430
  await runDaemonChild2();
3404
3431
  return;
3405
3432
  }
@@ -3433,6 +3460,7 @@ if (entryFile && entryFile === currentFile) {
3433
3460
  }
3434
3461
 
3435
3462
  export {
3463
+ getDaemonHost,
3436
3464
  setDaemonSessionTracker,
3437
3465
  removeDaemonInfo,
3438
3466
  trackTunnelStart,
@@ -16,7 +16,7 @@ function setLogLevel(level) {
16
16
  }
17
17
  import("./daemonConfig-G6S46GPJ.js").then(({ writeDaemonConfig }) => writeDaemonConfig({ logLevel: level })).catch(() => {
18
18
  });
19
- import("./TunnelManager-OPUMAZFX.js").then(({ TunnelManager }) => TunnelManager.getInstance().applyLogLevelToActiveTunnels(level)).catch(() => {
19
+ import("./TunnelManager-ZSVX7U3Z.js").then(({ TunnelManager }) => TunnelManager.getInstance().applyLogLevelToActiveTunnels(level)).catch(() => {
20
20
  });
21
21
  }
22
22
  var _logger = null;
@@ -4,7 +4,7 @@ import {
4
4
  import {
5
5
  getLogLevel,
6
6
  logger
7
- } from "./chunk-7G6SJEEA.js";
7
+ } from "./chunk-P7WNBN7F.js";
8
8
  import {
9
9
  getTunnelLogPath
10
10
  } from "./chunk-GBYF2H4H.js";
@@ -121,7 +121,7 @@ function startSpinner(name = "dots", text = "Loading") {
121
121
  }
122
122
  currentTimer = setInterval(() => {
123
123
  const frame = spinner.frames[i = ++i % spinner.frames.length];
124
- process.stdout.write(`\r${pico.cyan(frame)} ${text}`);
124
+ process.stdout.write(`\r${pico.cyan(frame)} ${text}\x1B[K`);
125
125
  }, spinner.interval);
126
126
  return () => stopSpinner();
127
127
  }
@@ -137,7 +137,7 @@ function stopSpinnerSuccess(message) {
137
137
  clearInterval(currentTimer);
138
138
  currentTimer = null;
139
139
  const finalMessage = message || currentText;
140
- process.stdout.write(`\r${pico.green("\u2714")} ${finalMessage}
140
+ process.stdout.write(`\r${pico.green("\u2714")} ${finalMessage}\x1B[K
141
141
  `);
142
142
  }
143
143
  }
@@ -146,7 +146,7 @@ function stopSpinnerFail(message) {
146
146
  clearInterval(currentTimer);
147
147
  currentTimer = null;
148
148
  const finalMessage = message || currentText;
149
- process.stdout.write(`\r${pico.red("\u2716")} ${finalMessage}
149
+ process.stdout.write(`\r${pico.red("\u2716")} ${finalMessage}\x1B[K
150
150
  `);
151
151
  }
152
152
  }
@@ -724,6 +724,29 @@ var TunnelManager = class _TunnelManager {
724
724
  }
725
725
  return Promise.reject(new Error(`Either configId or tunnelId must be provided`));
726
726
  }
727
+ /**
728
+ * Stop a tunnel instance and wait for the stop to settle, capped by a
729
+ * timeout. Failure is logged, not thrown: the caller replaces the
730
+ * instance either way and a hung stop must not block that forever.
731
+ */
732
+ async stopInstanceGuarded(managed, timeoutMs = 5e3) {
733
+ let timer;
734
+ try {
735
+ await Promise.race([
736
+ managed.instance.stop(),
737
+ new Promise((_, reject) => {
738
+ timer = setTimeout(() => reject(new Error(`Stop timed out after ${timeoutMs}ms`)), timeoutMs);
739
+ })
740
+ ]);
741
+ } catch (e) {
742
+ logger.warn("Failed to stop tunnel instance before replacement", {
743
+ tunnelId: managed.tunnelid,
744
+ error: errorMessage(e)
745
+ });
746
+ } finally {
747
+ if (timer) clearTimeout(timer);
748
+ }
749
+ }
727
750
  /**
728
751
  * Restarts a tunnel with its current configuration.
729
752
  * This function will stop the tunnel if it's running and start it again.
@@ -745,6 +768,9 @@ var TunnelManager = class _TunnelManager {
745
768
  const currentServe = existingTunnel.serve;
746
769
  const autoReconnect = existingTunnel.autoReconnect || false;
747
770
  const currentOrigin = existingTunnel.origin;
771
+ if (!existingTunnel.isStopped) {
772
+ await this.stopInstanceGuarded(existingTunnel);
773
+ }
748
774
  this.tunnelsByTunnelId.delete(tunnelid);
749
775
  this.tunnelsByConfigId.delete(existingTunnel.configId);
750
776
  this.tunnelStats.delete(tunnelid);
@@ -779,9 +805,26 @@ var TunnelManager = class _TunnelManager {
779
805
  throw new Error(`Failed to restart tunnel: ${error instanceof Error ? error.message : "Unknown error"}`);
780
806
  }
781
807
  }
808
+ /**
809
+ * Carry lifecycle state from a replaced tunnel over to its replacement:
810
+ * keep createdAt, start the replacement if the old tunnel was running,
811
+ * otherwise keep it stopped with the original timestamps.
812
+ */
813
+ async applyPreservedLifecycle(tunnel, prior) {
814
+ if (prior.createdAt) {
815
+ tunnel.createdAt = prior.createdAt;
816
+ }
817
+ if (!prior.isStopped) {
818
+ await this.startTunnel(tunnel.tunnelid);
819
+ return;
820
+ }
821
+ tunnel.isStopped = true;
822
+ tunnel.startedAt = prior.startedAt;
823
+ tunnel.stoppedAt = prior.stoppedAt ?? (/* @__PURE__ */ new Date()).toISOString();
824
+ }
782
825
  /**
783
826
  * Updates the configuration of an existing tunnel.
784
- *
827
+ *
785
828
  * This method handles the process of updating a tunnel's configuration while preserving
786
829
  * its state. If the tunnel is running, it will be stopped, updated, and restarted.
787
830
  * In case of failure, it attempts to restore the original configuration.
@@ -808,10 +851,13 @@ var TunnelManager = class _TunnelManager {
808
851
  const currentServe = existingTunnel.serve;
809
852
  const currentAutoReconnect = existingTunnel.autoReconnect || false;
810
853
  const currentOrigin = existingTunnel.origin;
854
+ const currentCreatedAt = existingTunnel.createdAt;
855
+ const currentStartedAt = existingTunnel.startedAt;
856
+ const currentStoppedAt = existingTunnel.stoppedAt;
811
857
  const requestedServe = this.resolveServePath(newConfig);
812
858
  try {
813
859
  if (!isStopped) {
814
- void existingTunnel.instance.stop();
860
+ await this.stopInstanceGuarded(existingTunnel);
815
861
  }
816
862
  this.tunnelsByTunnelId.delete(currentTunnelId);
817
863
  this.tunnelsByConfigId.delete(currentTunnelConfigId);
@@ -823,7 +869,6 @@ var TunnelManager = class _TunnelManager {
823
869
  };
824
870
  const effectiveServe = requestedServe !== void 0 ? requestedServe : currentServe;
825
871
  const effectiveTunnelName = newTunnelName !== void 0 ? newTunnelName : currentTunnelName;
826
- let configWithForwarding;
827
872
  const newTunnel = await this._createTunnelWithProcessedConfig({
828
873
  configId,
829
874
  tunnelid: currentTunnelId,
@@ -833,9 +878,12 @@ var TunnelManager = class _TunnelManager {
833
878
  serve: effectiveServe,
834
879
  autoReconnect: currentAutoReconnect
835
880
  });
836
- if (!isStopped) {
837
- await this.startTunnel(newTunnel.tunnelid);
838
- }
881
+ await this.applyPreservedLifecycle(newTunnel, {
882
+ isStopped,
883
+ createdAt: currentCreatedAt,
884
+ startedAt: currentStartedAt,
885
+ stoppedAt: currentStoppedAt
886
+ });
839
887
  logger.info("Tunnel configuration updated", {
840
888
  tunnelId: newTunnel.tunnelid,
841
889
  configId: newTunnel.configId,
@@ -857,9 +905,12 @@ var TunnelManager = class _TunnelManager {
857
905
  serve: currentServe,
858
906
  autoReconnect: currentAutoReconnect
859
907
  });
860
- if (!isStopped) {
861
- await this.startTunnel(originalTunnel.tunnelid);
862
- }
908
+ await this.applyPreservedLifecycle(originalTunnel, {
909
+ isStopped,
910
+ createdAt: currentCreatedAt,
911
+ startedAt: currentStartedAt,
912
+ stoppedAt: currentStoppedAt
913
+ });
863
914
  logger.warn("Restored original tunnel configuration after update failure", {
864
915
  currentTunnelId,
865
916
  error: errorMessage(error)
@@ -1476,6 +1527,9 @@ var TunnelManager = class _TunnelManager {
1476
1527
  try {
1477
1528
  logger.info("Tunnel reconnection completed", { tunnelId, urls });
1478
1529
  const managedTunnel = this.tunnelsByTunnelId.get(tunnelId);
1530
+ if (managedTunnel?.isStopping || managedTunnel?.isStopped) {
1531
+ return;
1532
+ }
1479
1533
  if (managedTunnel) {
1480
1534
  managedTunnel.isStopped = false;
1481
1535
  managedTunnel.startedAt = (/* @__PURE__ */ new Date()).toISOString();