Skip to content

Commit a0ba71d

Browse files
committed
nginx: only serve the SPA shell to browser navigation
The SPA fallback answered every unmatched path with index.html and a 200, including missing assets and mistyped fetch() paths — a "not found" then surfaces as a parse error somewhere downstream instead of a 404. Restrict the fallback to GET/HEAD requests that accept HTML, which is what browsers send when navigating; everything else keeps its 404. This is the rule FastAPI's app.frontend() applies, so a stack that later serves the build from FastAPI behaves the same way. The Docker job now asserts both halves of the rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011texLkDBELWbXsBf6San3M
1 parent 60dc6f0 commit a0ba71d

3 files changed

Lines changed: 53 additions & 4 deletions

File tree

.github/workflows/docker.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ jobs:
2525
- name: "Frontend served"
2626
run: curl -sf http://localhost:8080/ | grep -q "<title>React Template</title>"
2727

28+
# Locks in the fallback rule from frontend/docker/nginx.conf: a browser
29+
# navigating to a client-side route gets the shell, a missing asset does
30+
# not (a 200 there hides the 404 behind a parse error downstream).
31+
- name: "SPA fallback: client routes get index.html, missing assets 404"
32+
run: |
33+
curl -sf -H 'Accept: text/html' http://localhost:8080/about \
34+
| grep -q "<title>React Template</title>"
35+
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/assets/missing.js)
36+
echo "missing asset -> $code"
37+
test "$code" = 404
38+
2839
# One request is enough to prove the wiring: reaching /health under /api
2940
# means the image runs and nginx strips the prefix. The endpoints
3041
# themselves are the Contract workflow's job.

frontend/docker/nginx.compose.conf

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,27 @@ server {
2929
proxy_set_header X-Forwarded-Proto $scheme;
3030
}
3131

32-
# SPA fallback: let React Router handle any unknown path.
32+
# SPA fallback: unknown paths belong to React Router, so browser
33+
# navigation gets index.html. Everything else keeps its 404 — answering a
34+
# missing asset or a mistyped fetch() path with the HTML shell and a 200
35+
# turns a clear "not found" into a confusing parse error further down.
36+
# Same rule FastAPI's app.frontend() applies: only GET/HEAD requests that
37+
# ask for HTML get the shell, which is what a browser sends when
38+
# navigating (so `curl /some/route` with no Accept header 404s by design).
3339
location / {
34-
try_files $uri $uri/ /index.html;
40+
try_files $uri $uri/ @spa;
41+
}
42+
43+
location @spa {
44+
if ($request_method !~ ^(GET|HEAD)$) {
45+
return 404;
46+
}
47+
if ($http_accept !~* "text/html|application/xhtml\+xml") {
48+
return 404;
49+
}
50+
# Served straight from this location, so repeat the no-cache header
51+
# that `location = /index.html` above sets for the direct request.
52+
add_header Cache-Control "no-cache";
53+
try_files /index.html =404;
3554
}
3655
}

frontend/docker/nginx.conf

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,27 @@ server {
2929
# proxy_set_header X-Forwarded-Proto $scheme;
3030
# }
3131

32-
# SPA fallback: let React Router handle any unknown path.
32+
# SPA fallback: unknown paths belong to React Router, so browser
33+
# navigation gets index.html. Everything else keeps its 404 — answering a
34+
# missing asset or a mistyped fetch() path with the HTML shell and a 200
35+
# turns a clear "not found" into a confusing parse error further down.
36+
# Same rule FastAPI's app.frontend() applies: only GET/HEAD requests that
37+
# ask for HTML get the shell, which is what a browser sends when
38+
# navigating (so `curl /some/route` with no Accept header 404s by design).
3339
location / {
34-
try_files $uri $uri/ /index.html;
40+
try_files $uri $uri/ @spa;
41+
}
42+
43+
location @spa {
44+
if ($request_method !~ ^(GET|HEAD)$) {
45+
return 404;
46+
}
47+
if ($http_accept !~* "text/html|application/xhtml\+xml") {
48+
return 404;
49+
}
50+
# Served straight from this location, so repeat the no-cache header
51+
# that `location = /index.html` above sets for the direct request.
52+
add_header Cache-Control "no-cache";
53+
try_files /index.html =404;
3554
}
3655
}

0 commit comments

Comments
 (0)