Files
static/assets_test.go
zeekayandHanzo Dev 7375cb3aca a hashed asset is immutable, and a missing one is a 404
Two defects that compound into the ChunkLoadError on every site this serves.

An asset under /_next/static is named by a hash OF its bytes, so the file at that
URL can never change — a different build writes a different name. It was going
out with max-age=86400 and no immutable, so clients revalidated it every few
hours. That request can only ever learn "unchanged", and if it lands after a
deploy retired the hash it learns "gone" instead, turning a warm cache into a
failure. It is now public, max-age=31536000, immutable, ahead of the
CacheControl override: for content-addressed bytes this is not a policy question,
so it is not the override's to answer. Documents are untouched and must stay
that way — the document is what points at the new hashes.

The second half is what made that fatal rather than merely wasteful. SPA mode
answered EVERY unmatched path with index.html, including
/_next/static/chunks/8846-<hash>.js. So a stale chunk request got 200 and an
HTML body, the browser handed HTML to a JavaScript parser, and it died there
("Loading chunk 8846 failed") instead of taking a clean, retryable 404. The
fallback exists for ROUTES; an asset is not a route. A miss under a
content-addressed prefix, or with an asset extension, is now a bare 404 — ahead
of the custom error page too, since branded HTML is no better an answer to a
request for a stylesheet, only a more expensive one.

Cache policy now keys on the path of the file actually SERVED rather than the
path requested. The SPA fallback answers a route with index.html, so keying on
the request would have let a retired chunk URL look content-addressed and freeze
the app shell there for a year — this bug, reintroduced through the back door.

Routes are unchanged: an extensionless miss still reaches the shell with 200,
and a route with a dot in it is still a route. ".html" and ".txt" are
deliberately not asset extensions.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 13:57:13 -07:00

211 lines
6.2 KiB
Go

package static_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
static "github.com/hanzoai/static"
)
// hashedSite lays out a static export the way Next emits one: an app shell at
// the root and content-addressed assets under /_next/static.
func hashedSite(t *testing.T) string {
t.Helper()
dir := t.TempDir()
write := func(rel, body string) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("index.html", "<html><body>app shell</body></html>")
write("_next/static/chunks/webpack-cc208340efd468cd.js", "console.log(1)")
write("_next/static/css/8b1a9953c4611296.css", "body{}")
return dir
}
func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://localhost"+target, nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// A content-addressed asset is immutable.
//
// The bytes under /_next/static are named by a hash OF those bytes, so the file
// at a given URL can never change: a different build writes a different name.
// Revalidating it is therefore pure cost and pure risk — the request buys no new
// information, and if it lands after a deploy retired the old hash it turns a
// warm cache into a hard failure. `immutable` is the header that says so.
func TestContentHashedAssetIsImmutable(t *testing.T) {
t.Parallel()
cfg := static.CreateConfig()
cfg.Root = hashedSite(t)
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
for _, asset := range []string{
"/_next/static/chunks/webpack-cc208340efd468cd.js",
"/_next/static/css/8b1a9953c4611296.css",
} {
rec := get(t, handler, asset)
if rec.Code != http.StatusOK {
t.Fatalf("%s: expected 200, got %d", asset, rec.Code)
}
got := rec.Header().Get("Cache-Control")
if !strings.Contains(got, "immutable") {
t.Errorf("%s: expected an immutable Cache-Control, got %q", asset, got)
}
if !strings.Contains(got, "max-age=31536000") {
t.Errorf("%s: expected max-age=31536000, got %q", asset, got)
}
}
}
// The override configures the mutable files. It does not get to make a
// content-addressed asset revalidate, because for those the answer is not a
// matter of policy — the name is a hash of the bytes, so a stale hit is
// impossible and a revalidation is waste. Documented in the projects store as
// "content-hashed assets are always immutable regardless of this override".
func TestContentHashedAssetIsImmutableDespiteOverride(t *testing.T) {
t.Parallel()
cfg := static.CreateConfig()
cfg.Root = hashedSite(t)
cfg.CacheControl = map[string]string{
".js": "max-age=600",
"*": "max-age=60",
}
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
rec := get(t, handler, "/_next/static/chunks/webpack-cc208340efd468cd.js")
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Errorf("expected the override to be ignored for a hashed asset, got %q", got)
}
}
// The document is the thing that has to change: it is what points at the new
// hashes, so freezing it would strand every client on the previous build. Only
// the hashed assets are immutable, never the shell.
func TestDocumentIsNotImmutable(t *testing.T) {
t.Parallel()
cfg := static.CreateConfig()
cfg.Root = hashedSite(t)
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
for _, doc := range []string{"/", "/index.html"} {
rec := get(t, handler, doc)
if got := rec.Header().Get("Cache-Control"); strings.Contains(got, "immutable") {
t.Errorf("%s: the document must not be immutable, got %q", doc, got)
}
}
}
// A missing asset is a 404, not the app shell.
//
// This is the ChunkLoadError. The SPA fallback exists so an unmatched ROUTE
// reaches the client-side router; an asset is not a route. When a stale document
// asks for a chunk the current build no longer has, answering with index.html
// hands the browser HTML where it demanded JavaScript, and it dies in the parser
// with "Loading chunk N failed" instead of taking the clean, retryable 404.
func TestSPAFallbackDoesNotSwallowMissingAsset(t *testing.T) {
t.Parallel()
cfg := static.CreateConfig()
cfg.Root = hashedSite(t)
cfg.SPAMode = true
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
// Under the content-addressed prefix, and a bare asset extension elsewhere.
for _, miss := range []string{
"/_next/static/chunks/9107-e634f55e46e597be.js",
"/_next/static/css/deadbeefdeadbeef.css",
"/assets/app-deadbeef.js",
"/fonts/inter-c0ffee.woff2",
} {
rec := get(t, handler, miss)
if rec.Code != http.StatusNotFound {
t.Errorf("%s: expected 404, got %d", miss, rec.Code)
}
body := rec.Body.String()
if strings.Contains(strings.ToLower(body), "<html") {
t.Errorf("%s: answered with an HTML body: %.80q", miss, body)
}
if ct := rec.Header().Get("Content-Type"); strings.Contains(ct, "text/html") {
t.Errorf("%s: answered with Content-Type %q", miss, ct)
}
if len(body) > 1024 {
t.Errorf("%s: expected a tiny body, got %d bytes", miss, len(body))
}
}
}
// The fallback still does its job for real routes — including a route with a dot
// in it, which is a path segment and not a file extension.
func TestSPAFallbackStillServesRoutes(t *testing.T) {
t.Parallel()
cfg := static.CreateConfig()
cfg.Root = hashedSite(t)
cfg.SPAMode = true
handler, err := static.New(context.Background(), next(t), cfg, "static")
if err != nil {
t.Fatal(err)
}
for _, route := range []string{"/models/meta-llama", "/llm", "/referral", "/users/jane.doe"} {
rec := get(t, handler, route)
if rec.Code != http.StatusOK {
t.Errorf("%s: expected the shell with 200, got %d", route, rec.Code)
}
if !strings.Contains(rec.Body.String(), "app shell") {
t.Errorf("%s: expected the app shell, got %.80q", route, rec.Body.String())
}
}
}