From bcc78e733295692c2e2d10214c21bd462be6655e Mon Sep 17 00:00:00 2001 From: rxwatcher Date: Mon, 20 Jul 2026 13:37:19 +0200 Subject: [PATCH] fix(server): return 404 for missing content-hashed assets A /assets/ chunk from a previous build no longer exists after a deploy; serving the SPA shell at that URL makes the browser fail dynamic imports on a text/html module. Exclude /assets/ from the SPA fallback so the miss surfaces as a 404 the client can react to. Co-Authored-By: Claude Fable 5 --- internal/server/frontend.go | 9 +++++++++ internal/server/frontend_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/internal/server/frontend.go b/internal/server/frontend.go index cdb28eda..45090a96 100644 --- a/internal/server/frontend.go +++ b/internal/server/frontend.go @@ -156,6 +156,15 @@ func (h *frontendHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // A missing /assets/ file is a content-hashed chunk from another build, + // not an app route: answering with the shell makes dynamic imports fail + // on a text/html module. A 404 surfaces the real condition so the client + // preload-error handler can reload onto the current build. + if strings.HasPrefix(path, "/assets/") { + http.NotFound(w, r) + return + } + // SPA fallback: serve the (branded) index.html shell. shell, ok := h.brandedShell(r) if !ok { diff --git a/internal/server/frontend_test.go b/internal/server/frontend_test.go index 05520ddc..98d39c2a 100644 --- a/internal/server/frontend_test.go +++ b/internal/server/frontend_test.go @@ -79,6 +79,31 @@ func TestFrontendHandlerServesStaticAssetsWithoutCSP(t *testing.T) { } } +func TestFrontendHandlerReturns404ForMissingAssets(t *testing.T) { + handler := newFrontendTestHandler(t) + + // A content-hashed chunk from a previous build no longer exists after a + // deploy. Serving the SPA shell at a .js URL makes the browser fail with + // "Failed to fetch dynamically imported module"; a 404 lets clients (and + // the preload-error reload handler) see the real condition. + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/assets/view-OldHash.js", nil)) + + if rr.Code != http.StatusNotFound { + t.Fatalf("missing asset status = %d, want 404", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); strings.Contains(ct, "text/html") { + t.Fatalf("missing asset served as HTML (%q), the SPA fallback must not swallow /assets/", ct) + } + + // Non-asset app routes still get the shell. + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/library/ebooks", nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Header().Get("Content-Type"), "text/html") { + t.Fatalf("SPA route = %d %q, want 200 HTML", rr.Code, rr.Header().Get("Content-Type")) + } +} + func newFrontendTestHandler(t *testing.T) http.Handler { t.Helper() prev := WebDistFS