lunel-cli 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +204 -24
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -44,6 +44,13 @@ let activeControlWs = null;
44
44
  let activeDataWs = null;
45
45
  const activeTunnels = new Map();
46
46
  const PORT_SYNC_INTERVAL_MS = 30_000;
47
+ const CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS = 2_500;
48
+ const PROXY_WS_CONNECT_TIMEOUT_MS = 12_000;
49
+ const TUNNEL_SETUP_BUDGET_MS = 18_000;
50
+ const PROXY_WS_CONNECT_RETRY_ATTEMPTS = 1;
51
+ const PROXY_WS_RETRY_JITTER_MIN_MS = 200;
52
+ const PROXY_WS_RETRY_JITTER_MAX_MS = 500;
53
+ const PROXY_TUNNEL_LINGER_MS = 1_200;
47
54
  let portSyncTimer = null;
48
55
  let portScanInFlight = false;
49
56
  let lastDiscoveredPorts = [];
@@ -54,6 +61,55 @@ function redactSensitive(input) {
54
61
  .replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
55
62
  .replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
56
63
  }
64
+ function parseProxyControlFrame(raw) {
65
+ const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf-8") : null;
66
+ if (!text)
67
+ return null;
68
+ try {
69
+ const parsed = JSON.parse(text);
70
+ if (parsed?.v !== 1 || parsed?.t !== "proxy_ctrl")
71
+ return null;
72
+ if (parsed.action !== "fin" && parsed.action !== "rst")
73
+ return null;
74
+ return {
75
+ v: 1,
76
+ t: "proxy_ctrl",
77
+ action: parsed.action,
78
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
79
+ };
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ function sendProxyControl(tunnel, action, reason) {
86
+ if (tunnel.proxyWs.readyState !== WebSocket.OPEN)
87
+ return;
88
+ const frame = { v: 1, t: "proxy_ctrl", action, reason };
89
+ tunnel.proxyWs.send(JSON.stringify(frame));
90
+ }
91
+ function maybeFinalizeTunnel(tunnelId) {
92
+ const tunnel = activeTunnels.get(tunnelId);
93
+ if (!tunnel || tunnel.closing)
94
+ return;
95
+ if (!tunnel.localEnded || !tunnel.remoteEnded)
96
+ return;
97
+ if (tunnel.finalizeTimer)
98
+ return;
99
+ tunnel.finalizeTimer = setTimeout(() => {
100
+ const current = activeTunnels.get(tunnelId);
101
+ if (!current || current.closing)
102
+ return;
103
+ current.closing = true;
104
+ activeTunnels.delete(tunnelId);
105
+ if (!current.tcpSocket.destroyed) {
106
+ current.tcpSocket.destroy();
107
+ }
108
+ if (current.proxyWs.readyState === WebSocket.OPEN || current.proxyWs.readyState === WebSocket.CONNECTING) {
109
+ current.proxyWs.close();
110
+ }
111
+ }, PROXY_TUNNEL_LINGER_MS);
112
+ }
57
113
  // Popular development server ports to scan on connect
58
114
  const DEV_PORTS = [
59
115
  1234, // Parcel
@@ -1698,19 +1754,25 @@ function startPortSync(ws) {
1698
1754
  async function handleProxyConnect(payload) {
1699
1755
  const tunnelId = payload.tunnelId;
1700
1756
  const port = payload.port;
1757
+ const setupStartedAt = Date.now();
1758
+ const getRemainingSetupMs = () => TUNNEL_SETUP_BUDGET_MS - (Date.now() - setupStartedAt);
1701
1759
  if (!tunnelId)
1702
1760
  throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
1703
1761
  if (!port)
1704
1762
  throw Object.assign(new Error("port is required"), { code: "EINVAL" });
1705
1763
  if (!currentSessionCode && !currentSessionPassword)
1706
1764
  throw Object.assign(new Error("no active session"), { code: "ENOENT" });
1765
+ if (getRemainingSetupMs() <= 0) {
1766
+ throw Object.assign(new Error("Tunnel setup timeout before start"), { code: "ETIMEOUT" });
1767
+ }
1707
1768
  // 1. Open TCP connection to the local service
1708
1769
  const tcpSocket = createConnection({ port, host: "127.0.0.1" });
1770
+ const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, getRemainingSetupMs()));
1709
1771
  await new Promise((resolve, reject) => {
1710
1772
  const timeout = setTimeout(() => {
1711
1773
  tcpSocket.destroy();
1712
1774
  reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
1713
- }, 5000);
1775
+ }, tcpConnectTimeoutMs);
1714
1776
  tcpSocket.on("connect", () => {
1715
1777
  clearTimeout(timeout);
1716
1778
  resolve();
@@ -1726,25 +1788,74 @@ async function handleProxyConnect(payload) {
1726
1788
  ? `password=${encodeURIComponent(currentSessionPassword)}`
1727
1789
  : `code=${encodeURIComponent(currentSessionCode)}`;
1728
1790
  const proxyWsUrl = `${wsBase}/v1/ws/proxy?${authQuery}&tunnelId=${encodeURIComponent(tunnelId)}&role=cli`;
1729
- const proxyWs = new WebSocket(proxyWsUrl);
1730
- await new Promise((resolve, reject) => {
1731
- const timeout = setTimeout(() => {
1732
- proxyWs.close();
1733
- tcpSocket.destroy();
1734
- reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
1735
- }, 5000);
1736
- proxyWs.on("open", () => {
1737
- clearTimeout(timeout);
1738
- resolve();
1739
- });
1740
- proxyWs.on("error", (err) => {
1741
- clearTimeout(timeout);
1791
+ let proxyWs = null;
1792
+ let lastProxyError = null;
1793
+ for (let attempt = 0; attempt <= PROXY_WS_CONNECT_RETRY_ATTEMPTS; attempt++) {
1794
+ const remainingMs = getRemainingSetupMs();
1795
+ if (remainingMs <= 0) {
1742
1796
  tcpSocket.destroy();
1743
- reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
1744
- });
1745
- });
1797
+ throw Object.assign(new Error("Tunnel setup timeout while connecting proxy WS"), { code: "ETIMEOUT" });
1798
+ }
1799
+ const wsConnectTimeoutMs = Math.min(PROXY_WS_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
1800
+ const candidateWs = new WebSocket(proxyWsUrl);
1801
+ try {
1802
+ await new Promise((resolve, reject) => {
1803
+ const timeout = setTimeout(() => {
1804
+ candidateWs.close();
1805
+ reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
1806
+ }, wsConnectTimeoutMs);
1807
+ candidateWs.on("open", () => {
1808
+ clearTimeout(timeout);
1809
+ resolve();
1810
+ });
1811
+ candidateWs.on("error", (err) => {
1812
+ clearTimeout(timeout);
1813
+ reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
1814
+ });
1815
+ candidateWs.on("close", () => {
1816
+ clearTimeout(timeout);
1817
+ reject(Object.assign(new Error("Proxy WS closed during connect"), { code: "ECONNRESET" }));
1818
+ });
1819
+ });
1820
+ proxyWs = candidateWs;
1821
+ break;
1822
+ }
1823
+ catch (error) {
1824
+ lastProxyError = error;
1825
+ try {
1826
+ candidateWs.close();
1827
+ }
1828
+ catch {
1829
+ // ignore
1830
+ }
1831
+ if (attempt >= PROXY_WS_CONNECT_RETRY_ATTEMPTS) {
1832
+ break;
1833
+ }
1834
+ const jitterSpan = PROXY_WS_RETRY_JITTER_MAX_MS - PROXY_WS_RETRY_JITTER_MIN_MS;
1835
+ const jitterMs = PROXY_WS_RETRY_JITTER_MIN_MS + Math.floor(Math.random() * (jitterSpan + 1));
1836
+ if (getRemainingSetupMs() <= jitterMs) {
1837
+ break;
1838
+ }
1839
+ await new Promise((resolve) => setTimeout(resolve, jitterMs));
1840
+ }
1841
+ }
1842
+ if (!proxyWs) {
1843
+ tcpSocket.destroy();
1844
+ const err = lastProxyError || Object.assign(new Error("Proxy WS connect failed"), { code: "ECONNREFUSED" });
1845
+ throw err;
1846
+ }
1746
1847
  // 3. Store the tunnel
1747
- activeTunnels.set(tunnelId, { tunnelId, port, tcpSocket, proxyWs });
1848
+ activeTunnels.set(tunnelId, {
1849
+ tunnelId,
1850
+ port,
1851
+ tcpSocket,
1852
+ proxyWs,
1853
+ localEnded: false,
1854
+ remoteEnded: false,
1855
+ finSent: false,
1856
+ finalizeTimer: null,
1857
+ closing: false,
1858
+ });
1748
1859
  // 4. Pipe: TCP data -> proxy WS (as binary)
1749
1860
  tcpSocket.on("data", (chunk) => {
1750
1861
  if (proxyWs.readyState === WebSocket.OPEN) {
@@ -1753,18 +1864,70 @@ async function handleProxyConnect(payload) {
1753
1864
  });
1754
1865
  // 5. Pipe: proxy WS -> TCP socket (as binary)
1755
1866
  proxyWs.on("message", (data) => {
1867
+ const control = parseProxyControlFrame(data);
1868
+ if (control) {
1869
+ const tunnel = activeTunnels.get(tunnelId);
1870
+ if (!tunnel || tunnel.closing)
1871
+ return;
1872
+ if (control.action === "fin") {
1873
+ tunnel.remoteEnded = true;
1874
+ if (!tcpSocket.destroyed) {
1875
+ tcpSocket.end();
1876
+ }
1877
+ maybeFinalizeTunnel(tunnelId);
1878
+ }
1879
+ else {
1880
+ tunnel.closing = true;
1881
+ activeTunnels.delete(tunnelId);
1882
+ if (!tcpSocket.destroyed) {
1883
+ tcpSocket.destroy();
1884
+ }
1885
+ if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1886
+ proxyWs.close();
1887
+ }
1888
+ }
1889
+ return;
1890
+ }
1756
1891
  if (!tcpSocket.destroyed) {
1757
- tcpSocket.write(data);
1892
+ const chunk = Buffer.isBuffer(data)
1893
+ ? data
1894
+ : typeof data === "string"
1895
+ ? Buffer.from(data)
1896
+ : Array.isArray(data)
1897
+ ? Buffer.concat(data)
1898
+ : Buffer.from(data);
1899
+ tcpSocket.write(chunk);
1758
1900
  }
1759
1901
  });
1760
- // 6. Close cascade: TCP closes -> close WS
1761
- tcpSocket.on("close", () => {
1762
- activeTunnels.delete(tunnelId);
1763
- if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1764
- proxyWs.close();
1902
+ const markLocalEnded = () => {
1903
+ const tunnel = activeTunnels.get(tunnelId);
1904
+ if (!tunnel || tunnel.closing)
1905
+ return;
1906
+ tunnel.localEnded = true;
1907
+ if (!tunnel.finSent) {
1908
+ tunnel.finSent = true;
1909
+ sendProxyControl(tunnel, "fin");
1765
1910
  }
1911
+ maybeFinalizeTunnel(tunnelId);
1912
+ };
1913
+ // 6. Half-close handling
1914
+ tcpSocket.on("end", () => {
1915
+ markLocalEnded();
1916
+ });
1917
+ tcpSocket.on("close", () => {
1918
+ markLocalEnded();
1766
1919
  });
1767
1920
  tcpSocket.on("error", () => {
1921
+ const tunnel = activeTunnels.get(tunnelId);
1922
+ if (tunnel && !tunnel.finSent) {
1923
+ sendProxyControl(tunnel, "rst", "tcp_error");
1924
+ }
1925
+ if (tunnel) {
1926
+ tunnel.closing = true;
1927
+ if (tunnel.finalizeTimer) {
1928
+ clearTimeout(tunnel.finalizeTimer);
1929
+ }
1930
+ }
1768
1931
  activeTunnels.delete(tunnelId);
1769
1932
  if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1770
1933
  proxyWs.close();
@@ -1772,12 +1935,26 @@ async function handleProxyConnect(payload) {
1772
1935
  });
1773
1936
  // 7. Close cascade: WS closes -> close TCP
1774
1937
  proxyWs.on("close", () => {
1938
+ const tunnel = activeTunnels.get(tunnelId);
1939
+ if (tunnel) {
1940
+ tunnel.closing = true;
1941
+ if (tunnel.finalizeTimer) {
1942
+ clearTimeout(tunnel.finalizeTimer);
1943
+ }
1944
+ }
1775
1945
  activeTunnels.delete(tunnelId);
1776
1946
  if (!tcpSocket.destroyed) {
1777
1947
  tcpSocket.destroy();
1778
1948
  }
1779
1949
  });
1780
1950
  proxyWs.on("error", () => {
1951
+ const tunnel = activeTunnels.get(tunnelId);
1952
+ if (tunnel) {
1953
+ tunnel.closing = true;
1954
+ if (tunnel.finalizeTimer) {
1955
+ clearTimeout(tunnel.finalizeTimer);
1956
+ }
1957
+ }
1781
1958
  activeTunnels.delete(tunnelId);
1782
1959
  if (!tcpSocket.destroyed) {
1783
1960
  tcpSocket.destroy();
@@ -1787,6 +1964,9 @@ async function handleProxyConnect(payload) {
1787
1964
  }
1788
1965
  function cleanupAllTunnels() {
1789
1966
  for (const [, tunnel] of activeTunnels) {
1967
+ if (tunnel.finalizeTimer) {
1968
+ clearTimeout(tunnel.finalizeTimer);
1969
+ }
1790
1970
  tunnel.tcpSocket.destroy();
1791
1971
  if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
1792
1972
  tunnel.proxyWs.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lunel-cli",
3
- "version": "0.1.34",
3
+ "version": "0.1.35",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",