← back to Homesonspec
apps/web/public/sw.js
118 lines
// Homes on Spec — service worker.
// Strategy:
// • App shell + icons + offline page → precached on install (instant loads, offline-safe).
// • Navigations (HTML) → network-first, fall back to cache, then the offline page.
// • Static assets (_next, images, fonts) → stale-while-revalidate (fast, self-healing).
// • Listing/search API → network-first with a short cache, so a dropped signal on-site
// still shows the last-loaded homes instead of a dead screen.
// Bump CACHE_VERSION on any change here to force clients onto the new worker.
const CACHE_VERSION = "hos-v1";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const API_CACHE = `${CACHE_VERSION}-api`;
const SHELL_ASSETS = [
"/",
"/offline.html",
"/manifest.webmanifest",
"/icon-192.png",
"/icon-512.png",
"/apple-touch-icon.png",
];
// Cap for the runtime/api caches so they can't grow unbounded on a long session.
const RUNTIME_MAX = 50;
const API_MAX = 40;
async function trimCache(name, max) {
const cache = await caches.open(name);
const keys = await cache.keys(); // insertion order → slice off the oldest past the cap
if (keys.length <= max) return;
await Promise.all(keys.slice(0, keys.length - max).map((k) => cache.delete(k)));
}
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL_CACHE)
// Best-effort per asset — don't let one 404 abort the whole install.
.then((cache) => Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url))))
.then((results) => {
// Only take over early if the offline fallback itself cached. If it didn't,
// the old worker is safer than a half-populated shell that could blank the
// offline screen after a version bump.
const offline = results[SHELL_ASSETS.indexOf("/offline.html")];
if (offline && offline.status === "fulfilled") return self.skipWaiting();
})
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => !k.startsWith(CACHE_VERSION))
.map((k) => caches.delete(k))
)
)
.then(() => self.clients.claim())
);
});
// Only handle GET; never interfere with POST/PUT (contact forms, saves, analytics).
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; // let cross-origin (ads, tiles CDN) pass through
// HTML navigations → network-first, offline fallback.
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((res) => {
const copy = res.clone();
caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy).then(() => trimCache(RUNTIME_CACHE, RUNTIME_MAX)));
return res;
})
.catch(async () => {
const cached = await caches.match(request);
return cached || caches.match("/offline.html");
})
);
return;
}
// Listing/search API → network-first with a short-lived cache.
if (url.pathname.startsWith("/api/")) {
event.respondWith(
fetch(request)
.then((res) => {
const copy = res.clone();
caches.open(API_CACHE).then((c) => c.put(request, copy).then(() => trimCache(API_CACHE, API_MAX)));
return res;
})
.catch(() => caches.match(request))
);
return;
}
// Static assets → stale-while-revalidate.
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request)
.then((res) => {
const copy = res.clone();
caches.open(RUNTIME_CACHE).then((c) => c.put(request, copy).then(() => trimCache(RUNTIME_CACHE, RUNTIME_MAX)));
return res;
})
.catch(() => cached);
return cached || network;
})
);
});