Buffy commited on
Commit
ba9ed6e
·
1 Parent(s): 58e1c4d

Add Node runtime capability probe (userspace, no root)

Browse files

- node_runtime.py: downloads official Node tarball to local cache, starts
the Next.js standalone backend on 127.0.0.1:3210 as a child process
- node_probe.py: /respite/v2/node/{status,version,server,proxy} routes to
prove bootstrap, child exec, localhost HTTP, and reverse-proxying
- app.py: wire probe routes into the monkey-patched create_app
- drop committed __pycache__ noise

Files changed (6) hide show
  1. .gitignore +1 -0
  2. __pycache__/app.cpython-314.pyc +0 -0
  3. app.py +10 -0
  4. node_probe.py +99 -0
  5. node_runtime.py +128 -0
  6. requirements.txt +1 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ __pycache__/
__pycache__/app.cpython-314.pyc DELETED
Binary file (15 kB)
 
app.py CHANGED
@@ -21,6 +21,9 @@ import urllib.request
21
  import urllib.parse
22
  import json as _json
23
 
 
 
 
24
  for _stream in (sys.stdout, sys.stderr):
25
  if hasattr(_stream, "reconfigure"):
26
  _stream.reconfigure(encoding="utf-8", errors="backslashreplace")
@@ -864,6 +867,13 @@ def _patched_create_app(blocks, **kwargs):
864
  job = _start_benchmark_job("benchmark", model.strip() or None, force)
865
  return JSONResponse({"status": "accepted", "job_id": job, "poll": "/respite/benchmark?job_id=" + job}, status_code=202)
866
 
 
 
 
 
 
 
 
867
  _log("Routes injected via create_app monkey-patch")
868
  return fa_app
869
 
 
21
  import urllib.parse
22
  import json as _json
23
 
24
+ import node_probe
25
+ import node_runtime
26
+
27
  for _stream in (sys.stdout, sys.stderr):
28
  if hasattr(_stream, "reconfigure"):
29
  _stream.reconfigure(encoding="utf-8", errors="backslashreplace")
 
867
  job = _start_benchmark_job("benchmark", model.strip() or None, force)
868
  return JSONResponse({"status": "accepted", "job_id": job, "poll": "/respite/benchmark?job_id=" + job}, status_code=202)
869
 
870
+ # Node runtime capability probe + backend proxy
871
+ try:
872
+ node_probe.register_routes(fa_app)
873
+ threading.Thread(target=node_runtime.start_node_backend, daemon=True).start()
874
+ except Exception as _ne:
875
+ _log(f"node probe registration failed: {_ne}")
876
+
877
  _log("Routes injected via create_app monkey-patch")
878
  return fa_app
879
 
node_probe.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal Node capability probe — proves the runtime works inside the Space.
2
+
3
+ Mounted under /respite/v2/node/*:
4
+ /status → is the runtime bootstrapped, is the child alive
5
+ /version → runs `node --version` as a child process
6
+ /server → start a trivial Node HTTP server on localhost, then GET it
7
+ /proxy → reverse-proxy any /respite/v2/node/proxy/<path> to the Node server
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import threading
14
+ import time
15
+ import urllib.request
16
+
17
+ import httpx
18
+ from fastapi.responses import JSONResponse, Response
19
+
20
+ import node_runtime
21
+
22
+ _TRIVIAL_SERVER_JS = """const http = require('http');
23
+ const s = http.createServer((req, res) => {
24
+ res.writeHead(200, {'content-type': 'application/json'});
25
+ res.end(JSON.stringify({ok: true, url: req.url, ts: Date.now()}));
26
+ });
27
+ s.listen(3211, '127.0.0.1', () => console.log('trivial server on 3211'));
28
+ """
29
+ _trivial_proc = None
30
+ _trivial_lock = threading.Lock()
31
+
32
+
33
+ def _node_path() -> str | None:
34
+ try:
35
+ return node_runtime.ensure_node()
36
+ except Exception:
37
+ return None
38
+
39
+
40
+ def register_routes(fa_app):
41
+ @fa_app.get("/respite/v2/node/status")
42
+ def _status():
43
+ return JSONResponse({
44
+ "runtime_dir": node_runtime.RUNTIME_DIR,
45
+ "node_bin_exists": os.path.exists(node_runtime.NODE_BIN),
46
+ "backend_ready": node_runtime.node_ready(),
47
+ "backend_dir": node_runtime.BACKEND_DIR,
48
+ })
49
+
50
+ @fa_app.get("/respite/v2/node/version")
51
+ def _version():
52
+ node = _node_path()
53
+ if not node:
54
+ return JSONResponse({"error": "node bootstrap failed"}, status_code=500)
55
+ r = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=30)
56
+ return JSONResponse({"stdout": r.stdout.strip(), "stderr": r.stderr.strip()[:400], "code": r.returncode})
57
+
58
+ @fa_app.get("/respite/v2/node/server")
59
+ def _server():
60
+ """Start a trivial Node HTTP server on 127.0.0.1:3211 and fetch it."""
61
+ global _trivial_proc
62
+ node = _node_path()
63
+ if not node:
64
+ return JSONResponse({"error": "node bootstrap failed"}, status_code=500)
65
+ with _trivial_lock:
66
+ if _trivial_proc is None or _trivial_proc.poll() is not None:
67
+ path = os.path.join(node_runtime.RUNTIME_DIR, "trivial.js")
68
+ os.makedirs(node_runtime.RUNTIME_DIR, exist_ok=True)
69
+ with open(path, "w") as f:
70
+ f.write(_TRIVIAL_SERVER_JS)
71
+ _trivial_proc = subprocess.Popen(
72
+ [node, path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
73
+ )
74
+ time.sleep(1.5)
75
+ try:
76
+ with urllib.request.urlopen("http://127.0.0.1:3211/ping", timeout=5) as resp:
77
+ body = json.loads(resp.read().decode())
78
+ return JSONResponse({"fetch_ok": True, "body": body})
79
+ except Exception as e:
80
+ return JSONResponse({"fetch_ok": False, "error": str(e)}, status_code=500)
81
+
82
+ @fa_app.get("/respite/v2/node/proxy/{path:path}")
83
+ @fa_app.post("/respite/v2/node/proxy/{path:path}")
84
+ def _proxy(path: str):
85
+ """Forward to the Node backend on 127.0.0.1:3210 (streaming)."""
86
+ if not node_runtime.node_ready():
87
+ start_ok = node_runtime.start_node_backend()
88
+ if not start_ok:
89
+ return JSONResponse({"error": "node backend not running"}, status_code=503)
90
+ url = f"http://127.0.0.1:{node_runtime.NODE_PORT}/{path}"
91
+ try:
92
+ with httpx.stream("GET", url, timeout=30.0) as r:
93
+ headers = dict(r.headers)
94
+ body = b""
95
+ for chunk in r.iter_bytes():
96
+ body += chunk
97
+ return Response(content=body, status_code=r.status_code, headers=headers)
98
+ except Exception as e:
99
+ return JSONResponse({"error": str(e)}, status_code=502)
node_runtime.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-space Node.js runtime bootstrap for the Respite backend.
2
+
3
+ Runs the existing Next.js backend inside the Gradio Space container without
4
+ root: downloads an official self-contained Node tarball to a local cache,
5
+ extracts it, and launches the Next.js standalone server as a child process
6
+ bound to localhost. The FastAPI layer reverse-proxies /api/* to it.
7
+
8
+ Env:
9
+ NODE_VERSION Node version to fetch (default 22.14.0 LTS)
10
+ NEXT_PORT localhost port for the Node server (default 3210)
11
+ NEXT_DIR directory of the standalone build (default ./respite-backend)
12
+ NODE_PROXY_OFF set to "1" to skip the download/proxy entirely
13
+ """
14
+
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import tarfile
19
+ import threading
20
+ import urllib.request
21
+
22
+ BACKEND_DIR = os.environ.get("NEXT_DIR", os.path.join(os.path.dirname(os.path.abspath(__file__)), "respite-backend"))
23
+ NODE_VERSION = os.environ.get("NODE_VERSION", "22.14.0")
24
+ NODE_PORT = int(os.environ.get("NEXT_PORT", "3210"))
25
+
26
+ NODE_TARBALL = f"https://nodejs.org/dist/v{NODE_VERSION}/node-v{NODE_VERSION}-linux-x64.tar.xz"
27
+ RUNTIME_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".node-runtime")
28
+ NODE_BIN = os.path.join(RUNTIME_DIR, f"node-v{NODE_VERSION}-linux-x64", "bin", "node")
29
+
30
+ _node_proc = None
31
+ _node_lock = threading.Lock()
32
+ _node_ready = threading.Event()
33
+
34
+
35
+ def _log(msg):
36
+ print(f"[node-runtime] {msg}", flush=True)
37
+
38
+
39
+ def ensure_node() -> str:
40
+ """Download + extract Node into the local cache once. Returns node path."""
41
+ if os.path.exists(NODE_BIN):
42
+ return NODE_BIN
43
+ os.makedirs(RUNTIME_DIR, exist_ok=True)
44
+ tgz = os.path.join(RUNTIME_DIR, "node.tar.xz")
45
+ _log(f"downloading {NODE_TARBALL}")
46
+ urllib.request.urlretrieve(NODE_TARBALL, tgz)
47
+ _log("extracting")
48
+ with tarfile.open(tgz, "r:xz") as tf:
49
+ tf.extractall(RUNTIME_DIR)
50
+ os.remove(tgz)
51
+ os.chmod(NODE_BIN, 0o755)
52
+ _log(f"node ready at {NODE_BIN}")
53
+ return NODE_BIN
54
+
55
+
56
+ def _wait_ready(proc: subprocess.Popen, timeout: float = 90.0) -> bool:
57
+ """Wait until the Node server answers on its localhost port."""
58
+ import socket
59
+ import time
60
+
61
+ deadline = time.time() + timeout
62
+ while time.time() < deadline:
63
+ if proc.poll() is not None:
64
+ _log(f"node exited early with code {proc.returncode}")
65
+ return False
66
+ try:
67
+ with socket.create_connection(("127.0.0.1", NODE_PORT), timeout=1):
68
+ return True
69
+ except OSError:
70
+ time.sleep(0.5)
71
+ return False
72
+
73
+
74
+ def start_node_backend() -> bool:
75
+ """Launch the Next.js standalone server. Safe to call once at startup."""
76
+ global _node_proc
77
+ if os.environ.get("NODE_PROXY_OFF") == "1":
78
+ _log("disabled via NODE_PROXY_OFF")
79
+ return False
80
+ if not os.path.isdir(BACKEND_DIR):
81
+ _log(f"backend dir missing: {BACKEND_DIR}")
82
+ return False
83
+ with _node_lock:
84
+ if _node_proc and _node_proc.poll() is None:
85
+ return True
86
+ try:
87
+ node = ensure_node()
88
+ except Exception as e:
89
+ _log(f"node bootstrap failed: {e}")
90
+ return False
91
+ server_js = os.path.join(BACKEND_DIR, "server.js")
92
+ if not os.path.exists(server_js):
93
+ _log(f"no server.js in {BACKEND_DIR}")
94
+ return False
95
+ env = os.environ.copy()
96
+ env.update({
97
+ "PORT": str(NODE_PORT),
98
+ "HOSTNAME": "127.0.0.1",
99
+ "NODE_ENV": "production",
100
+ })
101
+ _log(f"starting node server on 127.0.0.1:{NODE_PORT}")
102
+ _node_proc = subprocess.Popen(
103
+ [node, server_js],
104
+ cwd=BACKEND_DIR,
105
+ env=env,
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.STDOUT,
108
+ text=True,
109
+ bufsize=1,
110
+ )
111
+ threading.Thread(target=_pump_logs, daemon=True).start()
112
+ ok = _wait_ready(_node_proc)
113
+ if ok:
114
+ _log("node backend is READY")
115
+ _node_ready.set()
116
+ else:
117
+ _log("node backend failed to become ready")
118
+ return ok
119
+
120
+
121
+ def _pump_logs():
122
+ assert _node_proc and _node_proc.stdout
123
+ for line in _node_proc.stdout:
124
+ _log(f"[next] {line.rstrip()}")
125
+
126
+
127
+ def node_ready() -> bool:
128
+ return _node_ready.is_set() and _node_proc is not None and _node_proc.poll() is None
requirements.txt CHANGED
@@ -9,3 +9,4 @@ git+https://github.com/Stability-AI/stable-audio-3.git
9
  ddgs
10
  transformers>=4.51.0
11
  accelerate>=1.6.0
 
 
9
  ddgs
10
  transformers>=4.51.0
11
  accelerate>=1.6.0
12
+ httpx