Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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}"]
50 changes: 48 additions & 2 deletions src/aps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -112,7 +121,15 @@ async def _lifespan(app: FastAPI):
</script></body></html>"""


@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
Expand Down Expand Up @@ -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)
Loading