Files
touchbase/public/sw.js
2026-05-02 14:05:30 -04:00

107 lines
3.3 KiB
JavaScript

// TouchBase service worker — minimal, hand-rolled.
//
// Strategy:
// - HTML pages (text/html): network-first → cache fallback for offline.
// - Static assets (/_next/static, /icon.svg, fonts): stale-while-revalidate.
// - API + auth + booking server actions: pass-through, NEVER cached.
// Stale appointment data is dangerous; better to fail than to lie.
//
// Cache name is versioned. Bump CACHE_VERSION on any deploy that should
// invalidate the cache for returning users.
const CACHE_VERSION = "v1";
const HTML_CACHE = `tb-html-${CACHE_VERSION}`;
const STATIC_CACHE = `tb-static-${CACHE_VERSION}`;
const ALL_CACHES = [HTML_CACHE, STATIC_CACHE];
self.addEventListener("install", () => {
// Activate the new SW immediately; users get the new caching rules on next nav.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names
.filter((n) => n.startsWith("tb-") && !ALL_CACHES.includes(n))
.map((n) => caches.delete(n)),
);
await self.clients.claim();
})(),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
// Only handle GET. Server actions (POST), auth callbacks, etc. always pass through.
if (req.method !== "GET") return;
const url = new URL(req.url);
// Same-origin only — don't cache cross-origin (analytics, fonts CDN, etc.).
if (url.origin !== self.location.origin) return;
// Never cache API, auth, or anything in the booking flow that could go stale.
// Stale slot data could let a customer "see" an already-taken slot.
if (
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/_next/data/") ||
url.pathname.startsWith("/admin/") ||
url.pathname.startsWith("/account/") ||
url.pathname.startsWith("/therapist/") ||
url.pathname.startsWith("/book")
) {
return; // pass through
}
// Static assets → stale-while-revalidate
if (
url.pathname.startsWith("/_next/static/") ||
url.pathname === "/icon.svg" ||
url.pathname === "/icon-mask.svg" ||
url.pathname === "/favicon.ico" ||
url.pathname === "/manifest.webmanifest"
) {
event.respondWith(staleWhileRevalidate(req, STATIC_CACHE));
return;
}
// HTML navigation → network-first → cache fallback
if (req.mode === "navigate" || req.headers.get("accept")?.includes("text/html")) {
event.respondWith(networkFirst(req, HTML_CACHE));
return;
}
});
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const fresh = await fetch(request);
if (fresh && fresh.ok) {
cache.put(request, fresh.clone());
}
return fresh;
} catch (err) {
const cached = await cache.match(request);
if (cached) return cached;
throw err;
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response && response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => undefined);
return cached ?? (await fetchPromise) ?? new Response("offline", { status: 503 });
}