Skip to content

Commit cf584e3

Browse files
feat: decouple frontend into its own static service
- web reads API base from window.SPECTRARAG_API_BASE (config.js), default same-origin so local + combined deploys are unchanged - FastAPI CORS from RAG_CORS_ORIGINS, off when unset - Dockerfile.web + nginx.web.conf: nginx image serving web/, API URL injected via --build-arg (not committed) - ADR 0030
1 parent 746b2e4 commit cf584e3

9 files changed

Lines changed: 108 additions & 9 deletions

File tree

Dockerfile.web

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Frontend service: a featherweight nginx image serving the static SPA (web/),
2+
# decoupled from the heavy ML API image. UI changes redeploy in ~minutes (this
3+
# builds in seconds) instead of rebuilding the multi-GB backend.
4+
#
5+
# The API URL is injected at build time (--build-arg API_BASE=https://…) and
6+
# written into app/config.js, so the prod endpoint stays out of the repo. With
7+
# no API_BASE the committed config.js (empty → same-origin) is used as-is.
8+
FROM nginx:1.27-alpine
9+
COPY web/ /usr/share/nginx/html/
10+
COPY nginx.web.conf /etc/nginx/conf.d/default.conf
11+
ARG API_BASE=""
12+
RUN if [ -n "$API_BASE" ]; then \
13+
printf 'window.SPECTRARAG_API_BASE=%s;\n' "\"$API_BASE\"" \
14+
> /usr/share/nginx/html/app/config.js; \
15+
fi
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# ADR 0030: Split the frontend off the backend image
2+
3+
Status: accepted
4+
5+
## Context
6+
7+
The SPA (`web/`) was baked into the FastAPI image and served by it. That image
8+
is ~8 GB (torch, docling, the bge-m3 model, page renders, the embedded Qdrant
9+
index), so a one-line change to a static `.jsx` file forced a full image
10+
rebuild + push — roughly an hour — even though the frontend is plain static
11+
files with no build step (Babel runs in the browser).
12+
13+
## Decision
14+
15+
Serve the frontend as its own Cloud Run service, separate from the API.
16+
17+
- **`spectrarag-web`** — a tiny nginx image (`Dockerfile.web`) that serves
18+
`web/`. Builds in seconds; redeploys in ~minutes.
19+
- **`spectrarag`** (API) — unchanged; rebuilds only when code or deps change.
20+
- The SPA reads its API base from `window.SPECTRARAG_API_BASE`
21+
(`web/app/config.js`), defaulting to same-origin so local dev and the
22+
combined deploy keep working untouched. The prod API URL is injected at
23+
build time (`--build-arg API_BASE=…`), not committed.
24+
- The API adds CORS (`CORSMiddleware`, origins from `RAG_CORS_ORIGINS`),
25+
enabled only when that env var is set — so same-origin deploys add no CORS.
26+
27+
The deploy specifics (URLs, the build/deploy commands, the CORS origin value)
28+
are local-only, like the visual-overlay build; this repo keeps the generic
29+
structure, not the private endpoints.
30+
31+
## Consequences
32+
33+
- Frontend iterations stop touching the ML pipeline — UI deploys in minutes.
34+
- Two services to operate instead of one (the frontend one is trivial/static).
35+
- Cross-origin is the one gotcha: the API now needs CORS for the web origin;
36+
page images load fine cross-origin via `<img>`.

docs/decisions/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ kept on purpose.
3333
- [0027](./0027-keyless-demo-chat.md) — Keyless demo chat through a caged server-side OpenRouter key
3434
- [0028](./0028-persisted-visual-index-cpu-serve.md) — Persisted ColQwen2 page index for CPU serving
3535
- [0029](./0029-runtime-document-upload.md) — Runtime document upload (flag-gated, text-leg, incremental)
36+
- [0030](./0030-frontend-backend-split.md) — Split the frontend off the backend image

nginx.web.conf

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
server {
2+
listen 8080;
3+
server_name _;
4+
root /usr/share/nginx/html;
5+
index index.html;
6+
7+
# Build-less SPA: routing is client-side (hash), so any unknown path falls
8+
# back to index.html. no-cache forces revalidation so a redeploy of the
9+
# static files shows immediately (cheap — they're small text assets).
10+
location / {
11+
try_files $uri $uri/ /index.html;
12+
add_header Cache-Control "no-cache";
13+
}
14+
}

src/api/main.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
121121
api_key = settings.public_api_key.get_secret_value() if settings.public_api_key else None
122122
app.middleware("http")(make_api_key_middleware(api_key))
123123

124+
# CORS only when the frontend is served from a separate origin (decoupled
125+
# deploy): origins from RAG_CORS_ORIGINS (comma-separated), empty = same-
126+
# origin/no CORS. Added last so it sits outermost and answers the preflight
127+
# OPTIONS before the auth middleware can short-circuit it.
128+
cors_origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
129+
if cors_origins:
130+
from fastapi.middleware.cors import CORSMiddleware
131+
132+
app.add_middleware(
133+
CORSMiddleware,
134+
allow_origins=cors_origins,
135+
allow_methods=["GET", "POST", "OPTIONS"],
136+
allow_headers=["*"],
137+
)
138+
124139
app.include_router(health.router)
125140
app.include_router(query.router)
126141
app.include_router(ingest.router)

src/config/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ class Settings(BaseSettings):
4343
# can't be hit by drive-by traffic. Health + OpenAPI metadata routes stay
4444
# exempt.
4545
public_api_key: SecretStr | None = None
46+
# Comma-separated allowed origins for CORS, set when the frontend is served
47+
# from a separate host (decoupled deploy). Empty = same-origin only, no CORS.
48+
cors_origins: str = ""
4649
# ADR 0027: caged OpenRouter key for the keyless demo path (/demo/chat).
4750
# Deliberately separate from `openrouter_api_key` so it can carry a hard
4851
# provider-side credit limit and be rotated or killed without touching

web/app/api.js

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
These helpers return data; the components own the rendering. */
1010
(function () {
1111
const ORIGIN = window.location.origin;
12+
// API base: same-origin by default (local dev + the monolith deploy). When the
13+
// frontend is hosted separately, the page sets window.SPECTRARAG_API_BASE to the
14+
// API service URL (via a local-only config.js) and every call routes there.
15+
const API = window.SPECTRARAG_API_BASE || ORIGIN;
1216

1317
// The real, supported model slate (mirrors the prior chat's <select>).
1418
// The ":free" entries are the demo chain's models, selectable here so a
@@ -38,7 +42,7 @@
3842
}
3943

4044
function pageImageUrl(paperId, page) {
41-
return `${ORIGIN}/pages/${encodeURIComponent(paperId)}/${encodeURIComponent(paperId)}_p${page}.png`;
45+
return `${API}/pages/${encodeURIComponent(paperId)}/${encodeURIComponent(paperId)}_p${page}.png`;
4246
}
4347

4448
// Pre-rendered figure/table thumbnail (scripts/render_figure_thumbs.py). The
@@ -47,12 +51,12 @@
4751
// full-page CSS-crop when a thumb is absent (e.g. a freshly uploaded paper).
4852
function figThumbUrl(paperId, chunkId) {
4953
const safe = chunkId.replace(/:/g, "_");
50-
return `${ORIGIN}/pages/${encodeURIComponent(paperId)}/thumbs/${encodeURIComponent(safe)}.webp`;
54+
return `${API}/pages/${encodeURIComponent(paperId)}/thumbs/${encodeURIComponent(safe)}.webp`;
5155
}
5256

5357
async function loadPapers() {
5458
try {
55-
const r = await fetch("/papers");
59+
const r = await fetch(`${API}/papers`);
5660
return r.ok ? await r.json() : [];
5761
} catch {
5862
return [];
@@ -61,7 +65,7 @@
6165

6266
async function loadHealth() {
6367
try {
64-
const r = await fetch("/health");
68+
const r = await fetch(`${API}/health`);
6569
return await r.json();
6670
} catch {
6771
return {};
@@ -72,7 +76,7 @@
7276
// docling role/label. Used by the Figures gallery and the corpus counts.
7377
async function loadFigures(limit = 1000) {
7478
try {
75-
const r = await fetch(`/figures?limit=${limit}`);
79+
const r = await fetch(`${API}/figures?limit=${limit}`);
7680
return r.ok ? await r.json() : [];
7781
} catch {
7882
return [];
@@ -105,7 +109,7 @@
105109
if (!apiKey) {
106110
throw new Error("Agentic search runs server-side and needs your OpenRouter key.");
107111
}
108-
const res = await fetch("/query/dci", {
112+
const res = await fetch(`${API}/query/dci`, {
109113
method: "POST",
110114
headers: { "Content-Type": "application/json", "X-OpenRouter-Key": apiKey },
111115
body: JSON.stringify(body),
@@ -124,7 +128,7 @@
124128
// but say which one is happening; give the permanent case a short budget.
125129
const start = performance.now();
126130
while (true) {
127-
const res = await fetch("/query", {
131+
const res = await fetch(`${API}/query`, {
128132
method: "POST",
129133
headers: { "Content-Type": "application/json" },
130134
body: JSON.stringify(body),
@@ -444,7 +448,7 @@
444448
// server-side — the browser only sends messages. A 429 means the shared
445449
// demo quota ran out; callers surface the bring-your-own-key prompt.
446450
async function streamDemoChat(messages, onDelta) {
447-
const res = await fetch("/demo/chat", {
451+
const res = await fetch(`${API}/demo/chat`, {
448452
method: "POST",
449453
headers: { "Content-Type": "application/json" },
450454
body: JSON.stringify({ messages }),
@@ -507,7 +511,7 @@
507511
async function ingestPdf(file) {
508512
const form = new FormData();
509513
form.append("file", file);
510-
const res = await fetch("/ingest", { method: "POST", body: form });
514+
const res = await fetch(`${API}/ingest`, { method: "POST", body: form });
511515
if (!res.ok) {
512516
let detail = await res.text();
513517
try { detail = JSON.parse(detail).detail || detail; } catch { /* keep raw text */ }

web/app/config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* Frontend → API base URL.
2+
3+
Empty string means "same origin" — the default for local dev and the combined
4+
(single-service) deploy, so api.js falls back to window.location.origin.
5+
6+
When the frontend is served from its own host (decoupled deploy), the build
7+
overwrites this file with the API service URL — see Dockerfile.web's
8+
`--build-arg API_BASE=...`. The URL itself is supplied at build time and is
9+
not committed. */
10+
window.SPECTRARAG_API_BASE = "";

web/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
1919
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
2020

21+
<script src="app/config.js"></script>
2122
<script src="app/api.js"></script>
2223
<script type="text/babel" src="app/shared.jsx"></script>
2324
<script type="text/babel" src="app/tweaks-panel.jsx"></script>

0 commit comments

Comments
 (0)