Skip to content

Derive static asset ETags from content instead of the build version - #126

Merged
LarsLaskowski merged 2 commits into
mainfrom
claude/wizardly-turing-yor7mc
Aug 23, 2026
Merged

Derive static asset ETags from content instead of the build version#126
LarsLaskowski merged 2 commits into
mainfrom
claude/wizardly-turing-yor7mc

Conversation

@LarsLaskowski

@LarsLaskowski LarsLaskowski commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Pull Request

📖 Description

A bug fix. web.Handler computed one ETag from the build version and applied it to every embedded asset. Combined with Cache-Control: no-cache this caused three problems, all of which I re-verified against main before changing anything:

  1. Unversioned builds served permanently stale assets. make run / go run ./cmd/pimonitor leaves main.version at "dev", so the validator was the constant "dev" and never changed. Editing internal/web/assets/app.js and restarting gave the browser a matching If-None-Match, http.ServeContent answered 304 Not Modified, and the old JavaScript kept running until a hard reload. This undermined the make run frontend-development workflow documented in CONTRIBUTING.md.
  2. One validator for many resources. /, /app.js, /style.css, /chart.js, /gauge.js and /theme-init.js all returned the identical ETag, though each URL is a distinct resource with its own representation.
  3. 404s carried the ETag. The header was set unconditionally before delegating to the file server, so GET /does-not-exist.js returned 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.

Handler no longer needs the version string, so the parameter is dropped rather than left as dead weight — internal/web is an internal package with a single call site (cmd/pimonitor/main.go). This is not a REST API change; /api/v1/config still reports version exactly as before.

🎫 Issues

Closes #102

👩‍💻 Reviewer Notes

  • etagKey resolves a request path to the map key the same way http.FileServerFS resolves a directory index, so GET / picks up index.html's hash.
  • The ETag is a strong validator (double-quoted, no W/ prefix), which is correct here: the bytes are served verbatim from the embedded FS.
  • Truncating SHA-256 to 16 bytes is for header brevity, not security — this is a cache validator, not an integrity check.
  • Smoke test, matching the reproduction in the issue:
    make run   # separate terminal
    curl -s -D- -o /dev/null http://localhost:8080/app.js    | grep -i etag
    curl -s -D- -o /dev/null http://localhost:8080/style.css | grep -i etag   # different value
    curl -s -o /dev/null -w '%{http_code}\n' -H 'If-None-Match: "dev"' http://localhost:8080/app.js   # 200, was 304
    curl -s -D- -o /dev/null http://localhost:8080/does-not-exist.js | grep -i etag                   # no Etag
    Then append a line to 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.go was reworked (docs/TESTS.md conventions: Test<Subject>_<Scenario>, no real /proc//sys access — these run entirely against the embedded FS and httptest).

New coverage for the reported invariants:

  • TestHandler_ETagDiffersPerAsset — every asset URL returns a distinct Etag. This is the invariant that was broken; it fails against main, where all six share "dev".
  • TestHandler_UnknownPathHasNoETagGET /does-not-exist.js is a 404 with no Etag header. Also fails against main.
  • TestHandler_ETagStableAcrossHandlers — two Handler() instances produce the same ETag per asset, so a restart does not needlessly invalidate a client's cache.
  • TestHandler_RefetchesOnStaleETag — a mismatched If-None-Match (the old "dev" validator) returns 200 with a body. Replaces TestHandler_RefetchesOnVersionChange, which no longer has meaning.

Adapted:

  • TestHandler_RevalidatesOnMatchingETag — now reads the served Etag and echoes it back, rather than hardcoding the version, and asserts 304.
  • TestHandler_SetsCacheHeaders — asserted Etag == "\"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 new Handler() signature.

Verification run locally:

  • go build ./... — clean
  • go vet ./... — clean
  • go test ./... -race -cover — all packages pass; internal/web at 83.9% statement coverage
  • golangci-lint run0 issues.
  • gofmt -l ./cmd ./internal — no output

I 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 no Etag, and — rebuilding after appending a line to app.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

  • I have added/updated tests for my changes (go test ./... -race -cover passes locally).
  • go vet ./... and golangci-lint run are clean.
  • I have tested my changes.
  • I have read the CONTRIBUTING documentation and followed the project's code style guidelines.
  • I have updated ARCHITECTURE.md if 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-cache still means one conditional request per asset per page load. Now that validators are content-derived, content-hashed filenames plus a long max-age would remove that round trip entirely — but that needs a rewrite step for the index.html references and is well beyond this fix.

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 LarsLaskowski left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 run0 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.

Comment thread internal/web/embed.go
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.
@sonarqubecloud

Copy link
Copy Markdown

@LarsLaskowski
LarsLaskowski merged commit 43a837c into main Aug 23, 2026
5 checks passed
@LarsLaskowski
LarsLaskowski deleted the claude/wizardly-turing-yor7mc branch August 23, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static asset ETag is derived only from version, serving stale assets on dev builds

1 participant