Pages redirects /index.html to /, and the response kept that redirect history when the service worker precached it. Chrome refuses to reuse such a response for a navigation, so the first controlled reload ended at ERR_FAILED even though the shell was present.\n\nPrecache the canonical address instead. A navigation also verifies the complete versioned cache before releasing the shell, and every asset branch resolves to a real offline response rather than rejecting respondWith.
- commit
- b9ed9858726d3d3a2a389c193f58da27583e0f03
- parent
- 81549a9cf08bcb2d13c785adf3bf62867f35ffd3
- author
- Dami <dami.etoile@gmail.com>
| M | assets/ | 80 | +++++++++++++++--------------- |
| M | tools/ | 8 | ++-- |
@@ -18,7 +18,11 @@
1818 const VERSION = "__VERSION__";1919 const CORE = __CORE__;2020 21-const SHELL = "/index.html";21+// 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.25+const SHELL = "/";2226 2327 // What is served when there is no shell and no network. It exists so that the2428 // one failure this app is supposed to be incapable of does not arrive as a@@ -82,34 +86,31 @@ self.addEventListener("fetch", (event) => {
8286 // installing a new worker, which is the only thing that should move an8387 // offline app forward.8488 //85- // Unless the shell is not there, which is a state this used to have no way86- // out of. The precache is written once, by `install`, and a browser only runs87- // `install` again when the bytes of this file change — so an empty cache88- // stayed empty until the next deploy. Cache storage is best effort and can be89- // evicted while the registration that filled it survives, and on a machine90- // where the app is being worked on it is also cleared by hand several times a91- // day. Every reload after that was `ERR_FAILED`: the promise below rejected,92- // and a rejected `respondWith` is a network error page with nothing on it.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.9396 //94- // So a missing shell repairs the whole precache rather than the one file the95- // navigation asked for — an eviction takes the assets too, and a shell on its96- // own renders nothing. `SHELL` is fetched by name rather than the request,97- // because a request for some other path is answered with a 404 page by both98- // the local server and Pages, and caching *that* as the shell would be a99- // worse failure than the one being fixed.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.100102 if (request.mode === "navigate") {101103 event.respondWith(102104 caches103- .match(SHELL)104- .then((cached) => {105- if (cached) return cached;106- event.waitUntil(107- caches108- .open(VERSION)109- .then((cache) => cache.addAll(CORE))110- .catch(() => {}),111- );112- return fetch(SHELL);105+ .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;113114 })114115 .catch(() => new Response(OFFLINE, OFFLINE_INIT)),115116 );@@ -119,17 +120,18 @@ self.addEventListener("fetch", (event) => {
119120 // Fingerprinted, so the copy on disk is the only copy that URL will ever have.120121 if (url.pathname.startsWith("/assets/")) {121122 event.respondWith(122- caches.match(request).then(123- (cached) =>124- cached ||125- fetch(request).then((response) => {126- if (response.ok) {127- const copy = response.clone();128- caches.open(VERSION).then((cache) => cache.put(request, copy));129- }130- return response;131- }),132- ),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" })),133135 );134136 return;135137 }@@ -137,10 +139,10 @@ self.addEventListener("fetch", (event) => {
137139 // Everything else keeps its name, so it is asked about and only falls back.138140 event.respondWith(139141 fetch(request)140- .then((response) => {142+ .then(async (response) => {141143 if (response.ok) {142- const copy = response.clone();143- caches.open(VERSION).then((cache) => cache.put(request, copy));144+ const cache = await caches.open(VERSION);145+ await cache.put(request, response.clone());144146 }145147 return response;146148 })@@ -139,7 +139,9 @@ writeFileSync(
139139 // builds of the same bytes produce the same worker and a browser sees no140140 // update where there is none.141141 const core = [142- "/index.html",142+ // Cloudflare Pages redirects /index.html to /. A redirected response cannot143+ // satisfy a service-worker navigation, so the canonical URL is the cache key.144+ "/",143145 "/manifest.webmanifest",144146 styles,145147 bootstrap,@@ -152,9 +154,7 @@ const core = [
152154 const digest = createHash("sha256");153155 digest.update(shell);154156 for (const path of core) {155- const disk = path.startsWith("/assets/")156- ? join(OUT, path.slice(1))157- : join(OUT, path.slice(1));157+ const disk = path === "/" ? join(OUT, "index.html") : join(OUT, path.slice(1));158158 digest.update(path);159159 digest.update(readFileSync(disk));160160 }