perf(server): ETag + max-age=0 on embedded HTML responses

The apps subsystem previously sent Cache-Control: public, max-age=300|3600,
must-revalidate but no ETag. With must-revalidate and no validator, the
browser cannot return 304 — it has to refetch the full body once max-age
expires. For mdedit that's 920 KB on every reload after an hour.

Add a content-addressed ETag (sha256 hex prefix, 32 chars) to:
- apps/handler.go's serveBody + serveEmbedded (both paths now emit ETag
  + handle If-None-Match short-circuit to 304)
- handler/directory.go's embedded:browse fallback (mirror behavior so
  the bare-directory landing serves the same way)

Drop max-age to 0 with must-revalidate: every page load revalidates,
but a matching ETag returns 304 with empty body. Steady-state cost of
a reload drops from N KB to a few hundred bytes. When the binary is
redeployed, the ETag changes (content hash) and the next request
returns 200 with the new bytes.

Tests in apps/handler_test.go cover both paths:
- TestServer_Embedded_ConditionalGET: full GET, matching INM, stale INM
- TestEmbeddedETag_Stable: same bytes → same ETag, different → different

Live smoke (curl against zddc-server -root /tmp/empty):
  GET /            → 200, ETag set, body = 80919 bytes (landing.html)
  GET / + INM:tag  → 304 Not Modified, empty body
This commit is contained in:
ZDDC 2026-05-03 23:28:18 -05:00
parent cc4ae3f0c4
commit ed7a7fc9c0
4 changed files with 168 additions and 21 deletions

View file

@ -1,6 +1,11 @@
package apps package apps
import _ "embed" import (
"crypto/sha256"
"encoding/hex"
_ "embed"
"sync"
)
// Embedded fallback: the five tool HTMLs from the time the binary was // Embedded fallback: the five tool HTMLs from the time the binary was
// built. Used as a last-resort served-bytes when (cache miss) AND // built. Used as a last-resort served-bytes when (cache miss) AND
@ -56,3 +61,29 @@ func EmbeddedBytes(app string) []byte {
} }
return b return b
} }
// EmbeddedETag returns a strong ETag (sha256-hex prefix, 32 chars) for the
// app's embedded bytes. Computed lazily on first call per-app and memoized
// — the embedded slot is fixed for the binary's lifetime, so the ETag
// changes only when the binary is redeployed. Empty slot returns "".
//
// Used by apps.Server.serveEmbedded to issue conditional-GET-friendly
// responses: with this ETag + Cache-Control: max-age=0, must-revalidate,
// every page load revalidates and gets a 304 unless the binary has been
// updated. Saves re-transmitting 50920 KB tool HTMLs on every reload.
func EmbeddedETag(app string) string {
if v, ok := etagCacheByApp.Load(app); ok {
return v.(string)
}
body := EmbeddedBytes(app)
if body == nil {
return ""
}
sum := sha256.Sum256(body)
etag := hex.EncodeToString(sum[:])[:32]
etagCacheByApp.Store(app, etag)
return etag
}
// etagCacheByApp memoizes EmbeddedETag results keyed by app name.
var etagCacheByApp sync.Map

View file

@ -1,13 +1,13 @@
package apps package apps
import ( import (
"bytes" "crypto/sha256"
"encoding/hex"
"errors" "errors"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
) )
@ -92,14 +92,14 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
// Malformed `.zddc` spec — operator's fault. Log and serve embedded. // Malformed `.zddc` spec — operator's fault. Log and serve embedded.
s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded", s.Fetcher.Logger.Warn("apps.Resolve failed; serving embedded",
"app", app, "request_dir", requestDir, "err", err) "app", app, "request_dir", requestDir, "err", err)
s.serveEmbedded(w, app, err) s.serveEmbedded(w, r, app, err)
return return
} }
if !hasOverride { if !hasOverride {
// No `.zddc apps:` entry anywhere up the chain and no `?v=` either → // No `.zddc apps:` entry anywhere up the chain and no `?v=` either →
// embedded is the authoritative default. // embedded is the authoritative default.
s.serveEmbedded(w, app, nil) s.serveEmbedded(w, r, app, nil)
return return
} }
@ -138,7 +138,7 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
s.Fetcher.Logger.Warn("path source unreadable; serving embedded", s.Fetcher.Logger.Warn("path source unreadable; serving embedded",
"app", app, "path", src.Path, "err", err) "app", app, "path", src.Path, "err", err)
} }
s.serveEmbedded(w, app, err) s.serveEmbedded(w, r, app, err)
return return
} }
s.serveBody(w, r, body, "path:"+src.Path) s.serveBody(w, r, body, "path:"+src.Path)
@ -149,7 +149,7 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
body, err := s.Fetcher.Fetch(r.Context(), src.URL) body, err := s.Fetcher.Fetch(r.Context(), src.URL)
if err != nil { if err != nil {
s.Fetcher.LogEmbeddedFallback(app, src.URL, err) s.Fetcher.LogEmbeddedFallback(app, src.URL, err)
s.serveEmbedded(w, app, err) s.serveEmbedded(w, r, app, err)
return return
} }
sourceTag := "fetch:" + src.URL sourceTag := "fetch:" + src.URL
@ -161,14 +161,39 @@ func (s *Server) Serve(w http.ResponseWriter, r *http.Request, app string, chain
s.serveBody(w, r, body, sourceTag) s.serveBody(w, r, body, sourceTag)
} }
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) { // writeWithETag writes body with a strong ETag derived from `etag`, the
w.Header().Set("Content-Type", "text/html; charset=utf-8") // cache-friendly headers, and short-circuits to 304 Not Modified when the
// client's `If-None-Match` matches. `max-age=0, must-revalidate` means the
// browser revalidates on every load — and the matching ETag returns 304
// with empty body, so the steady-state cost of a reload is ~200 bytes
// instead of the full HTML payload (50920 KB depending on the tool).
func writeWithETag(w http.ResponseWriter, r *http.Request, body []byte, etag, contentType, sourceHeader string) {
quotedTag := `"` + etag + `"`
w.Header().Set("ETag", quotedTag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-ZDDC-Source", sourceHeader) w.Header().Set("X-ZDDC-Source", sourceHeader)
w.Header().Set("Cache-Control", "public, max-age=3600, must-revalidate")
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(body)) if match := r.Header.Get("If-None-Match"); match != "" && match == quotedTag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(body)
} }
func (s *Server) serveEmbedded(w http.ResponseWriter, app string, _ error) { // bodyETag computes a stable 32-hex-char ETag for an arbitrary body. Used
// for the URL/path-sourced response path (the bytes vary per cache-fetch
// or per file read, so memoizing per-app would be wrong).
func bodyETag(body []byte) string {
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])[:32]
}
func (s *Server) serveBody(w http.ResponseWriter, r *http.Request, body []byte, sourceHeader string) {
writeWithETag(w, r, body, bodyETag(body), "text/html; charset=utf-8", sourceHeader)
}
func (s *Server) serveEmbedded(w http.ResponseWriter, r *http.Request, app string, _ error) {
body := EmbeddedBytes(app) body := EmbeddedBytes(app)
if len(body) == 0 { if len(body) == 0 {
w.Header().Set("Retry-After", "60") w.Header().Set("Retry-After", "60")
@ -179,8 +204,7 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, app string, _ error) {
http.StatusServiceUnavailable) http.StatusServiceUnavailable)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") writeWithETag(w, r, body, EmbeddedETag(app),
w.Header().Set("X-ZDDC-Source", "embedded:"+app+"@"+s.BuildVer) "text/html; charset=utf-8",
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") "embedded:"+app+"@"+s.BuildVer)
_, _ = w.Write(body)
} }

View file

@ -290,3 +290,88 @@ func TestServer_VParam_FullURLForm(t *testing.T) {
t.Errorf("body=%q", rec.Body.String()) t.Errorf("body=%q", rec.Body.String())
} }
} }
// TestServer_Embedded_ConditionalGET verifies the ETag/If-None-Match dance
// for the embedded fallback path: a fresh GET returns 200 with an ETag,
// and a follow-up with a matching If-None-Match returns 304 + empty body.
// This is the cache-friendliness fix that lets a browser revalidate
// against zddc-server's embedded HTML without re-transferring the bytes.
func TestServer_Embedded_ConditionalGET(t *testing.T) {
srv, _, root := newTestServer(t, []byte("upstream"))
saved := embeddedArchive
embeddedArchive = []byte("EMBEDDED archive bytes for ETag test")
defer func() {
embeddedArchive = saved
etagCacheByApp.Delete("archive") // reset memoization for sibling tests
}()
etagCacheByApp.Delete("archive") // ensure clean state for THIS test
chain := zddc.PolicyChain{Levels: []zddc.ZddcFile{{}}}
// First request: full body + ETag header.
rec1 := httptest.NewRecorder()
srv.Serve(rec1, httptest.NewRequest(http.MethodGet, "/archive.html", nil), "archive", chain, root)
if rec1.Code != http.StatusOK {
t.Fatalf("first GET: status=%d body=%s", rec1.Code, rec1.Body.String())
}
etag := rec1.Header().Get("ETag")
if etag == "" {
t.Fatalf("first GET: missing ETag header")
}
if cc := rec1.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=0") || !strings.Contains(cc, "must-revalidate") {
t.Errorf("first GET: Cache-Control=%q (want max-age=0 + must-revalidate)", cc)
}
if !strings.Contains(rec1.Body.String(), "EMBEDDED archive bytes") {
t.Errorf("first GET: body=%q", rec1.Body.String())
}
// Second request with matching If-None-Match: 304, empty body.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req2.Header.Set("If-None-Match", etag)
srv.Serve(rec2, req2, "archive", chain, root)
if rec2.Code != http.StatusNotModified {
t.Fatalf("If-None-Match match: status=%d (want 304)", rec2.Code)
}
if rec2.Body.Len() != 0 {
t.Errorf("304 response should have empty body; got %d bytes", rec2.Body.Len())
}
// Third request with stale If-None-Match: 200, full body.
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
req3.Header.Set("If-None-Match", `"deadbeef"`)
srv.Serve(rec3, req3, "archive", chain, root)
if rec3.Code != http.StatusOK {
t.Errorf("stale If-None-Match: status=%d (want 200)", rec3.Code)
}
if rec3.Body.Len() == 0 {
t.Errorf("stale If-None-Match: empty body; want full")
}
}
// TestEmbeddedETag_Stable asserts EmbeddedETag is deterministic and
// content-addressed: same bytes → same ETag, different bytes → different.
func TestEmbeddedETag_Stable(t *testing.T) {
saved := embeddedArchive
defer func() {
embeddedArchive = saved
etagCacheByApp.Delete("archive")
}()
embeddedArchive = []byte("alpha")
etagCacheByApp.Delete("archive")
a1 := EmbeddedETag("archive")
a2 := EmbeddedETag("archive")
if a1 == "" || a1 != a2 {
t.Errorf("EmbeddedETag should be stable for same bytes; got %q vs %q", a1, a2)
}
embeddedArchive = []byte("beta")
etagCacheByApp.Delete("archive")
b := EmbeddedETag("archive")
if b == a1 {
t.Errorf("EmbeddedETag should differ for different bytes; both %q", b)
}
}

View file

@ -118,12 +118,19 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
return return
} }
// ETag + max-age=0 + must-revalidate: every request re-validates and
// gets a 304 unless the binary has been redeployed (the ETag is a
// content hash, computed once at startup and memoized in apps.embed).
// Saves re-transmitting ~230 KB of browse.html on every page load
// while still picking up redeploys immediately.
etag := `"` + apps.EmbeddedETag("browse") + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse") w.Header().Set("X-ZDDC-Source", "embedded:browse")
// no-cache here too — browse.html has session-tied content (the if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
// directory listing it loads via fetch), and we want browser to w.WriteHeader(http.StatusNotModified)
// always re-validate so deployed-binary updates appear immediately return
// rather than after a 5-minute cache window. }
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(body) _, _ = w.Write(body)
} }