Cache Home's stylesheets and pictures by content version
Paul, 2026-09-29: images took a second and flashed in on every visit. Home served its stylesheets and icons no-cache with no validator, so each visit downloaded all of them again. Now every stylesheet and picture is linked as path?v=<content version> and that URL is cached for a year, immutably; the bare path revalidates by ETag (a 304). A deploy still takes effect at once: new bytes, new version, new URL. Scripts keep their bare paths (the scriptless allowlist names them) and revalidate by ETag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+189
−23
internal/home/app.go+2 −2internal/home/assetversion.go+79 −0internal/home/assetversion_test.go+56 −0internal/home/brand.go+1 −0internal/home/brand_test.go+1 −1internal/home/calma.go+4 −2internal/home/credits.go+1 −1internal/home/directory.go+2 −2internal/home/directory_catalog.go+3 −0internal/home/page.go+13 −3internal/home/passkey.go+16 −1internal/home/templates/credits.html+1 −1internal/home/templates/directory.html+1 −1internal/home/templates/layout.html+9 −9
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... cd handoff && CGO_ENABLED=0 go vet ./... cd roster && CGO_ENABLED=0 go vet ./... cd connect && CGO_ENABLED=0 go vet ./... === fmt === === build === CGO_ENABLED=0 go build ./... CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch cd handoff && CGO_ENABLED=0 go build ./... cd roster && CGO_ENABLED=0 go build ./... cd connect && CGO_ENABLED=0 go build ./... === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/home/cmd/home [no test files] ok amadan.net/oficina/home/cmd/home-placeholder 0.517s ? amadan.net/oficina/home/cmd/oficina [no test files] ok amadan.net/oficina/home/internal/guardstest 0.033s ok amadan.net/oficina/home/internal/home 8.323s ok amadan.net/oficina/home/internal/home/directorydata 0.917s ok amadan.net/oficina/home/internal/home/logtest 0.003s ok amadan.net/oficina/home/internal/home/seal 0.002s ok amadan.net/oficina/home/internal/home/sealtest 0.002s ok amadan.net/oficina/home/internal/operator 5.054s ok amadan.net/oficina/home/internal/operatorlaunch 4.113s ok amadan.net/oficina/home/internal/provisioner 38.552s cd handoff && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/handoff 0.320s ok amadan.net/oficina/home/handoff/seal 0.002s cd roster && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/roster 5.078s cd connect && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/connect 0.065s ok amadan.net/oficina/home/connect/linkstore 0.183s === race === go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/ ok amadan.net/oficina/home/internal/home 101.401s ok amadan.net/oficina/home/internal/operator 64.968s ok amadan.net/oficina/home/internal/operatorlaunch 22.364s ok amadan.net/oficina/home/internal/provisioner 288.222s ok amadan.net/oficina/home/internal/home/directorydata 14.971s cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/handoff 8.933s ok amadan.net/oficina/home/handoff/seal 1.008s cd roster && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/roster 6.553s cd connect && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/connect 1.595s ok amadan.net/oficina/home/connect/linkstore 3.679s
diff --git a/internal/home/app.go b/internal/home/app.go| index ecb12f8..bd36888 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -242,8 +242,8 @@ func (a *App) Router() http.Handler { |
| // Vendored from rastrillo's ui package, not linked from it: an app |
| // owns its stylesheet from the moment it lands, and |
| // `rastrillo doctor --fix` is what reconciles it on an upgrade. |
| - r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8")) |
| - r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/tokens.css", staticAsset("tokens.css")) |
| + mountCSS(r, "/static/theme.css", theme.CSS()) |
| mountBrand(r) |
| mountCalma(r) |
| mountSite(r) |
diff --git a/internal/home/assetversion.go b/internal/home/assetversion.go| new file mode 100644 |
| index 0000000..06378c4 |
| --- /dev/null |
| +++ b/internal/home/assetversion.go |
| @@ -0,0 +1,79 @@ |
| +package home |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| + "sync" |
| +) |
| + |
| +// Asset versioning. Home's stylesheets and pictures are served at stable |
| +// paths (a closed list, mountBrand and friends), and every page links |
| +// them as path?v=<content version>. serveAsset caches a request carrying |
| +// the current version for a year, immutably, and revalidates anything |
| +// else, so a returning visitor fetches only the page, and a deploy still |
| +// takes effect at once: new bytes, new version, new URL. |
| +// |
| +// Scripts are left at their bare paths: the scriptless sweep's allowlist |
| +// names them there (passkey.go), and they are revalidated by ETag. |
| + |
| +// contentVersion is the first 16 hex digits of the body's SHA-256. |
| +func contentVersion(body []byte) string { |
| + sum := sha256.Sum256(body) |
| + return hex.EncodeToString(sum[:8]) |
| +} |
| + |
| +var ( |
| + assetVersionsMu sync.RWMutex |
| + assetVersions = map[string]string{} |
| +) |
| + |
| +// versionAsset records path's current version. Called where each asset is |
| +// mounted; every router mounts the same bytes, so the value never changes. |
| +func versionAsset(path string, body []byte) { |
| + assetVersionsMu.Lock() |
| + assetVersions[path] = contentVersion(body) |
| + assetVersionsMu.Unlock() |
| +} |
| + |
| +// versioned is path with its version, or path unchanged if it is not a |
| +// versioned asset (a script, an estilo URL already versioned in its path). |
| +func versioned(path string) string { |
| + assetVersionsMu.RLock() |
| + v, ok := assetVersions[path] |
| + assetVersionsMu.RUnlock() |
| + if !ok { |
| + return path |
| + } |
| + return path + "?v=" + v |
| +} |
| + |
| +var staticRef = regexp.MustCompile(`((?:href|src)=")(/static/[^"?#]+)"`) |
| + |
| +// versionStaticRefs rewrites every href/src naming a versioned asset in a |
| +// static page (the front page, which is bytes, not a template). |
| +func versionStaticRefs(page []byte) []byte { |
| + return staticRef.ReplaceAllFunc(page, func(m []byte) []byte { |
| + parts := staticRef.FindSubmatch(m) |
| + return []byte(string(parts[1]) + versioned(string(parts[2])) + `"`) |
| + }) |
| +} |
| + |
| +// isVersionedAsset reports whether a path is one Home versions: used by |
| +// tests that check every page links assets by version. |
| +func isVersionedAsset(path string) bool { |
| + assetVersionsMu.RLock() |
| + defer assetVersionsMu.RUnlock() |
| + _, ok := assetVersions[strings.SplitN(path, "?", 2)[0]] |
| + return ok |
| +} |
| + |
| +// mountCSS mounts a versioned stylesheet. |
| +func mountCSS(r interface { |
| + Get(string, http.HandlerFunc) |
| +}, path string, body []byte) { |
| + versionAsset(path, body) |
| + r.Get(path, serveAsset(body, "text/css; charset=utf-8")) |
| +} |
diff --git a/internal/home/assetversion_test.go b/internal/home/assetversion_test.go| new file mode 100644 |
| index 0000000..c768988 |
| --- /dev/null |
| +++ b/internal/home/assetversion_test.go |
| @@ -0,0 +1,56 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Every stylesheet and picture a page links is linked by its content |
| +// version, and fetching that URL is cached immutably; the bare path and a |
| +// stale version revalidate, and a matching ETag is a 304. |
| +func TestAssetsAreLinkedByVersionAndCachedForAYear(t *testing.T) { |
| + app := newTestApp(t) |
| + get := func(host, path string, header ...string) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil) |
| + for i := 0; i+1 < len(header); i += 2 { |
| + req.Header.Set(header[i], header[i+1]) |
| + } |
| + rec := httptest.NewRecorder() |
| + app.Router().ServeHTTP(rec, req) |
| + return rec |
| + } |
| + ref := regexp.MustCompile(`(?:href|src)="(/static/[^"]+)"`) |
| + for _, page := range []struct{ host, path string }{{directoryHost, "/"}, {credentialHost, "/signin"}} { |
| + body := get(page.host, page.path).Body.String() |
| + var checked int |
| + for _, m := range ref.FindAllStringSubmatch(body, -1) { |
| + url := m[1] |
| + bare := strings.SplitN(url, "?", 2)[0] |
| + if strings.HasSuffix(bare, ".js") || strings.HasSuffix(bare, ".mjs") || strings.HasPrefix(bare, "/static/estilo/sky/") || strings.HasPrefix(bare, "/static/estilo/fonts/") { |
| + continue // scripts stay bare on purpose; estilo's URLs carry their version in the path |
| + } |
| + checked++ |
| + if !strings.Contains(url, "?v=") { |
| + t.Errorf("%s%s links %s without its version", page.host, page.path, url) |
| + continue |
| + } |
| + rec := get(page.host, url) |
| + if rec.Code != 200 || rec.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { |
| + t.Errorf("GET %s = %d %q", url, rec.Code, rec.Header().Get("Cache-Control")) |
| + } |
| + etag := rec.Header().Get("ETag") |
| + if b := get(page.host, bare); b.Header().Get("Cache-Control") != "no-cache" || b.Header().Get("ETag") != etag { |
| + t.Errorf("GET %s (bare) = %q %q", bare, b.Header().Get("Cache-Control"), b.Header().Get("ETag")) |
| + } |
| + if n := get(page.host, bare, "If-None-Match", etag); n.Code != http.StatusNotModified || n.Body.Len() != 0 { |
| + t.Errorf("GET %s with its ETag = %d, want 304", bare, n.Code) |
| + } |
| + } |
| + if checked < 4 { |
| + t.Errorf("%s%s: only %d versioned assets checked", page.host, page.path, checked) |
| + } |
| + } |
| +} |
diff --git a/internal/home/brand.go b/internal/home/brand.go| index 7afaeb5..c4ba74c 100644 |
| --- a/internal/home/brand.go |
| +++ b/internal/home/brand.go |
| @@ -32,6 +32,7 @@ func mountBrand(r chi.Router) { |
| if err != nil { |
| panic(err) // A missing embedded release asset is a build error. |
| } |
| + versionAsset("/static/brand/"+name, body) |
| r.Get("/static/brand/"+name, serveAsset(body, contentType)) |
| } |
| } |
diff --git a/internal/home/brand_test.go b/internal/home/brand_test.go| index 56d605e..0570ca1 100644 |
| --- a/internal/home/brand_test.go |
| +++ b/internal/home/brand_test.go |
| @@ -21,7 +21,7 @@ func TestDirectoryBrandAssetsArePublicAndListingKeepsNames(t *testing.T) { |
| body := page.Body.String() |
| for _, name := range []string{"Docs", "Sheets", "Slides", "Calendar", "Calls", "Memoria"} { |
| path := "/static/brand/" + strings.ToLower(name) + ".svg" |
| - if !strings.Contains(body, `src="`+path+`" alt=""`) || !strings.Contains(body, ">"+name+"<") { |
| + if !strings.Contains(body, `src="`+versioned(path)+`" alt=""`) || !strings.Contains(body, ">"+name+"<") { |
| t.Fatal("listing lost icon or name", name) |
| } |
| // No session on the asset request: every sign-in surface needs its |
diff --git a/internal/home/calma.go b/internal/home/calma.go| index 863c937..916fac6 100644 |
| --- a/internal/home/calma.go |
| +++ b/internal/home/calma.go |
| @@ -19,6 +19,8 @@ var calmaCSS []byte |
| // sky for pages that belong to no one team: all of Home's, today, since |
| // the directory lists apps rather than one team's. |
| var homeFuncs = map[string]any{ |
| + // v links a stylesheet or picture by its content version (assetversion.go). |
| + "v": versioned, |
| "suiteSky": func() sky.View { return sky.For(sky.SuiteSeed) }, |
| // Onest's stylesheet, under estilo's versioned prefix. |
| "fontsCSS": func() string { return theme.FontPrefix + "fonts.css" }, |
| @@ -59,6 +61,6 @@ var hopPages = map[string]bool{ |
| func mountCalma(r chi.Router) { |
| r.Handle(theme.FontPrefix+"*", theme.FontHandler()) |
| r.Handle(sky.Prefix+"*", sky.Handler()) |
| - r.Get("/static/estilo/sky.css", serveAsset(sky.CSS(), "text/css; charset=utf-8")) |
| - r.Get("/static/calma.css", serveAsset(calmaCSS, "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/estilo/sky.css", sky.CSS()) |
| + mountCSS(r, "/static/calma.css", calmaCSS) |
| } |
diff --git a/internal/home/credits.go b/internal/home/credits.go| index 1c469ff..07dded1 100644 |
| --- a/internal/home/credits.go |
| +++ b/internal/home/credits.go |
| @@ -30,5 +30,5 @@ func (a *App) mountCredits(r chi.Router) { |
| a.render(w, http.StatusOK, "credits.html", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}) |
| }) |
| - r.Get("/static/credits.css", serveAsset(credits.CSS(), "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/credits.css", credits.CSS()) |
| } |
diff --git a/internal/home/directory.go b/internal/home/directory.go| index 44d052b..c2c5a0d 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -117,8 +117,8 @@ func (a *App) directoryRouter() http.Handler { |
| a.render(w, 200, "directory_signed_out.html", map[string]any{}) |
| }) |
| r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) |
| - r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8")) |
| - r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/tokens.css", staticAsset("tokens.css")) |
| + mountCSS(r, "/static/theme.css", theme.CSS()) |
| mountBrand(r) |
| mountCalma(r) |
| mountSite(r) |
diff --git a/internal/home/directory_catalog.go b/internal/home/directory_catalog.go| index d94bec8..b417778 100644 |
| --- a/internal/home/directory_catalog.go |
| +++ b/internal/home/directory_catalog.go |
| @@ -36,6 +36,9 @@ func directoryCatalog(cfg Runtime) []catalogApp { |
| apps[i].Status = "Open preview" |
| } |
| } |
| + for i := range apps { |
| + apps[i].Icon = versioned(apps[i].Icon) |
| + } |
| return apps |
| } |
diff --git a/internal/home/page.go b/internal/home/page.go| index 984d5bd..6bdedfa 100644 |
| --- a/internal/home/page.go |
| +++ b/internal/home/page.go |
| @@ -4,6 +4,7 @@ import ( |
| "bytes" |
| "embed" |
| "net/http" |
| + "sync" |
| "amadan.net/oficina/estilo.git/theme" |
| "github.com/go-chi/chi/v5" |
| @@ -57,12 +58,21 @@ var appsCSS = func() []byte { |
| // mountBrand keeps one: a request path must never be treated as a file |
| // name. |
| func mountSite(r chi.Router) { |
| - r.Get("/static/site/home.css", serveAsset(siteAsset("home.css"), "text/css; charset=utf-8")) |
| - r.Get("/static/site/apps.css", serveAsset(appsCSS, "text/css; charset=utf-8")) |
| + mountCSS(r, "/static/site/home.css", siteAsset("home.css")) |
| + mountCSS(r, "/static/site/apps.css", appsCSS) |
| } |
| +// servedIndex is the front page with every stylesheet and picture linked |
| +// by its content version. Built on first serve, once the routers have |
| +// mounted (and so versioned) everything the page links. |
| +var ( |
| + servedIndexOnce sync.Once |
| + servedIndex []byte |
| +) |
| + |
| // handleIndex serves the front page. |
| func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) { |
| + servedIndexOnce.Do(func() { servedIndex = versionStaticRefs(indexPage) }) |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| - w.Write(indexPage) |
| + w.Write(servedIndex) |
| } |
diff --git a/internal/home/passkey.go b/internal/home/passkey.go| index 4eb2e64..d19fa1d 100644 |
| --- a/internal/home/passkey.go |
| +++ b/internal/home/passkey.go |
| @@ -199,9 +199,24 @@ func (a *App) handleRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Reque |
| // maps .mjs. A wrong MIME type makes a browser refuse a module silently, |
| // which is the worst way for this to fail. |
| func serveAsset(body []byte, contentType string) http.HandlerFunc { |
| + version := contentVersion(body) |
| + etag := `"` + version + `"` |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", contentType) |
| - w.Header().Set("Cache-Control", "no-cache") |
| + w.Header().Set("ETag", etag) |
| + // A URL carrying this body's version can never serve anything |
| + // else, so the browser keeps it for a year without asking. Any |
| + // other request โ the bare path, a stale version โ revalidates, |
| + // and the ETag makes that a 304 rather than the bytes again. |
| + if r.URL.Query().Get("v") == version { |
| + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| + } else { |
| + w.Header().Set("Cache-Control", "no-cache") |
| + } |
| + if r.Header.Get("If-None-Match") == etag { |
| + w.WriteHeader(http.StatusNotModified) |
| + return |
| + } |
| w.WriteHeader(http.StatusOK) |
| w.Write(body) |
| } |
diff --git a/internal/home/templates/credits.html b/internal/home/templates/credits.html| index b587fe5..596f7dd 100644 |
| --- a/internal/home/templates/credits.html |
| +++ b/internal/home/templates/credits.html |
| @@ -1,5 +1,5 @@ |
| {{define "title"}}Open source ยท Oficina{{end}} |
| -{{define "head"}}<link rel="stylesheet" href="/static/credits.css">{{end}} |
| +{{define "head"}}<link rel="stylesheet" href="{{v "/static/credits.css"}}">{{end}} |
| {{define "content"}}{{template "estilo/credits" .}}{{end}} |
| {{define "help"}}{{template "home/help" .}}{{end}} |
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html| index c5854b2..a085be3 100644 |
| --- a/internal/home/templates/directory.html |
| +++ b/internal/home/templates/directory.html |
| @@ -1,7 +1,7 @@ |
| {{define "title"}}Oficina{{end}} |
| {{define "content"}} |
| <header home-hub-head> |
| -<h1 estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="40" height="40">Oficina</h1> |
| +<h1 estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="40" height="40">Oficina</h1> |
| <form method="post" action="/signout"><button type="submit" rst-btn>Sign out</button></form> |
| </header> |
| <p home-hub-lede>You are signed in. Every app below opens with this one account.</p> |
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html| index 70a7b07..d536c5d 100644 |
| --- a/internal/home/templates/layout.html |
| +++ b/internal/home/templates/layout.html |
| @@ -1,18 +1,18 @@ |
| {{define "layout"}}<!doctype html> |
| <html lang="{{block "lang" .}}en{{end}}"> |
| <head> |
| -<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png"> |
| -<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg"> |
| -<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png"> |
| -<link rel="stylesheet" href="/static/brand/brand.css"> |
| +<link rel="icon" type="image/png" sizes="32x32" href="{{v "/static/brand/icon-32.png"}}"> |
| +<link rel="icon" type="image/svg+xml" href="{{v "/static/brand/icon.svg"}}"> |
| +<link rel="apple-touch-icon" sizes="180x180" href="{{v "/static/brand/apple-touch-icon.png"}}"> |
| +<link rel="stylesheet" href="{{v "/static/brand/brand.css"}}"> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>{{block "title" .}}Oficina{{end}}</title> |
| <link rel="stylesheet" href="{{fontsCSS}}"> |
| -<link rel="stylesheet" href="/static/tokens.css"> |
| -<link rel="stylesheet" href="/static/theme.css"> |
| -<link rel="stylesheet" href="/static/estilo/sky.css"> |
| -<link rel="stylesheet" href="/static/calma.css"> |
| +<link rel="stylesheet" href="{{v "/static/tokens.css"}}"> |
| +<link rel="stylesheet" href="{{v "/static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{v "/static/estilo/sky.css"}}"> |
| +<link rel="stylesheet" href="{{v "/static/calma.css"}}"> |
| {{block "head" .}}{{end}} |
| </head> |
| <body{{block "bodyattrs" .}}{{end}}> |
| @@ -20,7 +20,7 @@ |
| {{if not hop}}{{template "estilo-sky" suiteSky}}{{end}} |
| {{block "help" .}}{{end}} |
| <main rst-page{{if door}} home-door{{end}}> |
| -{{if door}}<p estilo-brand><img estilo-brand-icon src="/static/brand/icon.svg" alt="" width="36" height="36">Oficina</p>{{end}} |
| +{{if door}}<p estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="36" height="36">Oficina</p>{{end}} |
| {{template "content" .}} |
| </main> |
| </div> |