diff --git a/go.mod b/go.mod index 943aeda..3cff0c2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.5 require ( github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de github.com/alexedwards/scs/v2 v2.9.0 + github.com/andybalholm/brotli v1.2.2 github.com/brianvoe/gofakeit/v7 v7.15.0 github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc github.com/chromedp/chromedp v0.15.1 diff --git a/go.sum b/go.sum index 5035420..6c06e4d 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de h1:wNJVpr github.com/alexedwards/scs/pgxstore v0.0.0-20251002162104-209de6e426de/go.mod h1:hwveArYcjyOK66EViVgVU5Iqj7zyEsWjKXMQhDJrTLI= github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90= github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/brianvoe/gofakeit/v7 v7.15.0 h1:kGLYAWN8tnmxq2PelKVK6zwpM7kMxdz9SGPH31mFkNs= @@ -207,6 +209,8 @@ github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9R github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= diff --git a/internal/web/assets.go b/internal/web/assets.go index 286d554..f53666e 100644 --- a/internal/web/assets.go +++ b/internal/web/assets.go @@ -1,15 +1,37 @@ package web import ( + "bytes" + "compress/gzip" "crypto/sha256" "encoding/hex" "io/fs" + "mime" "net/http" - "net/url" "path" + "strconv" "strings" + + "github.com/andybalholm/brotli" ) +// staticAsset is one embedded static file, fingerprinted by content hash and +// pre-compressed. The assets are immutable (their URL changes when their bytes +// do), so we compress them once at startup with the best gzip/brotli levels and +// serve the pre-built bytes — no per-request compression CPU. +type staticAsset struct { + logical string // "ui.js" + hashedName string // "ui..js" + contentType string + raw []byte + gzip []byte // nil when compression didn't shrink the file + brotli []byte // nil when compression didn't shrink the file +} + +// negotiable reports whether the asset has any pre-compressed variant, i.e. +// whether its response varies on Accept-Encoding. +func (a *staticAsset) negotiable() bool { return a.gzip != nil || a.brotli != nil } + // assetManifest fingerprints the embedded static assets by content hash so their // URLs change whenever their bytes do. That lets us serve a fingerprinted URL // with a one-year immutable Cache-Control: the browser never revalidates, and a @@ -17,9 +39,8 @@ import ( // embedded FS carries a zero ModTime, so without this the assets have no // validators at all (no ETag/Last-Modified) and fall back to heuristic caching. type assetManifest struct { - byLogical map[string]string // "ui.js" -> "ui..js" (template lookups) - byHashed map[string]string // "ui..js" -> "ui.js" (request resolution) - fileServer http.Handler // serves the underlying (un-fingerprinted) files + byLogical map[string]*staticAsset // "ui.js" -> asset (template lookups, old links) + byHashed map[string]*staticAsset // "ui..js" -> asset (fingerprinted requests) } // assets is the process-wide manifest, built from the embedded static FS at @@ -33,9 +54,8 @@ func buildAssetManifest(fsys fs.FS, dir string) *assetManifest { panic("web: static sub FS: " + err.Error()) } m := &assetManifest{ - byLogical: map[string]string{}, - byHashed: map[string]string{}, - fileServer: http.FileServer(http.FS(sub)), + byLogical: map[string]*staticAsset{}, + byHashed: map[string]*staticAsset{}, } err = fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error { if err != nil { @@ -49,9 +69,9 @@ func buildAssetManifest(fsys fs.FS, dir string) *assetManifest { return err } sum := sha256.Sum256(data) - hashed := fingerprintName(p, hex.EncodeToString(sum[:])[:8]) - m.byLogical[p] = hashed - m.byHashed[hashed] = p + a := newStaticAsset(p, data, hex.EncodeToString(sum[:])[:8]) + m.byLogical[a.logical] = a + m.byHashed[a.hashedName] = a return nil }) if err != nil { @@ -60,6 +80,26 @@ func buildAssetManifest(fsys fs.FS, dir string) *assetManifest { return m } +func newStaticAsset(logical string, raw []byte, hash string) *staticAsset { + a := &staticAsset{ + logical: logical, + hashedName: fingerprintName(logical, hash), + contentType: assetContentType(logical, raw), + raw: raw, + } + // Only compress text-ish payloads, and only keep a variant when it actually + // shrinks the file (a tiny or already-dense asset can grow). + if compressibleType(a.contentType) { + if g := gzipBytes(raw); len(g) < len(raw) { + a.gzip = g + } + if b := brotliBytes(raw); len(b) < len(raw) { + a.brotli = b + } + } + return a +} + // fingerprintName inserts the content hash before the final extension, preserving // any directory prefix: "tabler.min.css" -> "tabler.min..css". func fingerprintName(p, hash string) string { @@ -68,40 +108,119 @@ func fingerprintName(p, hash string) string { return dir + strings.TrimSuffix(file, ext) + "." + hash + ext } +// assetContentType mirrors http.FileServer: extension first, sniff as fallback. +func assetContentType(name string, raw []byte) string { + if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { + return ct + } + return http.DetectContentType(raw) +} + +// compressibleType reports whether a content type benefits from text compression. +// Binary/already-compressed types (images, fonts, archives) are skipped. +func compressibleType(ct string) bool { + ct = strings.ToLower(ct) + if strings.HasPrefix(ct, "text/") { + return true + } + for _, tok := range []string{"javascript", "json", "xml", "svg", "css"} { + if strings.Contains(ct, tok) { + return true + } + } + return false +} + +func gzipBytes(raw []byte) []byte { + var buf bytes.Buffer + zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression) + _, _ = zw.Write(raw) + _ = zw.Close() + return buf.Bytes() +} + +func brotliBytes(raw []byte) []byte { + var buf bytes.Buffer + bw := brotli.NewWriterLevel(&buf, brotli.BestCompression) + _, _ = bw.Write(raw) + _ = bw.Close() + return buf.Bytes() +} + // URL maps a logical asset reference ("/static/ui.js") to its fingerprinted URL // ("/static/ui..js"). Unknown paths pass through unchanged so a stray // reference degrades to a working (un-immutable) URL rather than a 404; // TestTemplatesUseAssetHelper guards against introducing those. func (m *assetManifest) URL(logical string) string { name := strings.TrimPrefix(logical, "/static/") - if hashed, ok := m.byLogical[name]; ok { - return "/static/" + hashed + if a, ok := m.byLogical[name]; ok { + return "/static/" + a.hashedName } return logical } // serveHTTP serves a static asset. It expects the "/static/" prefix already -// stripped (see SharedRoutes). A recognized fingerprinted name is served with a -// one-year immutable Cache-Control after rewriting the request to the real file; -// any other name (an un-fingerprinted URL, an old link, a stale hash) is served -// as-is — a stale hash simply 404s through the file server. +// stripped (see SharedRoutes). A fingerprinted name is served with a one-year +// immutable Cache-Control; the logical name is served without it (an old link +// still works). Either way the best pre-compressed variant the client accepts is +// returned. Unknown names 404. Range requests are not supported — these assets +// are small scripts/styles that browsers fetch whole. func (m *assetManifest) serveHTTP(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/") - if real, ok := m.byHashed[name]; ok { - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") - r = cloneWithPath(r, "/"+real) + a, fingerprinted := m.byHashed[name] + if !fingerprinted { + a = m.byLogical[name] + } + if a == nil { + http.NotFound(w, r) + return + } + + h := w.Header() + h.Set("Content-Type", a.contentType) + if fingerprinted { + h.Set("Cache-Control", "public, max-age=31536000, immutable") + } + if a.negotiable() { + // Even when we end up serving identity, the response varies by encoding — + // shared caches must key on it. + h.Set("Vary", "Accept-Encoding") + } + + body := a.raw + ae := r.Header.Get("Accept-Encoding") + switch { + case a.brotli != nil && acceptsEncoding(ae, "br"): + h.Set("Content-Encoding", "br") + body = a.brotli + case a.gzip != nil && acceptsEncoding(ae, "gzip"): + h.Set("Content-Encoding", "gzip") + body = a.gzip + } + h.Set("Content-Length", strconv.Itoa(len(body))) + + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = w.Write(body) } - m.fileServer.ServeHTTP(w, r) } -// cloneWithPath returns a shallow copy of r with URL.Path rewritten, without -// mutating the caller's request (the URL is copied too). -func cloneWithPath(r *http.Request, p string) *http.Request { - r2 := new(http.Request) - *r2 = *r - u := new(url.URL) - *u = *r.URL - u.Path = p - r2.URL = u - return r2 +// acceptsEncoding reports whether an Accept-Encoding header accepts the given +// content coding, honoring an explicit "coding;q=0" refusal. +func acceptsEncoding(header, coding string) bool { + for _, part := range strings.Split(header, ",") { + fields := strings.Split(part, ";") + if !strings.EqualFold(strings.TrimSpace(fields[0]), coding) { + continue + } + for _, f := range fields[1:] { + if v, ok := strings.CutPrefix(strings.TrimSpace(f), "q="); ok { + if q, err := strconv.ParseFloat(v, 64); err == nil && q == 0 { + return false + } + } + } + return true + } + return false } diff --git a/internal/web/assets_test.go b/internal/web/assets_test.go index 956233c..a464252 100644 --- a/internal/web/assets_test.go +++ b/internal/web/assets_test.go @@ -1,12 +1,15 @@ package web import ( + "compress/gzip" + "io" "io/fs" "net/http" "net/http/httptest" "regexp" "testing" + "github.com/andybalholm/brotli" "github.com/go-chi/chi/v5" ) @@ -65,6 +68,119 @@ func TestStaticFingerprintedImmutable(t *testing.T) { } } +func TestStaticServesBrotli(t *testing.T) { + t.Parallel() + srv := staticRouter() + + want, err := fs.ReadFile(staticFS, "static/tabler.min.css") + if err != nil { + t.Fatalf("read embedded asset: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, assets.URL("/static/tabler.min.css"), nil) + req.Header.Set("Accept-Encoding", "gzip, deflate, br") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d, want 200", rec.Code) + } + if enc := rec.Header().Get("Content-Encoding"); enc != "br" { + t.Fatalf("Content-Encoding = %q, want br (preferred over gzip)", enc) + } + if v := rec.Header().Get("Vary"); v != "Accept-Encoding" { + t.Fatalf("Vary = %q, want Accept-Encoding", v) + } + if rec.Body.Len() >= len(want) { + t.Fatalf("brotli body %d not smaller than raw %d", rec.Body.Len(), len(want)) + } + got, err := io.ReadAll(brotli.NewReader(rec.Body)) + if err != nil { + t.Fatalf("brotli decode: %v", err) + } + if string(got) != string(want) { + t.Fatal("decoded brotli body does not match embedded file") + } +} + +func TestStaticServesGzipWhenBrotliUnwanted(t *testing.T) { + t.Parallel() + srv := staticRouter() + + want, err := fs.ReadFile(staticFS, "static/ui.js") + if err != nil { + t.Fatalf("read embedded asset: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, assets.URL("/static/ui.js"), nil) + req.Header.Set("Accept-Encoding", "gzip") // no br + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if enc := rec.Header().Get("Content-Encoding"); enc != "gzip" { + t.Fatalf("Content-Encoding = %q, want gzip", enc) + } + zr, err := gzip.NewReader(rec.Body) + if err != nil { + t.Fatalf("gzip reader: %v", err) + } + got, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("gzip decode: %v", err) + } + if string(got) != string(want) { + t.Fatal("decoded gzip body does not match embedded file") + } +} + +func TestStaticIdentityStillVaries(t *testing.T) { + t.Parallel() + srv := staticRouter() + + want, err := fs.ReadFile(staticFS, "static/ui.js") + if err != nil { + t.Fatalf("read embedded asset: %v", err) + } + + // No Accept-Encoding: serve raw, but still advertise that the resource varies + // so shared caches don't hand a compressed copy to a client that can't decode. + req := httptest.NewRequest(http.MethodGet, assets.URL("/static/ui.js"), nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if enc := rec.Header().Get("Content-Encoding"); enc != "" { + t.Fatalf("Content-Encoding = %q, want none", enc) + } + if v := rec.Header().Get("Vary"); v != "Accept-Encoding" { + t.Fatalf("Vary = %q, want Accept-Encoding", v) + } + if rec.Body.String() != string(want) { + t.Fatal("identity body does not match embedded file") + } +} + +func TestAcceptsEncoding(t *testing.T) { + t.Parallel() + cases := []struct { + header, coding string + want bool + }{ + {"gzip, deflate, br", "br", true}, + {"gzip, deflate, br", "gzip", true}, + {"gzip", "br", false}, + {"", "gzip", false}, + {"br;q=0", "br", false}, // explicit refusal + {"gzip;q=0.5", "gzip", true}, // low but nonzero + {"identity", "gzip", false}, + {"GZIP", "gzip", true}, // case-insensitive token + } + for _, c := range cases { + if got := acceptsEncoding(c.header, c.coding); got != c.want { + t.Errorf("acceptsEncoding(%q, %q) = %v, want %v", c.header, c.coding, got, c.want) + } + } +} + func TestStaticStaleHash404(t *testing.T) { t.Parallel() srv := staticRouter()