diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index 50f1c3d..889a662 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -69,6 +69,7 @@ func spaHandler(root string) http.Handler { } if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil { + setStaticCaching(w, upath) fs.ServeHTTP(w, r) return } @@ -81,10 +82,37 @@ func spaHandler(root string) http.Handler { http.NotFound(w, r) return } + setStaticCaching(w, "/index.html") http.ServeFile(w, r, index) }) } +// setStaticCaching entscheidet, was der Browser behalten darf. +// +// Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control, +// schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt. +// Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die +// alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem +// Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern +// als rätselhafter Fehler mitten in der Anwendung. +// +// Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst: +// +// - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt, +// ändert sich der Name. Solche Dateien darf der Browser für immer +// behalten – "immutable" spart ihm sogar die Rückfrage. +// - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei +// jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht +// speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet +// der Server dann meist nur mit 304 und ein paar Byte. +func setStaticCaching(w http.ResponseWriter, upath string) { + if strings.HasPrefix(upath, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + return + } + w.Header().Set("Cache-Control", "no-cache") +} + func main() { cfg, err := config.Load() if err != nil { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a717695..8c70a08 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,32 @@ import App from './App' // @ts-ignore import './index.css' +/** + * Sicherheitsnetz für den Fall, dass der Browser noch eine alte Fassung der + * Anwendung im Speicher hat und einen Baustein nachlädt, den es auf dem + * Server nicht mehr gibt. + * + * Die Bausteine tragen einen Hash im Namen; nach einem Update sind die alten + * fort. Wer die Seite offen liegen lässt und dann auf einen Bereich klickt, + * der erst dann geladen wird (der Kalender, der PDF-Betrachter), bekommt sonst + * einen Fehler, der nach einem Programmfehler aussieht – oder es passiert + * schlicht nichts. + * + * Einmal neu laden holt die neue Fassung. Das `sessionStorage`-Merkmal + * verhindert dabei das Schlimmere: Läge der Fehler doch woanders, drehte sich + * die Seite sonst endlos im Kreis. + */ +window.addEventListener('vite:preloadError', () => { + if (sessionStorage.getItem('aether-reloaded-for-update')) return + sessionStorage.setItem('aether-reloaded-for-update', '1') + window.location.reload() +}) +window.addEventListener('load', () => { + // Erfolgreich geladen: Die Sperre darf wieder fallen, sonst hilft sie beim + // nächsten Update nicht mehr. + setTimeout(() => sessionStorage.removeItem('aether-reloaded-for-update'), 5000) +}) + ReactDOM.createRoot(document.getElementById('root')!).render(