Derive static asset ETags from content instead of the build version - #126
Merged
Conversation
Handler applied one version-derived ETag to every embedded asset. With Cache-Control: no-cache that made unversioned builds serve permanently stale assets: `make run` leaves version at "dev", so the validator never changed, and an edited app.js kept revalidating as 304 until a hard reload. It was also semantically wrong — /app.js, /style.css and the rest are distinct resources sharing one validator — and the header was set before delegating to the file server, so 404s carried it too. Hash each embedded file once at construction (SHA-256, truncated to 16 bytes) and serve that as the asset's ETag, set only for paths that name a real asset. The asset set is fixed at compile time and tiny, so this costs microseconds and no per-request work. Handler no longer needs the version string, so drop the parameter; the package is internal with a single call site. Closes #102
LarsLaskowski
commented
Aug 23, 2026
Owner
Author
There was a problem hiding this comment.
Reviewed against the project checklist. Verified locally on 863fd18: go build ./..., go vet ./..., go test ./... -race -cover (all packages pass, internal/web 83.9%), golangci-lint run → 0 issues., gofmt -l ./cmd ./internal → no output.
No new dependencies, no exec.Command or /proc//sys surface touched, no privilege change, and no /api/v1/... JSON shape change (the dropped Handler parameter is internal, single call site).
One finding inline: the "no validator on a body without stable identity" rule still leaks on the redirect paths http.FileServerFS takes.
A map hit only says the request path names an asset, not that the response body will be that asset. http.FileServerFS redirects "*/index.html" to "./" and a trailing slash on a file to its base, and both branch before serveContent runs — so GET /index.html and GET /app.js/ answered 301 with a strong validator on an empty body. A 301 is cacheable by default, making this the same defect as emitting an ETag on a 404, and a conditional GET /index.html returned 301 + Etag instead of 304 or 200. Keep setting the header before delegating (http.ServeContent answers If-None-Match from the writer) and wrap the ResponseWriter to remove it again unless the status is 200, 206, or 304 — a 304 must repeat the validator that matched. Add the unit tests both helpers were missing: a table-driven TestETagKey over the path shapes that reach them, and fstest.MapFS-backed TestAssetETags cases asserting the hash tracks contents rather than names, plus handler tests for the redirect responses.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Pull Request
📖 Description
A bug fix.
web.Handlercomputed one ETag from the build version and applied it to every embedded asset. Combined withCache-Control: no-cachethis caused three problems, all of which I re-verified againstmainbefore changing anything:make run/go run ./cmd/pimonitorleavesmain.versionat"dev", so the validator was the constant"dev"and never changed. Editinginternal/web/assets/app.jsand restarting gave the browser a matchingIf-None-Match,http.ServeContentanswered304 Not Modified, and the old JavaScript kept running until a hard reload. This undermined themake runfrontend-development workflow documented inCONTRIBUTING.md./,/app.js,/style.css,/chart.js,/gauge.jsand/theme-init.jsall returned the identical ETag, though each URL is a distinct resource with its own representation.GET /does-not-exist.jsreturned a validator for a body with no stable identity.The fix hashes each embedded file once at
Handler()construction (SHA-256, truncated to 16 bytes, hex-encoded) and serves that per-asset value as the ETag, set only for request paths that resolve to a real asset. The asset set is fixed at compile time and tiny, so this costs microseconds at startup and no per-request work. Setting the header only for known assets also resolves problem 3.Handlerno longer needs the version string, so the parameter is dropped rather than left as dead weight —internal/webis an internal package with a single call site (cmd/pimonitor/main.go). This is not a REST API change;/api/v1/configstill reportsversionexactly as before.🎫 Issues
Closes #102
👩💻 Reviewer Notes
etagKeyresolves a request path to the map key the same wayhttp.FileServerFSresolves a directory index, soGET /picks upindex.html's hash.W/prefix), which is correct here: the bytes are served verbatim from the embedded FS.internal/web/assets/app.js, restart, and confirm the ETag changed and a normal (non-hard) browser reload picks up the new file.📑 Test Plan
internal/web/embed_test.gowas reworked (docs/TESTS.mdconventions:Test<Subject>_<Scenario>, no real/proc//sysaccess — these run entirely against the embedded FS andhttptest).New coverage for the reported invariants:
TestHandler_ETagDiffersPerAsset— every asset URL returns a distinctEtag. This is the invariant that was broken; it fails againstmain, where all six share"dev".TestHandler_UnknownPathHasNoETag—GET /does-not-exist.jsis a 404 with noEtagheader. Also fails againstmain.TestHandler_ETagStableAcrossHandlers— twoHandler()instances produce the same ETag per asset, so a restart does not needlessly invalidate a client's cache.TestHandler_RefetchesOnStaleETag— a mismatchedIf-None-Match(the old"dev"validator) returns 200 with a body. ReplacesTestHandler_RefetchesOnVersionChange, which no longer has meaning.Adapted:
TestHandler_RevalidatesOnMatchingETag— now reads the servedEtagand echoes it back, rather than hardcoding the version, and asserts 304.TestHandler_SetsCacheHeaders— assertedEtag == "\"1.2.3\""and would fail by construction; it now asserts the header is a non-empty, double-quoted validator.Unchanged behavior tests (
ServesIndex,ServesStaticAssets,ServesThemeToggle,UnknownPath404s) were kept, updated only for the newHandler()signature.Verification run locally:
go build ./...— cleango vet ./...— cleango test ./... -race -cover— all packages pass;internal/webat 83.9% statement coveragegolangci-lint run—0 issues.gofmt -l ./cmd ./internal— no outputI also verified the fix end-to-end against a running binary, not just in unit tests: distinct ETags per asset over real HTTP,
If-None-Match: "dev"→ 200, a matching validator → 304, a 404 with noEtag, and — rebuilding after appending a line toapp.js— a changed ETag with the previously-cached validator returning 200. No Raspberry Pi hardware is involved in this change, so nothing here is left unverified for want of a Pi; it is pure static-asset serving.✅ Checklist
General
go test ./... -race -coverpasses locally).go vet ./...andgolangci-lint runare clean.ARCHITECTURE.mdif this changes a documented design decision.The "Web dashboard (
internal/web)" section documented the version-derived ETag and its rationale, which this change invalidates. It now describes the content hash, why it replaced the version string, and the 404 behavior.REST API / configuration / packaging
Not applicable — no REST API, configuration, or packaging surface is touched.
docs/API.md,README.md,packaging/and the systemd units are unchanged.⏭ Next Steps
None required. Worth noting for later:
Cache-Control: no-cachestill means one conditional request per asset per page load. Now that validators are content-derived, content-hashed filenames plus a longmax-agewould remove that round trip entirely — but that needs a rewrite step for theindex.htmlreferences and is well beyond this fix.