Pre-compress static assets

This commit is contained in:
Jonathon Leight committed 2026-07-11 22:12:05 -04:00
1 parent 19bf9372c2
commit 3d9e0ff9a4
4 files changed
+270 -30

No files matched your search

+1
View File
@@ -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
+4
View File
@@ -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=
+149 -30
View File
@@ -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.<hash>.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.<hash>.js" (template lookups)
byHashed map[string]string // "ui.<hash>.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.<hash>.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.<hash>.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.<hash>.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
}
+116
View File
@@ -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()