lunel-cli 0.1.34 → 0.1.36

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 +274 -53
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -44,6 +44,14 @@ 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;
54
+ const LOOPBACK_HOSTS = ["127.0.0.1", "::1"];
47
55
  let portSyncTimer = null;
48
56
  let portScanInFlight = false;
49
57
  let lastDiscoveredPorts = [];
@@ -54,6 +62,55 @@ function redactSensitive(input) {
54
62
  .replace(/(password|token|authorization|resumeToken|x-manager-password)\s*[:=]\s*["']?[^"',\s}]+/gi, "$1=[redacted]")
55
63
  .replace(/[A-Za-z0-9+/=_-]{40,}/g, "[redacted_secret]");
56
64
  }
65
+ function parseProxyControlFrame(raw) {
66
+ const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf-8") : null;
67
+ if (!text)
68
+ return null;
69
+ try {
70
+ const parsed = JSON.parse(text);
71
+ if (parsed?.v !== 1 || parsed?.t !== "proxy_ctrl")
72
+ return null;
73
+ if (parsed.action !== "fin" && parsed.action !== "rst")
74
+ return null;
75
+ return {
76
+ v: 1,
77
+ t: "proxy_ctrl",
78
+ action: parsed.action,
79
+ reason: typeof parsed.reason === "string" ? parsed.reason : undefined,
80
+ };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ function sendProxyControl(tunnel, action, reason) {
87
+ if (tunnel.proxyWs.readyState !== WebSocket.OPEN)
88
+ return;
89
+ const frame = { v: 1, t: "proxy_ctrl", action, reason };
90
+ tunnel.proxyWs.send(JSON.stringify(frame));
91
+ }
92
+ function maybeFinalizeTunnel(tunnelId) {
93
+ const tunnel = activeTunnels.get(tunnelId);
94
+ if (!tunnel || tunnel.closing)
95
+ return;
96
+ if (!tunnel.localEnded || !tunnel.remoteEnded)
97
+ return;
98
+ if (tunnel.finalizeTimer)
99
+ return;
100
+ tunnel.finalizeTimer = setTimeout(() => {
101
+ const current = activeTunnels.get(tunnelId);
102
+ if (!current || current.closing)
103
+ return;
104
+ current.closing = true;
105
+ activeTunnels.delete(tunnelId);
106
+ if (!current.tcpSocket.destroyed) {
107
+ current.tcpSocket.destroy();
108
+ }
109
+ if (current.proxyWs.readyState === WebSocket.OPEN || current.proxyWs.readyState === WebSocket.CONNECTING) {
110
+ current.proxyWs.close();
111
+ }
112
+ }, PROXY_TUNNEL_LINGER_MS);
113
+ }
57
114
  // Popular development server ports to scan on connect
58
115
  const DEV_PORTS = [
59
116
  1234, // Parcel
@@ -1634,20 +1691,36 @@ async function scanDevPorts() {
1634
1691
  const openPorts = [];
1635
1692
  const checks = SCAN_PORTS.map((port) => {
1636
1693
  return new Promise((resolve) => {
1637
- const socket = createConnection({ port, host: "127.0.0.1" });
1638
- socket.setTimeout(200);
1639
- socket.on("connect", () => {
1640
- openPorts.push(port);
1641
- socket.destroy();
1642
- resolve();
1643
- });
1644
- socket.on("timeout", () => {
1645
- socket.destroy();
1646
- resolve();
1647
- });
1648
- socket.on("error", () => {
1649
- resolve();
1650
- });
1694
+ let finished = false;
1695
+ let pending = LOOPBACK_HOSTS.length;
1696
+ for (const host of LOOPBACK_HOSTS) {
1697
+ const socket = createConnection({ port, host });
1698
+ socket.setTimeout(200);
1699
+ socket.on("connect", () => {
1700
+ if (finished)
1701
+ return;
1702
+ finished = true;
1703
+ openPorts.push(port);
1704
+ socket.destroy();
1705
+ resolve();
1706
+ });
1707
+ const onDone = () => {
1708
+ if (finished)
1709
+ return;
1710
+ pending -= 1;
1711
+ if (pending <= 0) {
1712
+ finished = true;
1713
+ resolve();
1714
+ }
1715
+ };
1716
+ socket.on("timeout", () => {
1717
+ socket.destroy();
1718
+ onDone();
1719
+ });
1720
+ socket.on("error", () => {
1721
+ onDone();
1722
+ });
1723
+ }
1651
1724
  });
1652
1725
  });
1653
1726
  await Promise.all(checks);
@@ -1698,53 +1771,132 @@ function startPortSync(ws) {
1698
1771
  async function handleProxyConnect(payload) {
1699
1772
  const tunnelId = payload.tunnelId;
1700
1773
  const port = payload.port;
1774
+ const setupStartedAt = Date.now();
1775
+ const getRemainingSetupMs = () => TUNNEL_SETUP_BUDGET_MS - (Date.now() - setupStartedAt);
1701
1776
  if (!tunnelId)
1702
1777
  throw Object.assign(new Error("tunnelId is required"), { code: "EINVAL" });
1703
1778
  if (!port)
1704
1779
  throw Object.assign(new Error("port is required"), { code: "EINVAL" });
1705
1780
  if (!currentSessionCode && !currentSessionPassword)
1706
1781
  throw Object.assign(new Error("no active session"), { code: "ENOENT" });
1707
- // 1. Open TCP connection to the local service
1708
- const tcpSocket = createConnection({ port, host: "127.0.0.1" });
1709
- await new Promise((resolve, reject) => {
1710
- const timeout = setTimeout(() => {
1711
- tcpSocket.destroy();
1712
- reject(Object.assign(new Error(`TCP connect timeout to localhost:${port}`), { code: "ETIMEOUT" }));
1713
- }, 5000);
1714
- tcpSocket.on("connect", () => {
1715
- clearTimeout(timeout);
1716
- resolve();
1717
- });
1718
- tcpSocket.on("error", (err) => {
1719
- clearTimeout(timeout);
1720
- reject(Object.assign(new Error(`TCP connect failed: ${err.message}`), { code: "ECONNREFUSED" }));
1721
- });
1722
- });
1782
+ if (getRemainingSetupMs() <= 0) {
1783
+ throw Object.assign(new Error("Tunnel setup timeout before start"), { code: "ETIMEOUT" });
1784
+ }
1785
+ // 1. Open TCP connection to the local service (dual-stack localhost fallback)
1786
+ let tcpSocket = null;
1787
+ let tcpConnectError = null;
1788
+ for (const host of LOOPBACK_HOSTS) {
1789
+ const remainingMs = getRemainingSetupMs();
1790
+ if (remainingMs <= 0) {
1791
+ throw Object.assign(new Error("Tunnel setup timeout before local TCP connect"), { code: "ETIMEOUT" });
1792
+ }
1793
+ const tcpConnectTimeoutMs = Math.min(CLI_LOCAL_TCP_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
1794
+ const candidate = createConnection({ port, host });
1795
+ try {
1796
+ await new Promise((resolve, reject) => {
1797
+ const timeout = setTimeout(() => {
1798
+ candidate.destroy();
1799
+ reject(Object.assign(new Error(`TCP connect timeout to ${host}:${port}`), { code: "ETIMEOUT" }));
1800
+ }, tcpConnectTimeoutMs);
1801
+ candidate.on("connect", () => {
1802
+ clearTimeout(timeout);
1803
+ resolve();
1804
+ });
1805
+ candidate.on("error", (err) => {
1806
+ clearTimeout(timeout);
1807
+ reject(Object.assign(new Error(`TCP connect failed to ${host}:${port}: ${err.message}`), { code: "ECONNREFUSED" }));
1808
+ });
1809
+ });
1810
+ tcpSocket = candidate;
1811
+ break;
1812
+ }
1813
+ catch (error) {
1814
+ tcpConnectError = error;
1815
+ try {
1816
+ candidate.destroy();
1817
+ }
1818
+ catch {
1819
+ // ignore
1820
+ }
1821
+ }
1822
+ }
1823
+ if (!tcpSocket) {
1824
+ throw tcpConnectError || Object.assign(new Error(`TCP connect failed to localhost:${port}`), { code: "ECONNREFUSED" });
1825
+ }
1723
1826
  // 2. Open proxy WebSocket to gateway
1724
1827
  const wsBase = activeGatewayUrl.replace(/^http/, "ws");
1725
1828
  const authQuery = currentSessionPassword
1726
1829
  ? `password=${encodeURIComponent(currentSessionPassword)}`
1727
1830
  : `code=${encodeURIComponent(currentSessionCode)}`;
1728
1831
  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);
1832
+ let proxyWs = null;
1833
+ let lastProxyError = null;
1834
+ for (let attempt = 0; attempt <= PROXY_WS_CONNECT_RETRY_ATTEMPTS; attempt++) {
1835
+ const remainingMs = getRemainingSetupMs();
1836
+ if (remainingMs <= 0) {
1742
1837
  tcpSocket.destroy();
1743
- reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
1744
- });
1745
- });
1838
+ throw Object.assign(new Error("Tunnel setup timeout while connecting proxy WS"), { code: "ETIMEOUT" });
1839
+ }
1840
+ const wsConnectTimeoutMs = Math.min(PROXY_WS_CONNECT_TIMEOUT_MS, Math.max(250, remainingMs));
1841
+ const candidateWs = new WebSocket(proxyWsUrl);
1842
+ try {
1843
+ await new Promise((resolve, reject) => {
1844
+ const timeout = setTimeout(() => {
1845
+ candidateWs.close();
1846
+ reject(Object.assign(new Error("Proxy WS connect timeout"), { code: "ETIMEOUT" }));
1847
+ }, wsConnectTimeoutMs);
1848
+ candidateWs.on("open", () => {
1849
+ clearTimeout(timeout);
1850
+ resolve();
1851
+ });
1852
+ candidateWs.on("error", (err) => {
1853
+ clearTimeout(timeout);
1854
+ reject(Object.assign(new Error(`Proxy WS failed: ${err.message}`), { code: "ECONNREFUSED" }));
1855
+ });
1856
+ candidateWs.on("close", () => {
1857
+ clearTimeout(timeout);
1858
+ reject(Object.assign(new Error("Proxy WS closed during connect"), { code: "ECONNRESET" }));
1859
+ });
1860
+ });
1861
+ proxyWs = candidateWs;
1862
+ break;
1863
+ }
1864
+ catch (error) {
1865
+ lastProxyError = error;
1866
+ try {
1867
+ candidateWs.close();
1868
+ }
1869
+ catch {
1870
+ // ignore
1871
+ }
1872
+ if (attempt >= PROXY_WS_CONNECT_RETRY_ATTEMPTS) {
1873
+ break;
1874
+ }
1875
+ const jitterSpan = PROXY_WS_RETRY_JITTER_MAX_MS - PROXY_WS_RETRY_JITTER_MIN_MS;
1876
+ const jitterMs = PROXY_WS_RETRY_JITTER_MIN_MS + Math.floor(Math.random() * (jitterSpan + 1));
1877
+ if (getRemainingSetupMs() <= jitterMs) {
1878
+ break;
1879
+ }
1880
+ await new Promise((resolve) => setTimeout(resolve, jitterMs));
1881
+ }
1882
+ }
1883
+ if (!proxyWs) {
1884
+ tcpSocket.destroy();
1885
+ const err = lastProxyError || Object.assign(new Error("Proxy WS connect failed"), { code: "ECONNREFUSED" });
1886
+ throw err;
1887
+ }
1746
1888
  // 3. Store the tunnel
1747
- activeTunnels.set(tunnelId, { tunnelId, port, tcpSocket, proxyWs });
1889
+ activeTunnels.set(tunnelId, {
1890
+ tunnelId,
1891
+ port,
1892
+ tcpSocket,
1893
+ proxyWs,
1894
+ localEnded: false,
1895
+ remoteEnded: false,
1896
+ finSent: false,
1897
+ finalizeTimer: null,
1898
+ closing: false,
1899
+ });
1748
1900
  // 4. Pipe: TCP data -> proxy WS (as binary)
1749
1901
  tcpSocket.on("data", (chunk) => {
1750
1902
  if (proxyWs.readyState === WebSocket.OPEN) {
@@ -1753,18 +1905,70 @@ async function handleProxyConnect(payload) {
1753
1905
  });
1754
1906
  // 5. Pipe: proxy WS -> TCP socket (as binary)
1755
1907
  proxyWs.on("message", (data) => {
1908
+ const control = parseProxyControlFrame(data);
1909
+ if (control) {
1910
+ const tunnel = activeTunnels.get(tunnelId);
1911
+ if (!tunnel || tunnel.closing)
1912
+ return;
1913
+ if (control.action === "fin") {
1914
+ tunnel.remoteEnded = true;
1915
+ if (!tcpSocket.destroyed) {
1916
+ tcpSocket.end();
1917
+ }
1918
+ maybeFinalizeTunnel(tunnelId);
1919
+ }
1920
+ else {
1921
+ tunnel.closing = true;
1922
+ activeTunnels.delete(tunnelId);
1923
+ if (!tcpSocket.destroyed) {
1924
+ tcpSocket.destroy();
1925
+ }
1926
+ if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1927
+ proxyWs.close();
1928
+ }
1929
+ }
1930
+ return;
1931
+ }
1756
1932
  if (!tcpSocket.destroyed) {
1757
- tcpSocket.write(data);
1933
+ const chunk = Buffer.isBuffer(data)
1934
+ ? data
1935
+ : typeof data === "string"
1936
+ ? Buffer.from(data)
1937
+ : Array.isArray(data)
1938
+ ? Buffer.concat(data)
1939
+ : Buffer.from(data);
1940
+ tcpSocket.write(chunk);
1758
1941
  }
1759
1942
  });
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();
1943
+ const markLocalEnded = () => {
1944
+ const tunnel = activeTunnels.get(tunnelId);
1945
+ if (!tunnel || tunnel.closing)
1946
+ return;
1947
+ tunnel.localEnded = true;
1948
+ if (!tunnel.finSent) {
1949
+ tunnel.finSent = true;
1950
+ sendProxyControl(tunnel, "fin");
1765
1951
  }
1952
+ maybeFinalizeTunnel(tunnelId);
1953
+ };
1954
+ // 6. Half-close handling
1955
+ tcpSocket.on("end", () => {
1956
+ markLocalEnded();
1957
+ });
1958
+ tcpSocket.on("close", () => {
1959
+ markLocalEnded();
1766
1960
  });
1767
1961
  tcpSocket.on("error", () => {
1962
+ const tunnel = activeTunnels.get(tunnelId);
1963
+ if (tunnel && !tunnel.finSent) {
1964
+ sendProxyControl(tunnel, "rst", "tcp_error");
1965
+ }
1966
+ if (tunnel) {
1967
+ tunnel.closing = true;
1968
+ if (tunnel.finalizeTimer) {
1969
+ clearTimeout(tunnel.finalizeTimer);
1970
+ }
1971
+ }
1768
1972
  activeTunnels.delete(tunnelId);
1769
1973
  if (proxyWs.readyState === WebSocket.OPEN || proxyWs.readyState === WebSocket.CONNECTING) {
1770
1974
  proxyWs.close();
@@ -1772,12 +1976,26 @@ async function handleProxyConnect(payload) {
1772
1976
  });
1773
1977
  // 7. Close cascade: WS closes -> close TCP
1774
1978
  proxyWs.on("close", () => {
1979
+ const tunnel = activeTunnels.get(tunnelId);
1980
+ if (tunnel) {
1981
+ tunnel.closing = true;
1982
+ if (tunnel.finalizeTimer) {
1983
+ clearTimeout(tunnel.finalizeTimer);
1984
+ }
1985
+ }
1775
1986
  activeTunnels.delete(tunnelId);
1776
1987
  if (!tcpSocket.destroyed) {
1777
1988
  tcpSocket.destroy();
1778
1989
  }
1779
1990
  });
1780
1991
  proxyWs.on("error", () => {
1992
+ const tunnel = activeTunnels.get(tunnelId);
1993
+ if (tunnel) {
1994
+ tunnel.closing = true;
1995
+ if (tunnel.finalizeTimer) {
1996
+ clearTimeout(tunnel.finalizeTimer);
1997
+ }
1998
+ }
1781
1999
  activeTunnels.delete(tunnelId);
1782
2000
  if (!tcpSocket.destroyed) {
1783
2001
  tcpSocket.destroy();
@@ -1787,6 +2005,9 @@ async function handleProxyConnect(payload) {
1787
2005
  }
1788
2006
  function cleanupAllTunnels() {
1789
2007
  for (const [, tunnel] of activeTunnels) {
2008
+ if (tunnel.finalizeTimer) {
2009
+ clearTimeout(tunnel.finalizeTimer);
2010
+ }
1790
2011
  tunnel.tcpSocket.destroy();
1791
2012
  if (tunnel.proxyWs.readyState === WebSocket.OPEN) {
1792
2013
  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.36",
4
4
  "author": [
5
5
  {
6
6
  "name": "Soham Bharambe",