pinggy 0.3.2 → 0.3.3

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
@@ -402,7 +402,7 @@ var TunnelManager = class _TunnelManager {
402
402
  for (const rule of additionalForwarding) {
403
403
  if (rule && rule.localDomain && rule.localPort && rule.remoteDomain && isValidPort(rule.localPort)) {
404
404
  const forwardingRule = {
405
- type: import_pinggy2.TunnelType.Http,
405
+ type: rule.protocol,
406
406
  // In Future we can make this dynamic based on user input
407
407
  address: `${rule.localDomain}:${rule.localPort}`,
408
408
  listenAddress: rule.remotePort && isValidPort(rule.remotePort) ? `${rule.remoteDomain}:${rule.remotePort}` : rule.remoteDomain
@@ -1580,7 +1580,8 @@ function ipv6SafeSplitColon(s) {
1580
1580
  result.push(buf);
1581
1581
  return result;
1582
1582
  }
1583
- function parseForwarding(forwarding) {
1583
+ var VALID_PROTOCOLS = ["http", "tcp", "udp", "tls"];
1584
+ function parseDefaultForwarding(forwarding) {
1584
1585
  const parts = ipv6SafeSplitColon(forwarding);
1585
1586
  if (parts.length === 3) {
1586
1587
  const remotePort = parseInt(parts[0], 10);
@@ -1597,6 +1598,83 @@ function parseForwarding(forwarding) {
1597
1598
  }
1598
1599
  return new Error("forwarding address incorrect");
1599
1600
  }
1601
+ function parseAdditionalForwarding(forwarding) {
1602
+ const toPort = (v) => {
1603
+ const n = parseInt(v, 10);
1604
+ return Number.isNaN(n) ? null : n;
1605
+ };
1606
+ const validateDomain = (d) => d && domainRegex.test(d) ? d : null;
1607
+ let protocol = "http";
1608
+ let remoteDomainRaw;
1609
+ const protocolsRequiringDomainPort = ["tcp", "udp"];
1610
+ const lowForwarding = forwarding.toLowerCase();
1611
+ let remaining = forwarding;
1612
+ for (const p of VALID_PROTOCOLS) {
1613
+ if (lowForwarding.startsWith(p + "//")) {
1614
+ protocol = p;
1615
+ remaining = forwarding.slice(p.length + 2);
1616
+ break;
1617
+ }
1618
+ }
1619
+ if (protocol === "http" && remaining === forwarding) {
1620
+ const parts2 = ipv6SafeSplitColon(remaining);
1621
+ if (parts2.length !== 4) {
1622
+ return new Error(
1623
+ "forwarding must be in format: domain:remotePort:localDomain:localPort"
1624
+ );
1625
+ }
1626
+ const remoteDomain = validateDomain(removeIPv6Brackets(parts2[0]));
1627
+ const localDomain2 = removeIPv6Brackets(parts2[2] || "localhost");
1628
+ const localPort2 = toPort(parts2[3]);
1629
+ if (!remoteDomain) {
1630
+ return new Error("forwarding address incorrect: invalid domain");
1631
+ }
1632
+ if (localPort2 === null || !isValidPort(localPort2)) {
1633
+ return new Error("forwarding address incorrect: invalid local port");
1634
+ }
1635
+ return {
1636
+ protocol: "http",
1637
+ remoteDomain,
1638
+ remotePort: 0,
1639
+ localDomain: localDomain2,
1640
+ localPort: localPort2
1641
+ };
1642
+ }
1643
+ const domainPortMatch = remaining.match(/^([^:]+)\/(\d+):(.+)$/);
1644
+ if (!domainPortMatch) {
1645
+ return new Error(`forwarding must be in format: ${protocol}//domain/remotePort:localDomain:localPort`);
1646
+ }
1647
+ remoteDomainRaw = removeIPv6Brackets(domainPortMatch[1]);
1648
+ const remotePortNum = toPort(domainPortMatch[2]);
1649
+ const restParts = domainPortMatch[3];
1650
+ if (!remoteDomainRaw || !domainRegex.test(remoteDomainRaw)) {
1651
+ return new Error("forwarding address incorrect: invalid domain or remote port");
1652
+ }
1653
+ if (!remoteDomainRaw || remotePortNum === null || !isValidPort(remotePortNum)) {
1654
+ return new Error(`${protocol} forwarding: invalid domain or port in format ${protocol}//domain/remotePort`);
1655
+ }
1656
+ const parts = ipv6SafeSplitColon(restParts);
1657
+ if (parts.length !== 3) {
1658
+ return new Error(`forwarding format incorrect: expected ${protocol}//domain/remotePort:placeholder:localDomain:localPort`);
1659
+ }
1660
+ const localDomain = removeIPv6Brackets(parts[1] || "localhost");
1661
+ const localPort = toPort(parts[2]);
1662
+ if (localPort === null || !isValidPort(localPort)) {
1663
+ return new Error("forwarding address incorrect: invalid local port");
1664
+ }
1665
+ if (protocolsRequiringDomainPort.includes(protocol)) {
1666
+ if (!remoteDomainRaw || !remotePortNum) {
1667
+ return new Error(`${protocol} forwarding requires domain and port in format: ${protocol}//domain/remotePort:localDomain:localPort`);
1668
+ }
1669
+ }
1670
+ return {
1671
+ protocol,
1672
+ remoteDomain: remoteDomainRaw,
1673
+ remotePort: remotePortNum,
1674
+ localDomain,
1675
+ localPort
1676
+ };
1677
+ }
1600
1678
  function parseReverseTunnelAddr(finalConfig, values) {
1601
1679
  const reverseTunnel = values.R;
1602
1680
  if ((!Array.isArray(reverseTunnel) || reverseTunnel.length === 0) && !values.localport && !finalConfig.forwarding) {
@@ -1605,7 +1683,7 @@ function parseReverseTunnelAddr(finalConfig, values) {
1605
1683
  if (!Array.isArray(reverseTunnel) || reverseTunnel.length === 0) {
1606
1684
  return null;
1607
1685
  }
1608
- const forwarding = parseForwarding(reverseTunnel[0]);
1686
+ const forwarding = parseDefaultForwarding(reverseTunnel[0]);
1609
1687
  if (forwarding instanceof Error) {
1610
1688
  return forwarding;
1611
1689
  }
@@ -1613,7 +1691,7 @@ function parseReverseTunnelAddr(finalConfig, values) {
1613
1691
  if (reverseTunnel.length > 1) {
1614
1692
  finalConfig.additionalForwarding = [];
1615
1693
  for (const t of reverseTunnel.slice(1)) {
1616
- const f = parseForwarding(t);
1694
+ const f = parseAdditionalForwarding(t);
1617
1695
  if (f instanceof Error) {
1618
1696
  return f;
1619
1697
  }
@@ -2563,11 +2641,47 @@ async function createQrCodes(urls) {
2563
2641
 
2564
2642
  // src/tui/blessed/webDebuggerConnection.ts
2565
2643
  var import_ws2 = __toESM(require("ws"), 1);
2644
+
2645
+ // src/tui/blessed/config.ts
2646
+ var defaultTuiConfig = {
2647
+ maxRequestPairs: 100,
2648
+ visibleRequestCount: 10,
2649
+ viewportScrollMargin: 2,
2650
+ inactivityHttpSelectorTimeoutMs: 1e4
2651
+ };
2652
+ function getTuiConfig() {
2653
+ return {
2654
+ maxRequestPairs: defaultTuiConfig.maxRequestPairs,
2655
+ visibleRequestCount: defaultTuiConfig.visibleRequestCount,
2656
+ viewportScrollMargin: defaultTuiConfig.viewportScrollMargin,
2657
+ inactivityHttpSelectorTimeoutMs: defaultTuiConfig.inactivityHttpSelectorTimeoutMs
2658
+ };
2659
+ }
2660
+
2661
+ // src/tui/blessed/webDebuggerConnection.ts
2566
2662
  function createWebDebuggerConnection(webDebuggerUrl, onUpdate) {
2567
2663
  const pairs = /* @__PURE__ */ new Map();
2664
+ const pairKeys = [];
2568
2665
  let socket = null;
2569
2666
  let reconnectTimeout = null;
2570
2667
  let isStopped = false;
2668
+ const config = getTuiConfig();
2669
+ const maxPairs = config.maxRequestPairs;
2670
+ const trimPairs = () => {
2671
+ while (pairKeys.length > maxPairs) {
2672
+ const oldestKey = pairKeys.shift();
2673
+ if (oldestKey !== void 0) {
2674
+ pairs.delete(oldestKey);
2675
+ }
2676
+ }
2677
+ };
2678
+ const upsertPair = (key, pair) => {
2679
+ if (!pairs.has(key)) {
2680
+ pairKeys.push(key);
2681
+ }
2682
+ pairs.set(key, pair);
2683
+ trimPairs();
2684
+ };
2571
2685
  const connect = () => {
2572
2686
  const ws = new import_ws2.default(`ws://${webDebuggerUrl}/introspec/websocket`);
2573
2687
  socket = ws;
@@ -2579,34 +2693,36 @@ function createWebDebuggerConnection(webDebuggerUrl, onUpdate) {
2579
2693
  const raw = data.toString();
2580
2694
  const parsed = JSON.parse(raw);
2581
2695
  const msg = {
2582
- Req: parsed.Req || parsed.req,
2583
- Res: parsed.Res || parsed.res
2696
+ Req: parsed.req,
2697
+ Res: parsed.res
2584
2698
  };
2585
2699
  if (msg.Req) {
2586
2700
  const { key } = msg.Req;
2587
2701
  const existing = pairs.get(key);
2588
2702
  const merged = {
2589
2703
  request: msg.Req,
2590
- response: existing?.response,
2591
- reqHeaders: existing?.reqHeaders ?? {},
2592
- resHeaders: existing?.resHeaders ?? {},
2593
- headersLoaded: existing?.headersLoaded ?? false
2704
+ response: existing?.response
2594
2705
  };
2595
- pairs.set(key, merged);
2706
+ upsertPair(key, merged);
2596
2707
  }
2597
2708
  if (msg.Res) {
2598
2709
  const { key } = msg.Res;
2599
2710
  const existing = pairs.get(key);
2600
2711
  const merged = {
2601
2712
  request: existing?.request ?? {},
2602
- response: msg.Res,
2603
- reqHeaders: existing?.reqHeaders ?? {},
2604
- resHeaders: existing?.resHeaders ?? {},
2605
- headersLoaded: existing?.headersLoaded ?? false
2713
+ response: msg.Res
2606
2714
  };
2607
- pairs.set(key, merged);
2715
+ upsertPair(key, merged);
2716
+ }
2717
+ const reversedPairs = [];
2718
+ for (let i = pairKeys.length - 1; i >= 0; i--) {
2719
+ const key = pairKeys[i];
2720
+ const pair = pairs.get(key);
2721
+ if (pair) {
2722
+ reversedPairs.push(pair);
2723
+ }
2608
2724
  }
2609
- onUpdate(new Map(pairs));
2725
+ onUpdate(reversedPairs);
2610
2726
  } catch (err) {
2611
2727
  logger.error("Error parsing WebSocket message:", err.message || err);
2612
2728
  }
@@ -2878,7 +2994,7 @@ function getStatusColor(status) {
2878
2994
  const statusCode = match ? parseInt(match[1], 10) : 0;
2879
2995
  switch (true) {
2880
2996
  case (statusCode >= 100 && statusCode < 200):
2881
- return "orange";
2997
+ return "yellow";
2882
2998
  case (statusCode >= 200 && statusCode < 300):
2883
2999
  return "green";
2884
3000
  case (statusCode >= 300 && statusCode < 400):
@@ -2938,14 +3054,49 @@ Total Transfer: ${getBytesInt(stats.numTotalTxBytes)}`;
2938
3054
  screen.render();
2939
3055
  }
2940
3056
  function updateRequestsDisplay(requestsBox, screen, pairs, selectedIndex) {
2941
- if (!requestsBox) return;
2942
- const allPairs = [...pairs.values()];
2943
- const visiblePairs = allPairs.slice(-10);
2944
- const startIndex = allPairs.length - visiblePairs.length;
2945
- let content = "{yellow-fg}HTTP Requests:{/yellow-fg}\n";
3057
+ const config = getTuiConfig();
3058
+ const { maxRequestPairs, visibleRequestCount, viewportScrollMargin } = config;
3059
+ if (!requestsBox) {
3060
+ return { adjustedSelectedIndex: selectedIndex, trimmedPairs: pairs };
3061
+ }
3062
+ let allPairs = pairs;
3063
+ let trimmedPairs = pairs;
3064
+ if (allPairs.length > maxRequestPairs) {
3065
+ allPairs = allPairs.slice(0, maxRequestPairs);
3066
+ trimmedPairs = allPairs;
3067
+ }
3068
+ const totalPairs = allPairs.length;
3069
+ let adjustedSelectedIndex = selectedIndex;
3070
+ if (adjustedSelectedIndex >= totalPairs) {
3071
+ adjustedSelectedIndex = -1;
3072
+ }
3073
+ let viewportStart;
3074
+ if (totalPairs <= visibleRequestCount) {
3075
+ viewportStart = 0;
3076
+ } else if (adjustedSelectedIndex === -1) {
3077
+ viewportStart = 0;
3078
+ } else {
3079
+ viewportStart = 0;
3080
+ if (adjustedSelectedIndex >= visibleRequestCount - viewportScrollMargin) {
3081
+ viewportStart = Math.min(
3082
+ totalPairs - visibleRequestCount,
3083
+ adjustedSelectedIndex - viewportScrollMargin
3084
+ );
3085
+ }
3086
+ if (adjustedSelectedIndex < viewportStart + viewportScrollMargin) {
3087
+ viewportStart = Math.max(0, adjustedSelectedIndex - viewportScrollMargin);
3088
+ }
3089
+ }
3090
+ const viewportEnd = Math.min(viewportStart + visibleRequestCount, totalPairs);
3091
+ const visiblePairs = allPairs.slice(viewportStart, viewportEnd);
3092
+ let content = "{yellow-fg}HTTP Requests:{/yellow-fg}";
3093
+ if (viewportStart > 0) {
3094
+ content += ` {gray-fg}\u2191 ${viewportStart} more{/gray-fg}`;
3095
+ }
3096
+ content += "\n";
2946
3097
  visiblePairs.forEach((pair, i) => {
2947
- const globalIndex = startIndex + i;
2948
- const isSelected = selectedIndex === globalIndex;
3098
+ const globalIndex = viewportStart + i;
3099
+ const isSelected = adjustedSelectedIndex !== -1 && adjustedSelectedIndex === globalIndex;
2949
3100
  const prefix = isSelected ? "> " : " ";
2950
3101
  const method = pair.request?.method || "";
2951
3102
  const uri = pair.request?.uri || "";
@@ -2962,8 +3113,14 @@ function updateRequestsDisplay(requestsBox, screen, pairs, selectedIndex) {
2962
3113
  `;
2963
3114
  }
2964
3115
  });
3116
+ const itemsBelow = totalPairs - viewportEnd;
3117
+ if (itemsBelow > 0) {
3118
+ content += `{gray-fg} \u2193 ${itemsBelow} more{/gray-fg}
3119
+ `;
3120
+ }
2965
3121
  requestsBox.setContent(content);
2966
3122
  screen.render();
3123
+ return { adjustedSelectedIndex, trimmedPairs };
2967
3124
  }
2968
3125
  function updateQrCodeDisplay(qrCodeBox, screen, qrCodes, urls, currentQrIndex) {
2969
3126
  if (!qrCodeBox || qrCodes.length === 0) return;
@@ -3058,11 +3215,13 @@ function showKeyBindingsModal(screen, manager) {
3058
3215
  {bold}Ctrl+c{/bold} Exit
3059
3216
 
3060
3217
  Enter/Return Open selected request
3061
- Esc Return to main page
3218
+ Esc Return to main page (or close modals)
3062
3219
  UP (\u2191) Scroll up the requests
3063
3220
  Down (\u2193) Scroll down the requests
3064
3221
  Left (\u2190) Show qr code for previous url
3065
3222
  Right (\u2192) Show qr code for next url
3223
+ Home Jump to top of requests
3224
+ End Jump to bottom of requests
3066
3225
  Ctrl+c Force Exit
3067
3226
 
3068
3227
  {white-bg}{black-fg}Press ESC to close{/black-fg}{/white-bg}`;
@@ -3127,38 +3286,117 @@ function closeDisconnectModal(screen, manager) {
3127
3286
  manager.inDisconnectView = false;
3128
3287
  screen.render();
3129
3288
  }
3289
+ function showLoadingModal(screen, modalManager, message = "Loading...") {
3290
+ if (modalManager.loadingView) return;
3291
+ modalManager.loadingBox = import_blessed2.default.box({
3292
+ parent: screen,
3293
+ top: "center",
3294
+ left: "center",
3295
+ width: "60%",
3296
+ height: 8,
3297
+ border: { type: "line" },
3298
+ style: {
3299
+ border: { fg: "yellow" }
3300
+ },
3301
+ tags: true,
3302
+ content: `{center}{yellow-fg}{bold}${message}{/bold}{/yellow-fg}
3303
+
3304
+ {gray-fg}Press ESC to cancel{/gray-fg}{/center}`,
3305
+ valign: "middle"
3306
+ });
3307
+ modalManager.loadingView = true;
3308
+ screen.render();
3309
+ }
3310
+ function closeLoadingModal(screen, modalManager) {
3311
+ if (!modalManager.loadingView || !modalManager.loadingBox) return;
3312
+ modalManager.loadingBox.destroy();
3313
+ modalManager.loadingBox = null;
3314
+ modalManager.loadingView = false;
3315
+ screen.render();
3316
+ }
3317
+ function showErrorModal(screen, modalManager, title = "Error", message) {
3318
+ if (modalManager.loadingBox) {
3319
+ modalManager.loadingBox.destroy();
3320
+ modalManager.loadingBox = null;
3321
+ }
3322
+ modalManager.loadingBox = import_blessed2.default.box({
3323
+ parent: screen,
3324
+ top: "center",
3325
+ left: "center",
3326
+ width: "60%",
3327
+ height: 9,
3328
+ border: { type: "line" },
3329
+ style: {
3330
+ border: { fg: "red" }
3331
+ },
3332
+ tags: true,
3333
+ content: `{center}{red-fg}{bold}${title}{/bold}{/red-fg}
3334
+
3335
+ {white-fg}${message}{/white-fg}
3336
+
3337
+ {gray-fg}Press ESC to close{/gray-fg}{/center}`,
3338
+ valign: "middle"
3339
+ });
3340
+ modalManager.loadingView = true;
3341
+ screen.render();
3342
+ }
3130
3343
 
3131
3344
  // src/tui/blessed/headerFetcher.ts
3132
- async function fetchReqResHeaders(baseUrl, key) {
3345
+ async function fetchReqResHeaders(baseUrl, key, signal) {
3133
3346
  if (!baseUrl) {
3134
3347
  return { req: "", res: "" };
3135
3348
  }
3136
3349
  try {
3137
3350
  const [reqRes, resRes] = await Promise.all([
3138
3351
  fetch(`http://${baseUrl}/introspec/getrawrequestheader`, {
3139
- headers: { "X-Introspec-Key": key.toString() }
3352
+ headers: { "X-Introspec-Key": key.toString() },
3353
+ signal
3140
3354
  }),
3141
3355
  fetch(`http://${baseUrl}/introspec/getrawresponseheader`, {
3142
- headers: { "X-Introspec-Key": key.toString() }
3356
+ headers: { "X-Introspec-Key": key.toString() },
3357
+ signal
3143
3358
  })
3144
3359
  ]);
3145
3360
  const [req, res] = await Promise.all([reqRes.text(), resRes.text()]);
3146
3361
  return { req, res };
3147
3362
  } catch (err) {
3363
+ if (err?.name === "AbortError") {
3364
+ throw err;
3365
+ }
3148
3366
  logger.error("Error fetching headers:", err.message || err);
3149
- return { req: "", res: "" };
3367
+ throw err;
3150
3368
  }
3151
3369
  }
3152
3370
 
3153
3371
  // src/tui/blessed/components/KeyBindings.ts
3154
- function setupKeyBindings(screen, modalManager, state, callbacks, tunnelConfig, tunnelInstance) {
3372
+ function setupKeyBindings(screen, modalManager, state, callbacks, tunnelConfig) {
3373
+ let inactivityTimeout = null;
3374
+ const { inactivityHttpSelectorTimeoutMs } = getTuiConfig();
3375
+ const INACTIVITY_TIMEOUT_MS = inactivityHttpSelectorTimeoutMs;
3376
+ const resetInactivityTimer = () => {
3377
+ if (inactivityTimeout) {
3378
+ clearTimeout(inactivityTimeout);
3379
+ }
3380
+ if (state.selectedIndex !== -1) {
3381
+ inactivityTimeout = setTimeout(() => {
3382
+ callbacks.onSelectedIndexChange(-1, null);
3383
+ callbacks.updateRequestsDisplay();
3384
+ }, INACTIVITY_TIMEOUT_MS);
3385
+ }
3386
+ };
3155
3387
  screen.key(["C-c"], () => {
3156
- const manager = TunnelManager.getInstance();
3157
- manager.stopTunnel(tunnelInstance?.tunnelid || "");
3158
3388
  callbacks.onDestroy();
3159
3389
  process.exit(0);
3160
3390
  });
3161
3391
  screen.key(["escape"], () => {
3392
+ if (modalManager.loadingView) {
3393
+ if (modalManager.fetchAbortController) {
3394
+ modalManager.fetchAbortController.abort();
3395
+ modalManager.fetchAbortController = null;
3396
+ }
3397
+ closeLoadingModal(screen, modalManager);
3398
+ return;
3399
+ }
3162
3400
  if (modalManager.inDetailView) {
3163
3401
  closeDetailModal(screen, modalManager);
3164
3402
  return;
@@ -3167,40 +3405,97 @@ function setupKeyBindings(screen, modalManager, state, callbacks, tunnelConfig,
3167
3405
  closeKeyBindingsModal(screen, modalManager);
3168
3406
  return;
3169
3407
  }
3408
+ if (state.selectedIndex !== -1) {
3409
+ if (inactivityTimeout) {
3410
+ clearTimeout(inactivityTimeout);
3411
+ inactivityTimeout = null;
3412
+ }
3413
+ callbacks.onSelectedIndexChange(-1, null);
3414
+ callbacks.updateRequestsDisplay();
3415
+ }
3170
3416
  });
3171
3417
  screen.key(["up"], () => {
3172
- if (modalManager.inDetailView || modalManager.keyBindingView) return;
3173
- if (state.selectedIndex > 0) {
3174
- callbacks.onSelectedIndexChange(state.selectedIndex - 1);
3418
+ if (modalManager.inDetailView || modalManager.keyBindingView || modalManager.loadingView) return;
3419
+ resetInactivityTimer();
3420
+ if (state.selectedIndex === -1) {
3421
+ const requestKey = state.pairs[0]?.request?.key ?? null;
3422
+ callbacks.onSelectedIndexChange(0, requestKey);
3423
+ callbacks.updateRequestsDisplay();
3424
+ resetInactivityTimer();
3425
+ } else if (state.selectedIndex > 0) {
3426
+ const newIndex = state.selectedIndex - 1;
3427
+ const requestKey = state.pairs[newIndex]?.request?.key ?? null;
3428
+ callbacks.onSelectedIndexChange(newIndex, requestKey);
3175
3429
  callbacks.updateRequestsDisplay();
3176
3430
  }
3177
3431
  });
3178
3432
  screen.key(["down"], () => {
3179
- if (modalManager.inDetailView || modalManager.keyBindingView) return;
3180
- const allPairs = [...state.pairs.values()];
3181
- if (state.selectedIndex < allPairs.length - 1) {
3182
- callbacks.onSelectedIndexChange(state.selectedIndex + 1);
3433
+ if (modalManager.inDetailView || modalManager.keyBindingView || modalManager.loadingView) return;
3434
+ resetInactivityTimer();
3435
+ const config = getTuiConfig();
3436
+ const limitedLength = Math.min(state.pairs.length, config.maxRequestPairs);
3437
+ if (state.selectedIndex === -1) {
3438
+ if (limitedLength > 0) {
3439
+ const requestKey = state.pairs[0]?.request?.key ?? null;
3440
+ callbacks.onSelectedIndexChange(0, requestKey);
3441
+ callbacks.updateRequestsDisplay();
3442
+ resetInactivityTimer();
3443
+ }
3444
+ } else if (state.selectedIndex < limitedLength - 1) {
3445
+ const newIndex = state.selectedIndex + 1;
3446
+ const requestKey = state.pairs[newIndex]?.request?.key ?? null;
3447
+ callbacks.onSelectedIndexChange(newIndex, requestKey);
3448
+ callbacks.updateRequestsDisplay();
3449
+ }
3450
+ });
3451
+ screen.key(["end"], () => {
3452
+ if (modalManager.inDetailView || modalManager.keyBindingView || modalManager.loadingView) return;
3453
+ resetInactivityTimer();
3454
+ const config = getTuiConfig();
3455
+ const limitedLength = Math.min(state.pairs.length, config.maxRequestPairs);
3456
+ const lastIndex = Math.max(0, limitedLength - 1);
3457
+ if (state.selectedIndex !== lastIndex) {
3458
+ const requestKey = state.pairs[lastIndex]?.request?.key ?? null;
3459
+ callbacks.onSelectedIndexChange(lastIndex, requestKey);
3183
3460
  callbacks.updateRequestsDisplay();
3184
3461
  }
3185
3462
  });
3186
3463
  screen.key(["enter"], async () => {
3187
- if (modalManager.inDetailView || modalManager.keyBindingView) return;
3188
- const allPairs = [...state.pairs.values()];
3189
- const pair = allPairs[state.selectedIndex];
3464
+ if (modalManager.inDetailView || modalManager.keyBindingView || modalManager.loadingView) return;
3465
+ if (state.selectedIndex === -1) return;
3466
+ resetInactivityTimer();
3467
+ const pair = state.pairs[state.selectedIndex];
3190
3468
  if (pair?.request?.key !== void 0 && pair?.request?.key !== null) {
3469
+ const abortController = new AbortController();
3470
+ modalManager.fetchAbortController = abortController;
3471
+ showLoadingModal(screen, modalManager, "Fetching request details...");
3191
3472
  try {
3192
3473
  const headers = await fetchReqResHeaders(
3193
3474
  tunnelConfig?.webDebugger || "",
3194
- pair.request.key
3475
+ pair.request.key,
3476
+ abortController.signal
3195
3477
  );
3478
+ if (abortController.signal.aborted) {
3479
+ return;
3480
+ }
3481
+ closeLoadingModal(screen, modalManager);
3482
+ modalManager.fetchAbortController = null;
3196
3483
  showDetailModal(screen, modalManager, headers.req, headers.res);
3197
3484
  } catch (err) {
3485
+ if (err?.name === "AbortError" || abortController.signal.aborted) {
3486
+ logger.info("Fetch request cancelled by user");
3487
+ return;
3488
+ }
3489
+ closeLoadingModal(screen, modalManager);
3490
+ modalManager.fetchAbortController = null;
3491
+ const errorMessage = err?.message || String(err) || "Unknown error occurred";
3198
3492
  logger.error("Fetch error:", err);
3493
+ showErrorModal(screen, modalManager, "Failed to fetch request details", errorMessage);
3199
3494
  }
3200
3495
  }
3201
3496
  });
3202
3497
  screen.key(["h"], () => {
3203
- if (modalManager.inDetailView) return;
3498
+ if (modalManager.inDetailView || modalManager.loadingView) return;
3204
3499
  if (modalManager.keyBindingView) {
3205
3500
  closeKeyBindingsModal(screen, modalManager);
3206
3501
  } else {
@@ -3208,7 +3503,7 @@ function setupKeyBindings(screen, modalManager, state, callbacks, tunnelConfig,
3208
3503
  }
3209
3504
  });
3210
3505
  screen.key(["c"], async () => {
3211
- if (modalManager.inDetailView || modalManager.keyBindingView) return;
3506
+ if (modalManager.inDetailView || modalManager.keyBindingView || modalManager.loadingView) return;
3212
3507
  if (state.urls.length > 0) {
3213
3508
  try {
3214
3509
  const clipboardy = await import("clipboardy");
@@ -3219,7 +3514,7 @@ function setupKeyBindings(screen, modalManager, state, callbacks, tunnelConfig,
3219
3514
  }
3220
3515
  });
3221
3516
  screen.key(["left"], () => {
3222
- if (modalManager.inDetailView || modalManager.keyBindingView) return;
3517
+ if (modalManager.inDetailView || modalManager.keyBindingView || modalManager.loadingView) return;
3223
3518
  if (state.currentQrIndex > 0) {
3224
3519
  callbacks.onQrIndexChange(state.currentQrIndex - 1);
3225
3520
  callbacks.updateUrlsDisplay();
@@ -3227,7 +3522,7 @@ function setupKeyBindings(screen, modalManager, state, callbacks, tunnelConfig,
3227
3522
  }
3228
3523
  });
3229
3524
  screen.key(["right"], () => {
3230
- if (modalManager.inDetailView || modalManager.keyBindingView) return;
3525
+ if (modalManager.inDetailView || modalManager.keyBindingView || modalManager.loadingView) return;
3231
3526
  if (state.currentQrIndex < state.urls.length - 1) {
3232
3527
  callbacks.onQrIndexChange(state.currentQrIndex + 1);
3233
3528
  callbacks.updateUrlsDisplay();
@@ -3241,7 +3536,10 @@ var TunnelTui = class {
3241
3536
  constructor(props) {
3242
3537
  // State
3243
3538
  this.currentQrIndex = 0;
3244
- this.selectedIndex = 0;
3539
+ this.selectedIndex = -1;
3540
+ // -1 means no selection
3541
+ this.selectedRequestKey = null;
3542
+ // Track selected request by key
3245
3543
  this.qrCodes = [];
3246
3544
  this.stats = {
3247
3545
  elapsedTime: 0,
@@ -3251,7 +3549,7 @@ var TunnelTui = class {
3251
3549
  numTotalResBytes: 0,
3252
3550
  numTotalTxBytes: 0
3253
3551
  };
3254
- this.pairs = /* @__PURE__ */ new Map();
3552
+ this.pairs = [];
3255
3553
  this.webDebuggerConnection = null;
3256
3554
  this.modalManager = {
3257
3555
  detailModal: null,
@@ -3259,7 +3557,10 @@ var TunnelTui = class {
3259
3557
  disconnectModal: null,
3260
3558
  inDetailView: false,
3261
3559
  keyBindingView: false,
3262
- inDisconnectView: false
3560
+ inDisconnectView: false,
3561
+ loadingBox: null,
3562
+ loadingView: false,
3563
+ fetchAbortController: null
3263
3564
  };
3264
3565
  this.exitPromiseResolve = null;
3265
3566
  this.urls = props.urls;
@@ -3289,12 +3590,26 @@ var TunnelTui = class {
3289
3590
  this.updateStatsDisplay();
3290
3591
  };
3291
3592
  }
3593
+ clearSelection() {
3594
+ this.selectedIndex = -1;
3595
+ this.selectedRequestKey = null;
3596
+ }
3292
3597
  setupWebDebugger() {
3293
3598
  if (this.tunnelConfig?.webDebugger) {
3294
3599
  this.webDebuggerConnection = createWebDebuggerConnection(
3295
3600
  this.tunnelConfig.webDebugger,
3296
3601
  (pairs) => {
3297
3602
  this.pairs = pairs;
3603
+ if (this.selectedRequestKey !== null) {
3604
+ const newIndex = pairs.findIndex(
3605
+ (pair) => pair.request?.key === this.selectedRequestKey
3606
+ );
3607
+ if (newIndex !== -1) {
3608
+ this.selectedIndex = newIndex;
3609
+ } else {
3610
+ this.clearSelection();
3611
+ }
3612
+ }
3298
3613
  this.updateRequestsDisplay();
3299
3614
  }
3300
3615
  );
@@ -3357,12 +3672,22 @@ var TunnelTui = class {
3357
3672
  );
3358
3673
  }
3359
3674
  updateRequestsDisplay() {
3360
- updateRequestsDisplay(
3675
+ const result = updateRequestsDisplay(
3361
3676
  this.uiElements?.requestsBox,
3362
3677
  this.screen,
3363
3678
  this.pairs,
3364
3679
  this.selectedIndex
3365
3680
  );
3681
+ if (result.adjustedSelectedIndex !== this.selectedIndex) {
3682
+ if (result.adjustedSelectedIndex === -1) {
3683
+ this.clearSelection();
3684
+ } else {
3685
+ this.selectedIndex = result.adjustedSelectedIndex;
3686
+ }
3687
+ }
3688
+ if (result.trimmedPairs !== this.pairs) {
3689
+ this.pairs = result.trimmedPairs;
3690
+ }
3366
3691
  }
3367
3692
  updateQrCodeDisplay() {
3368
3693
  updateQrCodeDisplay(
@@ -3399,8 +3724,9 @@ var TunnelTui = class {
3399
3724
  onQrIndexChange: (index) => {
3400
3725
  self.currentQrIndex = index;
3401
3726
  },
3402
- onSelectedIndexChange: (index) => {
3727
+ onSelectedIndexChange: (index, requestKey) => {
3403
3728
  self.selectedIndex = index;
3729
+ self.selectedRequestKey = requestKey;
3404
3730
  },
3405
3731
  onDestroy: () => self.destroy(),
3406
3732
  updateUrlsDisplay: () => self.updateUrlsDisplay(),
@@ -3412,8 +3738,7 @@ var TunnelTui = class {
3412
3738
  this.modalManager,
3413
3739
  state,
3414
3740
  callbacks,
3415
- this.tunnelConfig,
3416
- this.tunnelInstance
3741
+ this.tunnelConfig
3417
3742
  );
3418
3743
  }
3419
3744
  handleResize() {
package/dist/index.d.cts CHANGED
@@ -5,10 +5,11 @@ import { z } from 'zod';
5
5
  import winston from 'winston';
6
6
 
7
7
  interface AdditionalForwarding {
8
- remoteDomain?: string;
9
- remotePort?: number;
10
8
  localDomain: string;
11
9
  localPort: number;
10
+ remoteDomain?: string;
11
+ remotePort?: number;
12
+ protocol?: 'http' | 'tcp' | 'udp' | 'tls';
12
13
  }
13
14
  declare enum TunnelStateType {
14
15
  New = "idle",