Repository: reflex-dev/reflex
Pull request: Remove curl and parallelize node/bun install
Use `httpx` to install nvm and bun instead of curl, removing one dependency. Also parallelize the process to install nvm/bun during `reflex init`

Problem statement:
Download node and bun in parallel
Currently we download node and bun sequentially during the initial `reflex init`. We can parallelize this.

Curl dependency in Bun and NVM installs
It would be good to remove this curl dependency.  Instead we can use Python to download the relevant installation script and then run them, using pure Python (e.g. requests.get, subprocess.run).

As part of this we should be able to remove curl completely from the dependency list (grep codebase for the word "curl" to see all our runtime checks, tests for curl - all that code can be removed).

Hints from issue discussion:

Commit message: Parallelize node/bun install

Patch to review:
diff --git a/reflex/constants.py b/reflex/constants.py
index d4f7725ae63..7fdd6a248bc 100644
--- a/reflex/constants.py
+++ b/reflex/constants.py
@@ -45,54 +45,57 @@ def get_value(key: str, default: Any = None, type_: Type = str) -> Type:
 # The current version of Reflex.
 VERSION = metadata.version(MODULE_NAME)
 
-# Project dependencies.
+# Files and directories used to init a new project.
 # The directory to store reflex dependencies.
-REFLEX_DIR = os.path.expandvars("$HOME/.reflex")
+REFLEX_DIR = os.path.expandvars(os.path.join("$HOME", f".{MODULE_NAME}"))
+# The root directory of the reflex library.
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+# The name of the assets directory.
+APP_ASSETS_DIR = "assets"
+# The template directory used during reflex init.
+TEMPLATE_DIR = os.path.join(ROOT_DIR, MODULE_NAME, ".templates")
+# The web subdirectory of the template directory.
+WEB_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, "web")
+# The assets subdirectory of the template directory.
+ASSETS_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, APP_ASSETS_DIR)
+# The jinja template directory.
+JINJA_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, "jinja")
 
 # Bun config.
 # The Bun version.
 BUN_VERSION = "0.7.0"
 # The directory to store the bun.
-BUN_ROOT_PATH = f"{REFLEX_DIR}/.bun"
+BUN_ROOT_PATH = os.path.join(REFLEX_DIR, ".bun")
 # The bun path.
-BUN_PATH = f"{BUN_ROOT_PATH}/bin/bun"
-# Command to install bun.
-INSTALL_BUN = f"curl -fsSL https://bun.sh/install | env BUN_INSTALL={BUN_ROOT_PATH} bash -s -- bun-v{BUN_VERSION}"
+BUN_PATH = os.path.join(BUN_ROOT_PATH, "bin", "bun")
+# URL to bun install script.
+BUN_INSTALL_URL = "https://bun.sh/install"
 
 # NVM / Node config.
+# The NVM version.
+NVM_VERSION = "0.39.1"
 # The Node version.
 NODE_VERSION = "18.17.0"
 # The minimum required node version.
-MIN_NODE_VERSION = "16.8.0"
+NODE_VERSION_MIN = "16.8.0"
 # The directory to store nvm.
-NVM_ROOT_PATH = f"{REFLEX_DIR}/.nvm"
+NVM_DIR = os.path.join(REFLEX_DIR, ".nvm")
 # The nvm path.
-NVM_PATH = f"{NVM_ROOT_PATH}/nvm.sh"
+NVM_PATH = os.path.join(NVM_DIR, "nvm.sh")
 # The node bin path.
-NODE_BIN_PATH = f"{NVM_ROOT_PATH}/versions/node/v{NODE_VERSION}/bin"
+NODE_BIN_PATH = os.path.join(NVM_DIR, "versions", "node", f"v{NODE_VERSION}", "bin")
 # The default path where node is installed.
-NODE_PATH = "node" if platform.system() == "Windows" else f"{NODE_BIN_PATH}/node"
+NODE_PATH = (
+    "node" if platform.system() == "Windows" else os.path.join(NODE_BIN_PATH, "node")
+)
 # The default path where npm is installed.
-NPM_PATH = "npm" if platform.system() == "Windows" else f"{NODE_BIN_PATH}/npm"
-# Command to install nvm.
-INSTALL_NVM = f"curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | env NVM_DIR={NVM_ROOT_PATH} bash"
-# Command to install node.
-INSTALL_NODE = f'bash -c "export NVM_DIR={NVM_ROOT_PATH} && . {NVM_ROOT_PATH}/nvm.sh && nvm install {NODE_VERSION}"'
-
-
-# Files and directories used to init a new project.
-# The root directory of the reflex library.
-ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-# The name of the assets directory.
-APP_ASSETS_DIR = "assets"
-# The template directory used during reflex init.
-TEMPLATE_DIR = os.path.join(ROOT_DIR, MODULE_NAME, ".templates")
-# The web subdirectory of the template directory.
-WEB_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, "web")
-# The assets subdirectory of the template directory.
-ASSETS_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, APP_ASSETS_DIR)
-# The jinja template directory.
-JINJA_TEMPLATE_DIR = os.path.join(TEMPLATE_DIR, "jinja")
+NPM_PATH = (
+    "npm" if platform.system() == "Windows" else os.path.join(NODE_BIN_PATH, "npm")
+)
+# The URL to the nvm install script.
+NVM_INSTALL_URL = (
+    f"https://raw.githubusercontent.com/nvm-sh/nvm/v{NVM_VERSION}/install.sh"
+)
 
 # The frontend directories in a project.
 # The web folder where the NextJS app is compiled to.
diff --git a/reflex/utils/build.py b/reflex/utils/build.py
index 744b45d6366..8a167c246ac 100644
--- a/reflex/utils/build.py
+++ b/reflex/utils/build.py
@@ -147,7 +147,7 @@ def export_app(
                 # Check for special strings and update the progress bar.
                 for special_string in checkpoints:
                     if special_string in line:
-                        if special_string == "Export successful":
+                        if special_string == checkpoints[-1]:
                             progress.update(task, completed=len(checkpoints))
                             break  # Exit the loop if the completion message is found
                         else:
diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py
index cdb87ce1e16..a81248320f3 100644
--- a/reflex/utils/prerequisites.py
+++ b/reflex/utils/prerequisites.py
@@ -9,12 +9,15 @@
 import re
 import subprocess
 import sys
+import tempfile
+import threading
 from datetime import datetime
 from fileinput import FileInput
 from pathlib import Path
 from types import ModuleType
 from typing import Optional
 
+import httpx
 import typer
 from alembic.util.exc import CommandError
 from packaging import version
@@ -44,7 +47,7 @@ def check_node_version():
         current_version = version.parse(result.stdout.decode())
         # Compare the version numbers
         return (
-            current_version >= version.parse(constants.MIN_NODE_VERSION)
+            current_version >= version.parse(constants.NODE_VERSION_MIN)
             if IS_WINDOWS
             else current_version == version.parse(constants.NODE_VERSION)
         )
@@ -281,31 +284,45 @@ def install_node():
         FileNotFoundError: if unzip or curl packages are not found.
         Exit: if installation failed
     """
+    # NVM is not supported on Windows.
     if IS_WINDOWS:
         console.print(
             f"[red]Node.js version {constants.NODE_VERSION} or higher is required to run Reflex."
         )
         raise typer.Exit()
 
-    # Only install if bun is not already installed.
-    console.log("Installing nvm...")
-
-    # Check if curl is installed
-    # TODO no need to shell out to curl
-    curl_path = path_ops.which("curl")
-    if curl_path is None:
-        raise FileNotFoundError("Reflex requires curl to be installed.")
-
     # Create the nvm directory and install.
-    path_ops.mkdir(constants.NVM_ROOT_PATH)
-    result = subprocess.run(constants.INSTALL_NVM, shell=True)
-
+    path_ops.mkdir(constants.NVM_DIR)
+
+    # Get the nvm install script.
+    response = httpx.get(constants.NVM_INSTALL_URL)
+    if response.status_code != httpx.codes.OK:
+        response.raise_for_status()
+
+    # Save the nvm install script to a temporary file.
+    script = tempfile.NamedTemporaryFile()
+    with open(script.name, "w") as f:
+        f.write(response.text)
+
+    # Run the nvm install script.
+    env = {
+        **os.environ,
+        "NVM_DIR": constants.NVM_DIR,
+    }
+    result = subprocess.run(["bash", f.name], env=env)
     if result.returncode != 0:
         raise typer.Exit(code=result.returncode)
 
-    console.log("Installing node...")
-    result = subprocess.run(constants.INSTALL_NODE, shell=True)
-
+    # Install node.
+    print("installing node")
+    result = subprocess.run(
+        [
+            "bash",
+            "-c",
+            f". {constants.NVM_DIR}/nvm.sh && nvm install {constants.NODE_VERSION}",
+        ],
+        env=env,
+    )
     if result.returncode != 0:
         raise typer.Exit(code=result.returncode)
 
@@ -319,27 +336,35 @@ def install_bun():
     """
     # Bun is not supported on Windows.
     if IS_WINDOWS:
-        console.log("Skipping bun installation on Windows.")
         return
 
-    # Only install if bun is not already installed.
-    if not os.path.exists(constants.BUN_PATH):
-        console.log("Installing bun...")
-
-        # Check if curl is installed
-        curl_path = path_ops.which("curl")
-        if curl_path is None:
-            raise FileNotFoundError("Reflex requires curl to be installed.")
-
-        # Check if unzip is installed
-        unzip_path = path_ops.which("unzip")
-        if unzip_path is None:
-            raise FileNotFoundError("Reflex requires unzip to be installed.")
-
-        result = subprocess.run(constants.INSTALL_BUN, shell=True)
+    # Skip if bun is already installed.
+    if os.path.exists(constants.BUN_PATH):
+        return
 
-        if result.returncode != 0:
-            raise typer.Exit(code=result.returncode)
+    # Check if unzip is installed
+    unzip_path = path_ops.which("unzip")
+    if unzip_path is None:
+        raise FileNotFoundError("Reflex requires unzip to be installed.")
+
+    # Get the bun install script.
+    response = httpx.get(constants.BUN_INSTALL_URL)
+    if response.status_code != httpx.codes.OK:
+        response.raise_for_status()
+
+    # Save the bun install script to a temporary file.
+    script = tempfile.NamedTemporaryFile()
+    with open(script.name, "w") as f:
+        f.write(response.text)
+
+    # Run the bun install script.
+    env = {
+        **os.environ,
+        "BUN_INSTALL": constants.BUN_ROOT_PATH,
+    }
+    result = subprocess.run(["bash", f.name, f"bun-v{constants.BUN_VERSION}"], env=env)
+    if result.returncode != 0:
+        raise typer.Exit(code=result.returncode)
 
 
 def install_frontend_packages():
@@ -392,8 +417,8 @@ def initialize_frontend_dependencies():
     path_ops.mkdir(constants.REFLEX_DIR)
 
     # Install the frontend dependencies.
-    initialize_bun()
-    initialize_node()
+    threading.Thread(target=initialize_bun).start()
+    threading.Thread(target=initialize_node).start()
 
     # Set up the web directory.
     initialize_web_directory()
diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py
index d462adc7ade..75fe5afa534 100644
--- a/reflex/utils/processes.py
+++ b/reflex/utils/processes.py
@@ -134,13 +134,15 @@ def new_process(args, **kwargs):
     Returns:
         Execute a child program in a new process.
     """
-    env = os.environ.copy()
-    env["PATH"] = os.pathsep.join([constants.NODE_BIN_PATH, env["PATH"]])
+    env = {
+        **os.environ,
+        "PATH": os.pathsep.join([constants.NODE_BIN_PATH, os.environ["PATH"]]),
+    }
     kwargs = {
         "env": env,
         "stderr": subprocess.STDOUT,
-        "stdout": subprocess.PIPE,  # Redirect stdout to a pipe
-        "universal_newlines": True,  # Set universal_newlines to True for text mode
+        "stdout": subprocess.PIPE,
+        "universal_newlines": True,
         "encoding": "UTF-8",
         **kwargs,
     }
diff --git a/tests/test_utils.py b/tests/test_utils.py
index e2cd9ba8ecd..7042cad0637 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -527,7 +527,7 @@ def test_node_install_windows(mocker):
 def test_node_install_unix(tmp_path, mocker):
     nvm_root_path = tmp_path / ".reflex" / ".nvm"
 
-    mocker.patch("reflex.utils.prerequisites.constants.NVM_ROOT_PATH", nvm_root_path)
+    mocker.patch("reflex.utils.prerequisites.constants.NVM_DIR", nvm_root_path)
     subprocess_run = mocker.patch(
         "reflex.utils.prerequisites.subprocess.run",
         return_value=subprocess.CompletedProcess(args="", returncode=0),
@@ -541,14 +541,15 @@ def test_node_install_unix(tmp_path, mocker):
     subprocess_run.call_count = 2
 
 
-def test_node_install_without_curl(mocker):
-    """Test that an error is thrown when installing node with curl not installed.
+def test_bun_install_without_unzip(mocker):
+    """Test that an error is thrown when installing bun with unzip not installed.
 
     Args:
         mocker: Pytest mocker object.
     """
-    mocker.patch("reflex.utils.prerequisites.path_ops.which", return_value=None)
+    mocker.patch("reflex.utils.path_ops.which", return_value=None)
+    mocker.patch("os.path.exists", return_value=False)
     mocker.patch("reflex.utils.prerequisites.IS_WINDOWS", False)
 
     with pytest.raises(FileNotFoundError):
-        prerequisites.install_node()
+        prerequisites.install_bun()


Code review comments:
diff_hunk: @@ -319,27 +336,35 @@ def install_bun():
     """
     # Bun is not supported on Windows.
     if IS_WINDOWS:
-        console.log("Skipping bun installation on Windows.")
         return
 
-    # Only install if bun is not already installed.
-    if not os.path.exists(constants.BUN_PATH):
-        console.log("Installing bun...")
-
-        # Check if curl is installed
-        curl_path = path_ops.which("curl")
-        if curl_path is None:
-            raise FileNotFoundError("Reflex requires curl to be installed.")
-
-        # Check if unzip is installed
-        unzip_path = path_ops.which("unzip")
-        if unzip_path is None:
-            raise FileNotFoundError("Reflex requires unzip to be installed.")
-
-        result = subprocess.run(constants.INSTALL_BUN, shell=True)
+    # Skip if bun is already installed.
+    if os.path.exists(constants.BUN_PATH):
+        return
 
-        if result.returncode != 0:
-            raise typer.Exit(code=result.returncode)
+    # Check if unzip is installed
+    unzip_path = path_ops.which("unzip")
+    if unzip_path is None:
+        raise FileNotFoundError("Reflex requires unzip to be installed.")
+
+    # Get the bun install script.
+    response = httpx.get(constants.BUN_INSTALL_URL)
path: reflex/utils/prerequisites.py
text: @user1:
We can potentially dedupe this further.... `def download_and_run()`:  same for both nvm and bun.

@author:
Great idea, factored this out to a function