From c672901bed95377ecc3fbb410f494699a5701189 Mon Sep 17 00:00:00 2001 From: ashwini Date: Sun, 14 Jun 2026 20:18:07 +0530 Subject: [PATCH] feat(deploy): Docker build + same-origin SPA serving Multi-stage Dockerfile builds the Vite SPA then runs uvicorn; FastAPI serves frontend/dist same-origin with an SPA fallback (client routes -> index.html, API segments stay JSON). VITE_FIREBASE_* flow through Docker build args. Binds $PORT and PYTHONPATH=src for the src-layout package. --- .dockerignore | 22 +++++++++++++++++++ Dockerfile | 53 +++++++++++++++++++++++++++++++++++++++++++++ src/aps/api/main.py | 50 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..484b0fa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.git +.gitignore +**/__pycache__ +**/*.pyc +.pytest_cache +.ruff_cache +.mypy_cache +.venv +venv +node_modules +frontend/node_modules +frontend/dist +frontend/.vite +.artifacts +.billing +.research +*.log +.env +tests +docs +*.md +!README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94b18cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# Single same-origin image: build the Vite SPA, then serve it from FastAPI alongside the API. +# Stage 1 compiles frontend/dist; stage 2 is the Python runtime that imports aps.api.main:app +# (which serves that dist — see the SPA catch-all in src/aps/api/main.py). + +# ---- Stage 1: build the React/Vite SPA ---- +FROM node:20-slim AS frontend +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ + +# Firebase web config — Vite inlines VITE_* vars present in the env at build time. Pass these +# as Railway build variables (Settings -> Variables, available to the build) and they flow +# through these ARGs. Unset -> empty (Firebase auth simply stays uninitialized, API still works). +ARG VITE_FIREBASE_API_KEY="" +ARG VITE_FIREBASE_AUTH_DOMAIN="" +ARG VITE_FIREBASE_PROJECT_ID="" +ARG VITE_FIREBASE_STORAGE_BUCKET="" +ARG VITE_FIREBASE_MESSAGING_SENDER_ID="" +ARG VITE_FIREBASE_APP_ID="" +# Optional: override to point the SPA at a different API host. Empty -> relative same-origin. +ARG VITE_API_BASE="" +ENV VITE_FIREBASE_API_KEY=$VITE_FIREBASE_API_KEY \ + VITE_FIREBASE_AUTH_DOMAIN=$VITE_FIREBASE_AUTH_DOMAIN \ + VITE_FIREBASE_PROJECT_ID=$VITE_FIREBASE_PROJECT_ID \ + VITE_FIREBASE_STORAGE_BUCKET=$VITE_FIREBASE_STORAGE_BUCKET \ + VITE_FIREBASE_MESSAGING_SENDER_ID=$VITE_FIREBASE_MESSAGING_SENDER_ID \ + VITE_FIREBASE_APP_ID=$VITE_FIREBASE_APP_ID \ + VITE_API_BASE=$VITE_API_BASE + +# Relative API base (VITE_API_BASE unset -> '') so the SPA calls /v1 + /api on this same host. +RUN npm run build + +# ---- Stage 2: Python runtime ---- +FROM python:3.12-slim AS runtime +WORKDIR /app +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app/src + +# Deps first for layer caching. requirements.txt mirrors pyproject's runtime deps. +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +# App source (src layout -> importable via PYTHONPATH=/app/src) + built SPA from stage 1. +COPY pyproject.toml README.md ./ +COPY src/ ./src/ +COPY --from=frontend /app/frontend/dist ./frontend/dist + +EXPOSE 8011 +# Railway injects $PORT; default 8011 for local `docker run`. +CMD ["sh", "-c", "uvicorn aps.api.main:app --host 0.0.0.0 --port ${PORT:-8011}"] diff --git a/src/aps/api/main.py b/src/aps/api/main.py index 4a96916..7b1bbfd 100644 --- a/src/aps/api/main.py +++ b/src/aps/api/main.py @@ -17,10 +17,13 @@ import time import uuid from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI, Header, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import StreamingResponse, PlainTextResponse, HTMLResponse +from fastapi.responses import ( + StreamingResponse, PlainTextResponse, HTMLResponse, FileResponse, +) from pydantic import BaseModel from aps.config.settings import ( @@ -62,6 +65,12 @@ async def _lifespan(app: FastAPI): app = FastAPI(title="Autonomous Product Studio", lifespan=_lifespan) setup_metrics(app) # mounts /metrics when prometheus_client is installed (no-op otherwise) +# Built SPA (Vite output). When present, this backend serves the React app from the SAME +# origin as the API (see the catch-all at the bottom of this module). main.py lives at +# src/aps/api/main.py → parents[3] is the repo root that holds frontend/dist. +_FRONTEND_DIST = Path(__file__).resolve().parents[3] / "frontend" / "dist" +_DIST_INDEX = _FRONTEND_DIST / "index.html" + # CORS so the Vite dev frontend (localhost:5173) can call this API from the browser. _cors = [o.strip() for o in get_settings().cors_origins.split(",") if o.strip()] app.add_middleware( @@ -112,7 +121,15 @@ async def _lifespan(app: FastAPI): """ -@app.get("/", response_class=HTMLResponse, include_in_schema=False) +@app.get("/", include_in_schema=False) +def index(): + """Serve the SPA when it's built; otherwise fall back to the log console.""" + if _DIST_INDEX.is_file(): + return FileResponse(_DIST_INDEX) + return HTMLResponse(_LOG_VIEWER_HTML) + + +@app.get("/logs", response_class=HTMLResponse, include_in_schema=False) def log_viewer() -> str: """Browser log console — live-tails the in-memory buffer (uvicorn/httpx/429/our events).""" return _LOG_VIEWER_HTML @@ -632,3 +649,32 @@ def launch(run_id: str, req: LaunchReq, x_aps_key: str | None = Header(default=N from aps.api.billing import router as billing_router # noqa: E402 app.include_router(billing_router) + +# ── Same-origin SPA (frontend/dist) ───────────────────────────────────────────────────── +# When the Vite build output is present, this backend ALSO serves the React app, so the +# SPA's relative `/v1` + `/api` calls and WebSocket upgrades hit the same host — no CORS, +# no separate frontend service, no backend URL baked into the build. Registered LAST so +# every API route plus the `/v1` and `/api` mounts above take precedence; the catch-all +# only handles paths the API never claimed (client-side routes → index.html). +if _FRONTEND_DIST.is_dir(): + from fastapi.staticfiles import StaticFiles # noqa: E402 + + _assets_dir = _FRONTEND_DIST / "assets" + if _assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=_assets_dir), name="assets") + + # Reserved first-path-segments that belong to the API — return JSON 404 instead of the + # SPA so a wrong API path stays an honest 404 rather than silently serving HTML. + _API_SEGMENTS = { + "v1", "api", "runs", "models", "health", "providers", "stats", + "metrics", "logs", "logs.json", "docs", "redoc", "openapi.json", + } + + @app.get("/{spa_path:path}", include_in_schema=False) + def spa(spa_path: str): + if spa_path.split("/", 1)[0] in _API_SEGMENTS: + raise HTTPException(status_code=404) + candidate = _FRONTEND_DIST / spa_path + if spa_path and candidate.is_file(): + return FileResponse(candidate) + return FileResponse(_DIST_INDEX)