"""User-space Node.js runtime bootstrap for the Respite backend. Runs the existing Next.js backend inside the Gradio Space container without root: downloads an official self-contained Node tarball to a local cache, extracts it, and launches the Next.js standalone server as a child process bound to localhost. The FastAPI layer reverse-proxies /api/* to it. Env: NODE_VERSION Node version to fetch (default 22.14.0 LTS) NEXT_PORT localhost port for the Node server (default 3210) NEXT_DIR directory of the standalone build (default ./respite-backend) NODE_PROXY_OFF set to "1" to skip the download/proxy entirely """ import os import shutil import subprocess import tarfile import threading import urllib.request BACKEND_DIR = os.environ.get("NEXT_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "respite-backend")) NODE_VERSION = os.environ.get("NODE_VERSION", "22.14.0") NODE_PORT = int(os.environ.get("NEXT_PORT", "3210")) NODE_TARBALL = f"https://nodejs.org/dist/v{NODE_VERSION}/node-v{NODE_VERSION}-linux-x64.tar.xz" RUNTIME_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".node-runtime") NODE_BIN = os.path.join(RUNTIME_DIR, f"node-v{NODE_VERSION}-linux-x64", "bin", "node") _node_proc = None _node_lock = threading.Lock() _node_ready = threading.Event() def _log(msg): print(f"[node-runtime] {msg}", flush=True) def ensure_node() -> str: """Download + extract Node into the local cache once. Returns node path.""" if os.path.exists(NODE_BIN): return NODE_BIN os.makedirs(RUNTIME_DIR, exist_ok=True) tgz = os.path.join(RUNTIME_DIR, "node.tar.xz") _log(f"downloading {NODE_TARBALL}") urllib.request.urlretrieve(NODE_TARBALL, tgz) _log("extracting") with tarfile.open(tgz, "r:xz") as tf: tf.extractall(RUNTIME_DIR) os.remove(tgz) os.chmod(NODE_BIN, 0o755) _log(f"node ready at {NODE_BIN}") return NODE_BIN def _wait_ready(proc: subprocess.Popen, timeout: float = 90.0) -> bool: """Wait until the Node server answers on its localhost port.""" import socket import time deadline = time.time() + timeout while time.time() < deadline: if proc.poll() is not None: _log(f"node exited early with code {proc.returncode}") return False try: with socket.create_connection(("127.0.0.1", NODE_PORT), timeout=1): return True except OSError: time.sleep(0.5) return False def _ensure_backend_extracted() -> bool: """Extract backend-bundle/respite-backend.tar.xz if the backend dir is absent.""" if os.path.isdir(BACKEND_DIR) and os.path.exists(os.path.join(BACKEND_DIR, "server.js")): return True bundle = os.path.join(os.path.dirname(os.path.abspath(__file__)), "backend-bundle", "respite-backend.tar.xz") if not os.path.exists(bundle): _log(f"no bundle at {bundle}") return False _log(f"extracting {bundle} -> {BACKEND_DIR}") os.makedirs(BACKEND_DIR, exist_ok=True) with tarfile.open(bundle, "r:xz") as tf: tf.extractall(BACKEND_DIR) _log("backend extracted") return True def start_node_backend() -> bool: """Launch the Next.js standalone server. Safe to call once at startup.""" global _node_proc if os.environ.get("NODE_PROXY_OFF") == "1": _log("disabled via NODE_PROXY_OFF") return False if not _ensure_backend_extracted(): return False with _node_lock: if _node_proc and _node_proc.poll() is None: return True try: node = ensure_node() except Exception as e: _log(f"node bootstrap failed: {e}") return False server_js = os.path.join(BACKEND_DIR, "server.js") if not os.path.exists(server_js): _log(f"no server.js in {BACKEND_DIR}") return False env = os.environ.copy() env.update({ "PORT": str(NODE_PORT), "HOSTNAME": "127.0.0.1", "NODE_ENV": "production", }) _log(f"starting node server on 127.0.0.1:{NODE_PORT}") _node_proc = subprocess.Popen( [node, server_js], cwd=BACKEND_DIR, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) threading.Thread(target=_pump_logs, daemon=True).start() ok = _wait_ready(_node_proc) if ok: _log("node backend is READY") _node_ready.set() else: _log("node backend failed to become ready") return ok def _pump_logs(): assert _node_proc and _node_proc.stdout for line in _node_proc.stdout: _log(f"[next] {line.rstrip()}") def node_ready() -> bool: return _node_ready.is_set() and _node_proc is not None and _node_proc.poll() is None