← 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;
    })
  );
});