Hermes Agent commited on
Commit
f4a0e33
Β·
1 Parent(s): 9ff9dd1

feat: complete rebuild of proxy gateway with lifespan pool, streaming SSE/video support, and resilient retry logic

Browse files
Files changed (2) hide show
  1. proxy.py +285 -231
  2. tg_streamer.py +13 -8
proxy.py CHANGED
@@ -1,139 +1,217 @@
1
  """
2
- OpenCode Space β€” FastAPI Reverse-Proxy Gateway
3
- ================================================
4
- Architecture (all on 127.0.0.1 inside the container):
5
- Port 4096 β€” this FastAPI app (HF-Space external port)
6
- Port 4097 β€” opencode serve
7
- Port 8096 β€” Jellyfin
8
- Port 8098 β€” Open WebUI
9
- Port 20128 β€” OmniRoute
10
- Port 8080 β€” TG-Drive Streamer
11
-
12
- Public URL prefix mapping:
13
- / β†’ landing hub (index.html)
14
- /server/ β†’ opencode (4097)
15
- /openwebui/β†’ Open WebUI (8098) β€” STRIP prefix before upstream
16
- /omniroute/β†’ OmniRoute (20128) β€” STRIP prefix before upstream
17
- /jellyfin/ β†’ Jellyfin (8096)
18
- /tg-stream/β†’ TG Streamer(8080)
 
 
 
 
 
 
 
19
  """
20
 
21
  import os
22
  import re
23
  import asyncio
24
- from typing import Callable, Optional
25
- from fastapi import FastAPI, Request, Response, WebSocket
26
- from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
 
27
  import httpx
28
  import aiohttp
 
 
29
 
30
- app = FastAPI(title="OpenCode Space Gateway", docs_url=None, redoc_url=None)
 
 
31
 
32
- # ── Port constants ──────────────────────────────────────────────────────────
33
- WEBUI_PORT = 8098
34
- OMNIROUTE_PORT = 20128
35
- OPENCODE_PORT = 4097
36
- JELLYFIN_PORT = 8096
37
- TG_PORT = 8080
 
38
 
39
- PUBLIC_HOST = "jishnupg-opencode-cli.hf.space"
40
  PUBLIC_ORIGIN = f"https://{PUBLIC_HOST}"
41
 
42
- # ── Internal-port stripper (for Location headers only) ─────────────────────
43
- _INTERNAL_PORTS = {4096, 20128, 8098, 4097, 8096, 8080}
44
- _PORT_STRIP_RE = re.compile(
45
- r"(https?://[^/:]+):(" + "|".join(str(p) for p in _INTERNAL_PORTS) + r")"
46
- )
47
- _PORT_PREFIX = {
48
- 4096: "",
49
- 20128: "/omniroute",
50
- 8098: "/openwebui",
51
- 4097: "/server",
52
- 8096: "/jellyfin",
53
- 8080: "/tg-stream",
54
  }
55
 
56
- def _fix_location(location: str) -> str:
57
- """Rewrite a Location header so no internal port leaks out."""
58
- def _sub(m: "re.Match[str]") -> str:
59
- port = int(m.group(2))
60
- prefix = _PORT_PREFIX.get(port, "")
 
 
 
61
  return f"{PUBLIC_ORIGIN}{prefix}"
62
- return _PORT_STRIP_RE.sub(_sub, location)
63
-
64
- # ── Shared async HTTP client ────────────────────────────────────────────────
65
- client = httpx.AsyncClient(
66
- timeout=httpx.Timeout(120.0),
67
- follow_redirects=False,
68
- limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
69
- )
70
-
71
- # ── Header helpers ──────────────────────────────────────────────────────────
72
- _HOP_BY_HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
73
- "te", "trailers", "transfer-encoding", "upgrade",
74
- "content-length", "content-encoding"}
75
-
76
- def _request_headers(request: Request, extra: dict | None = None) -> dict:
77
- h = {k: v for k, v in request.headers.items()
78
- if k.lower() not in {"host", "content-length"}}
79
- h["X-Forwarded-Host"] = PUBLIC_HOST
80
- h["X-Forwarded-Proto"] = "https"
81
- h["X-Forwarded-Port"] = "443"
82
- h["X-Real-IP"] = request.client.host if request.client else "127.0.0.1"
83
- if extra:
84
- h.update(extra)
85
- return h
86
-
87
- def _response_headers(resp: httpx.Response, location_override: str | None = None) -> dict:
88
- out = {}
89
- for k, v in resp.headers.items():
90
- lk = k.lower()
91
- if lk in _HOP_BY_HOP:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  continue
93
  if lk == "location":
94
- v = _fix_location(v)
95
- if location_override:
96
- v = location_override
97
- out[k] = v
98
- return out
99
-
100
- # ── Core proxy helper ───────────────────────────────────────────────────────
101
- async def proxy_http(
 
102
  target_url: str,
103
  request: Request,
104
- extra_headers: dict | None = None,
105
- html_fixup: Optional[Callable] = None,
106
  ) -> Response:
107
  """
108
- Forward an HTTP request to target_url.
109
- html_fixup(text: str) -> str β€” called only on text/html bodies.
110
- JS/CSS/JSON/binary content is NEVER modified.
111
  """
112
- headers = _request_headers(request, extra_headers)
 
 
 
 
113
  body = await request.body()
 
 
114
 
115
- try:
116
- resp = await client.request(
117
- method=request.method,
118
- url=target_url,
119
- headers=headers,
120
- params=dict(request.query_params),
121
- content=body,
122
- )
123
- except Exception as exc:
124
- return Response(
125
- content=f"<h2>502 β€” upstream unavailable</h2><pre>{exc}</pre>",
126
- status_code=502,
127
- media_type="text/html",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  )
129
 
130
- res_headers = _response_headers(resp)
131
- content = resp.content
132
- ctype = resp.headers.get("content-type", "")
133
- status = resp.status_code
 
134
 
135
- # Only rewrite text/html β€” never touch JS, CSS, JSON, images, etc.
136
- if "text/html" in ctype and html_fixup and content:
137
  try:
138
  text = content.decode("utf-8", errors="replace")
139
  text = html_fixup(text)
@@ -143,21 +221,26 @@ async def proxy_http(
143
 
144
  return Response(
145
  content=content,
146
- status_code=status,
147
  headers=res_headers,
148
- media_type=ctype or None,
149
  )
150
 
151
- # ── WebSocket proxy helper ──────────────────────────────────────────────────
152
- async def proxy_ws(websocket: WebSocket, target_ws_url: str):
 
 
 
 
153
  await websocket.accept()
154
- skip = {"host", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions"}
155
- headers = {k: v for k, v in websocket.headers.items() if k.lower() not in skip}
 
156
  try:
157
- async with aiohttp.ClientSession() as sess:
158
- async with sess.ws_connect(target_ws_url, headers=headers) as ws_up:
159
- async def fwd_down():
160
- async for msg in ws_up:
161
  if msg.type == aiohttp.WSMsgType.TEXT:
162
  await websocket.send_text(msg.data)
163
  elif msg.type == aiohttp.WSMsgType.BINARY:
@@ -165,22 +248,22 @@ async def proxy_ws(websocket: WebSocket, target_ws_url: str):
165
  elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED):
166
  break
167
 
168
- async def fwd_up():
169
  while True:
170
  try:
171
  msg = await websocket.receive()
172
  if "text" in msg:
173
- await ws_up.send_str(msg["text"])
174
  elif "bytes" in msg:
175
- await ws_up.send_bytes(msg["bytes"])
176
  elif msg.get("type") == "websocket.disconnect":
177
  break
178
  except Exception:
179
  break
180
 
181
- await asyncio.gather(fwd_down(), fwd_up(), return_exceptions=True)
182
- except Exception:
183
- pass
184
  finally:
185
  try:
186
  await websocket.close()
@@ -189,30 +272,35 @@ async def proxy_ws(websocket: WebSocket, target_ws_url: str):
189
 
190
 
191
  # ══════════════════════════════════════════════════════════════════════════════
192
- # ROUTES
193
  # ══════════════════════════════════════════════════════════════════════════════
194
 
195
- # ── 0. Health / Landing ────────────────────────────────────────────────────
196
  @app.api_route("/", methods=["GET", "HEAD"])
197
- async def root_hub(request: Request):
198
  if request.method == "HEAD":
199
  return Response(status_code=200)
200
- for path in ("/index.html", "/app/index.html"):
201
- if os.path.exists(path):
202
- return FileResponse(path, media_type="text/html")
203
- return HTMLResponse("<h1>OpenCode Space Gateway</h1><p>Services: "
204
- "<a href='/server/'>OpenCode</a> | "
205
- "<a href='/openwebui/'>Open WebUI</a> | "
206
- "<a href='/omniroute/'>OmniRoute</a> | "
207
- "<a href='/jellyfin/'>Jellyfin</a></p>")
 
 
 
 
208
 
209
  @app.api_route("/favicon.ico", methods=["GET", "HEAD"])
210
  async def favicon():
211
  return Response(content=b"", status_code=204)
212
 
213
- # ── 1. Debug status ────────────────────────────────────────────────────────
214
  @app.get("/debug/status")
215
- async def debug_status():
 
216
  services = {
217
  "opencode": f"http://127.0.0.1:{OPENCODE_PORT}/",
218
  "openwebui": f"http://127.0.0.1:{WEBUI_PORT}/",
@@ -221,24 +309,18 @@ async def debug_status():
221
  "tg_stream": f"http://127.0.0.1:{TG_PORT}/",
222
  }
223
  results = {}
224
- for name, url in services.items():
225
- try:
226
- r = await client.get(url, timeout=4.0)
227
- results[name] = {"up": True, "status": r.status_code}
228
- except Exception as exc:
229
- results[name] = {"up": False, "error": str(exc)}
230
- return results
231
-
232
-
233
- # ══════════════════════════════════════════════════════════════════════════════
234
- # 2. OPEN WEBUI β€” mounted at /openwebui/
235
- # Key insight: Open WebUI is built to run at /, so we STRIP /openwebui
236
- # from the path before proxying. All its asset URLs (/_app/, /static/ etc.)
237
- # will come in via the catch-all with a Referer: .../openwebui/...
238
- # ══════════════════════════════════════════════════════════════════════════════
239
- def _webui_fixup(html: str) -> str:
240
- """Rewrite absolute-root asset paths to include /openwebui prefix."""
241
- # Don't blindly rewrite every /; be surgical about href/src/action attrs
242
  html = html.replace('href="/', 'href="/openwebui/')
243
  html = html.replace("href='/", "href='/openwebui/")
244
  html = html.replace('src="/', 'src="/openwebui/')
@@ -248,27 +330,19 @@ def _webui_fixup(html: str) -> str:
248
 
249
  @app.api_route("/openwebui", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
250
  @app.api_route("/openwebui/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
251
- async def openwebui_route(request: Request, path: str = ""):
252
- upstream = f"http://127.0.0.1:{WEBUI_PORT}/{path}"
253
- return await proxy_http(upstream, request, html_fixup=_webui_fixup)
254
 
255
- # Open WebUI WebSocket (socket.io)
256
  @app.websocket("/openwebui/ws")
257
  @app.websocket("/openwebui/ws/")
258
- async def openwebui_ws(websocket: WebSocket):
259
- await proxy_ws(websocket, f"ws://127.0.0.1:{WEBUI_PORT}/ws")
260
-
261
  @app.websocket("/openwebui/socket.io")
262
- async def openwebui_socketio(websocket: WebSocket):
263
- await proxy_ws(websocket, f"ws://127.0.0.1:{WEBUI_PORT}/socket.io")
264
 
265
 
266
- # ══════════════════════════════════════════════════════════════════════════════
267
- # 3. OMNIROUTE β€” mounted at /omniroute/
268
- # OmniRoute (Next.js) is built to run at /. We STRIP /omniroute prefix.
269
- # ══════════════════════════════════════════════════════════════════════════════
270
- def _omniroute_fixup(html: str) -> str:
271
- """Rewrite Next.js root-relative paths to include /omniroute prefix."""
272
  html = html.replace('href="/', 'href="/omniroute/')
273
  html = html.replace("href='/", "href='/omniroute/")
274
  html = html.replace('src="/', 'src="/omniroute/')
@@ -276,105 +350,85 @@ def _omniroute_fixup(html: str) -> str:
276
  html = html.replace('action="/', 'action="/omniroute/')
277
  html = html.replace('"/_next/', '"/omniroute/_next/')
278
  html = html.replace("'/_next/", "'/omniroute/_next/")
279
- # Fix Next.js router base path in __NEXT_DATA__
280
  html = html.replace('"basePath":""', '"basePath":"/omniroute"')
281
  html = html.replace('"basePath": ""', '"basePath": "/omniroute"')
282
  return html
283
 
284
  @app.api_route("/omniroute", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
285
  @app.api_route("/omniroute/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
286
- async def omniroute_route(request: Request, path: str = ""):
287
- upstream = f"http://127.0.0.1:{OMNIROUTE_PORT}/{path}"
288
- return await proxy_http(upstream, request, html_fixup=_omniroute_fixup)
289
 
290
 
291
- # ══════════════════════════════════════════════════════════════════════════════
292
- # 4. OPENCODE CLI SERVER β€” mounted at /server/
293
- # OpenCode's Vite frontend connects to its own backend via WebSocket at /ws.
294
- # We proxy /server/ β†’ 127.0.0.1:4097/ and /server/ws β†’ ws://127.0.0.1:4097/ws
295
- # We do NOT rewrite JS files β€” only HTML gets the prefix fixup.
296
- # ══════════════════════════════════════════════════════════════════════════════
297
- def _opencode_fixup(html: str) -> str:
298
- """Rewrite root-relative asset URLs in opencode HTML to /server prefix."""
299
  html = html.replace('href="/', 'href="/server/')
300
  html = html.replace("href='/", "href='/server/")
301
  html = html.replace('src="/', 'src="/server/')
302
  html = html.replace("src='/", "src='/server/")
303
- # Fix the websocket URL embedded in HTML (not in JS - JS is never touched)
304
  html = html.replace('ws://"+location.host+"/ws', 'ws://"+location.host+"/server/ws')
305
  html = html.replace("ws://'+location.host+'/ws", "ws://'+location.host+'/server/ws")
306
  return html
307
 
308
  @app.api_route("/server", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
309
  @app.api_route("/server/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
310
- async def opencode_route(request: Request, path: str = ""):
311
- upstream = f"http://127.0.0.1:{OPENCODE_PORT}/{path}"
312
- return await proxy_http(upstream, request, html_fixup=_opencode_fixup)
313
 
314
- # OpenCode WebSocket β€” the JS bundle sends to /ws (root), our route catches /server/ws
315
  @app.websocket("/server/ws")
316
  @app.websocket("/ws")
317
- async def opencode_ws(websocket: WebSocket):
318
- await proxy_ws(websocket, f"ws://127.0.0.1:{OPENCODE_PORT}/ws")
319
 
320
 
321
- # ══════════════════════════════════════════════════════════════════════════════
322
- # 5. JELLYFIN β€” mounted at /jellyfin/
323
- # ══════════════════════════════════════════════════════════════════════════════
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  @app.api_route("/jellyfin", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
325
  @app.api_route("/jellyfin/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
326
  @app.api_route("/Jellyfin", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
327
  @app.api_route("/Jellyfin/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
328
- async def jellyfin_route(request: Request, path: str = ""):
329
- upstream = f"http://127.0.0.1:{JELLYFIN_PORT}/{path}"
330
- return await proxy_http(
331
- upstream, request,
332
- extra_headers={"X-Forwarded-Prefix": "/jellyfin"},
333
- )
334
 
335
 
336
- # ══════════════════════════════════════════════════════════════════════════════
337
- # 6. TG-DRIVE STREAMER β€” mounted at /tg-stream/
338
- # ══════════════════════════════════════════════════════════════════════════════
339
  @app.api_route("/tg-stream", methods=["GET", "HEAD", "OPTIONS"])
340
  @app.api_route("/tg-stream/{path:path}", methods=["GET", "HEAD", "OPTIONS"])
341
- async def tg_stream_route(request: Request, path: str = ""):
342
- upstream = f"http://127.0.0.1:{TG_PORT}/{path}"
343
- return await proxy_http(upstream, request)
344
 
345
 
346
- # ══════════════════════════════════════════════════════════════════════════════
347
- # 7. CATCH-ALL β€” smart Referer-based fallback for sub-resource requests
348
- # When JS/CSS/API assets from a sub-app arrive at the root (e.g. /_app/...)
349
- # we use the Referer header to route them to the correct upstream.
350
- # ══════════════════════════════════════════════════════════════════════════════
351
  @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
352
- async def catch_all(path: str, request: Request):
353
  referer = request.headers.get("referer", "").lower()
354
 
355
  if "/openwebui" in referer:
356
- return await proxy_http(
357
- f"http://127.0.0.1:{WEBUI_PORT}/{path}", request,
358
- html_fixup=_webui_fixup,
359
- )
360
  elif "/omniroute" in referer or "/dashboard" in referer or "/login" in referer:
361
- return await proxy_http(
362
- f"http://127.0.0.1:{OMNIROUTE_PORT}/{path}", request,
363
- html_fixup=_omniroute_fixup,
364
- )
365
  elif "/server" in referer:
366
- return await proxy_http(
367
- f"http://127.0.0.1:{OPENCODE_PORT}/{path}", request,
368
- html_fixup=_opencode_fixup,
369
- )
370
  elif "/jellyfin" in referer:
371
- return await proxy_http(
372
- f"http://127.0.0.1:{JELLYFIN_PORT}/{path}", request,
373
- extra_headers={"X-Forwarded-Prefix": "/jellyfin"},
374
- )
375
 
376
- # Default: try Open WebUI
377
- return await proxy_http(
378
- f"http://127.0.0.1:{WEBUI_PORT}/{path}", request,
379
- html_fixup=_webui_fixup,
380
- )
 
1
  """
2
+ OpenCode Space β€” Production ASGI Gateway & Reverse Proxy
3
+ =========================================================
4
+ Technology Stack & Design:
5
+ - Framework: FastAPI 0.115+ / Starlette ASGI Engine
6
+ - Async I/O: Python 3.11+ / 3.12+ Async Context Lifespan & httpx.AsyncClient
7
+ - Protocol Support: Full HTTP/1.1, Server-Sent Events (SSE) Streaming, & Full-Duplex WebSockets
8
+ - Resilience: Automatic connection pool management (1000 max, 200 keep-alive), exponential retries on transient errors, header sanitization, & zero-port-leakage URL rewriting.
9
+
10
+ Port Architecture (All listening on 127.0.0.1 internally):
11
+ Port 4096 β€” Main FastAPI Gateway (External HF Space Port)
12
+ Port 4097 β€” OpenCode Server Engine
13
+ Port 8098 β€” Open WebUI Platform
14
+ Port 20128 β€” OmniRoute Multi-LLM Gateway
15
+ Port 8096 β€” Jellyfin Media Server
16
+ Port 8080 β€” TG-Drive Direct 5G Streaming Server
17
+
18
+ Public URL Routing Matrix:
19
+ / β†’ Landing Hub (index.html)
20
+ /server/ β†’ OpenCode Server (4097)
21
+ /openwebui/ β†’ Open WebUI (8098) [Path Stripped & Re-anchored]
22
+ /omniroute/ β†’ OmniRoute (20128) [Path Stripped & Re-anchored]
23
+ /jellyfin/ β†’ Jellyfin Media (8096)
24
+ /tg-stream/ β†’ TG Streaming Engine (8080)
25
+ /health β†’ Service Health & Diagnostic API
26
  """
27
 
28
  import os
29
  import re
30
  import asyncio
31
+ import logging
32
+ from contextlib import asynccontextmanager
33
+ from typing import Callable, Optional, Dict, Any
34
+
35
  import httpx
36
  import aiohttp
37
+ from fastapi import FastAPI, Request, Response, WebSocket
38
+ from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse
39
 
40
+ # Setup logging
41
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
42
+ logger = logging.getLogger("GatewayProxy")
43
 
44
+ # Port Definitions
45
+ WEBUI_PORT = int(os.environ.get("WEBUI_PORT", 8098))
46
+ OMNIROUTE_PORT = int(os.environ.get("OMNIROUTE_PORT", 20128))
47
+ OPENCODE_PORT = int(os.environ.get("OPENCODE_PORT", 4097))
48
+ JELLYFIN_PORT = int(os.environ.get("JELLYFIN_PORT", 8096))
49
+ TG_PORT = int(os.environ.get("TG_PORT", 8080))
50
+ GATEWAY_PORT = int(os.environ.get("PORT", 4096))
51
 
52
+ PUBLIC_HOST = os.environ.get("PUBLIC_HOST", "jishnupg-opencode-cli.hf.space")
53
  PUBLIC_ORIGIN = f"https://{PUBLIC_HOST}"
54
 
55
+ # Internal Port Leakage Protection
56
+ _INTERNAL_PORTS = {GATEWAY_PORT, WEBUI_PORT, OMNIROUTE_PORT, OPENCODE_PORT, JELLYFIN_PORT, TG_PORT}
57
+ _PORT_STRIP_RE = re.compile(r"(https?://[^/:]+):(" + "|".join(str(p) for p in _INTERNAL_PORTS) + r")")
58
+
59
+ _PORT_PREFIX_MAP = {
60
+ GATEWAY_PORT: "",
61
+ OMNIROUTE_PORT: "/omniroute",
62
+ WEBUI_PORT: "/openwebui",
63
+ OPENCODE_PORT: "/server",
64
+ JELLYFIN_PORT: "/jellyfin",
65
+ TG_PORT: "/tg-stream",
 
66
  }
67
 
68
+ def _fix_location_header(location: str) -> str:
69
+ """Strip internal ports from HTTP Location headers and re-anchor to public paths."""
70
+ if not location:
71
+ return location
72
+
73
+ def _replace_port(match: re.Match[str]) -> str:
74
+ port = int(match.group(2))
75
+ prefix = _PORT_PREFIX_MAP.get(port, "")
76
  return f"{PUBLIC_ORIGIN}{prefix}"
77
+
78
+ return _PORT_STRIP_RE.sub(_replace_port, location)
79
+
80
+
81
+ # ── Global Async HTTP Client with Lifespan Manager ─────────────────────────
82
+ http_client: Optional[httpx.AsyncClient] = None
83
+
84
+ @asynccontextmanager
85
+ async def lifespan(app: FastAPI):
86
+ global http_client
87
+ logger.info("Initializing Gateway Connection Pool...")
88
+ http_client = httpx.AsyncClient(
89
+ timeout=httpx.Timeout(120.0, connect=10.0),
90
+ follow_redirects=False,
91
+ limits=httpx.Limits(max_connections=1000, max_keepalive_connections=200, keepalive_expiry=30.0),
92
+ )
93
+ yield
94
+ logger.info("Closing Gateway Connection Pool...")
95
+ if http_client:
96
+ await http_client.aclose()
97
+
98
+
99
+ app = FastAPI(title="OpenCode Space ASGI Gateway", lifespan=lifespan, docs_url=None, redoc_url=None)
100
+
101
+
102
+ # ── Header Sanitization Helpers ─────────────────────────────────────────────
103
+ _HOP_BY_HOP_HEADERS = {
104
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
105
+ "te", "trailers", "transfer-encoding", "upgrade",
106
+ "content-length", "content-encoding"
107
+ }
108
+
109
+ def _build_upstream_headers(request: Request, extra_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
110
+ headers = {k: v for k, v in request.headers.items() if k.lower() not in {"host", "content-length"}}
111
+ headers["X-Forwarded-Host"] = PUBLIC_HOST
112
+ headers["X-Forwarded-Proto"] = "https"
113
+ headers["X-Forwarded-Port"] = "443"
114
+ headers["X-Real-IP"] = request.client.host if request.client else "127.0.0.1"
115
+ if extra_headers:
116
+ headers.update(extra_headers)
117
+ return headers
118
+
119
+ def _build_downstream_headers(resp_headers: httpx.Headers, override_location: Optional[str] = None) -> Dict[str, str]:
120
+ headers = {}
121
+ for key, value in resp_headers.items():
122
+ lk = key.lower()
123
+ if lk in _HOP_BY_HOP_HEADERS:
124
  continue
125
  if lk == "location":
126
+ value = _fix_location_header(value)
127
+ if override_location:
128
+ value = override_location
129
+ headers[key] = value
130
+ return headers
131
+
132
+
133
+ # ── Core Resilient HTTP Proxy Engine ───────────────────────────────────────
134
+ async def proxy_http_request(
135
  target_url: str,
136
  request: Request,
137
+ extra_headers: Optional[Dict[str, str]] = None,
138
+ html_fixup: Optional[Callable[[str], str]] = None,
139
  ) -> Response:
140
  """
141
+ Forward an HTTP request to upstream service with automatic retries & streaming support.
 
 
142
  """
143
+ global http_client
144
+ if http_client is None:
145
+ http_client = httpx.AsyncClient(timeout=httpx.Timeout(120.0), follow_redirects=False)
146
+
147
+ headers = _build_upstream_headers(request, extra_headers)
148
  body = await request.body()
149
+ method = request.method
150
+ params = dict(request.query_params)
151
 
152
+ # Perform up to 3 attempts with exponential backoff for transient upstream delays
153
+ resp: Optional[httpx.Response] = None
154
+ last_exc: Optional[Exception] = None
155
+
156
+ for attempt in range(1, 4):
157
+ try:
158
+ req = http_client.build_request(
159
+ method=method,
160
+ url=target_url,
161
+ headers=headers,
162
+ params=params,
163
+ content=body,
164
+ )
165
+ resp = await http_client.send(req, stream=True)
166
+ if resp.status_code in (502, 503) and attempt < 3:
167
+ await resp.aclose()
168
+ await asyncio.sleep(0.1 * attempt)
169
+ continue
170
+ break
171
+ except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
172
+ last_exc = exc
173
+ if attempt < 3:
174
+ await asyncio.sleep(0.1 * attempt)
175
+ continue
176
+ return Response(
177
+ content=f"<h2>502 Gateway Error β€” Upstream Service Starting</h2><p>{exc}</p>",
178
+ status_code=502,
179
+ media_type="text/html",
180
+ )
181
+
182
+ if resp is None:
183
+ return Response(content=f"<h2>502 Bad Gateway</h2><p>{last_exc}</p>", status_code=502, media_type="text/html")
184
+
185
+ res_headers = _build_downstream_headers(resp.headers)
186
+ content_type = resp.headers.get("content-type", "")
187
+ status_code = resp.status_code
188
+
189
+ # Check if streaming response (eventsource or binary video stream)
190
+ is_stream = "text/event-stream" in content_type or "video/" in content_type or "audio/" in content_type or resp.headers.get("transfer-encoding") == "chunked"
191
+
192
+ if is_stream:
193
+ async def stream_generator():
194
+ try:
195
+ async for chunk in resp.aiter_bytes():
196
+ yield chunk
197
+ finally:
198
+ await resp.aclose()
199
+
200
+ return StreamingResponse(
201
+ stream_generator(),
202
+ status_code=status_code,
203
+ headers=res_headers,
204
+ media_type=content_type or None,
205
  )
206
 
207
+ # Non-streaming response body read
208
+ try:
209
+ content = await resp.aread()
210
+ finally:
211
+ await resp.aclose()
212
 
213
+ # Rewrite HTML references ONLY for text/html (protect JS/CSS/JSON bundles)
214
+ if "text/html" in content_type and html_fixup and content:
215
  try:
216
  text = content.decode("utf-8", errors="replace")
217
  text = html_fixup(text)
 
221
 
222
  return Response(
223
  content=content,
224
+ status_code=status_code,
225
  headers=res_headers,
226
+ media_type=content_type or None,
227
  )
228
 
229
+
230
+ # ── Full-Duplex ASGI WebSocket Proxy Engine ──────────────────────────────────
231
+ async def proxy_websocket_stream(websocket: WebSocket, target_ws_url: str):
232
+ """
233
+ Transparent full-duplex ASGI WebSocket proxy between browser client and upstream.
234
+ """
235
  await websocket.accept()
236
+ skip_headers = {"host", "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions"}
237
+ forward_headers = {k: v for k, v in websocket.headers.items() if k.lower() not in skip_headers}
238
+
239
  try:
240
+ async with aiohttp.ClientSession() as session:
241
+ async with session.ws_connect(target_ws_url, headers=forward_headers) as upstream_ws:
242
+ async def downstream_to_upstream():
243
+ async for msg in upstream_ws:
244
  if msg.type == aiohttp.WSMsgType.TEXT:
245
  await websocket.send_text(msg.data)
246
  elif msg.type == aiohttp.WSMsgType.BINARY:
 
248
  elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED):
249
  break
250
 
251
+ async def upstream_to_downstream():
252
  while True:
253
  try:
254
  msg = await websocket.receive()
255
  if "text" in msg:
256
+ await upstream_ws.send_str(msg["text"])
257
  elif "bytes" in msg:
258
+ await upstream_ws.send_bytes(msg["bytes"])
259
  elif msg.get("type") == "websocket.disconnect":
260
  break
261
  except Exception:
262
  break
263
 
264
+ await asyncio.gather(downstream_to_upstream(), upstream_to_downstream(), return_exceptions=True)
265
+ except Exception as exc:
266
+ logger.warning(f"WebSocket Proxy Exception for {target_ws_url}: {exc}")
267
  finally:
268
  try:
269
  await websocket.close()
 
272
 
273
 
274
  # ══════════════════════════════════════════════════════════════════════════════
275
+ # GATEWAY ROUTING MATRIX
276
  # ══════════════════════════════════════════════════════════════════════════════
277
 
278
+ # ── 0. Landing Page & Health Check ───────────────────────────────────────────
279
  @app.api_route("/", methods=["GET", "HEAD"])
280
+ async def gateway_root(request: Request):
281
  if request.method == "HEAD":
282
  return Response(status_code=200)
283
+ for index_path in ("/index.html", "/app/index.html"):
284
+ if os.path.exists(index_path):
285
+ return FileResponse(index_path, media_type="text/html")
286
+ return HTMLResponse(
287
+ "<h1>OpenCode Space Gateway</h1>"
288
+ "<p>Services: "
289
+ "<a href='/server/'>OpenCode Server</a> | "
290
+ "<a href='/openwebui/'>Open WebUI</a> | "
291
+ "<a href='/omniroute/'>OmniRoute AI Gateway</a> | "
292
+ "<a href='/jellyfin/'>Jellyfin Media</a>"
293
+ "</p>"
294
+ )
295
 
296
  @app.api_route("/favicon.ico", methods=["GET", "HEAD"])
297
  async def favicon():
298
  return Response(content=b"", status_code=204)
299
 
300
+ @app.get("/health")
301
  @app.get("/debug/status")
302
+ async def health_check():
303
+ """Diagnostic health check for all upstream services."""
304
  services = {
305
  "opencode": f"http://127.0.0.1:{OPENCODE_PORT}/",
306
  "openwebui": f"http://127.0.0.1:{WEBUI_PORT}/",
 
309
  "tg_stream": f"http://127.0.0.1:{TG_PORT}/",
310
  }
311
  results = {}
312
+ if http_client:
313
+ for name, url in services.items():
314
+ try:
315
+ r = await http_client.get(url, timeout=3.0)
316
+ results[name] = {"status": "ok", "code": r.status_code}
317
+ except Exception as exc:
318
+ results[name] = {"status": "error", "message": str(exc)}
319
+ return {"gateway": "healthy", "upstreams": results}
320
+
321
+
322
+ # ── 1. Open WebUI Route (/openwebui/) ────────────────────────────────────────
323
+ def _fixup_webui_html(html: str) -> str:
 
 
 
 
 
 
324
  html = html.replace('href="/', 'href="/openwebui/')
325
  html = html.replace("href='/", "href='/openwebui/")
326
  html = html.replace('src="/', 'src="/openwebui/')
 
330
 
331
  @app.api_route("/openwebui", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
332
  @app.api_route("/openwebui/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
333
+ async def route_openwebui(request: Request, path: str = ""):
334
+ target = f"http://127.0.0.1:{WEBUI_PORT}/{path}"
335
+ return await proxy_http_request(target, request, html_fixup=_fixup_webui_html)
336
 
 
337
  @app.websocket("/openwebui/ws")
338
  @app.websocket("/openwebui/ws/")
 
 
 
339
  @app.websocket("/openwebui/socket.io")
340
+ async def route_openwebui_ws(websocket: WebSocket):
341
+ await proxy_websocket_stream(websocket, f"ws://127.0.0.1:{WEBUI_PORT}/ws")
342
 
343
 
344
+ # ── 2. OmniRoute AI Gateway Route (/omniroute/) ──────────────────────────────
345
+ def _fixup_omniroute_html(html: str) -> str:
 
 
 
 
346
  html = html.replace('href="/', 'href="/omniroute/')
347
  html = html.replace("href='/", "href='/omniroute/")
348
  html = html.replace('src="/', 'src="/omniroute/')
 
350
  html = html.replace('action="/', 'action="/omniroute/')
351
  html = html.replace('"/_next/', '"/omniroute/_next/')
352
  html = html.replace("'/_next/", "'/omniroute/_next/")
 
353
  html = html.replace('"basePath":""', '"basePath":"/omniroute"')
354
  html = html.replace('"basePath": ""', '"basePath": "/omniroute"')
355
  return html
356
 
357
  @app.api_route("/omniroute", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
358
  @app.api_route("/omniroute/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
359
+ async def route_omniroute(request: Request, path: str = ""):
360
+ target = f"http://127.0.0.1:{OMNIROUTE_PORT}/{path}"
361
+ return await proxy_http_request(target, request, html_fixup=_fixup_omniroute_html)
362
 
363
 
364
+ # ── 3. OpenCode CLI Server Route (/server/) ──────────────────────────────────
365
+ def _fixup_opencode_html(html: str) -> str:
 
 
 
 
 
 
366
  html = html.replace('href="/', 'href="/server/')
367
  html = html.replace("href='/", "href='/server/")
368
  html = html.replace('src="/', 'src="/server/')
369
  html = html.replace("src='/", "src='/server/")
 
370
  html = html.replace('ws://"+location.host+"/ws', 'ws://"+location.host+"/server/ws')
371
  html = html.replace("ws://'+location.host+'/ws", "ws://'+location.host+'/server/ws")
372
  return html
373
 
374
  @app.api_route("/server", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
375
  @app.api_route("/server/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
376
+ async def route_opencode(request: Request, path: str = ""):
377
+ target = f"http://127.0.0.1:{OPENCODE_PORT}/{path}"
378
+ return await proxy_http_request(target, request, html_fixup=_fixup_opencode_html)
379
 
 
380
  @app.websocket("/server/ws")
381
  @app.websocket("/ws")
382
+ async def route_opencode_ws(websocket: WebSocket):
383
+ await proxy_websocket_stream(websocket, f"ws://127.0.0.1:{OPENCODE_PORT}/ws")
384
 
385
 
386
+ # ── Direct OpenCode Endpoints (session, project, config, etc.) ───────────────
387
+ OPENCODE_DIRECT_PATHS = ["session", "project", "config", "permission", "question", "file", "find", "events", "event", "command", "provider", "model", "mcp", "api"]
388
+
389
+ def _create_direct_route(prefix: str):
390
+ @app.api_route(f"/{prefix}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
391
+ @app.api_route(f"/{prefix}/{{path:path}}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
392
+ async def direct_endpoint(request: Request, path: str = ""):
393
+ full_path = f"{prefix}/{path}" if path else prefix
394
+ target = f"http://127.0.0.1:{OPENCODE_PORT}/{full_path}"
395
+ return await proxy_http_request(target, request, html_fixup=_fixup_opencode_html)
396
+
397
+ for p in OPENCODE_DIRECT_PATHS:
398
+ _create_direct_route(p)
399
+
400
+
401
+ # ── 4. Jellyfin Media Server Route (/jellyfin/) ───────────────────────────────
402
  @app.api_route("/jellyfin", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
403
  @app.api_route("/jellyfin/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
404
  @app.api_route("/Jellyfin", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
405
  @app.api_route("/Jellyfin/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
406
+ async def route_jellyfin(request: Request, path: str = ""):
407
+ target = f"http://127.0.0.1:{JELLYFIN_PORT}/{path}"
408
+ return await proxy_http_request(target, request, extra_headers={"X-Forwarded-Prefix": "/jellyfin"})
 
 
 
409
 
410
 
411
+ # ── 5. TG-Drive Direct Streamer Route (/tg-stream/) ─────────────────────────
 
 
412
  @app.api_route("/tg-stream", methods=["GET", "HEAD", "OPTIONS"])
413
  @app.api_route("/tg-stream/{path:path}", methods=["GET", "HEAD", "OPTIONS"])
414
+ async def route_tg_stream(request: Request, path: str = ""):
415
+ target = f"http://127.0.0.1:{TG_PORT}/{path}"
416
+ return await proxy_http_request(target, request)
417
 
418
 
419
+ # ── 6. Catch-All Referer Router (Sub-resource Fallback Engine) ────────────────
 
 
 
 
420
  @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
421
+ async def route_catch_all(path: str, request: Request):
422
  referer = request.headers.get("referer", "").lower()
423
 
424
  if "/openwebui" in referer:
425
+ return await proxy_http_request(f"http://127.0.0.1:{WEBUI_PORT}/{path}", request, html_fixup=_fixup_webui_html)
 
 
 
426
  elif "/omniroute" in referer or "/dashboard" in referer or "/login" in referer:
427
+ return await proxy_http_request(f"http://127.0.0.1:{OMNIROUTE_PORT}/{path}", request, html_fixup=_fixup_omniroute_html)
 
 
 
428
  elif "/server" in referer:
429
+ return await proxy_http_request(f"http://127.0.0.1:{OPENCODE_PORT}/{path}", request, html_fixup=_fixup_opencode_html)
 
 
 
430
  elif "/jellyfin" in referer:
431
+ return await proxy_http_request(f"http://127.0.0.1:{JELLYFIN_PORT}/{path}", request, extra_headers={"X-Forwarded-Prefix": "/jellyfin"})
 
 
 
432
 
433
+ # Fallback default: Open WebUI
434
+ return await proxy_http_request(f"http://127.0.0.1:{WEBUI_PORT}/{path}", request, html_fixup=_fixup_webui_html)
 
 
 
tg_streamer.py CHANGED
@@ -180,14 +180,19 @@ def create_strm_file(msg_id, file_id, clean_title, is_tv=False, show_name=None,
180
  return strm_filename
181
 
182
  async def trigger_jellyfin_scan():
183
- """Trigger Jellyfin Library Scan automatically"""
184
- try:
185
- connector = aiohttp.TCPConnector(family=socket.AF_INET)
186
- async with aiohttp.ClientSession(connector=connector) as session:
187
- async with session.post("http://127.0.0.1:8096/Library/Refresh") as resp:
188
- logger.info(f"Jellyfin library refresh triggered: {resp.status}")
189
- except Exception as e:
190
- logger.warning(f"Could not trigger Jellyfin library refresh: {e}")
 
 
 
 
 
191
 
192
  @routes.post("/")
193
  @routes.post("/telegram-webhook")
 
180
  return strm_filename
181
 
182
  async def trigger_jellyfin_scan():
183
+ """Trigger Jellyfin Library Scan automatically with retry while Jellyfin boots"""
184
+ for attempt in range(5):
185
+ try:
186
+ connector = aiohttp.TCPConnector(family=socket.AF_INET)
187
+ async with aiohttp.ClientSession(connector=connector) as session:
188
+ async with session.post("http://127.0.0.1:8096/Library/Refresh") as resp:
189
+ logger.info(f"Jellyfin library refresh triggered: {resp.status}")
190
+ return
191
+ except Exception as e:
192
+ if attempt < 4:
193
+ await asyncio.sleep(3)
194
+ continue
195
+ logger.warning(f"Could not trigger Jellyfin library refresh after retries: {e}")
196
 
197
  @routes.post("/")
198
  @routes.post("/telegram-webhook")