mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 05:32:05 +00:00
Pre-compress static assets
This commit is contained in:
1 parent
19bf9372c2
commit
3d9e0ff9a4
4 files changed
+270
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user