diff --git a/.gitignore b/.gitignore index b070237..a669939 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .env data/ +public/assets/* +!public/assets/.gitkeep __pycache__/ *.py[cod] *.egg-info/ @@ -7,3 +9,5 @@ __pycache__/ venv/ .DS_Store *.egg-info/ +public/assets/* +!public/assets/.gitkeep diff --git a/Dockerfile b/Dockerfile index c5e4e1e..3382e66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,11 @@ COPY pyproject.toml /app/pyproject.toml COPY server /app/server RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir . -COPY public /app/public -COPY meds.yaml messages.yaml /app/ +COPY frontend /app/frontend COPY tools /app/tools +RUN python tools/build_frontend.py + +COPY meds.yaml messages.yaml /app/ EXPOSE 8000 diff --git a/README.md b/README.md index cbe17dc..5ebb04e 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,14 @@ pip install -e . cp .env.example .env # APP_PIN und JWT_SECRET anpassen -# 3. VAPID keys (für Push) +# 3. Frontend bauen (gehashte Assets) +python tools/build_frontend.py + +# 4. VAPID keys (für Push) python tools/gen_vapid.py # Output in .env eintragen -# 4. Starten +# 5. Starten uvicorn server.app:app --reload --host 0.0.0.0 --port 8000 ``` @@ -190,14 +193,15 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min** ``` takeyourmeds/ +├── frontend/ # PWA-Quellen (JS, CSS, Templates) ├── compose.yml # Docker + Traefik ├── Dockerfile ├── meds.yaml # Medikamenten-Zeitplan ├── messages.yaml # Humor-Pool ├── icon.png / icon.svg # App-Icons (Quelle) ├── server/ # FastAPI Backend -├── public/ # PWA Frontend -├── tools/ # schema.sql, gen_vapid.py +├── public/ # Build-Output (index.html, sw.js, assets/) +├── tools/ # schema.sql, gen_vapid.py, build_frontend.py └── data/ # SQLite (gitignored) ``` @@ -207,10 +211,13 @@ takeyourmeds/ ```bash source .venv/bin/activate +python tools/build_frontend.py # nach Änderungen in frontend/ uvicorn server.app:app --reload --port 8000 ``` -Service Worker cached aggressiv — für SW-Änderungen: DevTools → Application → Clear storage, oder Inkognito. +Quellen liegen in `frontend/`; Build-Output in `public/` (gehashte JS/CSS unter `public/assets/`). PWA-Update-Strategie: `docs/PWA-STRATEGY.md`. + +Service Worker: bei Deploy neuer `CACHE`-Name automatisch via Build. Update-Banner erscheint bei veralteter Client-Version. --- diff --git a/docs/PWA-STRATEGY.md b/docs/PWA-STRATEGY.md new file mode 100644 index 0000000..4628bfe --- /dev/null +++ b/docs/PWA-STRATEGY.md @@ -0,0 +1,56 @@ +# PWA Update-Strategie + +Wiederverwendbare Strategie für Fränkys PWAs — damit Nutzer:innen **ohne Cache leeren** immer die aktuelle Version sehen. + +> Gitea-Wiki: manuell anlegen unter [PWA-Update-Strategie](https://gitea.schwenk.online/froxxxy/takeyourmeds/wiki/_pages) (MCP-Token hatte keine Wiki-Schreibrechte). + +## Warum? + +- Viele Nutzer:innen wissen nicht, was „Cache leeren" bedeutet (besonders auf dem Handy / als installierte PWA). +- Service Worker können alte Dateien **monatelang** vorhalten — ein Deploy reicht nicht. +- Ziel: Updates **sichtbar und kontrolliert** (Banner + Reload), nicht stilles Veralten. + +## Drei Ebenen (Tiers) + +### Tier 1 — Service Worker & Client + +| Element | Strategie | +|---------|-----------| +| `sw.js` | Nicht vom SW intercepten; Server: `Cache-Control: no-cache` | +| `index.html` | Network-first | +| Unversioniertes JS/CSS | Network-first | +| Cache-Name | Pro Build: `tym-v{version}-{buildHash}` | +| `version.json` | Network-only im SW; Client-Check beim Start/Focus | +| Update-Banner | `buildHash` ≠ `meta app-build` → „Aktualisieren" | + +### Tier 2 — Server & Lifecycle + +| Element | Maßnahme | +|---------|----------| +| HTTP-Header | `server/static_cache.py` Middleware | +| Focus | `registration.update()` + `version.json` Check | +| `controllerchange` | Einmalig `location.reload()` | + +### Tier 3 — Build + +| Element | Maßnahme | +|---------|----------| +| Quellen | `frontend/` | +| Output | `public/assets/*.{hash}.js/css` | +| Build | `python tools/build_frontend.py` (läuft im Dockerfile) | +| Gehashte Assets | `Cache-Control: immutable` | + +## Deploy-Checkliste + +- [ ] Version in `pyproject.toml` bumpen (optional) +- [ ] `python tools/build_frontend.py` (Docker macht das automatisch) +- [ ] `docker compose up -d --build` +- [ ] Inkognito: `sw.js` neue CACHE-Konstante, `version.json` neuer `buildHash` +- [ ] Alte PWA-Installation: Update-Banner nach App-Öffnung + +## Anti-Patterns + +- Cache-first für unversioniertes `app.js` +- `sw.js` im Precache +- Stub-Endpoints für fehlende APIs +- „Cache leeren" als einziger Fix in der Doku diff --git a/public/app.js b/frontend/app.js similarity index 98% rename from public/app.js rename to frontend/app.js index a305067..4dfb952 100644 --- a/public/app.js +++ b/frontend/app.js @@ -3,6 +3,7 @@ import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js"; import { setStreakBadge } from "./lib/badge.js"; import { enqueue, flushQueue } from "./lib/offlineQueue.js"; import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js"; +import { wireAppUpdates } from "./lib/updates.js"; import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js"; const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" }; @@ -179,6 +180,7 @@ navigator.serviceWorker?.addEventListener("message", (event) => { async function boot() { registerPwa(); + wireAppUpdates(); if (isLoggedIn()) { try { await initApp(); return; } catch { clearToken(); } } diff --git a/frontend/icon-192x192.png b/frontend/icon-192x192.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/frontend/icon-192x192.png differ diff --git a/frontend/icon-512x512.png b/frontend/icon-512x512.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/frontend/icon-512x512.png differ diff --git a/frontend/icon-72x72.png b/frontend/icon-72x72.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/frontend/icon-72x72.png differ diff --git a/frontend/icon.png b/frontend/icon.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/frontend/icon.png differ diff --git a/frontend/index.template.html b/frontend/index.template.html new file mode 100644 index 0000000..31e438d --- /dev/null +++ b/frontend/index.template.html @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + TakeYourMeds + + +
+ +
+
+ +

TakeYourMeds

+

Dein Gehirn. Deine Medis. Unser Sarkasmus.

+
+
+ + +
+ +
+ + + +
+ + + + + + + + + diff --git a/public/lib/api.js b/frontend/lib/api.js similarity index 100% rename from public/lib/api.js rename to frontend/lib/api.js diff --git a/public/lib/auth.js b/frontend/lib/auth.js similarity index 100% rename from public/lib/auth.js rename to frontend/lib/auth.js diff --git a/public/lib/badge.js b/frontend/lib/badge.js similarity index 100% rename from public/lib/badge.js rename to frontend/lib/badge.js diff --git a/public/lib/offlineQueue.js b/frontend/lib/offlineQueue.js similarity index 100% rename from public/lib/offlineQueue.js rename to frontend/lib/offlineQueue.js diff --git a/public/lib/pwa.js b/frontend/lib/pwa.js similarity index 90% rename from public/lib/pwa.js rename to frontend/lib/pwa.js index cbe9941..d3803e2 100644 --- a/public/lib/pwa.js +++ b/frontend/lib/pwa.js @@ -36,12 +36,13 @@ export function wirePwaInstall({ sectionId = "pwaInstallSection", buttonId = "in export async function registerPwa() { if (!("serviceWorker" in navigator)) return; try { - await navigator.serviceWorker.register("/sw.js"); + const reg = await navigator.serviceWorker.register("/sw.js"); if ("sync" in ServiceWorkerRegistration.prototype) { - navigator.serviceWorker.ready.then((reg) => { - reg.sync?.register("sync-logs").catch(() => {}); + navigator.serviceWorker.ready.then((readyReg) => { + readyReg.sync?.register("sync-logs").catch(() => {}); }); } + return reg; } catch (e) { console.error("SW registration failed:", e); } diff --git a/public/lib/ui.js b/frontend/lib/ui.js similarity index 100% rename from public/lib/ui.js rename to frontend/lib/ui.js diff --git a/frontend/lib/updates.js b/frontend/lib/updates.js new file mode 100644 index 0000000..1c3ad4c --- /dev/null +++ b/frontend/lib/updates.js @@ -0,0 +1,62 @@ +let bannerVisible = false; + +function showUpdateBanner() { + if (bannerVisible) return; + const banner = document.getElementById("updateBanner"); + if (!banner) return; + banner.hidden = false; + bannerVisible = true; +} + +async function applyUpdate() { + const reg = await navigator.serviceWorker?.getRegistration(); + if (reg?.waiting) { + reg.waiting.postMessage({ type: "SKIP_WAITING" }); + } else { + await reg?.update(); + location.reload(); + } +} + +export function wireAppUpdates() { + if (!("serviceWorker" in navigator)) return; + + let refreshing = false; + navigator.serviceWorker.addEventListener("controllerchange", () => { + if (refreshing) return; + refreshing = true; + location.reload(); + }); + + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type === "UPDATE_AVAILABLE") showUpdateBanner(); + }); + + const button = document.getElementById("updateBannerBtn"); + button?.addEventListener("click", () => applyUpdate()); + + const checkVersion = async () => { + try { + const reg = await navigator.serviceWorker.getRegistration(); + await reg?.update(); + + const built = document.querySelector('meta[name="app-build"]')?.content; + if (!built) return; + + const res = await fetch("/version.json", { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + if (data.buildHash && data.buildHash !== built) { + showUpdateBanner(); + } + } catch { + /* ignore — offline or transient */ + } + }; + + window.addEventListener("focus", checkVersion); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") checkVersion(); + }); + checkVersion(); +} diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..bbb96c1 --- /dev/null +++ b/frontend/manifest.json @@ -0,0 +1,48 @@ +{ + "name": "TakeYourMeds", + "short_name": "Medis", + "description": "Medikamenten-Erinnerung mit Sarkasmus und Dopamin", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#F12F12", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Genommen (Morgens)", + "short_name": "Morgens ✓", + "url": "/?action=take&slot=morning", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192" + } + ] + }, + { + "name": "Genommen (Mittags)", + "short_name": "Mittags ✓", + "url": "/?action=take&slot=noon", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192" + } + ] + } + ] +} \ No newline at end of file diff --git a/public/styles.css b/frontend/styles.css similarity index 94% rename from public/styles.css rename to frontend/styles.css index 3bcf6fe..3dcd7a4 100644 --- a/public/styles.css +++ b/frontend/styles.css @@ -244,3 +244,15 @@ body { } .easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; } .easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; } + +/* PWA update banner */ +.updateBanner { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 1500; + display: flex; align-items: center; justify-content: space-between; gap: 1rem; + padding: .85rem 1rem calc(.85rem + env(safe-area-inset-bottom)); + background: linear-gradient(135deg, var(--primary), #c41f1f); + color: #fff; box-shadow: 0 -4px 20px rgba(0,0,0,.35); + font-weight: 500; +} +.updateBanner[hidden] { display: none !important; } +.updateBanner .btn { flex-shrink: 0; padding: .5rem 1rem; font-size: .9rem; } diff --git a/frontend/sw.template.js b/frontend/sw.template.js new file mode 100644 index 0000000..dcc0759 --- /dev/null +++ b/frontend/sw.template.js @@ -0,0 +1,152 @@ +const CACHE = "{{CACHE_VERSION}}"; +const ASSETS = {{ASSETS_JSON}}; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((names) => + Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); +}); + +function isHashedAsset(pathname) { + return pathname.startsWith("/assets/"); +} + +async function networkOnly(request) { + return fetch(request); +} + +async function networkFirst(request, fallbackPath) { + try { + const response = await fetch(request); + if (response.ok) return response; + } catch { + /* offline */ + } + const cached = await caches.match(request); + if (cached) return cached; + if (fallbackPath) { + const fallback = await caches.match(fallbackPath); + if (fallback) return fallback; + } + return new Response("Offline", { status: 503, statusText: "Offline" }); +} + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE); + await cache.put(request, response.clone()); + } + return response; +} + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + // sw.js: never intercept — browser must revalidate with server + if (url.pathname === "/sw.js") return; + + if (url.pathname.startsWith("/api/")) { + event.respondWith( + fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), { + status: 503, + headers: { "Content-Type": "application/json" }, + })) + ); + return; + } + + if (url.pathname === "/version.json") { + event.respondWith(networkOnly(request)); + return; + } + + if (request.mode === "navigate") { + event.respondWith(networkFirst(request, "/index.html")); + return; + } + + if (isHashedAsset(url.pathname)) { + event.respondWith(cacheFirst(request)); + return; + } + + if (url.pathname === "/index.html" || url.pathname === "/manifest.json") { + event.respondWith(networkFirst(request)); + return; + } + + // Icons and other static root files + event.respondWith(networkFirst(request)); +}); + +self.addEventListener("push", (event) => { + if (!event.data) return; + let data = {}; + try { data = event.data.json(); } catch { data = { body: event.data.text() }; } + + const options = { + body: data.body || "Med-Time!", + icon: "/icon-192x192.png", + badge: "/icon-72x72.png", + silent: data.silent !== false, + tag: data.tag || "med-reminder", + renotify: true, + data: { slot_id: data.slot_id }, + actions: [ + { action: "take", title: "Genommen ✓" }, + { action: "snooze15", title: "+15 Min" }, + { action: "snooze30", title: "+30 Min" }, + ], + }; + + event.waitUntil( + self.registration.showNotification(data.title || "TakeYourMeds", options) + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const slotId = event.notification.data?.slot_id; + const action = event.action; + + if (action === "take" && slotId) { + event.waitUntil(self.clients.openWindow(`/?action=take&slot=${slotId}`)); + return; + } + + if ((action === "snooze15" || action === "snooze30") && slotId) { + const minutes = action === "snooze15" ? 15 : 30; + event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`)); + return; + } + + event.waitUntil(self.clients.openWindow("/")); +}); + +self.addEventListener("sync", (event) => { + if (event.tag === "sync-logs") { + event.waitUntil( + self.clients.matchAll().then((list) => { + list.forEach((c) => c.postMessage({ type: "SYNC_QUEUE" })); + }) + ); + } +}); diff --git a/public/assets/.gitkeep b/public/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/index.html b/public/index.html index 9f81e31..0156c15 100644 --- a/public/index.html +++ b/public/index.html @@ -5,12 +5,13 @@ + - + TakeYourMeds @@ -91,9 +92,13 @@ + - + diff --git a/public/sw.js b/public/sw.js index c0ae502..7e5c7e2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,20 +1,22 @@ -const CACHE = "tym-v1.0.1"; +const CACHE = "tym-v1.1.0-914619f3"; const ASSETS = [ "/", "/index.html", - "/styles.css", - "/app.js", + "/assets/api.89363e2a.js", + "/assets/app.58f3e95c.js", + "/assets/auth.a6c28cdb.js", + "/assets/badge.30848c28.js", + "/assets/offlineQueue.0a9bbfdd.js", + "/assets/pwa.b6a6d909.js", + "/assets/styles.993b74f1.css", + "/assets/ui.47fc1625.js", + "/assets/updates.57073981.js", "/manifest.json", - "/version.json", "/icon.png", + "/icon-72x72.png", "/icon-192x192.png", "/icon-512x512.png", - "/lib/api.js", - "/lib/auth.js", - "/lib/badge.js", - "/lib/offlineQueue.js", - "/lib/pwa.js", - "/lib/ui.js", + "/version.json" ]; self.addEventListener("install", (event) => { @@ -31,37 +33,86 @@ self.addEventListener("activate", (event) => { ); }); +self.addEventListener("message", (event) => { + if (event.data?.type === "SKIP_WAITING") self.skipWaiting(); +}); + +function isHashedAsset(pathname) { + return pathname.startsWith("/assets/"); +} + +async function networkOnly(request) { + return fetch(request); +} + +async function networkFirst(request, fallbackPath) { + try { + const response = await fetch(request); + if (response.ok) return response; + } catch { + /* offline */ + } + const cached = await caches.match(request); + if (cached) return cached; + if (fallbackPath) { + const fallback = await caches.match(fallbackPath); + if (fallback) return fallback; + } + return new Response("Offline", { status: 503, statusText: "Offline" }); +} + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(CACHE); + await cache.put(request, response.clone()); + } + return response; +} + self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") return; const url = new URL(request.url); if (url.origin !== self.location.origin) return; - if (url.pathname.startsWith("/api/")) { - event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), { - status: 503, headers: { "Content-Type": "application/json" }, - }))); - return; - } + // sw.js: never intercept — browser must revalidate with server + if (url.pathname === "/sw.js") return; - if (request.mode === "navigate") { + if (url.pathname.startsWith("/api/")) { event.respondWith( - fetch(request).catch(() => caches.match("/index.html")) + fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), { + status: 503, + headers: { "Content-Type": "application/json" }, + })) ); return; } - event.respondWith( - caches.match(request, { ignoreSearch: true }).then((cached) => - cached || fetch(request).then((resp) => { - if (resp.ok) { - const clone = resp.clone(); - caches.open(CACHE).then((c) => c.put(request, clone)); - } - return resp; - }) - ) - ); + if (url.pathname === "/version.json") { + event.respondWith(networkOnly(request)); + return; + } + + if (request.mode === "navigate") { + event.respondWith(networkFirst(request, "/index.html")); + return; + } + + if (isHashedAsset(url.pathname)) { + event.respondWith(cacheFirst(request)); + return; + } + + if (url.pathname === "/index.html" || url.pathname === "/manifest.json") { + event.respondWith(networkFirst(request)); + return; + } + + // Icons and other static root files + event.respondWith(networkFirst(request)); }); self.addEventListener("push", (event) => { @@ -89,31 +140,19 @@ self.addEventListener("push", (event) => { ); }); -async function apiPost(path, body) { - const clients = await self.clients.matchAll({ type: "window" }); - for (const client of clients) { - client.postMessage({ type: "API_ACTION", path, body }); - return; - } -} - self.addEventListener("notificationclick", (event) => { event.notification.close(); const slotId = event.notification.data?.slot_id; const action = event.action; if (action === "take" && slotId) { - event.waitUntil( - self.clients.openWindow(`/?action=take&slot=${slotId}`) - ); + event.waitUntil(self.clients.openWindow(`/?action=take&slot=${slotId}`)); return; } if ((action === "snooze15" || action === "snooze30") && slotId) { const minutes = action === "snooze15" ? 15 : 30; - event.waitUntil( - self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`) - ); + event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`)); return; } diff --git a/public/version.json b/public/version.json index a20213f..0183054 100644 --- a/public/version.json +++ b/public/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", - "buildTime": "2026-06-09T19:02:13.248375Z", - "buildHash": "92521fc3cbd964bd" -} \ No newline at end of file + "version": "1.1.0", + "buildTime": "2026-07-08T06:50:31.880953Z", + "buildHash": "914619f3" +} diff --git a/pyproject.toml b/pyproject.toml index 4d9880c..1ed11a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "takeyourmeds" -version = "1.0.0" +version = "1.1.0" description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor" requires-python = ">=3.11" dependencies = [ diff --git a/server/app.py b/server/app.py index 032c652..649d5f3 100644 --- a/server/app.py +++ b/server/app.py @@ -20,6 +20,7 @@ from server.push import save_subscription, send_slot_reminder from server.scheduler import schedule_snooze, start_scheduler from server.settings import settings from server.slots import build_today +from server.static_cache import CacheControlMiddleware from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS NO_STORE = {"Cache-Control": "no-store"} @@ -63,6 +64,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan) +app.add_middleware(CacheControlMiddleware) @app.post("/api/auth/pin") diff --git a/server/settings.py b/server/settings.py index ab80bad..42173c2 100644 --- a/server/settings.py +++ b/server/settings.py @@ -22,7 +22,7 @@ class Settings(BaseSettings): messages_path: str = "messages.yaml" public_dir: str = "public" - app_version: str = "1.0.0" + app_version: str = "1.1.0" settings = Settings() # type: ignore[call-arg] diff --git a/server/static_cache.py b/server/static_cache.py new file mode 100644 index 0000000..8a68a6b --- /dev/null +++ b/server/static_cache.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from starlette.types import ASGIApp, Receive, Scope, Send + +NO_CACHE = b"no-cache, must-revalidate" +NO_STORE = b"no-store" +IMMUTABLE = b"public, max-age=31536000, immutable" + + +def _cache_control_for_path(path: str) -> bytes | None: + if path in ("/sw.js", "/index.html", "/manifest.json"): + return NO_CACHE + if path == "/version.json": + return NO_STORE + if path.startswith("/assets/"): + return IMMUTABLE + if path.endswith((".js", ".css")) and not path.startswith("/assets/"): + return NO_CACHE + return None + + +class CacheControlMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + cache_control = _cache_control_for_path(path) + + async def send_wrapper(message: dict) -> None: + if message["type"] == "http.response.start" and cache_control is not None: + headers = list(message.get("headers", [])) + headers = [(k, v) for k, v in headers if k.lower() != b"cache-control"] + headers.append((b"cache-control", cache_control)) + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_wrapper) diff --git a/tools/build_frontend.py b/tools/build_frontend.py new file mode 100644 index 0000000..9bed2f6 --- /dev/null +++ b/tools/build_frontend.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Build frontend assets with content hashes for cache-immutable PWA deploy.""" + +from __future__ import annotations + +import hashlib +import json +import re +import shutil +import tomllib +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +FRONTEND = ROOT / "frontend" +PUBLIC = ROOT / "public" +ASSETS_DIR = PUBLIC / "assets" + +HASH_LEN = 8 +IMPORT_RE = re.compile(r'''from\s+["'](\./(?:lib/)?[^"']+\.js)["']''') + +STATIC_COPY = ("manifest.json",) +STATIC_ICONS = ( + "icon.png", + "icon-72x72.png", + "icon-192x192.png", + "icon-512x512.png", +) + + +def short_hash(content: bytes) -> str: + return hashlib.sha256(content).hexdigest()[:HASH_LEN] + + +def js_sources() -> list[tuple[str, str]]: + sources: list[tuple[str, str]] = [("app.js", "app")] + lib_dir = FRONTEND / "lib" + if lib_dir.is_dir(): + for path in sorted(lib_dir.glob("*.js")): + sources.append((f"lib/{path.name}", path.stem)) + return sources + + +def read_version() -> str: + pyproject = ROOT / "pyproject.toml" + with pyproject.open("rb") as fh: + data = tomllib.load(fh) + return str(data["project"]["version"]) + + +def rewrite_imports(source: str, stem_to_filename: dict[str, str]) -> str: + def replace(match: re.Match[str]) -> str: + import_path = match.group(1) + stem = Path(import_path).stem + hashed = stem_to_filename.get(stem) + if not hashed: + raise ValueError(f"Unknown import stem {stem!r} in {import_path!r}") + quote = match.group(0).split("from", 1)[1].strip()[0] + return f'from {quote}./{hashed}{quote}' + + return IMPORT_RE.sub(replace, source) + + +def clean_assets_dir() -> None: + ASSETS_DIR.mkdir(parents=True, exist_ok=True) + for path in ASSETS_DIR.iterdir(): + if path.name == ".gitkeep": + continue + if path.is_file(): + path.unlink() + + +def copy_static_files() -> list[str]: + copied: list[str] = [] + for name in STATIC_COPY: + src = FRONTEND / name + if not src.is_file(): + raise FileNotFoundError(f"Missing frontend source: {src}") + shutil.copy2(src, PUBLIC / name) + copied.append(f"/{name}") + + for name in STATIC_ICONS: + src = FRONTEND / name + if src.is_file(): + shutil.copy2(src, PUBLIC / name) + copied.append(f"/{name}") + return copied + + +def build() -> dict[str, object]: + if not FRONTEND.is_dir(): + raise FileNotFoundError(f"Frontend source directory not found: {FRONTEND}") + + PUBLIC.mkdir(parents=True, exist_ok=True) + clean_assets_dir() + + hashed_assets: dict[str, str] = {} + stem_to_filename: dict[str, str] = {} + file_hashes: list[str] = [] + + styles_src = FRONTEND / "styles.css" + if not styles_src.is_file(): + raise FileNotFoundError(f"Missing frontend source: {styles_src}") + styles_bytes = styles_src.read_bytes() + styles_hash = short_hash(styles_bytes) + styles_filename = f"styles.{styles_hash}.css" + (ASSETS_DIR / styles_filename).write_bytes(styles_bytes) + hashed_assets["styles"] = f"/assets/{styles_filename}" + file_hashes.append(styles_hash) + + for rel_path, stem in js_sources(): + src = FRONTEND / rel_path + if not src.is_file(): + raise FileNotFoundError(f"Missing frontend source: {src}") + content = src.read_text(encoding="utf-8") + content_hash = short_hash(content.encode("utf-8")) + filename = f"{stem}.{content_hash}.js" + stem_to_filename[stem] = filename + file_hashes.append(content_hash) + + for rel_path, stem in js_sources(): + src = FRONTEND / rel_path + content = rewrite_imports(src.read_text(encoding="utf-8"), stem_to_filename) + filename = stem_to_filename[stem] + (ASSETS_DIR / filename).write_text(content, encoding="utf-8") + hashed_assets[stem] = f"/assets/{filename}" + + build_hash = short_hash("".join(file_hashes).encode("utf-8")) + version = read_version() + build_time = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + cache_version = f"tym-v{version}-{build_hash}" + + static_paths = copy_static_files() + asset_paths = sorted(hashed_assets.values()) + sw_assets = ["/", "/index.html", *asset_paths, *static_paths, "/version.json"] + + index_template = (FRONTEND / "index.template.html").read_text(encoding="utf-8") + index_html = ( + index_template.replace("{{STYLES_HREF}}", hashed_assets["styles"]) + .replace("{{APP_SCRIPT_SRC}}", hashed_assets["app"]) + .replace("{{BUILD_HASH}}", build_hash) + ) + (PUBLIC / "index.html").write_text(index_html, encoding="utf-8") + + sw_template = (FRONTEND / "sw.template.js").read_text(encoding="utf-8") + sw_js = ( + sw_template.replace("{{CACHE_VERSION}}", cache_version) + .replace("{{ASSETS_JSON}}", json.dumps(sw_assets, indent=2)) + ) + (PUBLIC / "sw.js").write_text(sw_js, encoding="utf-8") + + version_json = { + "version": version, + "buildTime": build_time, + "buildHash": build_hash, + } + (PUBLIC / "version.json").write_text( + json.dumps(version_json, indent=2) + "\n", + encoding="utf-8", + ) + + return { + "version": version, + "buildHash": build_hash, + "cacheVersion": cache_version, + "assets": hashed_assets, + "static": static_paths, + } + + +def main() -> None: + result = build() + print(f"Built frontend v{result['version']} ({result['buildHash']})") + print(f"Cache: {result['cacheVersion']}") + for stem, url in sorted(result["assets"].items()): + print(f" {stem}: {url}") + + +if __name__ == "__main__": + main()