feat: auto-inject cache busters at server startup — eliminates merge conflicts (#481)

## 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 <you@example.com>
This commit is contained in:
Kpa-clawbot
2026-04-01 23:59:59 -07:00
committed by GitHub
co-authored by Kpa-clawbot Copilot you
parent f20431d816
commit bf2e721dd7
4 changed files with 137 additions and 38 deletions
+4 -8
View File
@@ -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 |
+79
View File
@@ -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 != "<html>SPA</html>" {
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 != "<html>SPA</html>" {
t.Errorf("expected SPA index.html content, got %s", body)
}
})
}
func TestSpaHandlerCacheBust(t *testing.T) {
dir := t.TempDir()
htmlWithBust := `<html><script src="app.js?v=__BUST__"></script><link href="style.css?v=__BUST__"></html>`
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) {
+26 -2
View File
@@ -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("<!DOCTYPE html><html><body><h1>CoreScope</h1><p>index.html not found</p></body></html>")
}
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
+28 -28
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="CoreScope">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/corescope/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1775111824">
<link rel="stylesheet" href="home.css?v=1775111824">
<link rel="stylesheet" href="live.css?v=1775111824">
<link rel="stylesheet" href="style.css?v=__BUST__">
<link rel="stylesheet" href="home.css?v=__BUST__">
<link rel="stylesheet" href="live.css?v=__BUST__">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -85,30 +85,30 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1775111824"></script>
<script src="customize.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1775111824"></script>
<script src="hop-resolver.js?v=1775111824"></script>
<script src="hop-display.js?v=1775111824"></script>
<script src="app.js?v=1775111824"></script>
<script src="home.js?v=1775111824"></script>
<script src="packet-filter.js?v=1775111824"></script>
<script src="packets.js?v=1775111824"></script>
<script src="geo-filter-overlay.js?v=1775111824"></script>
<script src="map.js?v=1775111825" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1775111824" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=__BUST__"></script>
<script src="customize.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=__BUST__"></script>
<script src="hop-resolver.js?v=__BUST__"></script>
<script src="hop-display.js?v=__BUST__"></script>
<script src="app.js?v=__BUST__"></script>
<script src="home.js?v=__BUST__"></script>
<script src="packet-filter.js?v=__BUST__"></script>
<script src="packets.js?v=__BUST__"></script>
<script src="geo-filter-overlay.js?v=__BUST__"></script>
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>