Files
datascape/static.go
T

136 lines
4.2 KiB
Go

package main
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
"time"
)
// assetVersions maps each embedded asset (path relative to assets/, e.g.
// "page/toc.js") to a short hash of its content. Templates reference assets
// through assetURL, which appends the hash as ?v=…, so a versioned URL names
// one exact file content and can be cached forever; a rebuilt asset gets a
// new URL.
var assetVersions = hashAssets()
func hashAssets() map[string]string {
versions := map[string]string{}
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := assets.ReadFile(p)
if err != nil {
return err
}
sum := sha256.Sum256(b)
versions[strings.TrimPrefix(p, "assets/")] = hex.EncodeToString(sum[:])[:12]
return nil
})
return versions
}
// assetGzip holds a gzip copy of every embedded asset that compresses well,
// keyed like assetVersions. Built once at startup so serving compressed costs
// no CPU per request; already-compressed files (fonts) are left out.
var assetGzip = gzipAssets()
func gzipAssets() map[string][]byte {
out := map[string][]byte{}
_ = fs.WalkDir(assets, "assets", func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := assets.ReadFile(p)
if err != nil {
return err
}
var buf bytes.Buffer
zw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression)
zw.Write(b)
zw.Close()
// Not worth a Content-Encoding round trip unless it saves ≥10%.
if buf.Len() < len(b)*9/10 {
out[strings.TrimPrefix(p, "assets/")] = buf.Bytes()
}
return nil
})
return out
}
// acceptsGzip reports whether the client accepts a gzip response body.
func acceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
enc, q, _ := strings.Cut(strings.TrimSpace(enc), ";")
if strings.TrimSpace(enc) == "gzip" && strings.ReplaceAll(q, " ", "") != "q=0" {
return true
}
}
return false
}
// assetURL is the "asset" template func: {{asset "style.css"}} renders
// /_/style.css?v=<hash>. An unknown name fails the template so a typo or a
// removed file surfaces as a render error instead of a silent 404.
func assetURL(name string) (string, error) {
v, ok := assetVersions[name]
if !ok {
return "", fmt.Errorf("unknown asset %q", name)
}
return "/_/" + name + "?v=" + v, nil
}
// assetFuncs is for the standalone fragment templates that do not take the
// full tmplFuncs.
var assetFuncs = template.FuncMap{"asset": assetURL}
// serveStatic serves the embedded assets under /_/. Embedded files carry no
// modtime, so http.FileServer alone sends no validators and browsers refetch
// every asset on every page. Instead:
// - a URL carrying the current ?v= hash is immutable;
// - fonts are immutable too (referenced unversioned from style.css, never
// change);
// - anything else (unversioned or stale ?v=, e.g. icons from CSS) gets the
// content hash as ETag and revalidates, answered with 304 when unchanged.
//
// Assets with a precompressed copy (see assetGzip) are sent gzipped to
// clients that accept it; the gzip variant gets its own ETag.
func serveStatic() http.Handler {
staticFS, _ := fs.Sub(assets, "assets")
static := http.StripPrefix("/_/", http.FileServer(http.FS(staticFS)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/_/")
v, ok := assetVersions[name]
gz, hasGz := assetGzip[name]
useGz := hasGz && acceptsGzip(r)
if hasGz {
w.Header().Set("Vary", "Accept-Encoding")
}
switch {
case ok && r.URL.Query().Get("v") == v, strings.HasPrefix(name, "fonts/"):
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case ok:
w.Header().Set("Cache-Control", "no-cache")
if useGz {
w.Header().Set("ETag", `"`+v+`-gz"`)
} else {
w.Header().Set("ETag", `"`+v+`"`)
}
}
if useGz {
w.Header().Set("Content-Encoding", "gzip")
// ServeContent derives Content-Type from the name's extension.
http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(gz))
return
}
static.ServeHTTP(w, r)
})
}