Respite-API / node_runtime.py
Buffy
Ship Next.js standalone backend bundle (6MB xz) for userspace Node runtime
71ea0d0
Raw
History Blame Contribute Delete
4.94 kB
"""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