From bf2e721dd7b969f4bfe0f9246bb7ab1976c6c232 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Wed, 1 Apr 2026 23:59:59 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20auto-inject=20cache=20busters=20at=20se?= =?UTF-8?q?rver=20startup=20=E2=80=94=20eliminates=20merge=20conflicts=20(?= =?UTF-8?q?#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Every PR that touches `public/` files requires manually bumping cache buster timestamps in `index.html` (e.g. `?v=1775111407`). Since all PRs change the same lines in the same file, this causes **constant merge conflicts** — it's been the #1 source of unnecessary PR friction. ## Solution Replace all hardcoded `?v=TIMESTAMP` values in `index.html` with a `?v=__BUST__` placeholder. The Go server replaces `__BUST__` with the current Unix timestamp **once at startup** when it reads `index.html`, then serves the pre-processed HTML from memory. Every server restart automatically picks up fresh cache busters — no manual intervention needed. ## What changed | File | Change | |------|--------| | `public/index.html` | All `v=1775111407` → `v=__BUST__` (28 occurrences) | | `cmd/server/main.go` | `spaHandler` reads index.html at init, replaces `__BUST__` with Unix timestamp, serves from memory for `/`, `/index.html`, and SPA fallback | | `cmd/server/helpers_test.go` | New `TestSpaHandlerCacheBust` — verifies placeholder replacement works for root, SPA fallback, and direct `/index.html` requests. Also added tests for root `/` and `/index.html` routes | | `AGENTS.md` | Rule 3 updated: cache busters are now automatic, agents should not manually edit them | ## Testing - `go build ./...` — compiles cleanly - `go test ./...` — all tests pass (including new cache-bust tests) - `node test-frontend-helpers.js && node test-packet-filter.js && node test-aging.js` — all frontend tests pass - No hardcoded timestamps remain in `index.html` --------- Co-authored-by: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: you --- AGENTS.md | 12 ++---- cmd/server/helpers_test.go | 79 ++++++++++++++++++++++++++++++++++++++ cmd/server/main.go | 28 +++++++++++++- public/index.html | 56 +++++++++++++-------------- 4 files changed, 137 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f6ab4b7..90296b57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ public/ — Frontend (vanilla JS, one file per page) — ACTIVE, NOT style.css — Main styles, CSS variables for theming live.css — Live page styles home.css — Home page styles - index.html — SPA shell, script/style tags with cache busters + index.html — SPA shell, script/style tags with __BUST__ placeholder (auto-replaced at server startup) test-fixtures/ — Real data SQLite fixture from staging (used for E2E tests) scripts/ — Tooling (coverage collector, fixture capture, frontend instrumentation) ``` @@ -84,12 +84,8 @@ Every change that touches logic MUST have tests. For Go backend: `cd cmd/server ### 2. No commit without browser validation After pushing, verify the change works in an actual browser. Use `browser profile=openclaw` against the running instance. Take a screenshot if the change is visual. If you can't validate it, say so — don't claim it works. -### 3. Cache busters — ALWAYS bump them -Every time you change a `.js` or `.css` file in `public/`, bump the cache buster in `index.html`. This has caused 7 separate production regressions. Use: -```bash -NEWV=$(date +%s) && sed -i "s/v=[0-9]*/v=$NEWV/g" public/index.html -``` -Do this in the SAME commit as the code change, not as a follow-up. +### 3. Cache busters are automatic — do NOT manually edit them +Cache busters are injected automatically by the Go server at startup. The `__BUST__` placeholder in `index.html` is replaced with a Unix timestamp when the server reads the file. No manual bumping needed — every server restart picks up new asset versions. Do NOT replace `__BUST__` with hardcoded timestamps. ### 4. Verify API response shape before building UI Before writing client code that consumes an API endpoint, check what the endpoint ACTUALLY returns. Use `curl` or check the server code. Don't assume fields exist — grouped packets (`groupByHash=true`) have different fields than raw packets. This has caused multiple breakages. @@ -351,7 +347,7 @@ One logical change per commit. Each commit is deployable. Each commit has its te | Pitfall | Times it happened | Prevention | |---------|-------------------|------------| -| Forgot cache busters | 7 | Always bump in same commit | +| Forgot cache busters | 7 | Now automatic — `__BUST__` replaced at server startup | | Grouped packets missing fields | 3 | curl the actual API first | | last_seen vs last_heard mismatch | 4 | Always use `last_heard \|\| last_seen` | | CSS selectors don't match SVG | 2 | Manipulate SVG in JS after generation | diff --git a/cmd/server/helpers_test.go b/cmd/server/helpers_test.go index 9df42610..7ec5c703 100644 --- a/cmd/server/helpers_test.go +++ b/cmd/server/helpers_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -326,6 +327,84 @@ func TestSpaHandler(t *testing.T) { t.Errorf("expected no-cache header for .html, got %s", cc) } }) + + t.Run("root path serves index.html", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if body != "SPA" { + t.Errorf("expected SPA index.html content, got %s", body) + } + ct := w.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("expected text/html content type, got %s", ct) + } + }) + + t.Run("/index.html serves pre-processed content", func(t *testing.T) { + req := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != 200 { + t.Errorf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if body != "SPA" { + t.Errorf("expected SPA index.html content, got %s", body) + } + }) +} + +func TestSpaHandlerCacheBust(t *testing.T) { + dir := t.TempDir() + htmlWithBust := `` + os.WriteFile(filepath.Join(dir, "index.html"), []byte(htmlWithBust), 0644) + + fs := http.FileServer(http.Dir(dir)) + handler := spaHandler(dir, fs) + + t.Run("__BUST__ is replaced with a Unix timestamp", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "__BUST__") { + t.Errorf("__BUST__ placeholder was not replaced in response: %s", body) + } + // Verify it was replaced with digits (Unix timestamp) + if !strings.Contains(body, "v=") { + t.Errorf("expected v= query params in response, got: %s", body) + } + }) + + t.Run("SPA fallback also has busted values", func(t *testing.T) { + req := httptest.NewRequest("GET", "/nonexistent/route", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "__BUST__") { + t.Errorf("__BUST__ placeholder was not replaced in SPA fallback: %s", body) + } + }) + + t.Run("/index.html also has busted values", func(t *testing.T) { + req := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + body := w.Body.String() + if strings.Contains(body, "__BUST__") { + t.Errorf("__BUST__ placeholder was not replaced for /index.html: %s", body) + } + }) } func TestWriteJSON(t *testing.T) { diff --git a/cmd/server/main.go b/cmd/server/main.go index a1bde04c..a1abc4f2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -11,9 +11,9 @@ import ( "os" "os/exec" "os/signal" - "sync" "path/filepath" "strings" + "sync" "syscall" "time" @@ -242,11 +242,35 @@ func main() { } // spaHandler serves static files, falling back to index.html for SPA routes. +// It reads index.html once at creation time and replaces the __BUST__ placeholder +// with a Unix timestamp so browsers fetch fresh JS/CSS after each server restart. func spaHandler(root string, fs http.Handler) http.Handler { + // Pre-process index.html: replace __BUST__ with a cache-bust timestamp + indexPath := filepath.Join(root, "index.html") + rawHTML, err := os.ReadFile(indexPath) + if err != nil { + log.Printf("[static] warning: could not read index.html for cache-bust: %v", err) + rawHTML = []byte("

CoreScope

index.html not found

") + } + bustValue := fmt.Sprintf("%d", time.Now().Unix()) + indexHTML := []byte(strings.ReplaceAll(string(rawHTML), "__BUST__", bustValue)) + log.Printf("[static] cache-bust value: %s", bustValue) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Serve pre-processed index.html for root and /index.html + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write(indexHTML) + return + } + path := filepath.Join(root, r.URL.Path) if _, err := os.Stat(path); os.IsNotExist(err) { - http.ServeFile(w, r, filepath.Join(root, "index.html")) + // SPA fallback — serve pre-processed index.html + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write(indexHTML) return } // Disable caching for JS/CSS/HTML diff --git a/public/index.html b/public/index.html index e429a784..2440282c 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -85,30 +85,30 @@
- - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +