carlos / platform Public

edge: static responses revalidate, on a version ETag

A static response carried Last-Modified and nothing else, so browsers
guessed a freshness lifetime from the file's age and reused their copy
without asking: a site last deployed a month ago kept showing the old
page for days after the next deploy (#195).

Every answer out of a site tree now sends Cache-Control: no-cache, and
each file an ETag naming the version that served it. Revalidation is a
304 until a deploy changes the version. The mtime was also the wrong
validator: it differs between edges and runs backwards on a rollback.
Human

Prompt

OK, go ahead and message it. And let's fix all the bugs.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Static sites send Cache-Control: no-cache and a version ETag, so a deploy shows on the next load instead of after the browser's guessed freshness lifetime.
Agent notes
Discussion #195, item 1 only. Item 2 (letting an app mark paths immutable) is not built. No user-facing strings. A browser entry cached before this change holds no ETag and revalidates on If-Modified-Since alone, so a rollback can still read as unmodified for that one entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d758eadb411ab7d88178d7b8b864276206364920 parent 8d8e4ba
2 files changed, +133 −3
  • internal/edge/static.go +26 −3
  • internal/edge/static_cache_test.go +107 −0

CI passed — run details

diff --git a/internal/edge/static.go b/internal/edge/static.go
index ea43c68ba..da2eba89c 100644
--- a/internal/edge/static.go
+++ b/internal/edge/static.go
@@ -163,7 +163,16 @@ func (s *staticSite) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Set before any body is written, so it lands on the tree's own 404s too
// (the handler serving that tree IS a version).
w.Header().Set("X-Carlos-Version", version)
- s.serveTree(dir, w, r)
+ // Every answer out of the tree is revalidated, its 404s included. With
+ // Last-Modified alone a browser GUESSES a freshness lifetime — typically a
+ // tenth of the file's age — and reuses its copy without asking, so a site
+ // last deployed a month ago kept showing the old page for days after the
+ // next deploy (discussion #195). no-cache is "store it, but ask first";
+ // the ETag serveTree sets is what makes asking cost a 304. It also keeps
+ // these responses out of the edge's own shared cache, which admits only a
+ // positive lifetime — as they always were, having named none.
+ w.Header().Set("Cache-Control", "no-cache")
+ s.serveTree(dir, version, w, r)
}
// dir is where version's tree lives once extracted.
@@ -363,7 +372,19 @@ func extractSiteTar(data []byte, dst string) error {
// serveTree answers one request from an extracted version dir:
// http.FileServer semantics with index.html for directories and a clean 404
// (never a directory listing) when neither the file nor an index exists.
-func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Request) {
+//
+// The validator is the VERSION, never the file's mtime. An extracted tree's
+// mtimes say when THIS box unpacked it: they differ between edges serving the
+// same build, and they run backwards on a rollback — the tree rolled back to
+// was unpacked before the one the browser holds, so If-Modified-Since alone
+// answers "not modified" and the browser keeps the build that was withdrawn.
+// A version names its bytes exactly (trees are immutable once extracted), is
+// the same on every edge, and http.ServeFile checks If-None-Match ahead of
+// If-Modified-Since. One value for every path is sound: an entity tag is
+// scoped to its URL. version is path-safe by construction (ValidVersion:
+// [a-zA-Z0-9._-]), so it needs no quoting beyond the quotes. The edge's
+// compression weakens it when it gzips (compress.go, weakenETag).
+func (s *staticSite) serveTree(dir, version string, w http.ResponseWriter, r *http.Request) {
// Clean rooted at "/" so no request path can climb out of the tree.
p := path.Clean("/" + r.URL.Path)
fp := filepath.Join(dir, filepath.FromSlash(p))
@@ -376,6 +397,8 @@ func (s *staticSite) serveTree(dir string, w http.ResponseWriter, r *http.Reques
http.NotFound(w, r)
return
}
- // ServeFile brings Content-Type, ranges, If-Modified-Since for free.
+ // ServeFile brings Content-Type, ranges and the conditional-request
+ // handling for free; it honours an ETag set before the call.
+ w.Header().Set("ETag", `"`+version+`"`)
http.ServeFile(w, r, fp)
}
diff --git a/internal/edge/static_cache_test.go b/internal/edge/static_cache_test.go
new file mode 100644
index 000000000..d1bb8b53e
--- /dev/null
+++ b/internal/edge/static_cache_test.go
@@ -0,0 +1,107 @@
+package edge
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// condGet is a GET with the validators a browser holding a cached copy sends.
+func condGet(t *testing.T, base, host, path string, hdr map[string]string) (*http.Response, string) {
+ t.Helper()
+ req, err := http.NewRequest("GET", base+path, nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = host
+ for k, v := range hdr {
+ req.Header.Set(k, v)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET %s%s (host %s): %v", base, path, host, err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ return resp, string(b)
+}
+
+// A static response used to carry Last-Modified and nothing else, and a
+// browser handed only that guesses a freshness lifetime — typically a tenth of
+// the file's age — and reuses its copy without asking. A site last deployed a
+// month ago kept showing the old page for days after the next deploy
+// (discussion #195). no-cache makes every load ask; the version ETag makes the
+// answer a cheap 304 until a deploy actually changes it.
+func TestStaticResponsesMakeTheBrowserRevalidate(t *testing.T) {
+ _, _, _, srv := staticFixture(t)
+
+ for _, path := range []string{"/", "/docs/page.html", "/style.css"} {
+ resp := getPathResp(t, srv.URL, "site.example.com", path)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GET %s = %d, want 200", path, resp.StatusCode)
+ }
+ if got := resp.Header.Get("Cache-Control"); got != "no-cache" {
+ t.Errorf("GET %s Cache-Control = %q, want %q", path, got, "no-cache")
+ }
+ if got := resp.Header.Get("ETag"); got != `"v1"` {
+ t.Errorf("GET %s ETag = %q, want %q (the version whose tree answered)", path, got, `"v1"`)
+ }
+ }
+
+ // The tree's own 404 must not be cached on a guess either: the page it
+ // stands in for may exist in the next deploy. It names no representation,
+ // so it carries no validator.
+ resp := getPathResp(t, srv.URL, "site.example.com", "/missing.html")
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("GET /missing.html = %d, want 404", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Cache-Control"); got != "no-cache" {
+ t.Errorf("404 Cache-Control = %q, want %q", got, "no-cache")
+ }
+ if got := resp.Header.Get("ETag"); got != "" {
+ t.Errorf("404 ETag = %q, want none", got)
+ }
+}
+
+// The validator is the VERSION, not the file's mtime. An unchanged version
+// revalidates to a 304; a deploy answers 200 with the new bytes; and so does a
+// rollback — where the mtime gets it wrong, because the tree rolled back to was
+// extracted BEFORE the one the browser holds, so If-Modified-Since alone reads
+// "not modified" and the browser keeps the build that was just withdrawn.
+func TestStaticValidatorFollowsTheVersion(t *testing.T) {
+ store, _, priv, srv := staticFixture(t)
+ promote := func(ver string) {
+ t.Helper()
+ if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", ver); err != nil {
+ t.Fatalf("Promote %s: %v", ver, err)
+ }
+ }
+
+ resp, _ := condGet(t, srv.URL, "site.example.com", "/", map[string]string{"If-None-Match": `"v1"`})
+ if resp.StatusCode != http.StatusNotModified {
+ t.Fatalf("revalidating v1 against v1 = %d, want 304", resp.StatusCode)
+ }
+
+ shipSite(t, store, "site", "v2", map[string]string{"index.html": "home v2"})
+ promote("v2")
+ resp, body := condGet(t, srv.URL, "site.example.com", "/", map[string]string{"If-None-Match": `"v1"`})
+ if resp.StatusCode != http.StatusOK || body != "home v2" {
+ t.Fatalf("revalidating v1 after a deploy = %d %q, want 200 %q", resp.StatusCode, body, "home v2")
+ }
+ if got := resp.Header.Get("ETag"); got != `"v2"` {
+ t.Fatalf("ETag after the deploy = %q, want %q", got, `"v2"`)
+ }
+ v2Modified := resp.Header.Get("Last-Modified")
+
+ promote("v1") // roll back to a tree extracted before v2's
+ resp, body = condGet(t, srv.URL, "site.example.com", "/", map[string]string{
+ "If-None-Match": `"v2"`,
+ "If-Modified-Since": v2Modified,
+ })
+ if resp.StatusCode != http.StatusOK || body != "home v1" {
+ t.Fatalf("revalidating v2 after a rollback = %d %q, want 200 %q", resp.StatusCode, body, "home v1")
+ }
+}