Nightly Node.js and Big Endian cross compilation Node.js (#1590)

This PR adds support for

`./emsdk install node-nightly-64bit`

which finds the latest nightly node.js version and installs that. It is
a moving target, so the next day when a new nightly is produced,
reissuing `./emsdk install node-nightly-64bit` will install the newer
published version.

Also, this PR adds a fixed 22.18.0 version for the cross compilation
s390x Node.js target as well. On an x64 Linux system, this allows
running

`./emsdk install node-big-endian-crosscompile-22.16.0-64bit`

to install the big endian Node.js. This greatly simplifies the steps at
https://github.com/emscripten-core/emscripten/compare/main...juj:emscripten:bigendian_test_suite#diff-c36b90121be240017fa490a1c00e63e47fa3235f5c1be0593e2b7502d017c778R9985-R10000
and enables a trivial way to switch between LE and BE Node.js versions
for testing.

CC @slavek-kucera
diff --git a/emsdk.py b/emsdk.py
index 1a553d4..3ac1ab5 100644
--- a/emsdk.py
+++ b/emsdk.py
@@ -1477,6 +1477,77 @@
   return root
 
 
+def fetch_nightly_node_versions():
+  url = "https://nodejs.org/download/nightly/"
+  with urlopen(url) as response:
+    html = response.read().decode("utf-8")
+
+  # Regex to capture href values like v7.0.0-nightly2016080175c6d9dd95/
+  pattern = re.compile(r'<a href="(v[0-9]+\.[0-9]+\.[0-9]+-nightly[0-9a-f]+)/">')
+  matches = pattern.findall(html)
+  return matches
+
+
+def dir_installed_nightly_node_versions():
+  path = os.path.abspath('node')
+  return [name for name in os.listdir(path) if os.path.isdir(os.path.join(path, name)) and name.startswith("nightly-")]
+
+
+def extract_newest_node_nightly_version(versions):
+  def parse(v):
+    # example: v7.0.0-nightly2016080175c6d9dd95
+    m = re.match(r'v(\d+)\.(\d+)\.(\d+)-nightly(\d+)', v)
+    if m:
+      major, minor, patch, nightly = m.groups()
+      return [int(major), int(minor), int(patch), int(nightly)]
+    else:
+      return []
+
+  try:
+    return max(versions, key=lambda v: parse(v))
+  except Exception:
+    return None
+
+
+def download_node_nightly(tool):
+  nightly_versions = fetch_nightly_node_versions()
+  latest_nightly = extract_newest_node_nightly_version(nightly_versions)
+  print('Latest Node.js Nightly download available is "' + latest_nightly + '"')
+
+  output_dir = os.path.abspath('node/nightly-' + latest_nightly)
+  # Node.js zip structure quirk: Linux and macOS archives have a /bin,
+  # Windows does not. Unify the file structures.
+  if WINDOWS:
+    output_dir += '/bin'
+
+  if os.path.isdir(output_dir):
+    return True
+
+  url = tool.url.replace('%version%', latest_nightly)
+  if WINDOWS:
+    os_ = 'win'
+  elif LINUX:
+    os_ = 'linux'
+  elif MACOS:
+    os_ = 'darwin'
+  else:
+    os_ = ''
+  if platform.machine().lower() in ["x86_64", "amd64"]:
+    arch = 'x64'
+  elif platform.machine().lower() in ["arm64", "aarch64"]:
+    arch = 'arm64'
+  if WINDOWS:
+    zip_suffix = 'zip'
+  else:
+    zip_suffix = 'tar.gz'
+  url = url.replace('%os%', os_)
+  url = url.replace('%arch%', arch)
+  url = url.replace('%zip_suffix%', zip_suffix)
+  download_and_extract(url, output_dir)
+  open(tool.get_version_file_path(), 'w').write('node-nightly-64bit')
+  return True
+
+
 # returns a tuple (string,string) of config files paths that need to used
 # to activate emsdk env depending on $SHELL, defaults to bash.
 def get_emsdk_shell_env_configs():
@@ -1511,7 +1582,10 @@
     activated_config['NODE_JS'] = node_fallback
 
   for name, value in activated_config.items():
-    cfg += name + " = '" + value + "'\n"
+    if value.startswith('['):
+      cfg += name + " = " + value + "\n"
+    else:
+      cfg += name + " = '" + value + "'\n"
 
   emroot = find_emscripten_root(active_tools)
   if emroot:
@@ -1606,6 +1680,11 @@
     str = str.replace('%.exe%', '.exe' if WINDOWS else '')
     if '%llvm_build_bin_dir%' in str:
       str = str.replace('%llvm_build_bin_dir%', llvm_build_bin_dir(self))
+    if '%latest_downloaded_node_nightly_dir%' in str:
+      installed_node_nightlys = dir_installed_nightly_node_versions()
+      latest_node_nightly = extract_newest_node_nightly_version(installed_node_nightlys)
+      if latest_node_nightly:
+        str = str.replace('%latest_downloaded_node_nightly_dir%', latest_node_nightly)
 
     return str
 
@@ -1727,10 +1806,11 @@
           return False
 
     if self.download_url() is None:
-      # This tool does not contain downloadable elements, so it is installed by default.
+      debug_print(str(self) + ' has no files to download, so is installed by default.')
       return True
 
     content_exists = is_nonempty_directory(self.installation_path())
+    debug_print(str(self) + ' installation path is ' + self.installation_path() + ', exists: ' + str(content_exists) + '.')
 
     # For e.g. fastcomp clang from git repo, the activated PATH is the
     # directory where the compiler is built to, and installation_path is
@@ -1876,12 +1956,14 @@
     print("Installing tool '" + str(self) + "'..")
     url = self.download_url()
 
-    if hasattr(self, 'custom_install_script') and self.custom_install_script == 'build_llvm':
-      success = build_llvm(self)
-    elif hasattr(self, 'custom_install_script') and self.custom_install_script == 'build_ninja':
-      success = build_ninja(self)
-    elif hasattr(self, 'custom_install_script') and self.custom_install_script == 'build_ccache':
-      success = build_ccache(self)
+    custom_install_scripts = {
+      'build_llvm': build_llvm,
+      'build_ninja': build_ninja,
+      'build_ccache': build_ccache,
+      'download_node_nightly': download_node_nightly
+    }
+    if hasattr(self, 'custom_install_script') and self.custom_install_script in custom_install_scripts:
+      success = custom_install_scripts[self.custom_install_script](self)
     elif hasattr(self, 'git_branch'):
       success = git_clone_checkout_and_pull(url, self.installation_path(), self.git_branch, getattr(self, 'remote_name', 'origin'))
     elif url.endswith(ARCHIVE_SUFFIXES):
@@ -1896,7 +1978,7 @@
     if hasattr(self, 'custom_install_script'):
       if self.custom_install_script == 'emscripten_npm_install':
         success = emscripten_npm_install(self, self.installation_path())
-      elif self.custom_install_script in ('build_llvm', 'build_ninja', 'build_ccache'):
+      elif self.custom_install_script in ('build_llvm', 'build_ninja', 'build_ccache', 'download_node_nightly'):
         # 'build_llvm' is a special one that does the download on its
         # own, others do the download manually.
         pass
diff --git a/emsdk_manifest.json b/emsdk_manifest.json
index 1d05304..3b2c21b 100644
--- a/emsdk_manifest.json
+++ b/emsdk_manifest.json
@@ -204,6 +204,29 @@
     "activated_cfg": "NODE_JS='%installation_dir%/bin/node%.exe%'",
     "activated_env": "EMSDK_NODE=%installation_dir%/bin/node%.exe%"
   },
+  {
+    "id": "node-big-endian-crosscompile",
+    "version": "22.16.0",
+    "arch": "x86_64",
+    "bitness": 64,
+    "linux_url": "https://nodejs.org/download/release/v22.16.0/node-v22.16.0-linux-s390x.tar.gz",
+    "activated_path": "%installation_dir%/bin",
+    "activated_path_skip": "node",
+    "activated_cfg": "NODE_JS_TEST=['qemu-s390x', '-L', '/usr/s390x-linux-gnu/', '%installation_dir%/bin/node']"
+  },
+  {
+    "id": "node",
+    "version": "nightly",
+    "bitness": 64,
+    "install_path": "node/%latest_downloaded_node_nightly_dir%",
+    "custom_install_script": "download_node_nightly",
+    "activated_path": "node/%latest_downloaded_node_nightly_dir%/bin",
+    "activated_path_skip": "node",
+    "activated_cfg": "NODE_JS='%installation_dir%/bin/node%.exe%'",
+    "activated_env": "EMSDK_NODE=%installation_dir%/bin/node%.exe%",
+    "url": "https://nodejs.org/download/nightly/%version%/node-%version%-%os%-%arch%.%zip_suffix%",
+    "git_branch": "nightly"
+  },
 
 
   {