Spaces:
Running on Zero
Running on Zero
| """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 | |