diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bae5ca5..d44abe7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -25,6 +25,17 @@ jobs: - name: "Frontend served" run: curl -sf http://localhost:8080/ | grep -q "React Template" + # Locks in the fallback rule from frontend/docker/nginx.conf: a browser + # navigating to a client-side route gets the shell, a missing asset does + # not (a 200 there hides the 404 behind a parse error downstream). + - name: "SPA fallback: client routes get index.html, missing assets 404" + run: | + curl -sf -H 'Accept: text/html' http://localhost:8080/about \ + | grep -q "React Template" + code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/assets/missing.js) + echo "missing asset -> $code" + test "$code" = 404 + # One request is enough to prove the wiring: reaching /health under /api # means the image runs and nginx strips the prefix. The endpoints # themselves are the Contract workflow's job. diff --git a/frontend/docker/nginx.compose.conf b/frontend/docker/nginx.compose.conf index e5af44f..5793817 100644 --- a/frontend/docker/nginx.compose.conf +++ b/frontend/docker/nginx.compose.conf @@ -29,8 +29,27 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # SPA fallback: let React Router handle any unknown path. + # SPA fallback: unknown paths belong to React Router, so browser + # navigation gets index.html. Everything else keeps its 404 — answering a + # missing asset or a mistyped fetch() path with the HTML shell and a 200 + # turns a clear "not found" into a confusing parse error further down. + # Same rule FastAPI's app.frontend() applies: only GET/HEAD requests that + # ask for HTML get the shell, which is what a browser sends when + # navigating (so `curl /some/route` with no Accept header 404s by design). location / { - try_files $uri $uri/ /index.html; + try_files $uri $uri/ @spa; + } + + location @spa { + if ($request_method !~ ^(GET|HEAD)$) { + return 404; + } + if ($http_accept !~* "text/html|application/xhtml\+xml") { + return 404; + } + # Served straight from this location, so repeat the no-cache header + # that `location = /index.html` above sets for the direct request. + add_header Cache-Control "no-cache"; + try_files /index.html =404; } } diff --git a/frontend/docker/nginx.conf b/frontend/docker/nginx.conf index c06bbda..5575426 100644 --- a/frontend/docker/nginx.conf +++ b/frontend/docker/nginx.conf @@ -29,8 +29,27 @@ server { # proxy_set_header X-Forwarded-Proto $scheme; # } - # SPA fallback: let React Router handle any unknown path. + # SPA fallback: unknown paths belong to React Router, so browser + # navigation gets index.html. Everything else keeps its 404 — answering a + # missing asset or a mistyped fetch() path with the HTML shell and a 200 + # turns a clear "not found" into a confusing parse error further down. + # Same rule FastAPI's app.frontend() applies: only GET/HEAD requests that + # ask for HTML get the shell, which is what a browser sends when + # navigating (so `curl /some/route` with no Accept header 404s by design). location / { - try_files $uri $uri/ /index.html; + try_files $uri $uri/ @spa; + } + + location @spa { + if ($request_method !~ ^(GET|HEAD)$) { + return 404; + } + if ($http_accept !~* "text/html|application/xhtml\+xml") { + return 404; + } + # Served straight from this location, so repeat the no-cache header + # that `location = /index.html` above sets for the direct request. + add_header Cache-Control "no-cache"; + try_files /index.html =404; } }