1// The offline copy.2//3// The version and the manifest below are both written by the build. The version4// is a digest of everything precached, so the cache key changes exactly when the5// contents do — never when nothing has, and never *not* when something has. It6// used to be a number kept by hand, and by the tenth deploy it had drifted:7// thirteen commits touched the app and three of them remembered to bump it.8//9// That is also what makes an update happen at all. A browser decides whether to10// install a new worker by comparing this file byte for byte with the one it has,11// so the manifest being *in here* is the mechanism, not a convenience. It is why12// this file is never fingerprinted itself: a hashed service worker would have to13// be updated in order to be updated.14//15// Everything under /assets/ carries a digest in its own name, so those files are16// immutable and are answered from the cache without ever asking. The shell, the17// manifest and the icons keep their names and are asked about every time.18const VERSION = "__VERSION__";19const CORE = __CORE__;2021// Pages redirects /index.html to /. A redirected response cannot be returned22// from a service worker for a navigation request, so caching /index.html makes23// the first controlled reload fail with ERR_FAILED even though the response is24// present. Cache the canonical URL and the response has no redirect history.25const SHELL = "/";2627// What is served when there is no shell and no network. It exists so that the28// one failure this app is supposed to be incapable of does not arrive as a29// browser error page, which says `ERR_FAILED` and gives a reader no way to tell30// a broken install from a broken app. Inline and unstyled on purpose: it is the31// case where nothing else loaded.32const OFFLINE = `<!doctype html>33<meta charset="utf-8">34<meta name="viewport" content="width=device-width, initial-scale=1">35<title>klot.ski</title>36<body style="font:16px/1.5 system-ui,sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;background:#e8f4f2">37<div style="max-width:24rem;padding:1.5rem">38<h1 style="font-size:1rem;margin:0 0 .5rem">klot.ski is offline</h1>39<p style="margin:0 0 1rem">Its offline copy is missing, so there is nothing to open until it can be fetched again.</p>40<p style="margin:0"><button onclick="location.reload()" style="font:inherit;padding:.4rem .9rem">Try again</button></p>41</div>`;4243const OFFLINE_INIT = {44 status: 503,45 statusText: "Offline",46 headers: { "Content-Type": "text/html; charset=utf-8" },47};4849self.addEventListener("install", (event) => {50 event.waitUntil(caches.open(VERSION).then((cache) => cache.addAll(CORE)));51 // Deliberately no skipWaiting. A new worker waits until the page decides it is52 // a good moment, which for this app is between one puzzle and the next: see53 // `applyUpdate` in browser_ffi. Taking over immediately would swap the code54 // under a board somebody is halfway through, and would let this worker's55 // activation delete assets the running one still intends to lazily fetch —56 // the solver worker is not loaded until the first solve.57});5859self.addEventListener("message", (event) => {60 if (event.data === "SKIP_WAITING") self.skipWaiting();61});6263self.addEventListener("activate", (event) => {64 event.waitUntil(65 caches66 .keys()67 .then((keys) =>68 Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))),69 )70 .then(() => self.clients.claim()),71 );72 // Safe to delete the old caches here *because* of the missing skipWaiting73 // above: activation waits for every page running the previous version to go,74 // so nothing is still reading what is being thrown away.75});7677self.addEventListener("fetch", (event) => {78 const request = event.request;79 if (request.method !== "GET") return;80 const url = new URL(request.url);81 if (url.origin !== self.location.origin) return;8283 // A navigation is answered from the shell that was precached with this84 // version, so the page and the assets it names can never come from two85 // different builds. The network is not consulted: a new shell arrives by86 // installing a new worker, which is the only thing that should move an87 // offline app forward.88 //89 // Unless some of the precache is not there, which is a state this used to90 // have no way out of. The precache is written once, by `install`, and a91 // browser only runs `install` again when the bytes of this file change — so a92 // damaged cache stayed damaged until the next deploy. Cache storage is best93 // effort and can be evicted while the registration that filled it survives,94 // and on a machine where the app is being worked on it is also cleared by95 // hand several times a day.96 //97 // A navigation therefore verifies the whole versioned precache before it98 // releases the shell. A shell on its own only moves the failure into a script99 // or stylesheet request, where a rejected `respondWith` is still ERR_FAILED100 // and leaves a blank page. Repairing through `addAll` also keeps the shell and101 // every URL it names on one build.102 if (request.mode === "navigate") {103 event.respondWith(104 caches105 .open(VERSION)106 .then(async (cache) => {107 const core = await Promise.all(CORE.map((path) => cache.match(path)));108 if (core.some((response) => !response)) {109 await cache.addAll(CORE);110 }111 const shell = await cache.match(SHELL);112 if (!shell) throw new Error("precache completed without a shell");113 return shell;114 })115 .catch(() => new Response(OFFLINE, OFFLINE_INIT)),116 );117 return;118 }119120 // Fingerprinted, so the copy on disk is the only copy that URL will ever have.121 if (url.pathname.startsWith("/assets/")) {122 event.respondWith(123 caches124 .open(VERSION)125 .then(async (cache) => {126 const cached = await cache.match(request);127 if (cached) return cached;128 const response = await fetch(request);129 if (response.ok) {130 await cache.put(request, response.clone());131 }132 return response;133 })134 .catch(() => new Response("Offline", { status: 503, statusText: "Offline" })),135 );136 return;137 }138139 // Everything else keeps its name, so it is asked about and only falls back.140 event.respondWith(141 fetch(request)142 .then(async (response) => {143 if (response.ok) {144 const cache = await caches.open(VERSION);145 await cache.put(request, response.clone());146 }147 return response;148 })149 .catch(() =>150 caches.match(request).then((cached) => {151 if (cached) return cached;152 return new Response("Offline", { status: 503, statusText: "Offline" });153 }),154 ),155 );156});