fix: serve Vue SPA from FastAPI, drop separate port 8535

Python http.server can't do SPA routing and Caddy was forwarding
/turnstone/* paths that the static server couldn't resolve.

- app/rest.py: mount web/dist/assets as StaticFiles; add SPA catch-all
  route that serves index.html for any unmatched path
- manage.sh: start/stop/status simplified to single process on :8534;
  remove UI_PORT / UI_PID_FILE; drop http.server invocation
- Caddyfile: replace split API/:8534 + SPA/:8535 block with a single
  strip_prefix + reverse_proxy to :8534
This commit is contained in:
pyr0ball 2026-05-08 17:27:46 -07:00
parent 1f5854e90b
commit 9e46cd4c7f
2 changed files with 41 additions and 36 deletions

View file

@ -1,4 +1,9 @@
"""Turnstone REST API — thin HTTP wrapper around the search and ingest services.""" """Turnstone REST API — thin HTTP wrapper around the search and ingest services.
Serves the Vue SPA from web/dist/ as static files so a single uvicorn process
handles both API routes and the frontend. Caddy strips the /turnstone prefix
before forwarding, so FastAPI always sees paths starting with /api/, /health, etc.
"""
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
@ -8,10 +13,13 @@ from typing import Annotated
from fastapi import FastAPI, Query from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.services.search import search as _search, list_sources as _list_sources, format_results from app.services.search import search as _search, list_sources as _list_sources, format_results
DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db")) DB_PATH = Path(os.environ.get("TURNSTONE_DB", Path(__file__).parent.parent / "data" / "turnstone.db"))
DIST_DIR = Path(__file__).parent.parent / "web" / "dist"
app = FastAPI(title="Turnstone API", version="0.1.0") app = FastAPI(title="Turnstone API", version="0.1.0")
@ -22,6 +30,11 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Serve built Vue assets if the dist directory exists.
# Must be mounted before the SPA catch-all route below.
if DIST_DIR.exists():
app.mount("/assets", StaticFiles(directory=str(DIST_DIR / "assets")), name="assets")
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
@ -88,3 +101,15 @@ def diagnose(
def list_sources() -> dict: def list_sources() -> dict:
sources = _list_sources(DB_PATH) sources = _list_sources(DB_PATH)
return {"sources": sources} return {"sources": sources}
# SPA catch-all — must be last. Serves index.html for any path that doesn't
# match an API route, enabling Vue Router's client-side navigation.
@app.get("/{path:path}")
def spa_fallback(path: str) -> FileResponse:
if DIST_DIR.exists():
candidate = DIST_DIR / path
if candidate.is_file():
return FileResponse(str(candidate))
return FileResponse(str(DIST_DIR / "index.html"))
return FileResponse("/dev/null", status_code=503)

View file

@ -12,13 +12,11 @@ error() { echo -e "${RED}[turnstone]${NC} $*" >&2; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
API_PORT=8534 API_PORT=8534 # FastAPI: serves REST API + built Vue SPA
UI_PORT=8535 VITE_PORT=5174 # Vite HMR port in dev mode (proxies /api → 8534)
VITE_PORT=5174 # local HMR port in dev mode (proxies /api → 8534)
LOG_DIR="log" LOG_DIR="log"
API_PID_FILE=".turnstone-api.pid" API_PID_FILE=".turnstone-api.pid"
UI_PID_FILE=".turnstone-ui.pid"
DB="${TURNSTONE_DB:-${SCRIPT_DIR}/data/turnstone.db}" DB="${TURNSTONE_DB:-${SCRIPT_DIR}/data/turnstone.db}"
@ -71,11 +69,11 @@ usage() {
echo " Usage: ./manage.sh <command> [args]" echo " Usage: ./manage.sh <command> [args]"
echo "" echo ""
echo " Production-like (built SPA + uvicorn):" echo " Production-like (built SPA + uvicorn):"
echo -e " ${GREEN}start${NC} Build Vue SPA, start API (:${API_PORT}) + static UI (:${UI_PORT})" echo -e " ${GREEN}start${NC} Build Vue SPA, start FastAPI + SPA on :${API_PORT}"
echo -e " ${GREEN}stop${NC} Stop API and UI servers" echo -e " ${GREEN}stop${NC} Stop the server"
echo -e " ${GREEN}restart${NC} Stop then start" echo -e " ${GREEN}restart${NC} Stop then start"
echo -e " ${GREEN}status${NC} Show running processes" echo -e " ${GREEN}status${NC} Show running process"
echo -e " ${GREEN}logs [api|ui]${NC} Tail log files (default: api)" echo -e " ${GREEN}logs${NC} Tail server log"
echo -e " ${GREEN}open${NC} Open UI in browser" echo -e " ${GREEN}open${NC} Open UI in browser"
echo "" echo ""
echo " Development (hot-reload):" echo " Development (hot-reload):"
@ -108,7 +106,7 @@ case "$CMD" in
start) start)
if _is_alive "$API_PID_FILE"; then if _is_alive "$API_PID_FILE"; then
warn "API already running (PID $(<"$API_PID_FILE")) — use 'restart' to rebuild." warn "Already running (PID $(<"$API_PID_FILE")) — use 'restart' to rebuild."
exit 0 exit 0
fi fi
mkdir -p "$LOG_DIR" data mkdir -p "$LOG_DIR" data
@ -117,25 +115,17 @@ case "$CMD" in
(cd web && npm run build) 2>&1 | tee "${LOG_DIR}/build.log" | grep -E "built in|error" || true (cd web && npm run build) 2>&1 | tee "${LOG_DIR}/build.log" | grep -E "built in|error" || true
success "SPA built → web/dist/" success "SPA built → web/dist/"
info "Starting FastAPI on port ${API_PORT}" info "Starting on port ${API_PORT}"
TURNSTONE_DB="$DB" nohup "$PYTHON" -m uvicorn app.rest:app \ TURNSTONE_DB="$DB" nohup "$PYTHON" -m uvicorn app.rest:app \
--host 0.0.0.0 --port "$API_PORT" \ --host 0.0.0.0 --port "$API_PORT" \
>> "${LOG_DIR}/api.log" 2>&1 & >> "${LOG_DIR}/api.log" 2>&1 &
echo $! > "$API_PID_FILE" echo $! > "$API_PID_FILE"
_wait_for_port "$API_PORT" "FastAPI" "$API_PID_FILE" _wait_for_port "$API_PORT" "Turnstone" "$API_PID_FILE"
success "API → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))" success "Running → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))"
info "Starting static UI server on port ${UI_PORT}"
nohup "$PYTHON" -m http.server "$UI_PORT" --directory web/dist \
>> "${LOG_DIR}/ui.log" 2>&1 &
echo $! > "$UI_PID_FILE"
_wait_for_port "$UI_PORT" "UI server" "$UI_PID_FILE"
success "UI → http://localhost:${UI_PORT} (PID $(<"$UI_PID_FILE"))"
;; ;;
stop) stop)
_kill_pid_file "$API_PID_FILE" "FastAPI" _kill_pid_file "$API_PID_FILE" "Turnstone"
_kill_pid_file "$UI_PID_FILE" "UI server"
;; ;;
restart) restart)
@ -146,29 +136,19 @@ case "$CMD" in
status) status)
echo "" echo ""
if _is_alive "$API_PID_FILE"; then if _is_alive "$API_PID_FILE"; then
success "FastAPI RUNNING PID $(<"$API_PID_FILE") → http://localhost:${API_PORT}" success "Turnstone RUNNING PID $(<"$API_PID_FILE") → http://localhost:${API_PORT}"
else else
echo -e " FastAPI ${RED}STOPPED${NC}" echo -e " Turnstone ${RED}STOPPED${NC}"
fi
if _is_alive "$UI_PID_FILE"; then
success "UI server RUNNING PID $(<"$UI_PID_FILE") → http://localhost:${UI_PORT}"
else
echo -e " UI server ${RED}STOPPED${NC}"
fi fi
echo "" echo ""
;; ;;
logs) logs)
target="${1:-api}" tail -f "${LOG_DIR}/api.log"
case "$target" in
api) tail -f "${LOG_DIR}/api.log" ;;
ui) tail -f "${LOG_DIR}/ui.log" ;;
*) error "Unknown log target: $target. Use 'api' or 'ui'." ;;
esac
;; ;;
open) open)
URL="http://localhost:${UI_PORT}" URL="http://localhost:${API_PORT}"
info "Opening ${URL}" info "Opening ${URL}"
if command -v xdg-open &>/dev/null; then xdg-open "$URL" if command -v xdg-open &>/dev/null; then xdg-open "$URL"
elif command -v open &>/dev/null; then open "$URL" elif command -v open &>/dev/null; then open "$URL"