Spaces:
Sleeping
fix: HF Space proxy startup — CORS flags, port conflict, playwright deps
Browse filesThree bugs prevented the FastAPI+Streamlit reverse proxy from working on HF:
1. Remove STREAMLIT_SERVER_PORT=7860 + STREAMLIT_SERVER_ADDRESS=0.0.0.0 from
Dockerfile ENV — these were left over from the old single-process architecture
and conflict with the proxy design (uvicorn on 7860, Streamlit on 8501).
2. Add --server.enableCORS false --server.enableXsrfProtection false to the
Streamlit subprocess in start_streamlit() — Streamlit's default CORS middleware
rejects requests whose Origin doesn't match 127.0.0.1:8501, which breaks every
request forwarded by the FastAPI proxy from the public HF Space domain.
3. Remove --with-deps from _ensure_playwright() in ui.py (timeout 120s→30s) —
apt-get requires root; failing silently as uid 1000 on HF added wasted startup
latency on every cold boot. Chromium is pre-installed in the Dockerfile.
4. Add company_ats to PIPELINE_STEPS so it appears in the run progress UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile +5 -4
- HISTORY.md +30 -0
- api_server.py +88 -3
- ui.py +6 -2
|
@@ -85,10 +85,11 @@ RUN chmod +x start.sh
|
|
| 85 |
# Expose Streamlit port
|
| 86 |
EXPOSE 7860
|
| 87 |
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
| 92 |
|
| 93 |
-
# start.sh boots ever-jobs on port 3001, then launches Streamlit on 7860
|
| 94 |
CMD ["./start.sh"]
|
|
|
|
| 85 |
# Expose Streamlit port
|
| 86 |
EXPOSE 7860
|
| 87 |
|
| 88 |
+
# Only keep non-conflicting Streamlit env vars.
|
| 89 |
+
# STREAMLIT_SERVER_PORT and STREAMLIT_SERVER_ADDRESS are intentionally omitted:
|
| 90 |
+
# they would conflict with the proxy architecture where api_server.py runs
|
| 91 |
+
# uvicorn on 7860 and Streamlit is started on 127.0.0.1:8501 via CLI args.
|
| 92 |
+
ENV STREAMLIT_SERVER_HEADLESS=true \
|
| 93 |
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
| 94 |
|
|
|
|
| 95 |
CMD ["./start.sh"]
|
|
@@ -4,6 +4,36 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## 2026-06-23 (PM4) — ever-jobs sidecar disabled by default (clean/fast startup)
|
| 8 |
|
| 9 |
User's HF log showed the ever-jobs Node sidecar crashing on boot
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## 2026-06-23 (PM5) — Fix HF Space startup: CORS proxy flags + port conflict + playwright
|
| 8 |
+
|
| 9 |
+
Three bugs prevented the FastAPI + Streamlit proxy from working on HF Spaces:
|
| 10 |
+
|
| 11 |
+
1. **`STREAMLIT_SERVER_PORT=7860` removed from Dockerfile ENV** — this env var
|
| 12 |
+
conflicted with the proxy architecture: `api_server.py` runs uvicorn on 7860
|
| 13 |
+
and Streamlit on 8501 (via CLI `--server.port 8501`). If Streamlit used the
|
| 14 |
+
env var instead of the CLI arg, it would try to bind to 7860 while uvicorn
|
| 15 |
+
already held that port, causing one of them to crash.
|
| 16 |
+
|
| 17 |
+
2. **`--server.enableCORS false --server.enableXsrfProtection false` added to
|
| 18 |
+
`start_streamlit()`** — Streamlit's default CORS middleware rejects requests
|
| 19 |
+
whose `Origin` header doesn't match `127.0.0.1:8501`. The FastAPI proxy
|
| 20 |
+
forwards the browser's `Origin` (`https://*.hf.space`) to Streamlit, which
|
| 21 |
+
then rejects it. Disabling these checks is the standard practice for running
|
| 22 |
+
Streamlit behind a reverse proxy.
|
| 23 |
+
|
| 24 |
+
3. **`_ensure_playwright()` in `ui.py`: removed `--with-deps` + reduced timeout
|
| 25 |
+
from 120s → 30s** — `playwright install chromium --with-deps` runs
|
| 26 |
+
`apt-get install` for system deps, which requires root. Running as `user`
|
| 27 |
+
(uid 1000 on HF) it fails silently, but the command would attempt downloads
|
| 28 |
+
before failing, adding pointless latency on every cold start. Browser is
|
| 29 |
+
pre-installed in the Dockerfile so `--with-deps` is never needed on HF.
|
| 30 |
+
|
| 31 |
+
4. **`company_ats` added to `PIPELINE_STEPS`** — added in the Scrapling commit
|
| 32 |
+
as a scraper but missing from the step-tracker list, so it never showed up
|
| 33 |
+
in the run progress UI.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
## 2026-06-23 (PM4) — ever-jobs sidecar disabled by default (clean/fast startup)
|
| 38 |
|
| 39 |
User's HF log showed the ever-jobs Node sidecar crashing on boot
|
|
@@ -16,12 +16,14 @@ import base64
|
|
| 16 |
import hashlib
|
| 17 |
import hmac
|
| 18 |
import io
|
|
|
|
| 19 |
import os
|
| 20 |
import re
|
| 21 |
import shutil
|
| 22 |
import subprocess
|
| 23 |
import sys
|
| 24 |
import tempfile
|
|
|
|
| 25 |
|
| 26 |
import httpx
|
| 27 |
import uvicorn
|
|
@@ -747,6 +749,25 @@ async def repair_with_feedback(
|
|
| 747 |
# ── Streamlit subprocess ──────────────────────────────────────────────────────
|
| 748 |
_streamlit_proc: subprocess.Popen | None = None
|
| 749 |
_STREAMLIT_PORT = 8501
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
|
| 751 |
|
| 752 |
def start_streamlit() -> None:
|
|
@@ -765,8 +786,24 @@ def start_streamlit() -> None:
|
|
| 765 |
"127.0.0.1",
|
| 766 |
"--server.headless",
|
| 767 |
"true",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
]
|
| 769 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
|
| 771 |
|
| 772 |
# ── WebSocket proxy for /_stcore/stream (Streamlit live updates) ──────────────
|
|
@@ -780,6 +817,15 @@ async def ws_proxy(websocket: WebSocket):
|
|
| 780 |
import websockets.exceptions as _ws_exc
|
| 781 |
|
| 782 |
await websocket.accept()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
upstream_url = (
|
| 784 |
f"ws://127.0.0.1:{_STREAMLIT_PORT}/_stcore/stream"
|
| 785 |
f"?{websocket.scope.get('query_string', b'').decode()}"
|
|
@@ -815,8 +861,16 @@ async def ws_proxy(websocket: WebSocket):
|
|
| 815 |
|
| 816 |
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
| 817 |
|
| 818 |
-
except Exception:
|
| 819 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 820 |
finally:
|
| 821 |
try:
|
| 822 |
await websocket.close()
|
|
@@ -837,6 +891,15 @@ async def proxy(request: Request, path: str):
|
|
| 837 |
if path == "api" or path.startswith("api/"):
|
| 838 |
return JSONResponse({"error": "not_found", "path": "/" + path}, status_code=404)
|
| 839 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
url = f"http://127.0.0.1:{_STREAMLIT_PORT}/{path}"
|
| 841 |
params = dict(request.query_params)
|
| 842 |
headers = {
|
|
@@ -860,13 +923,35 @@ async def proxy(request: Request, path: str):
|
|
| 860 |
"connection", "keep-alive"}
|
| 861 |
clean_headers = {k: v for k, v in resp.headers.items()
|
| 862 |
if k.lower() not in _DROP}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
return StarletteResponse(
|
| 864 |
content=resp.content,
|
| 865 |
status_code=resp.status_code,
|
| 866 |
headers=clean_headers,
|
| 867 |
media_type=resp.headers.get("content-type"),
|
| 868 |
)
|
| 869 |
-
except httpx.ConnectError:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 870 |
# Streamlit may still be starting up — return a friendly retry message.
|
| 871 |
return StarletteResponse(
|
| 872 |
content=b"Streamlit is starting, please wait...",
|
|
|
|
| 16 |
import hashlib
|
| 17 |
import hmac
|
| 18 |
import io
|
| 19 |
+
import json
|
| 20 |
import os
|
| 21 |
import re
|
| 22 |
import shutil
|
| 23 |
import subprocess
|
| 24 |
import sys
|
| 25 |
import tempfile
|
| 26 |
+
import time
|
| 27 |
|
| 28 |
import httpx
|
| 29 |
import uvicorn
|
|
|
|
| 749 |
# ── Streamlit subprocess ──────────────────────────────────────────────────────
|
| 750 |
_streamlit_proc: subprocess.Popen | None = None
|
| 751 |
_STREAMLIT_PORT = 8501
|
| 752 |
+
_DEBUG_LOG_PATH = "debug-03623a.log"
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
def _debug_log(run_id: str, hypothesis_id: str, location: str, message: str, data: dict) -> None:
|
| 756 |
+
"""Append one NDJSON runtime debug event for this session."""
|
| 757 |
+
payload = {
|
| 758 |
+
"sessionId": "03623a",
|
| 759 |
+
"runId": run_id,
|
| 760 |
+
"hypothesisId": hypothesis_id,
|
| 761 |
+
"location": location,
|
| 762 |
+
"message": message,
|
| 763 |
+
"data": data,
|
| 764 |
+
"timestamp": int(time.time() * 1000),
|
| 765 |
+
}
|
| 766 |
+
try:
|
| 767 |
+
with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
| 768 |
+
f.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
| 769 |
+
except Exception:
|
| 770 |
+
pass
|
| 771 |
|
| 772 |
|
| 773 |
def start_streamlit() -> None:
|
|
|
|
| 786 |
"127.0.0.1",
|
| 787 |
"--server.headless",
|
| 788 |
"true",
|
| 789 |
+
# Required for reverse-proxy operation: without these, Streamlit's
|
| 790 |
+
# CORS/XSRF middleware rejects requests whose Origin header comes
|
| 791 |
+
# from the public HF Space domain rather than 127.0.0.1:8501.
|
| 792 |
+
"--server.enableCORS",
|
| 793 |
+
"false",
|
| 794 |
+
"--server.enableXsrfProtection",
|
| 795 |
+
"false",
|
| 796 |
]
|
| 797 |
)
|
| 798 |
+
# #region agent log
|
| 799 |
+
_debug_log(
|
| 800 |
+
run_id="open-check-1",
|
| 801 |
+
hypothesis_id="H4",
|
| 802 |
+
location="api_server.py:start_streamlit",
|
| 803 |
+
message="streamlit_process_started",
|
| 804 |
+
data={"pid": _streamlit_proc.pid, "port": _STREAMLIT_PORT},
|
| 805 |
+
)
|
| 806 |
+
# #endregion
|
| 807 |
|
| 808 |
|
| 809 |
# ── WebSocket proxy for /_stcore/stream (Streamlit live updates) ──────────────
|
|
|
|
| 817 |
import websockets.exceptions as _ws_exc
|
| 818 |
|
| 819 |
await websocket.accept()
|
| 820 |
+
# #region agent log
|
| 821 |
+
_debug_log(
|
| 822 |
+
run_id="open-check-1",
|
| 823 |
+
hypothesis_id="H3",
|
| 824 |
+
location="api_server.py:ws_proxy",
|
| 825 |
+
message="ws_client_connected",
|
| 826 |
+
data={"client": str(websocket.client)},
|
| 827 |
+
)
|
| 828 |
+
# #endregion
|
| 829 |
upstream_url = (
|
| 830 |
f"ws://127.0.0.1:{_STREAMLIT_PORT}/_stcore/stream"
|
| 831 |
f"?{websocket.scope.get('query_string', b'').decode()}"
|
|
|
|
| 861 |
|
| 862 |
await asyncio.gather(client_to_upstream(), upstream_to_client())
|
| 863 |
|
| 864 |
+
except Exception as exc:
|
| 865 |
+
# #region agent log
|
| 866 |
+
_debug_log(
|
| 867 |
+
run_id="open-check-1",
|
| 868 |
+
hypothesis_id="H3",
|
| 869 |
+
location="api_server.py:ws_proxy",
|
| 870 |
+
message="ws_upstream_connect_or_proxy_failed",
|
| 871 |
+
data={"error": str(exc)[:200]},
|
| 872 |
+
)
|
| 873 |
+
# #endregion
|
| 874 |
finally:
|
| 875 |
try:
|
| 876 |
await websocket.close()
|
|
|
|
| 891 |
if path == "api" or path.startswith("api/"):
|
| 892 |
return JSONResponse({"error": "not_found", "path": "/" + path}, status_code=404)
|
| 893 |
|
| 894 |
+
# #region agent log
|
| 895 |
+
_debug_log(
|
| 896 |
+
run_id="open-check-1",
|
| 897 |
+
hypothesis_id="H2",
|
| 898 |
+
location="api_server.py:proxy_entry",
|
| 899 |
+
message="proxy_request_received",
|
| 900 |
+
data={"method": request.method, "path": path},
|
| 901 |
+
)
|
| 902 |
+
# #endregion
|
| 903 |
url = f"http://127.0.0.1:{_STREAMLIT_PORT}/{path}"
|
| 904 |
params = dict(request.query_params)
|
| 905 |
headers = {
|
|
|
|
| 923 |
"connection", "keep-alive"}
|
| 924 |
clean_headers = {k: v for k, v in resp.headers.items()
|
| 925 |
if k.lower() not in _DROP}
|
| 926 |
+
# #region agent log
|
| 927 |
+
_debug_log(
|
| 928 |
+
run_id="open-check-1",
|
| 929 |
+
hypothesis_id="H2",
|
| 930 |
+
location="api_server.py:proxy_response",
|
| 931 |
+
message="proxy_upstream_response",
|
| 932 |
+
data={
|
| 933 |
+
"path": path,
|
| 934 |
+
"status_code": resp.status_code,
|
| 935 |
+
"content_type": resp.headers.get("content-type", ""),
|
| 936 |
+
},
|
| 937 |
+
)
|
| 938 |
+
# #endregion
|
| 939 |
return StarletteResponse(
|
| 940 |
content=resp.content,
|
| 941 |
status_code=resp.status_code,
|
| 942 |
headers=clean_headers,
|
| 943 |
media_type=resp.headers.get("content-type"),
|
| 944 |
)
|
| 945 |
+
except httpx.ConnectError as exc:
|
| 946 |
+
# #region agent log
|
| 947 |
+
_debug_log(
|
| 948 |
+
run_id="open-check-1",
|
| 949 |
+
hypothesis_id="H4",
|
| 950 |
+
location="api_server.py:proxy_connect_error",
|
| 951 |
+
message="streamlit_connect_error",
|
| 952 |
+
data={"path": path, "error": str(exc)[:200]},
|
| 953 |
+
)
|
| 954 |
+
# #endregion
|
| 955 |
# Streamlit may still be starting up — return a friendly retry message.
|
| 956 |
return StarletteResponse(
|
| 957 |
content=b"Streamlit is starting, please wait...",
|
|
@@ -616,12 +616,15 @@ hr { border-color: #E2E8F0 !important; opacity: 0.5 !important; }
|
|
| 616 |
""", unsafe_allow_html=True)
|
| 617 |
|
| 618 |
# ── Playwright install (runs once per server lifetime on HF Spaces) ──────────
|
|
|
|
|
|
|
|
|
|
| 619 |
@st.cache_resource(show_spinner=False)
|
| 620 |
def _ensure_playwright():
|
| 621 |
import subprocess, sys as _sys
|
| 622 |
result = subprocess.run(
|
| 623 |
-
[_sys.executable, "-m", "playwright", "install", "chromium"
|
| 624 |
-
capture_output=True, text=True, timeout=
|
| 625 |
)
|
| 626 |
return result.returncode == 0
|
| 627 |
|
|
@@ -669,6 +672,7 @@ PIPELINE_STEPS = [
|
|
| 669 |
{"id": "remotive", "icon": "🌍", "title": "Remotive"},
|
| 670 |
{"id": "weworkremotely", "icon": "💻", "title": "WeWorkRemotely"},
|
| 671 |
{"id": "naukri", "icon": "🇮🇳", "title": "Naukri"},
|
|
|
|
| 672 |
{"id": "ever_jobs", "icon": "🌐", "title": "EverJobs (160+)"},
|
| 673 |
{"id": "assess", "icon": "🤖", "title": "AI Assessment"},
|
| 674 |
{"id": "resumes", "icon": "📝", "title": "Generate Resumes"},
|
|
|
|
| 616 |
""", unsafe_allow_html=True)
|
| 617 |
|
| 618 |
# ── Playwright install (runs once per server lifetime on HF Spaces) ──────────
|
| 619 |
+
# On HF Spaces, Chromium is pre-installed in the Dockerfile so this is a fast
|
| 620 |
+
# no-op check. We omit --with-deps because system-package installs require root
|
| 621 |
+
# and would fail silently, adding ~10s of pointless startup latency every boot.
|
| 622 |
@st.cache_resource(show_spinner=False)
|
| 623 |
def _ensure_playwright():
|
| 624 |
import subprocess, sys as _sys
|
| 625 |
result = subprocess.run(
|
| 626 |
+
[_sys.executable, "-m", "playwright", "install", "chromium"],
|
| 627 |
+
capture_output=True, text=True, timeout=30,
|
| 628 |
)
|
| 629 |
return result.returncode == 0
|
| 630 |
|
|
|
|
| 672 |
{"id": "remotive", "icon": "🌍", "title": "Remotive"},
|
| 673 |
{"id": "weworkremotely", "icon": "💻", "title": "WeWorkRemotely"},
|
| 674 |
{"id": "naukri", "icon": "🇮🇳", "title": "Naukri"},
|
| 675 |
+
{"id": "company_ats", "icon": "🏢", "title": "Company ATS"},
|
| 676 |
{"id": "ever_jobs", "icon": "🌐", "title": "EverJobs (160+)"},
|
| 677 |
{"id": "assess", "icon": "🤖", "title": "AI Assessment"},
|
| 678 |
{"id": "resumes", "icon": "📝", "title": "Generate Resumes"},
|