feat: PWA update strategy with hashed assets and cache control

Move frontend sources to frontend/, build content-hashed assets at deploy,
network-first SW for shell/JS, immutable cache for /assets/, version.json
update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-07-08 08:51:12 +02:00
parent bff14167c9
commit 7cec3030c1
29 changed files with 779 additions and 61 deletions
+82 -43
View File
@@ -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;
}