From 5ca23c0553d1107eeb5f1199934af71749f40bb6 Mon Sep 17 00:00:00 2001 From: bigbes Date: Sun, 19 Jul 2026 00:51:08 +0300 Subject: [PATCH] web: content-hash the bundle and colour the status column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two diff-view fixes reported against the deployed instance. Empty file tree after an upgrade: bundle.js had a stable filename served with a 1-hour cache, so browsers kept a pre-fix bundle and the @pierre/trees sidebar rendered blank. Content-hash the bundle like the stylesheet — Makefile emits bundle..js, server.go globs it into a BundleHref, the templates reference {{.BundleHref}}, and the hashed name is served immutable. A deploy now always busts the cache. Unreadable status column: the changed-files table rendered A/M/D in the dim default cell colour, near-invisible on the dark chrome. Render each status as a semantic .diff-status badge (added=green, modified=amber, deleted=red, renamed=blue) with light/dark variants, via statusClass/statusLabel helpers and a small self-contained style block (the sourcehut-derived main.min.css can't be rebuilt locally). Tests resolve the hashed bundle via a bundleName helper and assert its immutable cache header; docs updated for the hashed filename. --- Makefile | 11 ++++- README.md | 9 +++-- config.example.ini | 4 +- web/chrome.go | 2 + web/router.go | 2 +- web/server.go | 28 ++++++++++++- web/static/{bundle.js => bundle.3e3b8462.js} | 0 web/templates.go | 42 +++++++++++++++++++- web/templates/commit.html | 4 +- web/templates/compare.html | 4 +- web/templates/layout.html | 28 +++++++++++++ web/web_test.go | 28 +++++++++---- 12 files changed, 139 insertions(+), 23 deletions(-) rename web/static/{bundle.js => bundle.3e3b8462.js} (100%) diff --git a/Makefile b/Makefile index 72b9e17a101f95c4a5271c7d5c85ca1a4e13a4c9..8984939138acd50a46dc935dd76b28c36a4dac58 100644 --- a/Makefile +++ b/Makefile @@ -42,12 +42,19 @@ css: web/static/main.min.$$(sha256sum web/static/main.min.css | cut -c1-8).css rm -f web/static/main.css -# Frontend diff/tree bundle: built once, output committed to web/static. -# Requires frontend/ (added in phase 2b). +# Frontend diff/tree bundle: built once, output committed to web/static. The +# filename carries a content hash (like the CSS) so a deploy busts the browser +# cache — a stale bundle.js is otherwise served for up to max-age and can leave +# the file tree rendering blank after an upgrade. Produces exactly ONE +# web/static/bundle..js (old ones removed). bundle: + mkdir -p web/static + rm -f web/static/bundle.js web/static/bundle.*.js cd frontend && npm ci && \ npx esbuild src/app.ts --bundle --minify --format=esm \ --outfile=../web/static/bundle.js + mv web/static/bundle.js \ + web/static/bundle.$$(sha256sum web/static/bundle.js | cut -c1-8).js # Local development run. Requires a ./config.ini in the working directory (or # ../config.ini, /etc/sr.ht/config.ini): copy config.example.ini and fill in the diff --git a/README.md b/README.md index 6ada61cd3e3948312f8037ef48032f65bda22d5e..ec21de6165b74aa8a14c87bbe37c0bd557bead15 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ fork carries one production S3 patch on top of upstream. `core.sr.ht`'s `make install`). `dart-sass` also works for local builds; the production `css` target uses `sassc -I /usr/share/sourcehut/scss`. - **Node.js** (build time only) — the frontend diff bundle (`make bundle`, - esbuild). The vendored `web/static/bundle.js` is committed, so Node is not - needed at runtime. + esbuild). The vendored, content-hashed `web/static/bundle..js` is + committed (the hash busts the browser cache on deploy, like the stylesheet), so + Node is not needed at runtime. ```sh make build # compile ./comparesrht @@ -211,7 +212,7 @@ compare.sr.ht deploys like any other SourceHut web service. On the instance: ```sh make install PREFIX=/usr/local # → /usr/local/bin/comparesrht - # → /usr/share/sourcehut/compare.sr.ht/static/{bundle.js,main.min..css,logo.svg} + # → /usr/share/sourcehut/compare.sr.ht/static/{bundle..js,main.min..css,logo.svg} ``` 2. **Config.** Append the `[compare.sr.ht]` section (`origin=` and @@ -258,7 +259,7 @@ compare.sr.ht deploys like any other SourceHut web service. On the instance: output was fidelity-verified against real `git` (see `gitx/fidelity_test.go`) so the patches `@pierre/diffs` parses carry standard `diff --git` headers and match git's rename/binary handling. -- **10 MB vendored bundle.** `web/static/bundle.js` is a ~10 MB committed +- **10 MB vendored bundle.** `web/static/bundle..js` is a ~10 MB committed esbuild output. It is large because `@pierre/diffs` bundles Shiki with its grammars/themes for client-side syntax highlighting. We accept the size: it is built once, served with a long immutable cache lifetime, gzips/brotlis down diff --git a/config.example.ini b/config.example.ini index f6e67312a8887dbff635db96f8e7dcd010303086..a4f8cd55ddb3360ae246fe3383169d7421252f60 100644 --- a/config.example.ini +++ b/config.example.ini @@ -15,8 +15,8 @@ # domain (*.srht.bigb.es). origin=https://compare.srht.bigb.es # -# Directory the built static assets (bundle.js, main.min..css, logo.svg) -# are installed to; globbed at startup to resolve the hashed CSS filename. +# Directory the built static assets (bundle..js, main.min..css, +# logo.svg) are installed to; globbed at startup to resolve the hashed filenames. static-dir=/usr/share/sourcehut/compare.sr.ht/static # # Address the HTTP server binds to. Optional; defaults to 127.0.0.1:5090. diff --git a/web/chrome.go b/web/chrome.go index 22e70dc8f24b0ef3d9fd3e4eaaf3dfcc9a0c6995..ca1bff30280211e9634d34675717fac554a32b0b 100644 --- a/web/chrome.go +++ b/web/chrome.go @@ -84,6 +84,7 @@ type viewData struct { RegisterURL string ProfileURL string CSSHref string + BundleHref string Environment string ShowBanner bool @@ -121,6 +122,7 @@ func (s *Server) chrome(r *http.Request) viewData { RegisterURL: s.metaOrigin, ProfileURL: profileURL, CSSHref: s.cssHref, + BundleHref: s.bundleHref, Environment: strings.ToUpper(s.environment), ShowBanner: s.environment != "" && s.environment != "production", } diff --git a/web/router.go b/web/router.go index 05b6ac208f9a12d46987fdf75e8044d5efefde13..454d6b62c15f2eaca601a372e7a898ffdde9781b 100644 --- a/web/router.go +++ b/web/router.go @@ -37,7 +37,7 @@ func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(name, ".js") { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") } - if hashedCSSRe.MatchString(name) { + if hashedCSSRe.MatchString(name) || hashedBundleRe.MatchString(name) { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") } else { w.Header().Set("Cache-Control", "public, max-age=3600") diff --git a/web/server.go b/web/server.go index f0d9094c7ba93f5c3c78e5a15804c546f023689c..cab70cc2b781a1c019401ed3fd2e5e87ae154b17 100644 --- a/web/server.go +++ b/web/server.go @@ -34,8 +34,8 @@ import ( "path" "regexp" - "sourcecraft.dev/bigbes/sr-ht-core/config" "github.com/vaughan0/go-ini" + "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-compare/authz" ) @@ -44,6 +44,12 @@ import ( // with an immutable cache lifetime (the hash changes whenever the bytes do). var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`) +// hashedBundleRe matches the content-addressed frontend bundle. Like the +// stylesheet it carries a content hash in its name so a deploy busts the browser +// cache (a stale bundle.js is why the file tree can render blank after an +// upgrade); a matching name is served immutable. +var hashedBundleRe = regexp.MustCompile(`^bundle\.[0-9a-f]{6,}\.js$`) + // Server holds the immutable configuration a request handler needs. It is built // once at startup and is safe for concurrent use. type Server struct { @@ -57,6 +63,7 @@ type Server struct { compareOrigin string hubOrigin string cssHref string + bundleHref string nav []navItem staticFileServer http.Handler @@ -86,6 +93,11 @@ func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) { return nil, err } + bundleHref, err := resolveBundleHref() + if err != nil { + return nil, err + } + staticSub, err := fs.Sub(staticFS, "static") if err != nil { return nil, fmt.Errorf("web: sub static FS: %w", err) @@ -101,6 +113,7 @@ func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) { compareOrigin: compareOrigin, hubOrigin: config.GetOrigin(conf, "hub.sr.ht", true), cssHref: cssHref, + bundleHref: bundleHref, nav: buildNav(conf), staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))), }, nil @@ -118,3 +131,16 @@ func resolveCSSHref() (string, error) { } return "/static/" + path.Base(matches[0]), nil } + +// resolveBundleHref globs the embedded static FS for the content-addressed +// frontend bundle and returns its site-absolute URL. +func resolveBundleHref() (string, error) { + matches, err := fs.Glob(staticFS, "static/bundle.*.js") + if err != nil { + return "", fmt.Errorf("web: glob bundle: %w", err) + } + if len(matches) == 0 { + return "", fmt.Errorf("web: no bundle.*.js in embedded static assets") + } + return "/static/" + path.Base(matches[0]), nil +} diff --git a/web/static/bundle.js b/web/static/bundle.3e3b8462.js similarity index 100% rename from web/static/bundle.js rename to web/static/bundle.3e3b8462.js diff --git a/web/templates.go b/web/templates.go index c034514f8332e8806fe3588b11d1e0aa5b8e8dc1..f3244d8522fcf15b912a3633a40a66b22f79d01f 100644 --- a/web/templates.go +++ b/web/templates.go @@ -17,8 +17,8 @@ import ( //go:embed templates/*.html var tmplFS embed.FS -// staticFS holds the built front-end assets (bundle.js, the hashed stylesheet, -// logo.svg). It is served read-only under /static/. +// staticFS holds the built front-end assets (the hashed bundle..js, the +// hashed stylesheet, logo.svg). It is served read-only under /static/. // //go:embed static var staticFS embed.FS @@ -36,6 +36,44 @@ var funcMap = template.FuncMap{ "date": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 MST") }, + // statusClass maps a git file-change status letter to a CSS modifier used by + // the .diff-status badge (see the diff-status rules in layout.html). Anything + // unrecognized falls back to the neutral "o". + "statusClass": func(s string) string { + if s == "" { + return "o" + } + switch s[0] { + case 'A', 'a': + return "a" + case 'M', 'm': + return "m" + case 'D', 'd': + return "d" + case 'R', 'r': + return "r" + default: + return "o" + } + }, + // statusLabel spells out a status letter for the badge's tooltip. + "statusLabel": func(s string) string { + if s == "" { + return "changed" + } + switch s[0] { + case 'A', 'a': + return "added" + case 'M', 'm': + return "modified" + case 'D', 'd': + return "deleted" + case 'R', 'r': + return "renamed" + default: + return "changed" + } + }, } // pageNames are the content templates; each is parsed with layout.html. diff --git a/web/templates/commit.html b/web/templates/commit.html index 1d7a5f35767318053db5e1e0c930c69a4fdaf849..04bab73cde56d9dd5380b18f6045b43203735b85 100644 --- a/web/templates/commit.html +++ b/web/templates/commit.html @@ -55,7 +55,7 @@ {{if and .OldPath (ne .OldPath .Path)}}{{.OldPath}} → {{end}}{{.Path}} {{if .Binary}}BIN{{end}} - {{.Status}} + {{.Status}} {{if .Additions}}+{{.Additions}}{{end}} {{if .Deletions}}-{{.Deletions}}{{end}} @@ -77,5 +77,5 @@ {{end}} {{define "scripts"}} - + {{end}} diff --git a/web/templates/compare.html b/web/templates/compare.html index ca07e5b98ae9a5496f8add33c7cf7969d159c01a..a0934b081ec1c7e0eebf36bb87ca81785666242f 100644 --- a/web/templates/compare.html +++ b/web/templates/compare.html @@ -57,7 +57,7 @@ {{if and .OldPath (ne .OldPath .Path)}}{{.OldPath}} → {{end}}{{.Path}} {{if .Binary}}BIN{{end}} - {{.Status}} + {{.Status}} {{if .Additions}}+{{.Additions}}{{end}} {{if .Deletions}}-{{.Deletions}}{{end}} @@ -79,5 +79,5 @@ {{end}} {{define "scripts"}} - + {{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html index 271bb8fb1b861691513129ab7fc170202265cc6a..78cd0b40b616e2bceb9ebeee5b73df0cfbfb1a85 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -6,6 +6,34 @@ {{.Title}} + {{block "head" .}}{{end}} diff --git a/web/web_test.go b/web/web_test.go index 4c5840b4d39940414f644fab4496d59c6ed9d748..2ce54fdd2a554635dc3a28dbb3e5998a85c4d9fc 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -14,11 +14,11 @@ import ( "strings" "testing" - "sourcecraft.dev/bigbes/sr-ht-core/config" - "sourcecraft.dev/bigbes/sr-ht-core/crypto" "github.com/fernet/fernet-go" "github.com/go-chi/chi/v5" "github.com/vaughan0/go-ini" + "sourcecraft.dev/bigbes/sr-ht-core/config" + "sourcecraft.dev/bigbes/sr-ht-core/crypto" "sourcecraft.dev/bigbes/sr-ht-compare/authz" "sourcecraft.dev/bigbes/sr-ht-compare/core" @@ -222,8 +222,8 @@ func TestComparePage(t *testing.T) { if !strings.Contains(body, `id="compare-data"`) { t.Fatal("missing compare-data script") } - if !strings.Contains(body, `src="/static/bundle.js"`) { - t.Fatal("missing bundle.js script tag") + if !strings.Contains(body, `src="/static/`+bundleName(t)+`"`) { + t.Fatal("missing hashed bundle script tag") } cd := extractCompareData(t, body) @@ -415,12 +415,16 @@ func TestStaticBundleAndCSS(t *testing.T) { root, _ := gitFixture(t) srvHandler := testServer(t, root, demoAuthorizer()) - rec := get(t, srvHandler, "/static/bundle.js", "") + bundle := bundleName(t) + rec := get(t, srvHandler, "/static/"+bundle, "") if rec.Code != http.StatusOK { - t.Fatalf("bundle.js status = %d", rec.Code) + t.Fatalf("%s status = %d", bundle, rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { - t.Fatalf("bundle.js content-type = %q", ct) + t.Fatalf("%s content-type = %q", bundle, ct) + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") { + t.Fatalf("hashed bundle cache-control = %q, want immutable", cc) } css := cssName(t) @@ -464,6 +468,16 @@ func cssName(t *testing.T) string { return strings.TrimPrefix(href, "/static/") } +// bundleName resolves the content-hashed frontend bundle filename (bundle..js). +func bundleName(t *testing.T) string { + t.Helper() + href, err := resolveBundleHref() + if err != nil { + t.Fatal(err) + } + return strings.TrimPrefix(href, "/static/") +} + // extractCompareData pulls and decodes the embedded JSON payload from a page. func extractCompareData(t *testing.T, body string) compareData { t.Helper()