Spaces:
Sleeping
Sleeping
Commit Β·
537f246
1
Parent(s): 68fa974
feat: serve full-stack app from single HF Space container
Browse files- Dockerfile: add Node 20 build stage to compile React frontend,
copy dist/ into /app/static/ in final Python image
- main.py: mount /assets static dir + root & SPA catch-all routes
that serve index.html so React Router works end-to-end
- useWebSocket.ts: auto-derive WS URL from window.location (wss when
served over HTTPS, e.g. HF Space) β no VITE_WS_URL needed
- requirements.txt: add aiofiles (required by StaticFiles middleware)
- Dockerfile +18 -3
- backend/app/main.py +32 -1
- backend/requirements.txt +1 -0
- frontend/src/hooks/useWebSocket.ts +8 -1
Dockerfile
CHANGED
|
@@ -1,11 +1,23 @@
|
|
| 1 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
-
# SanketSetu
|
| 3 |
# Build context: repo root (SanketSetu/)
|
| 4 |
#
|
| 5 |
-
# docker build -t sanketsetu
|
| 6 |
-
# docker run -p
|
| 7 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
FROM python:3.12-slim AS base
|
| 10 |
|
| 11 |
# System libraries needed by OpenCV headless + Pillow
|
|
@@ -21,6 +33,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|
| 21 |
# ββ Application source ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
COPY backend/app/ ./app/
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
# ββ Model artefacts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
# Copied to /models so the container is fully self-contained.
|
| 26 |
# Override at runtime with -e WEIGHTS_DIR=/mnt/models + bind-mount if preferred.
|
|
|
|
| 1 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2 |
+
# SanketSetu β Dockerfile (full-stack: React frontend + FastAPI backend)
|
| 3 |
# Build context: repo root (SanketSetu/)
|
| 4 |
#
|
| 5 |
+
# docker build -t sanketsetu .
|
| 6 |
+
# docker run -p 7860:7860 sanketsetu
|
| 7 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 8 |
|
| 9 |
+
# ββ Stage 1: Build React frontend ββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
FROM node:20-slim AS frontend-builder
|
| 11 |
+
|
| 12 |
+
WORKDIR /frontend
|
| 13 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 14 |
+
RUN npm ci --prefer-offline
|
| 15 |
+
|
| 16 |
+
COPY frontend/ ./
|
| 17 |
+
# No VITE_WS_URL β the hook derives it from window.location at runtime
|
| 18 |
+
RUN npm run build
|
| 19 |
+
|
| 20 |
+
# ββ Stage 2: Python backend βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
FROM python:3.12-slim AS base
|
| 22 |
|
| 23 |
# System libraries needed by OpenCV headless + Pillow
|
|
|
|
| 33 |
# ββ Application source ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
COPY backend/app/ ./app/
|
| 35 |
|
| 36 |
+
# ββ Frontend static files (built in Stage 1) βββββββββββββββββββββββββββββββββ
|
| 37 |
+
COPY --from=frontend-builder /frontend/dist ./static/
|
| 38 |
+
|
| 39 |
# ββ Model artefacts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
# Copied to /models so the container is fully self-contained.
|
| 41 |
# Override at runtime with -e WEIGHTS_DIR=/mnt/models + bind-mount if preferred.
|
backend/app/main.py
CHANGED
|
@@ -31,7 +31,8 @@ except ImportError:
|
|
| 31 |
import numpy as np
|
| 32 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
|
| 33 |
from fastapi.middleware.cors import CORSMiddleware
|
| 34 |
-
from fastapi.responses import JSONResponse
|
|
|
|
| 35 |
|
| 36 |
from app import config
|
| 37 |
from app.models.loader import load_models, get_model_store
|
|
@@ -92,6 +93,11 @@ app.add_middleware(
|
|
| 92 |
allow_headers=["*"],
|
| 93 |
)
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
# ---------------------------------------------------------------------------
|
| 97 |
# Global exception handler
|
|
@@ -155,6 +161,18 @@ def _available_pipelines() -> list[str]:
|
|
| 155 |
# REST endpoints
|
| 156 |
# ---------------------------------------------------------------------------
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
@app.get("/health", response_model=HealthResponse)
|
| 159 |
async def health():
|
| 160 |
try:
|
|
@@ -278,3 +296,16 @@ async def ws_image(ws: WebSocket):
|
|
| 278 |
|
| 279 |
except WebSocketDisconnect:
|
| 280 |
logger.info("Image client disconnected: %s", session_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
import numpy as np
|
| 32 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
|
| 33 |
from fastapi.middleware.cors import CORSMiddleware
|
| 34 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 35 |
+
from fastapi.staticfiles import StaticFiles
|
| 36 |
|
| 37 |
from app import config
|
| 38 |
from app.models.loader import load_models, get_model_store
|
|
|
|
| 93 |
allow_headers=["*"],
|
| 94 |
)
|
| 95 |
|
| 96 |
+
# ββ Serve React frontend static files (if built into /app/static) ββββββββββββ
|
| 97 |
+
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
| 98 |
+
if _STATIC_DIR.is_dir():
|
| 99 |
+
app.mount("/assets", StaticFiles(directory=str(_STATIC_DIR / "assets")), name="assets")
|
| 100 |
+
|
| 101 |
|
| 102 |
# ---------------------------------------------------------------------------
|
| 103 |
# Global exception handler
|
|
|
|
| 161 |
# REST endpoints
|
| 162 |
# ---------------------------------------------------------------------------
|
| 163 |
|
| 164 |
+
@app.get("/", include_in_schema=False)
|
| 165 |
+
@app.get("/index.html", include_in_schema=False)
|
| 166 |
+
async def serve_frontend():
|
| 167 |
+
"""Serve the React SPA index for the root and any unknown path."""
|
| 168 |
+
index = _STATIC_DIR / "index.html"
|
| 169 |
+
if index.is_file():
|
| 170 |
+
return FileResponse(str(index), media_type="text/html")
|
| 171 |
+
# Fallback: redirect to API docs if frontend not bundled
|
| 172 |
+
from fastapi.responses import RedirectResponse
|
| 173 |
+
return RedirectResponse(url="/docs")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
@app.get("/health", response_model=HealthResponse)
|
| 177 |
async def health():
|
| 178 |
try:
|
|
|
|
| 296 |
|
| 297 |
except WebSocketDisconnect:
|
| 298 |
logger.info("Image client disconnected: %s", session_id)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
# SPA catch-all β must be LAST so it doesn't shadow API routes
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 305 |
+
async def serve_spa(full_path: str):
|
| 306 |
+
"""Return index.html for any unknown path so React Router handles routing."""
|
| 307 |
+
index = _STATIC_DIR / "index.html"
|
| 308 |
+
if index.is_file():
|
| 309 |
+
return FileResponse(str(index), media_type="text/html")
|
| 310 |
+
from fastapi.responses import RedirectResponse
|
| 311 |
+
return RedirectResponse(url="/docs")
|
backend/requirements.txt
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
fastapi>=0.115.0
|
| 2 |
uvicorn[standard]>=0.30.0
|
| 3 |
websockets>=12.0
|
|
|
|
| 4 |
xgboost>=2.0.0
|
| 5 |
lightgbm>=4.3.0
|
| 6 |
scikit-learn>=1.4.0
|
|
|
|
| 1 |
fastapi>=0.115.0
|
| 2 |
uvicorn[standard]>=0.30.0
|
| 3 |
websockets>=12.0
|
| 4 |
+
aiofiles>=23.0.0
|
| 5 |
xgboost>=2.0.0
|
| 6 |
lightgbm>=4.3.0
|
| 7 |
scikit-learn>=1.4.0
|
frontend/src/hooks/useWebSocket.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
| 1 |
import { useEffect, useRef, useState, useCallback } from 'react';
|
| 2 |
import type { PredictionResponse } from '../types';
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
const RECONNECT_BASE_MS = 1000;
|
| 6 |
const MAX_RECONNECT_MS = 30_000;
|
| 7 |
const MAX_SEND_RATE = 15; // frames/sec β normal
|
|
|
|
| 1 |
import { useEffect, useRef, useState, useCallback } from 'react';
|
| 2 |
import type { PredictionResponse } from '../types';
|
| 3 |
|
| 4 |
+
// Derive WebSocket base URL from the current page origin so the hook works
|
| 5 |
+
// on any deployment (HF Space, Vercel + backend, localhost) without extra config.
|
| 6 |
+
function _defaultWsUrl(): string {
|
| 7 |
+
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL as string;
|
| 8 |
+
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
| 9 |
+
return `${proto}://${window.location.host}`;
|
| 10 |
+
}
|
| 11 |
+
const WS_URL = _defaultWsUrl();
|
| 12 |
const RECONNECT_BASE_MS = 1000;
|
| 13 |
const MAX_RECONNECT_MS = 30_000;
|
| 14 |
const MAX_SEND_RATE = 15; // frames/sec β normal
|