pinggy 0.5.3 → 0.5.6

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/dist/index.cjs CHANGED
@@ -51,7 +51,7 @@ function startSpinner(name = "dots", text = "Loading") {
51
51
  }
52
52
  currentTimer = setInterval(() => {
53
53
  const frame = spinner.frames[i = ++i % spinner.frames.length];
54
- process.stdout.write(`\r${import_picocolors.default.cyan(frame)} ${text}`);
54
+ process.stdout.write(`\r${import_picocolors.default.cyan(frame)} ${text}\x1B[K`);
55
55
  }, spinner.interval);
56
56
  return () => stopSpinner();
57
57
  }
@@ -67,7 +67,7 @@ function stopSpinnerSuccess(message) {
67
67
  clearInterval(currentTimer);
68
68
  currentTimer = null;
69
69
  const finalMessage = message || currentText;
70
- process.stdout.write(`\r${import_picocolors.default.green("\u2714")} ${finalMessage}
70
+ process.stdout.write(`\r${import_picocolors.default.green("\u2714")} ${finalMessage}\x1B[K
71
71
  `);
72
72
  }
73
73
  }
@@ -76,7 +76,7 @@ function stopSpinnerFail(message) {
76
76
  clearInterval(currentTimer);
77
77
  currentTimer = null;
78
78
  const finalMessage = message || currentText;
79
- process.stdout.write(`\r${import_picocolors.default.red("\u2716")} ${finalMessage}
79
+ process.stdout.write(`\r${import_picocolors.default.red("\u2716")} ${finalMessage}\x1B[K
80
80
  `);
81
81
  }
82
82
  }
@@ -1063,6 +1063,29 @@ var init_TunnelManager = __esm({
1063
1063
  }
1064
1064
  return Promise.reject(new Error(`Either configId or tunnelId must be provided`));
1065
1065
  }
1066
+ /**
1067
+ * Stop a tunnel instance and wait for the stop to settle, capped by a
1068
+ * timeout. Failure is logged, not thrown: the caller replaces the
1069
+ * instance either way and a hung stop must not block that forever.
1070
+ */
1071
+ async stopInstanceGuarded(managed, timeoutMs = 5e3) {
1072
+ let timer;
1073
+ try {
1074
+ await Promise.race([
1075
+ managed.instance.stop(),
1076
+ new Promise((_, reject) => {
1077
+ timer = setTimeout(() => reject(new Error(`Stop timed out after ${timeoutMs}ms`)), timeoutMs);
1078
+ })
1079
+ ]);
1080
+ } catch (e) {
1081
+ logger.warn("Failed to stop tunnel instance before replacement", {
1082
+ tunnelId: managed.tunnelid,
1083
+ error: errorMessage(e)
1084
+ });
1085
+ } finally {
1086
+ if (timer) clearTimeout(timer);
1087
+ }
1088
+ }
1066
1089
  /**
1067
1090
  * Restarts a tunnel with its current configuration.
1068
1091
  * This function will stop the tunnel if it's running and start it again.
@@ -1084,6 +1107,9 @@ var init_TunnelManager = __esm({
1084
1107
  const currentServe = existingTunnel.serve;
1085
1108
  const autoReconnect = existingTunnel.autoReconnect || false;
1086
1109
  const currentOrigin = existingTunnel.origin;
1110
+ if (!existingTunnel.isStopped) {
1111
+ await this.stopInstanceGuarded(existingTunnel);
1112
+ }
1087
1113
  this.tunnelsByTunnelId.delete(tunnelid);
1088
1114
  this.tunnelsByConfigId.delete(existingTunnel.configId);
1089
1115
  this.tunnelStats.delete(tunnelid);
@@ -1118,9 +1144,26 @@ var init_TunnelManager = __esm({
1118
1144
  throw new Error(`Failed to restart tunnel: ${error instanceof Error ? error.message : "Unknown error"}`);
1119
1145
  }
1120
1146
  }
1147
+ /**
1148
+ * Carry lifecycle state from a replaced tunnel over to its replacement:
1149
+ * keep createdAt, start the replacement if the old tunnel was running,
1150
+ * otherwise keep it stopped with the original timestamps.
1151
+ */
1152
+ async applyPreservedLifecycle(tunnel, prior) {
1153
+ if (prior.createdAt) {
1154
+ tunnel.createdAt = prior.createdAt;
1155
+ }
1156
+ if (!prior.isStopped) {
1157
+ await this.startTunnel(tunnel.tunnelid);
1158
+ return;
1159
+ }
1160
+ tunnel.isStopped = true;
1161
+ tunnel.startedAt = prior.startedAt;
1162
+ tunnel.stoppedAt = prior.stoppedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1163
+ }
1121
1164
  /**
1122
1165
  * Updates the configuration of an existing tunnel.
1123
- *
1166
+ *
1124
1167
  * This method handles the process of updating a tunnel's configuration while preserving
1125
1168
  * its state. If the tunnel is running, it will be stopped, updated, and restarted.
1126
1169
  * In case of failure, it attempts to restore the original configuration.
@@ -1147,10 +1190,13 @@ var init_TunnelManager = __esm({
1147
1190
  const currentServe = existingTunnel.serve;
1148
1191
  const currentAutoReconnect = existingTunnel.autoReconnect || false;
1149
1192
  const currentOrigin = existingTunnel.origin;
1193
+ const currentCreatedAt = existingTunnel.createdAt;
1194
+ const currentStartedAt = existingTunnel.startedAt;
1195
+ const currentStoppedAt = existingTunnel.stoppedAt;
1150
1196
  const requestedServe = this.resolveServePath(newConfig);
1151
1197
  try {
1152
1198
  if (!isStopped) {
1153
- void existingTunnel.instance.stop();
1199
+ await this.stopInstanceGuarded(existingTunnel);
1154
1200
  }
1155
1201
  this.tunnelsByTunnelId.delete(currentTunnelId);
1156
1202
  this.tunnelsByConfigId.delete(currentTunnelConfigId);
@@ -1162,7 +1208,6 @@ var init_TunnelManager = __esm({
1162
1208
  };
1163
1209
  const effectiveServe = requestedServe !== void 0 ? requestedServe : currentServe;
1164
1210
  const effectiveTunnelName = newTunnelName !== void 0 ? newTunnelName : currentTunnelName;
1165
- let configWithForwarding;
1166
1211
  const newTunnel = await this._createTunnelWithProcessedConfig({
1167
1212
  configId,
1168
1213
  tunnelid: currentTunnelId,
@@ -1172,9 +1217,12 @@ var init_TunnelManager = __esm({
1172
1217
  serve: effectiveServe,
1173
1218
  autoReconnect: currentAutoReconnect
1174
1219
  });
1175
- if (!isStopped) {
1176
- await this.startTunnel(newTunnel.tunnelid);
1177
- }
1220
+ await this.applyPreservedLifecycle(newTunnel, {
1221
+ isStopped,
1222
+ createdAt: currentCreatedAt,
1223
+ startedAt: currentStartedAt,
1224
+ stoppedAt: currentStoppedAt
1225
+ });
1178
1226
  logger.info("Tunnel configuration updated", {
1179
1227
  tunnelId: newTunnel.tunnelid,
1180
1228
  configId: newTunnel.configId,
@@ -1196,9 +1244,12 @@ var init_TunnelManager = __esm({
1196
1244
  serve: currentServe,
1197
1245
  autoReconnect: currentAutoReconnect
1198
1246
  });
1199
- if (!isStopped) {
1200
- await this.startTunnel(originalTunnel.tunnelid);
1201
- }
1247
+ await this.applyPreservedLifecycle(originalTunnel, {
1248
+ isStopped,
1249
+ createdAt: currentCreatedAt,
1250
+ startedAt: currentStartedAt,
1251
+ stoppedAt: currentStoppedAt
1252
+ });
1202
1253
  logger.warn("Restored original tunnel configuration after update failure", {
1203
1254
  currentTunnelId,
1204
1255
  error: errorMessage(error)
@@ -1815,6 +1866,9 @@ var init_TunnelManager = __esm({
1815
1866
  try {
1816
1867
  logger.info("Tunnel reconnection completed", { tunnelId, urls });
1817
1868
  const managedTunnel = this.tunnelsByTunnelId.get(tunnelId);
1869
+ if (managedTunnel?.isStopping || managedTunnel?.isStopped) {
1870
+ return;
1871
+ }
1818
1872
  if (managedTunnel) {
1819
1873
  managedTunnel.isStopped = false;
1820
1874
  managedTunnel.startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2152,6 +2206,7 @@ function pinggyOptionsToTunnelConfigV1(opts, configStoredInCli) {
2152
2206
  httpsOnly: opts.httpsOnly ?? false,
2153
2207
  originalRequestUrl: opts.originalRequestUrl ?? false,
2154
2208
  allowPreflight: opts.allowPreflight ?? false,
2209
+ haProxy: opts.haProxy,
2155
2210
  optional: opts.optional || {}
2156
2211
  };
2157
2212
  }
@@ -2341,7 +2396,7 @@ var init_remote_schema = __esm({
2341
2396
  ForwardingEntryV2Schema = import_zod.z.object({
2342
2397
  listenAddress: import_zod.z.string().optional(),
2343
2398
  address: import_zod.z.string(),
2344
- type: import_zod.z.enum([import_pinggy3.TunnelType.Http, import_pinggy3.TunnelType.Tcp, import_pinggy3.TunnelType.Udp, import_pinggy3.TunnelType.Tls, import_pinggy3.TunnelType.TlsTcp])
2399
+ type: import_zod.z.enum([import_pinggy3.TunnelType.Http, import_pinggy3.TunnelType.Tcp, import_pinggy3.TunnelType.Udp, import_pinggy3.TunnelType.Tls, import_pinggy3.TunnelType.TlsTcp]).default(import_pinggy3.TunnelType.Http)
2345
2400
  });
2346
2401
  OptionalSchema = import_zod.z.object({
2347
2402
  sniServerName: import_zod.z.string().optional(),
@@ -2385,7 +2440,22 @@ var init_remote_schema = __esm({
2385
2440
  httpsOnly: import_zod.z.boolean().optional(),
2386
2441
  originalRequestUrl: import_zod.z.boolean().optional(),
2387
2442
  allowPreflight: import_zod.z.boolean().optional(),
2388
- optional: OptionalSchema.optional()
2443
+ haProxy: import_zod.z.enum([import_pinggy3.HaProxyVersion.V1, import_pinggy3.HaProxyVersion.V2]).optional(),
2444
+ serve: import_zod.z.string().optional(),
2445
+ optional: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
2446
+ }).superRefine((config, ctx) => {
2447
+ if (!config.haProxy) {
2448
+ return;
2449
+ }
2450
+ const entries = Array.isArray(config.forwarding) ? config.forwarding : [];
2451
+ const primaryType = entries.length > 0 ? entries[0].type : void 0;
2452
+ if (primaryType !== import_pinggy3.TunnelType.Tcp) {
2453
+ ctx.addIssue({
2454
+ code: "custom",
2455
+ path: ["haProxy"],
2456
+ message: `haProxy can only be used with "tcp" tunnels, but the first forwarding rule has type: ${primaryType ?? import_pinggy3.TunnelType.Http}`
2457
+ });
2458
+ }
2389
2459
  });
2390
2460
  TUNNEL_CONFIG_V1_KEYS = Object.keys(
2391
2461
  TunnelConfigV1Schema.shape
@@ -2525,11 +2595,11 @@ var init_handler = __esm({
2525
2595
  startPromise.catch((err) => {
2526
2596
  logger.error("No-wait startTunnel failed", { tunnelid, err: String(err) });
2527
2597
  });
2528
- return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.optional?.serve);
2598
+ return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.serve);
2529
2599
  }
2530
2600
  await startPromise;
2531
2601
  const tunnelPconfig = await this.tunnelManager.getTunnelConfig("", tunnelid);
2532
- return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.optional?.serve);
2602
+ return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.serve);
2533
2603
  } catch (err) {
2534
2604
  if (err instanceof TunnelAlreadyRunningError) {
2535
2605
  return this.error(ErrorCode.TunnelAlreadyRunningError, err, err.message);
@@ -2551,10 +2621,12 @@ var init_handler = __esm({
2551
2621
  if (noWait) {
2552
2622
  const existing = this.tunnelManager.getManagedTunnel(config.configid);
2553
2623
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2554
- this.tunnelManager.updateConfig(updateOpts).catch((err) => {
2555
- logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
2556
- });
2557
- return this.buildPendingTunnelResponse(existing.tunnelid, existing.tunnelConfig, config.configid, existing.tunnelName, existing.serve);
2624
+ if (!existing.isStopped) {
2625
+ this.tunnelManager.updateConfig(updateOpts).catch((err) => {
2626
+ logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
2627
+ });
2628
+ return this.buildPendingTunnelResponse(existing.tunnelid, existing.tunnelConfig, config.configid, existing.tunnelName, existing.serve);
2629
+ }
2558
2630
  }
2559
2631
  const tunnel = await this.tunnelManager.updateConfig(updateOpts);
2560
2632
  if (!tunnel.instance || !tunnel.tunnelConfig)
@@ -2569,10 +2641,12 @@ var init_handler = __esm({
2569
2641
  if (noWait) {
2570
2642
  const existing = this.tunnelManager.getManagedTunnel(config.configId);
2571
2643
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2572
- this.tunnelManager.updateConfig(config).catch((err) => {
2573
- logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
2574
- });
2575
- return this.buildPendingTunnelResponseV2(existing.tunnelid, existing.tunnelConfig, config, config.configId, existing.tunnelName, existing.serve);
2644
+ if (!existing.isStopped) {
2645
+ this.tunnelManager.updateConfig(config).catch((err) => {
2646
+ logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
2647
+ });
2648
+ return this.buildPendingTunnelResponseV2(existing.tunnelid, existing.tunnelConfig, config, config.configId, existing.tunnelName, existing.serve);
2649
+ }
2576
2650
  }
2577
2651
  const tunnel = await this.tunnelManager.updateConfig(config);
2578
2652
  if (!tunnel.instance || !tunnel.tunnelConfig)
@@ -2795,8 +2869,8 @@ var init_ipcClient = __esm({
2795
2869
  async stopTunnel(tunnelid) {
2796
2870
  return this.call(Route.StopTunnel, { tunnelid });
2797
2871
  }
2798
- async restartTunnel(tunnelid) {
2799
- return this.call(Route.RestartTunnel, { tunnelid });
2872
+ async restartTunnel(tunnelid, noWait) {
2873
+ return this.call(Route.RestartTunnel, { tunnelid, noWait });
2800
2874
  }
2801
2875
  // v1 operations (used by remote management via daemon)
2802
2876
  async startTunnelV1(config, mode, noWait) {
@@ -2978,6 +3052,7 @@ function printHelpMessage() {
2978
3052
  console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
2979
3053
  console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
2980
3054
  console.log(" x:passpreflight Pass CORS preflight requests unchanged");
3055
+ console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol, TCP tunnels only (default v1)");
2981
3056
  console.log(" a:Key:Val Add header");
2982
3057
  console.log(" u:Key:Val Update header");
2983
3058
  console.log(" r:Key Remove header");
@@ -3103,7 +3178,7 @@ var init_parseArgs = __esm({
3103
3178
  });
3104
3179
 
3105
3180
  // src/remote_management/websocket_printer.ts
3106
- var import_picocolors3, PENDING_START_TIMEOUT_MS, RemoteManagementWebSocketPrinter, remoteManagementWebSocketPrinter;
3181
+ var import_picocolors3, PENDING_TIMEOUT_MS, PendingTracker, RemoteManagementWebSocketPrinter, remoteManagementWebSocketPrinter;
3107
3182
  var init_websocket_printer = __esm({
3108
3183
  "src/remote_management/websocket_printer.ts"() {
3109
3184
  "use strict";
@@ -3112,13 +3187,71 @@ var init_websocket_printer = __esm({
3112
3187
  init_types();
3113
3188
  init_printer();
3114
3189
  import_picocolors3 = __toESM(require("picocolors"), 1);
3115
- PENDING_START_TIMEOUT_MS = 5 * 60 * 1e3;
3116
- RemoteManagementWebSocketPrinter = class {
3190
+ PENDING_TIMEOUT_MS = 5 * 60 * 1e3;
3191
+ PendingTracker = class {
3117
3192
  constructor() {
3118
- this.pendingStarts = /* @__PURE__ */ new Map();
3193
+ this.entries = /* @__PURE__ */ new Map();
3194
+ }
3195
+ queue(entry) {
3196
+ this.latestConfigId = entry.configId;
3197
+ this.entries.set(entry.configId, entry);
3198
+ }
3199
+ /** Store an entry without claiming the latest cursor. */
3200
+ upsert(entry) {
3201
+ this.entries.set(entry.configId, entry);
3202
+ }
3203
+ get(configId) {
3204
+ return this.entries.get(configId);
3205
+ }
3206
+ find(configId, tunnelId) {
3207
+ const byConfigId = this.entries.get(configId);
3208
+ if (byConfigId) {
3209
+ return byConfigId;
3210
+ }
3211
+ if (!tunnelId) {
3212
+ return void 0;
3213
+ }
3214
+ for (const entry of this.entries.values()) {
3215
+ if (entry.tunnelId === tunnelId) {
3216
+ return entry;
3217
+ }
3218
+ }
3219
+ return void 0;
3220
+ }
3221
+ delete(configId) {
3222
+ this.entries.delete(configId);
3223
+ if (this.latestConfigId === configId) {
3224
+ this.latestConfigId = void 0;
3225
+ }
3226
+ }
3227
+ get latest() {
3228
+ return this.latestConfigId;
3229
+ }
3230
+ get hasLatest() {
3231
+ return this.entries.size > 0 && this.latestConfigId !== void 0;
3232
+ }
3233
+ isLatest(configId) {
3234
+ return configId === this.latestConfigId;
3119
3235
  }
3120
- setTunnelHandler(tunnelHandler) {
3121
- this.tunnelHandler = tunnelHandler;
3236
+ cleanupExpired(onTimeout) {
3237
+ const now = Date.now();
3238
+ for (const [configId, entry] of this.entries) {
3239
+ if (now - entry.queuedAt <= PENDING_TIMEOUT_MS) {
3240
+ continue;
3241
+ }
3242
+ const wasLatest = this.isLatest(configId);
3243
+ this.delete(configId);
3244
+ onTimeout(entry, wasLatest);
3245
+ }
3246
+ }
3247
+ };
3248
+ RemoteManagementWebSocketPrinter = class {
3249
+ constructor() {
3250
+ this.pendingStarts = new PendingTracker();
3251
+ this.pendingActions = new PendingTracker();
3252
+ // tunnelId -> details, fed by monitorList. Stop/restart requests only carry
3253
+ // a tunnelId; without this the spinner shows the raw id.
3254
+ this.tunnelDetailsCache = /* @__PURE__ */ new Map();
3122
3255
  }
3123
3256
  queueStart(config) {
3124
3257
  this.cleanupExpiredPendingStarts();
@@ -3127,24 +3260,23 @@ var init_websocket_printer = __esm({
3127
3260
  configName: this.getConfigNameFromRequest(config),
3128
3261
  queuedAt: Date.now()
3129
3262
  };
3130
- this.latestPendingConfigId = entry.configId;
3131
- this.pendingStarts.set(entry.configId, entry);
3263
+ this.pendingStarts.queue(entry);
3132
3264
  printer_default.startSpinner("Starting tunnel with config name: " + entry.configName);
3133
3265
  }
3134
3266
  failQueuedStart(config, reason) {
3135
3267
  const configId = this.getConfigIdFromRequest(config);
3136
3268
  const pending = this.pendingStarts.get(configId);
3137
3269
  const configName = pending?.configName || this.getConfigNameFromRequest(config);
3270
+ const wasLatest = this.pendingStarts.isLatest(configId);
3138
3271
  this.pendingStarts.delete(configId);
3139
- if (this.latestPendingConfigId === configId) {
3140
- this.latestPendingConfigId = void 0;
3272
+ if (wasLatest) {
3141
3273
  printer_default.stopSpinnerFail(`Failed to start tunnel with config name: ${configName}. ${reason}`);
3142
3274
  }
3143
3275
  }
3144
3276
  handleStartResult(config, result) {
3145
3277
  this.cleanupExpiredPendingStarts();
3146
3278
  const requestedConfigId = this.getConfigIdFromRequest(config);
3147
- if (this.latestPendingConfigId && requestedConfigId !== this.latestPendingConfigId) {
3279
+ if (this.pendingStarts.latest && !this.pendingStarts.isLatest(requestedConfigId)) {
3148
3280
  this.pendingStarts.delete(requestedConfigId);
3149
3281
  return;
3150
3282
  }
@@ -3152,20 +3284,20 @@ var init_websocket_printer = __esm({
3152
3284
  this.failQueuedStart(config, result.message);
3153
3285
  return;
3154
3286
  }
3155
- const configId = this.getConfigIdFromTunnel(result);
3156
3287
  const pending = this.pendingStarts.get(requestedConfigId) || {
3157
3288
  configId: requestedConfigId,
3158
3289
  configName: this.getConfigNameFromRequest(config),
3159
3290
  queuedAt: Date.now()
3160
3291
  };
3161
3292
  pending.tunnelId = result.tunnelid;
3162
- this.pendingStarts.set(requestedConfigId, pending);
3293
+ this.pendingStarts.upsert(pending);
3163
3294
  if (result.remoteurls.length > 0) {
3164
3295
  this.completePendingStart(pending, result.remoteurls);
3165
3296
  }
3166
3297
  }
3167
3298
  printStopRequested(tunnelId) {
3168
3299
  const details = this.resolveTunnelDetails(tunnelId);
3300
+ this.clearPendingAction(details.configId, tunnelId);
3169
3301
  printer_default.startSpinner("Stopping tunnel with config name: " + details.configName);
3170
3302
  }
3171
3303
  handleStopResult(tunnelId, result) {
@@ -3179,6 +3311,7 @@ var init_websocket_printer = __esm({
3179
3311
  }
3180
3312
  printRestartRequested(tunnelId) {
3181
3313
  const details = this.resolveTunnelDetails(tunnelId);
3314
+ this.clearPendingAction(details.configId, tunnelId);
3182
3315
  printer_default.startSpinner("Restarting tunnel with config name: " + details.configName);
3183
3316
  }
3184
3317
  handleRestartResult(tunnelId, result) {
@@ -3188,48 +3321,101 @@ var init_websocket_printer = __esm({
3188
3321
  printer_default.stopSpinnerFail("Failed to restart tunnel with config name: " + details.configName);
3189
3322
  return;
3190
3323
  }
3191
- printer_default.stopSpinnerSuccess("Restarted tunnel with config name: " + details.configName);
3192
- if (result.remoteurls?.length > 0) {
3193
- printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3194
- (result.remoteurls ?? []).forEach(
3195
- (url) => printer_default.print(" " + import_picocolors3.default.magentaBright(url))
3196
- );
3324
+ this.queuePendingAction("restart", details.configId, details.configName, result.tunnelid);
3325
+ }
3326
+ printUpdateConfigRequested(config) {
3327
+ const configName = this.getConfigNameFromRequest(config);
3328
+ this.clearPendingAction(this.getConfigIdFromRequest(config));
3329
+ printer_default.startSpinner("Updating tunnel configuration for: " + configName);
3330
+ }
3331
+ handleUpdateConfigResult(config, result) {
3332
+ const configId = this.getConfigIdFromRequest(config);
3333
+ const configName = this.getConfigNameFromRequest(config);
3334
+ if (isErrorResponse(result)) {
3335
+ printer_default.stopSpinnerFail(`Failed to update tunnel configuration for: ${configName}. ${result.message}`);
3336
+ return;
3337
+ }
3338
+ if (result.status.state === "exited" /* Exited */) {
3339
+ printer_default.stopSpinnerSuccess(`Tunnel configuration updated: ${configName}.`);
3340
+ return;
3197
3341
  }
3342
+ this.queuePendingAction("update", configId, configName, result.tunnelid);
3343
+ }
3344
+ clearPendingAction(configId, tunnelId) {
3345
+ const entry = this.pendingActions.find(configId, tunnelId);
3346
+ if (!entry) {
3347
+ return;
3348
+ }
3349
+ this.pendingActions.delete(entry.configId);
3350
+ }
3351
+ queuePendingAction(action, configId, configName, tunnelId) {
3352
+ this.cleanupExpiredPendingActions();
3353
+ this.pendingActions.queue({ action, configId, configName, queuedAt: Date.now(), tunnelId });
3354
+ }
3355
+ printActionComplete(action, configName, urls) {
3356
+ const message = action === "restart" ? `Restarted tunnel with config name: ${configName}` : `Tunnel configuration updated and restarted: ${configName}`;
3357
+ printer_default.stopSpinnerSuccess(message);
3358
+ printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3359
+ urls.forEach((url) => printer_default.print(" " + import_picocolors3.default.magentaBright(url)));
3198
3360
  }
3199
3361
  monitorList(result) {
3200
3362
  this.cleanupExpiredPendingStarts();
3201
- if (!Array.isArray(result) || this.pendingStarts.size === 0 || !this.latestPendingConfigId) {
3363
+ this.cleanupExpiredPendingActions();
3364
+ if (!Array.isArray(result)) {
3202
3365
  return;
3203
3366
  }
3204
3367
  for (const tunnel of result) {
3205
- const pending = this.findPendingStart(tunnel);
3206
- if (!pending) {
3207
- continue;
3368
+ this.tunnelDetailsCache.set(tunnel.tunnelid, {
3369
+ configId: this.getConfigIdFromTunnel(tunnel),
3370
+ configName: this.getConfigNameFromTunnel(tunnel)
3371
+ });
3372
+ }
3373
+ this.resolvePendingFromList(
3374
+ result,
3375
+ this.pendingStarts,
3376
+ (entry, urls) => this.completePendingStart(entry, urls),
3377
+ (entry, reason) => printer_default.stopSpinnerFail(`Tunnel start did not complete for config name: ${entry.configName}. ${reason}`)
3378
+ );
3379
+ this.resolvePendingFromList(
3380
+ result,
3381
+ this.pendingActions,
3382
+ (entry, urls) => {
3383
+ this.pendingActions.delete(entry.configId);
3384
+ this.printActionComplete(entry.action, entry.configName, urls);
3385
+ },
3386
+ (entry, reason) => {
3387
+ const verb = entry.action === "restart" ? "Restart" : "Configuration update";
3388
+ printer_default.stopSpinnerFail(`${verb} did not complete for config name: ${entry.configName}. ${reason}`);
3208
3389
  }
3209
- if (pending.configId !== this.latestPendingConfigId) {
3390
+ );
3391
+ }
3392
+ resolvePendingFromList(tunnels, tracker, onComplete, onExited) {
3393
+ if (!tracker.hasLatest) {
3394
+ return;
3395
+ }
3396
+ for (const tunnel of tunnels) {
3397
+ const entry = tracker.find(this.getConfigIdFromTunnel(tunnel), tunnel.tunnelid);
3398
+ if (!entry || !tracker.isLatest(entry.configId)) {
3210
3399
  continue;
3211
3400
  }
3212
- pending.tunnelId = tunnel.tunnelid;
3213
- this.pendingStarts.set(pending.configId, pending);
3401
+ entry.tunnelId = tunnel.tunnelid;
3402
+ tracker.upsert(entry);
3214
3403
  if (tunnel.remoteurls.length > 0) {
3215
- this.completePendingStart(pending, tunnel.remoteurls);
3404
+ onComplete(entry, tunnel.remoteurls);
3216
3405
  continue;
3217
3406
  }
3218
3407
  if (tunnel.status.state === "exited" /* Exited */) {
3219
- const reason = tunnel.status.errormsg || "Tunnel exited before a public URL was assigned";
3220
- this.pendingStarts.delete(pending.configId);
3221
- this.latestPendingConfigId = void 0;
3222
- printer_default.stopSpinnerFail(`Tunnel start did not complete for config name: ${pending.configName}. ${reason}`);
3408
+ tracker.delete(entry.configId);
3409
+ onExited(entry, tunnel.status.errormsg || "Tunnel exited before a public URL was assigned");
3223
3410
  }
3224
3411
  }
3225
3412
  }
3226
3413
  completePendingStart(entry, urls) {
3227
- if (this.latestPendingConfigId && entry.configId !== this.latestPendingConfigId) {
3414
+ if (this.pendingStarts.latest && !this.pendingStarts.isLatest(entry.configId)) {
3228
3415
  this.pendingStarts.delete(entry.configId);
3229
3416
  return;
3230
3417
  }
3231
3418
  this.pendingStarts.delete(entry.configId);
3232
- this.latestPendingConfigId = void 0;
3233
3419
  printer_default.stopSpinnerSuccess(`Tunnel started with config name: ${entry.configName}.`);
3234
3420
  printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3235
3421
  (urls ?? []).forEach(
@@ -3237,28 +3423,27 @@ var init_websocket_printer = __esm({
3237
3423
  );
3238
3424
  }
3239
3425
  cleanupExpiredPendingStarts() {
3240
- const now = Date.now();
3241
- for (const [configId, entry] of this.pendingStarts.entries()) {
3242
- if (now - entry.queuedAt <= PENDING_START_TIMEOUT_MS) {
3243
- continue;
3426
+ this.pendingStarts.cleanupExpired((entry, wasLatest) => {
3427
+ const message = `Timed out while waiting for tunnel URL for config name: ${entry.configName}`;
3428
+ if (wasLatest) {
3429
+ printer_default.stopSpinnerFail(message);
3430
+ } else {
3431
+ printer_default.warn(message);
3244
3432
  }
3245
- this.pendingStarts.delete(configId);
3246
- printer_default.warn(`Timed out while waiting for tunnel URL for config name: ${entry.configName}`);
3247
- logger.warn("Pending websocket start entry expired", { configId, tunnelId: entry.tunnelId });
3248
- }
3433
+ logger.warn("Pending websocket start entry expired", { configId: entry.configId, tunnelId: entry.tunnelId });
3434
+ });
3249
3435
  }
3250
- findPendingStart(tunnel) {
3251
- const configId = this.getConfigIdFromTunnel(tunnel);
3252
- const byConfigId = this.pendingStarts.get(configId);
3253
- if (byConfigId) {
3254
- return byConfigId;
3255
- }
3256
- for (const entry of this.pendingStarts.values()) {
3257
- if (entry.tunnelId === tunnel.tunnelid) {
3258
- return entry;
3436
+ cleanupExpiredPendingActions() {
3437
+ this.pendingActions.cleanupExpired((entry, wasLatest) => {
3438
+ const verb = entry.action === "restart" ? "restart" : "configuration update";
3439
+ const message = `Timed out while waiting for tunnel URL after ${verb} for config name: ${entry.configName}`;
3440
+ if (wasLatest) {
3441
+ printer_default.stopSpinnerFail(message);
3442
+ } else {
3443
+ printer_default.warn(message);
3259
3444
  }
3260
- }
3261
- return void 0;
3445
+ logger.warn("Pending websocket action entry expired", { configId: entry.configId, tunnelId: entry.tunnelId, action: entry.action });
3446
+ });
3262
3447
  }
3263
3448
  resolveTunnelDetails(tunnelId, result) {
3264
3449
  if (result && !isErrorResponse(result)) {
@@ -3267,7 +3452,7 @@ var init_websocket_printer = __esm({
3267
3452
  configName: this.getConfigNameFromTunnel(result)
3268
3453
  };
3269
3454
  }
3270
- return {
3455
+ return this.tunnelDetailsCache.get(tunnelId) ?? {
3271
3456
  configId: tunnelId,
3272
3457
  configName: tunnelId
3273
3458
  };
@@ -3346,7 +3531,6 @@ var init_websocket_handlers = __esm({
3346
3531
  WebSocketCommandHandler = class {
3347
3532
  constructor(handler) {
3348
3533
  this.tunnelHandler = handler ?? new TunnelOperations();
3349
- remoteManagementWebSocketPrinter.setTunnelHandler(this.tunnelHandler);
3350
3534
  }
3351
3535
  safeParse(text) {
3352
3536
  if (!text) return void 0;
@@ -3413,8 +3597,10 @@ var init_websocket_handlers = __esm({
3413
3597
  }
3414
3598
  }
3415
3599
  async handleStopReq(req, raw) {
3600
+ let tunnelID;
3416
3601
  try {
3417
3602
  const dc = StopSchema.parse(raw);
3603
+ tunnelID = dc.tunnelID;
3418
3604
  remoteManagementWebSocketPrinter.printStopRequested(dc.tunnelID);
3419
3605
  const result = await this.tunnelHandler.handleStop(dc.tunnelID);
3420
3606
  remoteManagementWebSocketPrinter.handleStopResult(dc.tunnelID, result);
@@ -3424,6 +3610,9 @@ var init_websocket_handlers = __esm({
3424
3610
  printer_default.warn("Validation failed for stop request");
3425
3611
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3426
3612
  }
3613
+ if (tunnelID) {
3614
+ remoteManagementWebSocketPrinter.handleStopResult(tunnelID, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3615
+ }
3427
3616
  printer_default.warn(`Error in handleStopReq error: ${String(e)}`);
3428
3617
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3429
3618
  }
@@ -3443,8 +3632,10 @@ var init_websocket_handlers = __esm({
3443
3632
  }
3444
3633
  }
3445
3634
  async handleRestartReq(req, raw) {
3635
+ let tunnelID;
3446
3636
  try {
3447
3637
  const dc = RestartSchema.parse(raw);
3638
+ tunnelID = dc.tunnelID;
3448
3639
  remoteManagementWebSocketPrinter.printRestartRequested(dc.tunnelID);
3449
3640
  const result = await this.tunnelHandler.handleRestart(dc.tunnelID, true);
3450
3641
  remoteManagementWebSocketPrinter.handleRestartResult(dc.tunnelID, result);
@@ -3454,34 +3645,51 @@ var init_websocket_handlers = __esm({
3454
3645
  printer_default.warn("Validation failed for restart request");
3455
3646
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3456
3647
  }
3648
+ if (tunnelID) {
3649
+ remoteManagementWebSocketPrinter.handleRestartResult(tunnelID, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3650
+ }
3457
3651
  printer_default.warn(`Error in handleRestartReq error: ${String(e)}`);
3458
3652
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3459
3653
  }
3460
3654
  }
3461
3655
  async handleUpdateConfigReq(req, raw) {
3656
+ let parsedConfig;
3462
3657
  try {
3463
3658
  const dc = UpdateConfigSchema.parse(raw);
3659
+ parsedConfig = dc.tunnelConfig;
3660
+ remoteManagementWebSocketPrinter.printUpdateConfigRequested(dc.tunnelConfig);
3464
3661
  const result = await this.tunnelHandler.handleUpdateConfig(dc.tunnelConfig, true);
3662
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(dc.tunnelConfig, result);
3465
3663
  return this.wrapResponse(result, req);
3466
3664
  } catch (e) {
3467
3665
  if (e instanceof import_zod2.default.ZodError) {
3468
3666
  printer_default.warn("Validation failed for updateconfig request");
3469
3667
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3470
3668
  }
3669
+ if (parsedConfig) {
3670
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(parsedConfig, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3671
+ }
3471
3672
  printer_default.warn(`Error in handleUpdateConfigReq error: ${String(e)}`);
3472
3673
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3473
3674
  }
3474
3675
  }
3475
3676
  async handleUpdateConfigV2Req(req, raw) {
3677
+ let parsedConfig;
3476
3678
  try {
3477
3679
  const dc = UpdateConfigV2Schema.parse(raw);
3680
+ parsedConfig = dc.tunnelConfig;
3681
+ remoteManagementWebSocketPrinter.printUpdateConfigRequested(dc.tunnelConfig);
3478
3682
  const result = await this.tunnelHandler.handleUpdateConfigV2(dc.tunnelConfig, true);
3683
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(dc.tunnelConfig, result);
3479
3684
  return this.wrapResponse(result, req);
3480
3685
  } catch (e) {
3481
3686
  if (e instanceof import_zod2.default.ZodError) {
3482
3687
  printer_default.warn("Validation failed for update-config-v2 request");
3483
3688
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3484
3689
  }
3690
+ if (parsedConfig) {
3691
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(parsedConfig, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3692
+ }
3485
3693
  printer_default.warn(`Error in handleUpdateConfigV2Req error: ${String(e)}`);
3486
3694
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3487
3695
  }
@@ -3916,10 +4124,20 @@ function parseExtendedOptions(options, config, localServerTls) {
3916
4124
  case "fullrequesturl":
3917
4125
  config.originalRequestUrl = true;
3918
4126
  break;
4127
+ case "haproxy":
4128
+ case "haproxy:v1":
4129
+ config.haProxy = import_pinggy4.HaProxyVersion.V1;
4130
+ break;
4131
+ case "haproxy:v2":
4132
+ config.haProxy = import_pinggy4.HaProxyVersion.V2;
4133
+ break;
3919
4134
  default: {
3920
4135
  if (value && (value.startsWith("localServerTls") || value.startsWith("localservertls"))) {
3921
4136
  const parts = value.split(/:(.+)/);
3922
4137
  localServerTls = parts[1] ? parts[1] : "";
4138
+ } else if (value && value.startsWith("haproxy:")) {
4139
+ printer_default.warn(`Invalid HAProxy version in "${opt}". Use x:haproxy, x:haproxy:v1 or x:haproxy:v2`);
4140
+ logger.warn(`Warning: Invalid HAProxy version in "${opt}". Use x:haproxy, x:haproxy:v1 or x:haproxy:v2`);
3923
4141
  } else {
3924
4142
  printer_default.warn(`Unknown extended option "${value}"`);
3925
4143
  logger.warn(`Warning: Unknown extended option "${value}"`);
@@ -4021,11 +4239,12 @@ function isValidIpV6Cidr(input) {
4021
4239
  }
4022
4240
  return false;
4023
4241
  }
4024
- var import_net;
4242
+ var import_pinggy4, import_net;
4025
4243
  var init_extendedOptions = __esm({
4026
4244
  "src/cli/extendedOptions.ts"() {
4027
4245
  "use strict";
4028
4246
  init_cjs_shims();
4247
+ import_pinggy4 = require("@pinggy/pinggy");
4029
4248
  import_net = require("net");
4030
4249
  init_logger();
4031
4250
  init_printer();
@@ -4062,7 +4281,7 @@ function parseUserAndDomain(str) {
4062
4281
  const [user, domain] = str.split("@", 2);
4063
4282
  if (isValidServerAddress(domain)) {
4064
4283
  let processKeyword2 = function(keyword) {
4065
- if ([import_pinggy4.TunnelType.Http, import_pinggy4.TunnelType.Tcp, import_pinggy4.TunnelType.Tls, import_pinggy4.TunnelType.Udp, import_pinggy4.TunnelType.TlsTcp].includes(keyword)) {
4284
+ if ([import_pinggy5.TunnelType.Http, import_pinggy5.TunnelType.Tcp, import_pinggy5.TunnelType.Tls, import_pinggy5.TunnelType.Udp, import_pinggy5.TunnelType.TlsTcp].includes(keyword)) {
4066
4285
  type = keyword;
4067
4286
  } else if (keyword === "force") {
4068
4287
  forceFlag = true;
@@ -4150,7 +4369,7 @@ function parseUsers(positionalArgs, explicitToken) {
4150
4369
  }
4151
4370
  function parseType(finalConfig, values, inferredType) {
4152
4371
  const t = inferredType || values.type;
4153
- if (t === import_pinggy4.TunnelType.Http || t === import_pinggy4.TunnelType.Tcp || t === import_pinggy4.TunnelType.Tls || t === import_pinggy4.TunnelType.Udp || t === import_pinggy4.TunnelType.TlsTcp) {
4372
+ if (t === import_pinggy5.TunnelType.Http || t === import_pinggy5.TunnelType.Tcp || t === import_pinggy5.TunnelType.Tls || t === import_pinggy5.TunnelType.Udp || t === import_pinggy5.TunnelType.TlsTcp) {
4154
4373
  return t;
4155
4374
  }
4156
4375
  }
@@ -4248,7 +4467,7 @@ function parseAdditionalForwarding(forwarding) {
4248
4467
  }
4249
4468
  const firstPart = parsed[0];
4250
4469
  const [hostPart] = firstPart.split("@");
4251
- let protocol = import_pinggy4.TunnelType.Http;
4470
+ let protocol = import_pinggy5.TunnelType.Http;
4252
4471
  let remoteDomainRaw;
4253
4472
  let remotePort = 0;
4254
4473
  if (hostPart.includes("//")) {
@@ -4281,7 +4500,7 @@ function parseAdditionalForwarding(forwarding) {
4281
4500
  if (!isValidServerAddress(remoteDomainRaw)) {
4282
4501
  return new Error("invalid remote domain");
4283
4502
  }
4284
- protocol = import_pinggy4.TunnelType.Http;
4503
+ protocol = import_pinggy5.TunnelType.Http;
4285
4504
  remotePort = 0;
4286
4505
  }
4287
4506
  const localDomain = removeIPv6Brackets(parsed[2] || "localhost");
@@ -4311,7 +4530,7 @@ function parseReverseTunnelAddr(finalConfig, values, primaryType) {
4311
4530
  if (parsed instanceof Error) return parsed;
4312
4531
  forwardingData.push({
4313
4532
  address: `${parsed.localDomain}:${parsed.localPort}`,
4314
- type: primaryType || import_pinggy4.TunnelType.Http
4533
+ type: primaryType || import_pinggy5.TunnelType.Http
4315
4534
  });
4316
4535
  } else if (slicedForwarding.length === 4) {
4317
4536
  const parsed = parseAdditionalForwarding(forwarding);
@@ -4377,7 +4596,7 @@ function normalizeForwardingToArray(finalConfig, type) {
4377
4596
  if (typeof fwd !== "string") {
4378
4597
  return;
4379
4598
  }
4380
- finalConfig.forwarding = fwd.trim() === "" ? [] : [{ type: type || import_pinggy4.TunnelType.Http, address: fwd.trim() }];
4599
+ finalConfig.forwarding = fwd.trim() === "" ? [] : [{ type: type || import_pinggy5.TunnelType.Http, address: fwd.trim() }];
4381
4600
  }
4382
4601
  function parseArgs2(finalConfig, remainingPositionals) {
4383
4602
  let localserverTls = "";
@@ -4439,6 +4658,20 @@ function hasRemoteManagement(values) {
4439
4658
  const token = values["remote-management"];
4440
4659
  return typeof token === "string" && token.trim().length > 0;
4441
4660
  }
4661
+ function validateHaProxy(finalConfig) {
4662
+ if (!finalConfig.haProxy) {
4663
+ return null;
4664
+ }
4665
+ const forwarding = finalConfig.forwarding;
4666
+ const entries = Array.isArray(forwarding) ? forwarding : [];
4667
+ const primaryType = entries.length > 0 ? entries[0].type : void 0;
4668
+ if (primaryType === import_pinggy5.TunnelType.Tcp) {
4669
+ return null;
4670
+ }
4671
+ return new Error(
4672
+ `HAProxy PROXY protocol (x:haproxy) can only be used with TCP tunnels, but this tunnel is "${primaryType ?? import_pinggy5.TunnelType.Http}". Make it a TCP tunnel (--type tcp, or the tcp@ server prefix) or drop x:haproxy.`
4673
+ );
4674
+ }
4442
4675
  function buildFinalConfig(values, positionals, baseConfig) {
4443
4676
  let token;
4444
4677
  let server;
@@ -4501,10 +4734,14 @@ function buildFinalConfig(values, positionals, baseConfig) {
4501
4734
  }
4502
4735
  normalizeForwardingToArray(finalConfig, type);
4503
4736
  parseArgs2(finalConfig, remainingPositionals);
4737
+ const haProxyErr = validateHaProxy(finalConfig);
4738
+ if (haProxyErr instanceof Error) {
4739
+ throw haProxyErr;
4740
+ }
4504
4741
  storeJson(finalConfig, saveconf);
4505
4742
  return finalConfig;
4506
4743
  }
4507
- var import_pinggy4, import_fs6, import_path6, import_net2, domainRegex, KEYWORDS, VALID_PROTOCOLS;
4744
+ var import_pinggy5, import_fs6, import_path6, import_net2, domainRegex, KEYWORDS, VALID_PROTOCOLS;
4508
4745
  var init_buildConfig = __esm({
4509
4746
  "src/cli/buildConfig.ts"() {
4510
4747
  "use strict";
@@ -4513,21 +4750,21 @@ var init_buildConfig = __esm({
4513
4750
  init_extendedOptions();
4514
4751
  init_logger();
4515
4752
  init_util();
4516
- import_pinggy4 = require("@pinggy/pinggy");
4753
+ import_pinggy5 = require("@pinggy/pinggy");
4517
4754
  import_fs6 = __toESM(require("fs"), 1);
4518
4755
  import_path6 = __toESM(require("path"), 1);
4519
4756
  import_net2 = require("net");
4520
4757
  domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
4521
4758
  KEYWORDS = /* @__PURE__ */ new Set([
4522
- import_pinggy4.TunnelType.Http,
4523
- import_pinggy4.TunnelType.Tcp,
4524
- import_pinggy4.TunnelType.Tls,
4525
- import_pinggy4.TunnelType.Udp,
4526
- import_pinggy4.TunnelType.TlsTcp,
4759
+ import_pinggy5.TunnelType.Http,
4760
+ import_pinggy5.TunnelType.Tcp,
4761
+ import_pinggy5.TunnelType.Tls,
4762
+ import_pinggy5.TunnelType.Udp,
4763
+ import_pinggy5.TunnelType.TlsTcp,
4527
4764
  "force",
4528
4765
  "qr"
4529
4766
  ]);
4530
- VALID_PROTOCOLS = [import_pinggy4.TunnelType.Http, import_pinggy4.TunnelType.Tcp, import_pinggy4.TunnelType.Udp, import_pinggy4.TunnelType.Tls, import_pinggy4.TunnelType.TlsTcp];
4767
+ VALID_PROTOCOLS = [import_pinggy5.TunnelType.Http, import_pinggy5.TunnelType.Tcp, import_pinggy5.TunnelType.Udp, import_pinggy5.TunnelType.Tls, import_pinggy5.TunnelType.TlsTcp];
4531
4768
  }
4532
4769
  });
4533
4770
 
@@ -5111,7 +5348,7 @@ var init_webDebuggerConnection = __esm({
5111
5348
  });
5112
5349
 
5113
5350
  // src/tui/ink/asciArt.ts
5114
- var asciiArtPinggyLogo;
5351
+ var asciiArtPinggyLogo, asciiArtPinggyLogoCompact, asciiArtPinggyLogoMedium;
5115
5352
  var init_asciArt = __esm({
5116
5353
  "src/tui/ink/asciArt.ts"() {
5117
5354
  "use strict";
@@ -5123,6 +5360,13 @@ var init_asciArt = __esm({
5123
5360
  \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
5124
5361
  \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
5125
5362
  \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 `;
5363
+ asciiArtPinggyLogoCompact = "P I N G G Y";
5364
+ asciiArtPinggyLogoMedium = [
5365
+ " ___ ___ _ _ ___ _____ __",
5366
+ "| _ \\_ _| \\| |/ __|/ __\\ \\ / /",
5367
+ "| _/| || .` | (_ | (_ |\\ V /",
5368
+ "|_| |___|_|\\_|\\___|\\___| |_|"
5369
+ ].join("\n");
5126
5370
  }
5127
5371
  });
5128
5372
 
@@ -5135,6 +5379,54 @@ function colorizeGradient(text) {
5135
5379
  return `{${color}-fg}${line}{/${color}-fg}`;
5136
5380
  }).join("\n");
5137
5381
  }
5382
+ function colorizeGradientHorizontal(text) {
5383
+ const colors = ["red", "yellow", "green", "cyan", "blue", "magenta"];
5384
+ let colorIndex = 0;
5385
+ return [...text].map((char) => {
5386
+ if (char === " ") return char;
5387
+ const color = colors[colorIndex % colors.length];
5388
+ colorIndex++;
5389
+ return `{${color}-fg}${char}{/${color}-fg}`;
5390
+ }).join("");
5391
+ }
5392
+ function selectLogo(width, height) {
5393
+ const widthTier = width >= FULL_LOGO_THRESHOLD ? 2 : width >= MEDIUM_LOGO_THRESHOLD ? 1 : 0;
5394
+ const heightTier = height >= FULL_LOGO_HEIGHT_THRESHOLD ? 2 : height >= MEDIUM_LOGO_HEIGHT_THRESHOLD ? 1 : 0;
5395
+ const tier = Math.min(widthTier, heightTier);
5396
+ if (tier === 2) {
5397
+ return { content: colorizeGradient(asciiArtPinggyLogo), boxHeight: 7 };
5398
+ }
5399
+ if (tier === 1) {
5400
+ return { content: colorizeGradient(asciiArtPinggyLogoMedium), boxHeight: 5 };
5401
+ }
5402
+ return {
5403
+ content: `{bold}${colorizeGradientHorizontal(asciiArtPinggyLogoCompact)}{/bold}`,
5404
+ boxHeight: 1
5405
+ };
5406
+ }
5407
+ function wrapText(text, width) {
5408
+ const lines = [];
5409
+ let current = "";
5410
+ for (const word of text.split(/\s+/).filter(Boolean)) {
5411
+ const candidate = current ? `${current} ${word}` : word;
5412
+ if (candidate.length <= width) {
5413
+ current = candidate;
5414
+ continue;
5415
+ }
5416
+ if (current) {
5417
+ lines.push(current);
5418
+ current = "";
5419
+ }
5420
+ let rest = word;
5421
+ while (rest.length > width) {
5422
+ lines.push(rest.slice(0, width));
5423
+ rest = rest.slice(width);
5424
+ }
5425
+ current = rest;
5426
+ }
5427
+ if (current) lines.push(current);
5428
+ return lines.length ? lines : [""];
5429
+ }
5138
5430
  function createWarningUI(screen) {
5139
5431
  return import_blessed.default.box({
5140
5432
  parent: screen,
@@ -5161,21 +5453,26 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
5161
5453
  height: "100%",
5162
5454
  padding: 1
5163
5455
  });
5456
+ const { content: logoContent, boxHeight: logoHeight } = selectLogo(
5457
+ screen.width,
5458
+ screen.height
5459
+ );
5164
5460
  const logoBox = import_blessed.default.box({
5165
5461
  parent: mainContainer,
5166
5462
  top: 0,
5167
5463
  left: 0,
5168
5464
  width: "100%",
5169
- height: 7,
5170
- content: colorizeGradient(asciiArtPinggyLogo),
5465
+ height: logoHeight,
5466
+ content: logoContent,
5171
5467
  tags: true
5172
5468
  });
5469
+ const contentTop = logoHeight + 1;
5173
5470
  const contentBox = import_blessed.default.box({
5174
5471
  parent: mainContainer,
5175
- top: 8,
5472
+ top: contentTop,
5176
5473
  left: 0,
5177
5474
  width: "100%-2",
5178
- height: "100%-10",
5475
+ height: `100%-${contentTop + 2}`,
5179
5476
  padding: 0,
5180
5477
  border: {
5181
5478
  type: "line"
@@ -5188,20 +5485,24 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
5188
5485
  });
5189
5486
  let greetHeight = 0;
5190
5487
  if (greet) {
5191
- const greetBox = import_blessed.default.box({
5488
+ const contentInnerWidth = screen.width - 6;
5489
+ const greetWidth = Math.max(20, Math.floor(contentInnerWidth * 0.6));
5490
+ const greetLines = wrapText(greet, greetWidth);
5491
+ const greetBoxHeight = greetLines.length + 1;
5492
+ import_blessed.default.box({
5192
5493
  parent: contentBox,
5193
5494
  top: 0,
5194
5495
  left: "center",
5195
- width: "60%",
5196
- height: 4,
5197
- content: `{bold}${greet}{/bold}`,
5496
+ width: greetWidth,
5497
+ height: greetBoxHeight,
5498
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
5198
5499
  tags: true,
5199
5500
  align: "center",
5200
5501
  style: {
5201
5502
  fg: "green"
5202
5503
  }
5203
5504
  });
5204
- greetHeight = 4;
5505
+ greetHeight = greetBoxHeight;
5205
5506
  }
5206
5507
  const upperSectionTop = greetHeight > 0 ? greetHeight : 0;
5207
5508
  const upperSection = import_blessed.default.box({
@@ -5295,21 +5596,22 @@ function createSimpleUI(screen, urls, greet) {
5295
5596
  });
5296
5597
  let currentTop = 0;
5297
5598
  if (greet) {
5599
+ const greetWidth = Math.max(20, Math.floor((screen.width - 2) * 0.9));
5600
+ const greetLines = wrapText(greet, greetWidth);
5298
5601
  import_blessed.default.box({
5299
5602
  parent: mainContainer,
5300
5603
  top: currentTop,
5301
5604
  left: "center",
5302
- width: "90%",
5303
- height: "shrink",
5304
- content: `{bold}${greet}{/bold}`,
5605
+ width: greetWidth,
5606
+ height: greetLines.length,
5607
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
5305
5608
  tags: true,
5306
5609
  align: "center",
5307
5610
  style: {
5308
5611
  fg: "green"
5309
5612
  }
5310
5613
  });
5311
- const lines = Math.ceil(greet.length / (screen.width * 0.9));
5312
- currentTop += Math.max(lines, 1) + 1;
5614
+ currentTop += greetLines.length + 1;
5313
5615
  }
5314
5616
  const urlsBox = import_blessed.default.box({
5315
5617
  parent: mainContainer,
@@ -5348,7 +5650,7 @@ function createSimpleUI(screen, urls, greet) {
5348
5650
  footerBox
5349
5651
  };
5350
5652
  }
5351
- var import_blessed, MIN_WIDTH_WARNING, SIMPLE_LAYOUT_THRESHOLD;
5653
+ var import_blessed, MIN_WIDTH_WARNING, SIMPLE_LAYOUT_THRESHOLD, MEDIUM_LOGO_THRESHOLD, FULL_LOGO_THRESHOLD, MEDIUM_LOGO_HEIGHT_THRESHOLD, FULL_LOGO_HEIGHT_THRESHOLD;
5352
5654
  var init_UIComponents = __esm({
5353
5655
  "src/tui/blessed/components/UIComponents.ts"() {
5354
5656
  "use strict";
@@ -5357,6 +5659,10 @@ var init_UIComponents = __esm({
5357
5659
  init_asciArt();
5358
5660
  MIN_WIDTH_WARNING = 60;
5359
5661
  SIMPLE_LAYOUT_THRESHOLD = 80;
5662
+ MEDIUM_LOGO_THRESHOLD = 100;
5663
+ FULL_LOGO_THRESHOLD = 120;
5664
+ MEDIUM_LOGO_HEIGHT_THRESHOLD = 26;
5665
+ FULL_LOGO_HEIGHT_THRESHOLD = 34;
5360
5666
  }
5361
5667
  });
5362
5668
 
@@ -8322,7 +8628,7 @@ var init_ipcServer = __esm({
8322
8628
  },
8323
8629
  [Route.RestartTunnel]: async (req) => {
8324
8630
  if (!req.tunnelid) throw new Error("Missing 'tunnelid' field");
8325
- return await this.ops.handleRestart(req.tunnelid);
8631
+ return await this.ops.handleRestart(req.tunnelid, req.noWait);
8326
8632
  },
8327
8633
  [Route.UpdateConfig]: async (req) => {
8328
8634
  if (!req.config) throw new Error("Missing 'config' field");
@@ -9817,9 +10123,9 @@ var init_tunnelClient = __esm({
9817
10123
  this.assertClient();
9818
10124
  return this.ipc.getTunnel(tunnelId);
9819
10125
  }
9820
- async handleRestart(tunnelId) {
10126
+ async handleRestart(tunnelId, noWait) {
9821
10127
  this.assertClient();
9822
- return this.ipc.restartTunnel(tunnelId);
10128
+ return this.ipc.restartTunnel(tunnelId, noWait);
9823
10129
  }
9824
10130
  async shutdown() {
9825
10131
  this.assertClient();