File size: 4,935 Bytes
ba9ed6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71ea0d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba9ed6e
 
 
 
 
 
71ea0d0
ba9ed6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
"""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