serviceWorkerSource

tcmts.cli.web.serviceWorkerSource · #gkglvwqz · term · runs anywhere

The service worker of a PWA's build (sw.js, beside its page): it keeps the build (files, relative to it: its pages, a static site's trees, and its assets) in a cache of its own when it installs, and takes over at once. A build that calls no server (offlineFirst) opens a page it keeps from that cache, online or not, so it starts at once; one that does asks the network for its page first. A path it keeps no page for is the network's, and, with no network, shell's (the page that renders any path in the browser). Assets come from the cache (their names are their content). A new build's service worker replaces it when the page is next opened or comes back on screen; the build before it is kept, for a tab still running it.

export function serviceWorkerSource(
  key: string,
  files: string[],
  offlineFirst: boolean,
  shell = "./",
): string {
  return `// generated by tcm: the service worker of build ${key}
const here = new URL("./", self.location.href).href;
// caches are the site's, not the app's: this app's are named by where it is
const prefix = "tcm " + here + " ";
const BUILD = prefix + ${JSON.stringify(key)};
const FILES = ${JSON.stringify(files)};
const OFFLINE_FIRST = ${offlineFirst};
const SHELL = new URL(${JSON.stringify(shell)}, here).href;
const assets = new URL("./_app/assets/", here).href;

self.addEventListener("install", (e) => {
  e.waitUntil(
    caches
      .open(BUILD)
      // (pages asked again, past any HTTP cache: they name the assets of this build; assets are named by their content)
      .then((c) => c.addAll(FILES.map((f) => new Request(new URL(f, here), { cache: f.startsWith("./_app/assets/") ? "default" : "no-cache" }))))
      .then(() => self.skipWaiting()),
  );
});

self.addEventListener("activate", (e) => {
  e.waitUntil(
    (async () => {
      const older = (await caches.keys()).filter((n) => n.startsWith(prefix) && n !== BUILD);
      // the build before this one stays, for a tab still running it
      for (const n of older.slice(0, -1)) await caches.delete(n);
      await self.clients.claim();
    })(),
  );
});

// the page kept for a URL's path, if any (its query and fragment are the page's to read)
const kept = (url) => caches.open(BUILD).then((c) => c.match(url, { ignoreSearch: true }));
const shell = () => kept(SHELL);

self.addEventListener("fetch", (e) => {
  const req = e.request;
  if (req.method !== "GET" || !req.url.startsWith(here)) return;
  if (req.mode === "navigate") {
    const offline = (err) => kept(req.url).then((r) => r ?? shell()).then((r) => r ?? Promise.reject(err));
    e.respondWith(
      OFFLINE_FIRST
        ? kept(req.url).then((r) => r ?? fetch(req).catch((err) => shell().then((s) => s ?? Promise.reject(err))))
        : fetch(req).catch(offline),
    );
  } else if (req.url.startsWith(assets)) {
    e.respondWith(
      caches.match(req).then(
        (r) =>
          r ??
          fetch(req).then((res) => {
            if (res.ok) {
              const copy = res.clone();
              e.waitUntil(caches.open(BUILD).then((c) => c.put(req, copy)));
            }
            return res;
          }),
      ),
    );
  }
});
`;
}

used by