Keep opened pages readable offline with a service worker
/sw.js is a Hugo template, one per language build, naming that build's fingerprinted files: those are served cache-first, since a name carrying its own hash cannot go stale. Everything else stays network-first, with the last copy seen as the fallback and a translated /offline page for what was never opened. Not registered under `hugo server` or ?ogcard.
b3a4f36 parent: 94576dd added
blog/content/offline.es.md +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | ++++ | |
| 2 | +title = 'Sin conexión' | |
| 3 | +description = 'Se muestra cuando una página no está disponible sin conexión.' | |
| 4 | +layout = 'offline' | |
| 5 | + | |
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | |
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | |
| 8 | +# the sitemap; render = 'always' still builds it. | |
| 9 | +[build] | |
| 10 | +list = 'never' | |
| 11 | +render = 'always' | |
| 12 | + | |
| 13 | +[params] | |
| 14 | +noindex = true | |
| 15 | ++++ | |
| 16 | + | |
| 17 | +Esta página no está guardada en este dispositivo y no hay conexión para ir a buscarla. Las que ya has abierto se pueden seguir leyendo. | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | ++++ | ||
| 2 | +title = 'Sin conexión' | ||
| 3 | +description = 'Se muestra cuando una página no está disponible sin conexión.' | ||
| 4 | +layout = 'offline' | ||
| 5 | + | ||
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | ||
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | ||
| 8 | +# the sitemap; render = 'always' still builds it. | ||
| 9 | +[build] | ||
| 10 | +list = 'never' | ||
| 11 | +render = 'always' | ||
| 12 | + | ||
| 13 | +[params] | ||
| 14 | +noindex = true | ||
| 15 | ++++ | ||
| 16 | + | ||
| 17 | +Esta página no está guardada en este dispositivo y no hay conexión para ir a buscarla. Las que ya has abierto se pueden seguir leyendo. | ||
added
blog/content/offline.fr.md +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | ++++ | |
| 2 | +title = 'Hors ligne' | |
| 3 | +description = 'Affichée quand une page n’est pas disponible sans connexion.' | |
| 4 | +layout = 'offline' | |
| 5 | + | |
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | |
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | |
| 8 | +# the sitemap; render = 'always' still builds it. | |
| 9 | +[build] | |
| 10 | +list = 'never' | |
| 11 | +render = 'always' | |
| 12 | + | |
| 13 | +[params] | |
| 14 | +noindex = true | |
| 15 | ++++ | |
| 16 | + | |
| 17 | +Cette page n’est pas enregistrée sur cet appareil, et il n’y a pas de connexion pour aller la chercher. Celles que vous avez déjà ouvertes restent lisibles. | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | ++++ | ||
| 2 | +title = 'Hors ligne' | ||
| 3 | +description = 'Affichée quand une page n’est pas disponible sans connexion.' | ||
| 4 | +layout = 'offline' | ||
| 5 | + | ||
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | ||
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | ||
| 8 | +# the sitemap; render = 'always' still builds it. | ||
| 9 | +[build] | ||
| 10 | +list = 'never' | ||
| 11 | +render = 'always' | ||
| 12 | + | ||
| 13 | +[params] | ||
| 14 | +noindex = true | ||
| 15 | ++++ | ||
| 16 | + | ||
| 17 | +Cette page n’est pas enregistrée sur cet appareil, et il n’y a pas de connexion pour aller la chercher. Celles que vous avez déjà ouvertes restent lisibles. | ||
added
blog/content/offline.ja.md +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | ++++ | |
| 2 | +title = 'オフライン' | |
| 3 | +description = '接続がなく、ページを表示できないときに表示されます。' | |
| 4 | +layout = 'offline' | |
| 5 | + | |
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | |
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | |
| 8 | +# the sitemap; render = 'always' still builds it. | |
| 9 | +[build] | |
| 10 | +list = 'never' | |
| 11 | +render = 'always' | |
| 12 | + | |
| 13 | +[params] | |
| 14 | +noindex = true | |
| 15 | ++++ | |
| 16 | + | |
| 17 | +このページはこの端末に保存されておらず、取得するための接続もありません。すでに開いたページは引き続き読めます。 | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | ++++ | ||
| 2 | +title = 'オフライン' | ||
| 3 | +description = '接続がなく、ページを表示できないときに表示されます。' | ||
| 4 | +layout = 'offline' | ||
| 5 | + | ||
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | ||
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | ||
| 8 | +# the sitemap; render = 'always' still builds it. | ||
| 9 | +[build] | ||
| 10 | +list = 'never' | ||
| 11 | +render = 'always' | ||
| 12 | + | ||
| 13 | +[params] | ||
| 14 | +noindex = true | ||
| 15 | ++++ | ||
| 16 | + | ||
| 17 | +このページはこの端末に保存されておらず、取得するための接続もありません。すでに開いたページは引き続き読めます。 | ||
added
blog/content/offline.md +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | ++++ | |
| 2 | +title = 'Offline' | |
| 3 | +description = 'Shown when a page is not available without a connection.' | |
| 4 | +layout = 'offline' | |
| 5 | + | |
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | |
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | |
| 8 | +# the sitemap; render = 'always' still builds it. | |
| 9 | +[build] | |
| 10 | +list = 'never' | |
| 11 | +render = 'always' | |
| 12 | + | |
| 13 | +[params] | |
| 14 | +noindex = true | |
| 15 | ++++ | |
| 16 | + | |
| 17 | +This page isn’t saved on this device, and there is no connection to fetch it over. The pages you have already opened are still readable. | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | ++++ | ||
| 2 | +title = 'Offline' | ||
| 3 | +description = 'Shown when a page is not available without a connection.' | ||
| 4 | +layout = 'offline' | ||
| 5 | + | ||
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | ||
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | ||
| 8 | +# the sitemap; render = 'always' still builds it. | ||
| 9 | +[build] | ||
| 10 | +list = 'never' | ||
| 11 | +render = 'always' | ||
| 12 | + | ||
| 13 | +[params] | ||
| 14 | +noindex = true | ||
| 15 | ++++ | ||
| 16 | + | ||
| 17 | +This page isn’t saved on this device, and there is no connection to fetch it over. The pages you have already opened are still readable. | ||
added
blog/content/offline.pt.md +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | ++++ | |
| 2 | +title = 'Sem conexão' | |
| 3 | +description = 'Exibida quando uma página não está disponível sem conexão.' | |
| 4 | +layout = 'offline' | |
| 5 | + | |
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | |
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | |
| 8 | +# the sitemap; render = 'always' still builds it. | |
| 9 | +[build] | |
| 10 | +list = 'never' | |
| 11 | +render = 'always' | |
| 12 | + | |
| 13 | +[params] | |
| 14 | +noindex = true | |
| 15 | ++++ | |
| 16 | + | |
| 17 | +Esta página não está salva neste dispositivo e não há conexão para buscá-la. As que você já abriu continuam disponíveis para leitura. | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | ++++ | ||
| 2 | +title = 'Sem conexão' | ||
| 3 | +description = 'Exibida quando uma página não está disponível sem conexão.' | ||
| 4 | +layout = 'offline' | ||
| 5 | + | ||
| 6 | +# Never linked to: sw.js serves it in place of a page it has no copy of. | ||
| 7 | +# list = 'never' keeps it out of the home page, the post list, the feed and | ||
| 8 | +# the sitemap; render = 'always' still builds it. | ||
| 9 | +[build] | ||
| 10 | +list = 'never' | ||
| 11 | +render = 'always' | ||
| 12 | + | ||
| 13 | +[params] | ||
| 14 | +noindex = true | ||
| 15 | ++++ | ||
| 16 | + | ||
| 17 | +Esta página não está salva neste dispositivo e não há conexão para buscá-la. As que você já abriu continuam disponíveis para leitura. | ||
modified
blog/hugo.toml +10 -1 | @@ -76,11 +76,20 @@ baseName = "feed" | ||
| 76 | 76 | isPlainText = true |
| 77 | 77 | notAlternative = true |
| 78 | 78 | |
| 79 | +# The service worker, /sw.js (layouts/_default/home.serviceworker.js). A | |
| 80 | +# template for the same reason the two above are: one per language build, each | |
| 81 | +# naming that build's fingerprinted files. | |
| 82 | +[outputFormats.serviceworker] | |
| 83 | +mediaType = "text/javascript" | |
| 84 | +baseName = "sw" | |
| 85 | +isPlainText = true | |
| 86 | +notAlternative = true | |
| 87 | + | |
| 79 | 88 | [outputs] |
| 80 | 89 | # html and rss are the defaults for the home page and have to be repeated - |
| 81 | 90 | # naming the key at all replaces the list rather than adding to it. Other page |
| 82 | 91 | # kinds are untouched, so sections keep their own index.xml. |
| 83 | -home = ["html", "rss", "feedstyle", "feedscript"] | |
| 92 | +home = ["html", "rss", "feedstyle", "feedscript", "serviceworker"] | |
| 84 | 93 | |
| 85 | 94 | [permalinks] |
| 86 | 95 | [permalinks.page] |
| @@ -76,11 +76,20 @@ baseName = "feed" | |||
| 76 | isPlainText = true | 76 | isPlainText = true |
| 77 | notAlternative = true | 77 | notAlternative = true |
| 78 | 78 | ||
| 79 | +# The service worker, /sw.js (layouts/_default/home.serviceworker.js). A | ||
| 80 | +# template for the same reason the two above are: one per language build, each | ||
| 81 | +# naming that build's fingerprinted files. | ||
| 82 | +[outputFormats.serviceworker] | ||
| 83 | +mediaType = "text/javascript" | ||
| 84 | +baseName = "sw" | ||
| 85 | +isPlainText = true | ||
| 86 | +notAlternative = true | ||
| 87 | + | ||
| 79 | [outputs] | 88 | [outputs] |
| 80 | # html and rss are the defaults for the home page and have to be repeated - | 89 | # html and rss are the defaults for the home page and have to be repeated - |
| 81 | # naming the key at all replaces the list rather than adding to it. Other page | 90 | # naming the key at all replaces the list rather than adding to it. Other page |
| 82 | # kinds are untouched, so sections keep their own index.xml. | 91 | # kinds are untouched, so sections keep their own index.xml. |
| 83 | -home = ["html", "rss", "feedstyle", "feedscript"] | 92 | +home = ["html", "rss", "feedstyle", "feedscript", "serviceworker"] |
| 84 | 93 | ||
| 85 | [permalinks] | 94 | [permalinks] |
| 86 | [permalinks.page] | 95 | [permalinks.page] |
modified
blog/layouts/_default/baseof.html +20 -0 | @@ -370,6 +370,26 @@ | ||
| 370 | 370 | window.goatcounter?.count?.({path: location.host + location.pathname + location.search, title: document.title}); |
| 371 | 371 | }); |
| 372 | 372 | </script> |
| 373 | + {{- /* | |
| 374 | + /sw.js (_default/home.serviceworker.js) keeps a copy of what the reader | |
| 375 | + opens, so it can be opened again without a connection. Registered once | |
| 376 | + the page has loaded, so installing it - which fetches the stylesheet, | |
| 377 | + the scripts and every font - never competes with the page for the | |
| 378 | + network on the one visit where nothing is cached yet. | |
| 379 | + | |
| 380 | + Not under `hugo server`: a worker answering for the stylesheet is the | |
| 381 | + last thing wanted while editing it, and `make prod-server` is there to | |
| 382 | + try this against the real artifact. Not under ?ogcard either (the root | |
| 383 | + was marked by the boot script above): that is the build photographing | |
| 384 | + its own pages, and it should photograph them as they were just built. | |
| 385 | + */}} | |
| 386 | + {{- if not hugo.IsServer }} | |
| 387 | + <script> | |
| 388 | + if ("serviceWorker" in navigator && !("ogcard" in document.documentElement.dataset)) { | |
| 389 | + addEventListener("load", () => navigator.serviceWorker.register("/sw.js")); | |
| 390 | + } | |
| 391 | + </script> | |
| 392 | + {{- end }} | |
| 373 | 393 | {{ block "head" . }}{{ end }} |
| 374 | 394 | </head> |
| 375 | 395 | {{- /* |
| @@ -370,6 +370,26 @@ | |||
| 370 | window.goatcounter?.count?.({path: location.host + location.pathname + location.search, title: document.title}); | 370 | window.goatcounter?.count?.({path: location.host + location.pathname + location.search, title: document.title}); |
| 371 | }); | 371 | }); |
| 372 | </script> | 372 | </script> |
| 373 | + {{- /* | ||
| 374 | + /sw.js (_default/home.serviceworker.js) keeps a copy of what the reader | ||
| 375 | + opens, so it can be opened again without a connection. Registered once | ||
| 376 | + the page has loaded, so installing it - which fetches the stylesheet, | ||
| 377 | + the scripts and every font - never competes with the page for the | ||
| 378 | + network on the one visit where nothing is cached yet. | ||
| 379 | + | ||
| 380 | + Not under `hugo server`: a worker answering for the stylesheet is the | ||
| 381 | + last thing wanted while editing it, and `make prod-server` is there to | ||
| 382 | + try this against the real artifact. Not under ?ogcard either (the root | ||
| 383 | + was marked by the boot script above): that is the build photographing | ||
| 384 | + its own pages, and it should photograph them as they were just built. | ||
| 385 | + */}} | ||
| 386 | + {{- if not hugo.IsServer }} | ||
| 387 | + <script> | ||
| 388 | + if ("serviceWorker" in navigator && !("ogcard" in document.documentElement.dataset)) { | ||
| 389 | + addEventListener("load", () => navigator.serviceWorker.register("/sw.js")); | ||
| 390 | + } | ||
| 391 | + </script> | ||
| 392 | + {{- end }} | ||
| 373 | {{ block "head" . }}{{ end }} | 393 | {{ block "head" . }}{{ end }} |
| 374 | </head> | 394 | </head> |
| 375 | {{- /* | 395 | {{- /* |
added
blog/layouts/_default/home.serviceworker.js +176 -0 | new file mode 100644 | ||
| @@ -0,0 +1,176 @@ | ||
| 1 | +{{- /* | |
| 2 | + The service worker, published as /sw.js: what lets a page the reader has | |
| 3 | + already opened be opened again with no connection, and what saves a round | |
| 4 | + trip for every file whose name already says it cannot have changed. | |
| 5 | + | |
| 6 | + A Hugo template for the same reason feed.js and feed.xsl are (see hugo.toml): | |
| 7 | + each language is its own origin and so needs its own worker, and that worker | |
| 8 | + has to name this build's fingerprinted files. It also has to sit at the root - | |
| 9 | + a worker controls nothing above the directory it is served from - under a name | |
| 10 | + that never changes, which rules out fingerprinting the worker itself. | |
| 11 | + | |
| 12 | + $assets repeats the pipelines of _default/baseof.html, because a pipeline is | |
| 13 | + what decides the URL: keep the two in step. Falling out of step breaks | |
| 14 | + nothing while there is a connection - any fingerprinted file is kept the | |
| 15 | + first time it is asked for - but a file missing from this list is not there | |
| 16 | + for a reader who goes offline after a single visit. | |
| 17 | +*/ -}} | |
| 18 | +{{- $assets := slice }} | |
| 19 | +{{- with resources.Get "css/style.scss" }} | |
| 20 | + {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }} | |
| 21 | + {{- with . | toCSS $opts | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }} | |
| 22 | +{{- end }} | |
| 23 | +{{- range slice "js/htmx.min.js" "js/hx-preload.min.js" }} | |
| 24 | + {{- with resources.Get . | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }} | |
| 25 | +{{- end }} | |
| 26 | +{{- with resources.Get "js/quote-share.js" | minify | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }} | |
| 27 | +{{- /* What a first visit has to leave behind beyond those: the home page, the | |
| 28 | + page shown in place of one that was never opened, and the fonts - every one | |
| 29 | + of them, since which of the five a page needs depends on the characters in | |
| 30 | + it, and a page read offline is not the one that was on screen at install. */}} | |
| 31 | +{{- $offline := "" }} | |
| 32 | +{{- with site.GetPage "/offline" }}{{ $offline = .RelPermalink }}{{ end }} | |
| 33 | +{{- $seed := slice ("/" | relLangURL) }} | |
| 34 | +{{- with $offline }}{{ $seed = $seed | append . }}{{ end }} | |
| 35 | +{{- range os.ReadDir "static/fonts" }} | |
| 36 | + {{- $seed = $seed | append (printf "/fonts/%s" .Name | relURL) }} | |
| 37 | +{{- end }} | |
| 38 | +// Two caches, told apart by whether a copy can go stale. IMMUTABLE holds the | |
| 39 | +// fingerprinted files: a name that carries the hash of its own content means | |
| 40 | +// the same bytes for good, so a copy is as good as the network and is never | |
| 41 | +// checked against it. SEEN holds the last copy of everything else this origin | |
| 42 | +// served - pages, fonts, the feed, images - which is only ever a fallback. | |
| 43 | +// | |
| 44 | +// Neither name carries a version. Nothing in them needs throwing away when a | |
| 45 | +// new build arrives: a page is replaced the next time it is fetched, and a | |
| 46 | +// fingerprinted file is dropped by sweep() once nothing names it any more. | |
| 47 | +const IMMUTABLE = "immutable", SEEN = "seen"; | |
| 48 | +const ASSETS = {{ $assets | jsonify }}; | |
| 49 | +const SEED = {{ $seed | jsonify }}; | |
| 50 | +const OFFLINE = {{ $offline | jsonify }}; | |
| 51 | +// Hugo's fingerprint: the sha256 of the file, in hex, ahead of the extension. | |
| 52 | +const FINGERPRINTED = /\.[0-9a-f]{64}\.\w+$/; | |
| 53 | + | |
| 54 | +// Everything is keyed by path alone. The site is static, so a query string | |
| 55 | +// (?ogcard, a tracking parameter someone's link picked up) changes nothing | |
| 56 | +// about what comes back, and keying on it would keep one copy per spelling. | |
| 57 | +const pathOf = (request) => new URL(request.url).pathname; | |
| 58 | + | |
| 59 | +// A copy is kept on the side while the response itself goes straight back to | |
| 60 | +// the page, rather than awaited: put() only settles once the whole body is | |
| 61 | +// stored, and waiting on it would hold the first byte back until the last. | |
| 62 | +// | |
| 63 | +// Only a plain 200. A redirected response cannot answer a navigation later | |
| 64 | +// (the browser refuses one it did not follow itself), and Pages answers | |
| 65 | +// /posts with a redirect to /posts/, so keeping what fetch() followed would | |
| 66 | +// store exactly that. | |
| 67 | +const keep = (event, name, response) => { | |
| 68 | + if (response.status === 200 && !response.redirected) { | |
| 69 | + const copy = response.clone(); | |
| 70 | + event.waitUntil( | |
| 71 | + caches.open(name).then((cache) => cache.put(pathOf(event.request), copy)), | |
| 72 | + ); | |
| 73 | + } | |
| 74 | + return response; | |
| 75 | +}; | |
| 76 | + | |
| 77 | +const cacheFirst = async (event) => { | |
| 78 | + const cached = await (await caches.open(IMMUTABLE)).match(pathOf(event.request)); | |
| 79 | + return cached ?? keep(event, IMMUTABLE, await fetch(event.request)); | |
| 80 | +}; | |
| 81 | + | |
| 82 | +// The network's answer whenever there is one, so being online never means | |
| 83 | +// reading an old copy of a post. Without a connection: the copy kept from last | |
| 84 | +// time, and failing that - for a request that is going to put a page on | |
| 85 | +// screen - the offline page. hx-boost asks for a page with fetch() rather than | |
| 86 | +// by navigating, which is what the HX-Request header test is for; what it gets | |
| 87 | +// back is a whole document either way, so the two share their copies. | |
| 88 | +const networkFirst = async (event) => { | |
| 89 | + const {request} = event; | |
| 90 | + try { | |
| 91 | + // preloadResponse is the request the browser already started while this | |
| 92 | + // worker was booting (see "activate"); undefined for anything that is not | |
| 93 | + // a navigation. | |
| 94 | + return keep(event, SEEN, (await event.preloadResponse) ?? (await fetch(request))); | |
| 95 | + } catch { | |
| 96 | + const seen = await caches.open(SEEN); | |
| 97 | + let cached = await seen.match(pathOf(request)); | |
| 98 | + if (!cached && OFFLINE && (request.mode === "navigate" || request.headers.has("HX-Request"))) { | |
| 99 | + cached = await seen.match(OFFLINE); | |
| 100 | + } | |
| 101 | + // Rebuilt rather than handed back as stored: a response out of a cache | |
| 102 | + // still carries the address it was fetched from, and htmx pushes that | |
| 103 | + // address into history in preference to the one it asked for - so a | |
| 104 | + // boosted link answered with the offline page left /offline/ in the | |
| 105 | + // address bar, where reloading could never reach the page that was | |
| 106 | + // wanted. A response with no address of its own leaves the request's. | |
| 107 | + return cached ? new Response(cached.body, cached) : Response.error(); | |
| 108 | + } | |
| 109 | +}; | |
| 110 | + | |
| 111 | +// A fingerprinted file outlives its build for as long as a kept page still | |
| 112 | +// names it. A reader who opened a post under an older build and has not been | |
| 113 | +// back to it holds that build's HTML, which links that build's stylesheet - | |
| 114 | +// delete the stylesheet because a newer one exists, and the post comes up | |
| 115 | +// unstyled the day it is opened without a connection. So the test is not | |
| 116 | +// "is it current" but "does anything still ask for it". | |
| 117 | +const sweep = async () => { | |
| 118 | + const seen = await caches.open(SEEN), immutable = await caches.open(IMMUTABLE); | |
| 119 | + let named = ""; | |
| 120 | + for (const key of await seen.keys()) { | |
| 121 | + const response = await seen.match(key); | |
| 122 | + // Only what can name a file: pages, the feed and its stylesheet, feed.js. | |
| 123 | + if (/html|xml|xsl|javascript/.test(response?.headers.get("content-type") ?? "")) { | |
| 124 | + named += await response.text(); | |
| 125 | + } | |
| 126 | + } | |
| 127 | + for (const key of await immutable.keys()) { | |
| 128 | + const path = pathOf(key); | |
| 129 | + if (!ASSETS.includes(path) && !named.includes(path)) await immutable.delete(key); | |
| 130 | + } | |
| 131 | +}; | |
| 132 | + | |
| 133 | +self.addEventListener("install", (event) => { | |
| 134 | + event.waitUntil((async () => { | |
| 135 | + await (await caches.open(IMMUTABLE)).addAll(ASSETS); | |
| 136 | + await (await caches.open(SEEN)).addAll(SEED); | |
| 137 | + // Nothing to wait for: the caches are shared and only ever added to, so | |
| 138 | + // this worker and the one it replaces can serve the same open pages. | |
| 139 | + await self.skipWaiting(); | |
| 140 | + })()); | |
| 141 | +}); | |
| 142 | + | |
| 143 | +self.addEventListener("activate", (event) => { | |
| 144 | + event.waitUntil((async () => { | |
| 145 | + // Network-first means a navigation always waits on the network, and | |
| 146 | + // without this it would also wait on the worker starting up first. | |
| 147 | + await self.registration.navigationPreload?.enable(); | |
| 148 | + // Take over the pages already open instead of from their next load. | |
| 149 | + // hx-boost turns every later navigation into a fetch from the same | |
| 150 | + // document, so there may be no next load for the rest of the visit. | |
| 151 | + await self.clients.claim(); | |
| 152 | + // Those pages were themselves loaded before there was anything to keep | |
| 153 | + // them - and the page someone arrived on is the likeliest one they will | |
| 154 | + // want back. Best effort: failing here must not fail the activation. | |
| 155 | + const seen = await caches.open(SEEN); | |
| 156 | + const open = await self.clients.matchAll({type: "window"}); | |
| 157 | + await Promise.allSettled(open.map(async (client) => { | |
| 158 | + const response = await fetch(client.url); | |
| 159 | + if (response.status === 200 && !response.redirected) { | |
| 160 | + await seen.put(new URL(client.url).pathname, response); | |
| 161 | + } | |
| 162 | + })); | |
| 163 | + await sweep(); | |
| 164 | + })()); | |
| 165 | +}); | |
| 166 | + | |
| 167 | +self.addEventListener("fetch", (event) => { | |
| 168 | + const {request} = event; | |
| 169 | + // Left to the browser: anything that is not a read, other origins (the | |
| 170 | + // visit counter), and range requests - a <video> asks for its file in | |
| 171 | + // pieces, each a 206 that the Cache API will not store. | |
| 172 | + if (request.method !== "GET" || request.headers.has("range")) return; | |
| 173 | + const url = new URL(request.url); | |
| 174 | + if (url.origin !== location.origin) return; | |
| 175 | + event.respondWith(FINGERPRINTED.test(url.pathname) ? cacheFirst(event) : networkFirst(event)); | |
| 176 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,176 @@ | |||
| 1 | +{{- /* | ||
| 2 | + The service worker, published as /sw.js: what lets a page the reader has | ||
| 3 | + already opened be opened again with no connection, and what saves a round | ||
| 4 | + trip for every file whose name already says it cannot have changed. | ||
| 5 | + | ||
| 6 | + A Hugo template for the same reason feed.js and feed.xsl are (see hugo.toml): | ||
| 7 | + each language is its own origin and so needs its own worker, and that worker | ||
| 8 | + has to name this build's fingerprinted files. It also has to sit at the root - | ||
| 9 | + a worker controls nothing above the directory it is served from - under a name | ||
| 10 | + that never changes, which rules out fingerprinting the worker itself. | ||
| 11 | + | ||
| 12 | + $assets repeats the pipelines of _default/baseof.html, because a pipeline is | ||
| 13 | + what decides the URL: keep the two in step. Falling out of step breaks | ||
| 14 | + nothing while there is a connection - any fingerprinted file is kept the | ||
| 15 | + first time it is asked for - but a file missing from this list is not there | ||
| 16 | + for a reader who goes offline after a single visit. | ||
| 17 | +*/ -}} | ||
| 18 | +{{- $assets := slice }} | ||
| 19 | +{{- with resources.Get "css/style.scss" }} | ||
| 20 | + {{- $opts := dict "transpiler" "dartsass" "outputStyle" "compressed" }} | ||
| 21 | + {{- with . | toCSS $opts | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }} | ||
| 22 | +{{- end }} | ||
| 23 | +{{- range slice "js/htmx.min.js" "js/hx-preload.min.js" }} | ||
| 24 | + {{- with resources.Get . | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }} | ||
| 25 | +{{- end }} | ||
| 26 | +{{- with resources.Get "js/quote-share.js" | minify | fingerprint }}{{ $assets = $assets | append .RelPermalink }}{{ end }} | ||
| 27 | +{{- /* What a first visit has to leave behind beyond those: the home page, the | ||
| 28 | + page shown in place of one that was never opened, and the fonts - every one | ||
| 29 | + of them, since which of the five a page needs depends on the characters in | ||
| 30 | + it, and a page read offline is not the one that was on screen at install. */}} | ||
| 31 | +{{- $offline := "" }} | ||
| 32 | +{{- with site.GetPage "/offline" }}{{ $offline = .RelPermalink }}{{ end }} | ||
| 33 | +{{- $seed := slice ("/" | relLangURL) }} | ||
| 34 | +{{- with $offline }}{{ $seed = $seed | append . }}{{ end }} | ||
| 35 | +{{- range os.ReadDir "static/fonts" }} | ||
| 36 | + {{- $seed = $seed | append (printf "/fonts/%s" .Name | relURL) }} | ||
| 37 | +{{- end }} | ||
| 38 | +// Two caches, told apart by whether a copy can go stale. IMMUTABLE holds the | ||
| 39 | +// fingerprinted files: a name that carries the hash of its own content means | ||
| 40 | +// the same bytes for good, so a copy is as good as the network and is never | ||
| 41 | +// checked against it. SEEN holds the last copy of everything else this origin | ||
| 42 | +// served - pages, fonts, the feed, images - which is only ever a fallback. | ||
| 43 | +// | ||
| 44 | +// Neither name carries a version. Nothing in them needs throwing away when a | ||
| 45 | +// new build arrives: a page is replaced the next time it is fetched, and a | ||
| 46 | +// fingerprinted file is dropped by sweep() once nothing names it any more. | ||
| 47 | +const IMMUTABLE = "immutable", SEEN = "seen"; | ||
| 48 | +const ASSETS = {{ $assets | jsonify }}; | ||
| 49 | +const SEED = {{ $seed | jsonify }}; | ||
| 50 | +const OFFLINE = {{ $offline | jsonify }}; | ||
| 51 | +// Hugo's fingerprint: the sha256 of the file, in hex, ahead of the extension. | ||
| 52 | +const FINGERPRINTED = /\.[0-9a-f]{64}\.\w+$/; | ||
| 53 | + | ||
| 54 | +// Everything is keyed by path alone. The site is static, so a query string | ||
| 55 | +// (?ogcard, a tracking parameter someone's link picked up) changes nothing | ||
| 56 | +// about what comes back, and keying on it would keep one copy per spelling. | ||
| 57 | +const pathOf = (request) => new URL(request.url).pathname; | ||
| 58 | + | ||
| 59 | +// A copy is kept on the side while the response itself goes straight back to | ||
| 60 | +// the page, rather than awaited: put() only settles once the whole body is | ||
| 61 | +// stored, and waiting on it would hold the first byte back until the last. | ||
| 62 | +// | ||
| 63 | +// Only a plain 200. A redirected response cannot answer a navigation later | ||
| 64 | +// (the browser refuses one it did not follow itself), and Pages answers | ||
| 65 | +// /posts with a redirect to /posts/, so keeping what fetch() followed would | ||
| 66 | +// store exactly that. | ||
| 67 | +const keep = (event, name, response) => { | ||
| 68 | + if (response.status === 200 && !response.redirected) { | ||
| 69 | + const copy = response.clone(); | ||
| 70 | + event.waitUntil( | ||
| 71 | + caches.open(name).then((cache) => cache.put(pathOf(event.request), copy)), | ||
| 72 | + ); | ||
| 73 | + } | ||
| 74 | + return response; | ||
| 75 | +}; | ||
| 76 | + | ||
| 77 | +const cacheFirst = async (event) => { | ||
| 78 | + const cached = await (await caches.open(IMMUTABLE)).match(pathOf(event.request)); | ||
| 79 | + return cached ?? keep(event, IMMUTABLE, await fetch(event.request)); | ||
| 80 | +}; | ||
| 81 | + | ||
| 82 | +// The network's answer whenever there is one, so being online never means | ||
| 83 | +// reading an old copy of a post. Without a connection: the copy kept from last | ||
| 84 | +// time, and failing that - for a request that is going to put a page on | ||
| 85 | +// screen - the offline page. hx-boost asks for a page with fetch() rather than | ||
| 86 | +// by navigating, which is what the HX-Request header test is for; what it gets | ||
| 87 | +// back is a whole document either way, so the two share their copies. | ||
| 88 | +const networkFirst = async (event) => { | ||
| 89 | + const {request} = event; | ||
| 90 | + try { | ||
| 91 | + // preloadResponse is the request the browser already started while this | ||
| 92 | + // worker was booting (see "activate"); undefined for anything that is not | ||
| 93 | + // a navigation. | ||
| 94 | + return keep(event, SEEN, (await event.preloadResponse) ?? (await fetch(request))); | ||
| 95 | + } catch { | ||
| 96 | + const seen = await caches.open(SEEN); | ||
| 97 | + let cached = await seen.match(pathOf(request)); | ||
| 98 | + if (!cached && OFFLINE && (request.mode === "navigate" || request.headers.has("HX-Request"))) { | ||
| 99 | + cached = await seen.match(OFFLINE); | ||
| 100 | + } | ||
| 101 | + // Rebuilt rather than handed back as stored: a response out of a cache | ||
| 102 | + // still carries the address it was fetched from, and htmx pushes that | ||
| 103 | + // address into history in preference to the one it asked for - so a | ||
| 104 | + // boosted link answered with the offline page left /offline/ in the | ||
| 105 | + // address bar, where reloading could never reach the page that was | ||
| 106 | + // wanted. A response with no address of its own leaves the request's. | ||
| 107 | + return cached ? new Response(cached.body, cached) : Response.error(); | ||
| 108 | + } | ||
| 109 | +}; | ||
| 110 | + | ||
| 111 | +// A fingerprinted file outlives its build for as long as a kept page still | ||
| 112 | +// names it. A reader who opened a post under an older build and has not been | ||
| 113 | +// back to it holds that build's HTML, which links that build's stylesheet - | ||
| 114 | +// delete the stylesheet because a newer one exists, and the post comes up | ||
| 115 | +// unstyled the day it is opened without a connection. So the test is not | ||
| 116 | +// "is it current" but "does anything still ask for it". | ||
| 117 | +const sweep = async () => { | ||
| 118 | + const seen = await caches.open(SEEN), immutable = await caches.open(IMMUTABLE); | ||
| 119 | + let named = ""; | ||
| 120 | + for (const key of await seen.keys()) { | ||
| 121 | + const response = await seen.match(key); | ||
| 122 | + // Only what can name a file: pages, the feed and its stylesheet, feed.js. | ||
| 123 | + if (/html|xml|xsl|javascript/.test(response?.headers.get("content-type") ?? "")) { | ||
| 124 | + named += await response.text(); | ||
| 125 | + } | ||
| 126 | + } | ||
| 127 | + for (const key of await immutable.keys()) { | ||
| 128 | + const path = pathOf(key); | ||
| 129 | + if (!ASSETS.includes(path) && !named.includes(path)) await immutable.delete(key); | ||
| 130 | + } | ||
| 131 | +}; | ||
| 132 | + | ||
| 133 | +self.addEventListener("install", (event) => { | ||
| 134 | + event.waitUntil((async () => { | ||
| 135 | + await (await caches.open(IMMUTABLE)).addAll(ASSETS); | ||
| 136 | + await (await caches.open(SEEN)).addAll(SEED); | ||
| 137 | + // Nothing to wait for: the caches are shared and only ever added to, so | ||
| 138 | + // this worker and the one it replaces can serve the same open pages. | ||
| 139 | + await self.skipWaiting(); | ||
| 140 | + })()); | ||
| 141 | +}); | ||
| 142 | + | ||
| 143 | +self.addEventListener("activate", (event) => { | ||
| 144 | + event.waitUntil((async () => { | ||
| 145 | + // Network-first means a navigation always waits on the network, and | ||
| 146 | + // without this it would also wait on the worker starting up first. | ||
| 147 | + await self.registration.navigationPreload?.enable(); | ||
| 148 | + // Take over the pages already open instead of from their next load. | ||
| 149 | + // hx-boost turns every later navigation into a fetch from the same | ||
| 150 | + // document, so there may be no next load for the rest of the visit. | ||
| 151 | + await self.clients.claim(); | ||
| 152 | + // Those pages were themselves loaded before there was anything to keep | ||
| 153 | + // them - and the page someone arrived on is the likeliest one they will | ||
| 154 | + // want back. Best effort: failing here must not fail the activation. | ||
| 155 | + const seen = await caches.open(SEEN); | ||
| 156 | + const open = await self.clients.matchAll({type: "window"}); | ||
| 157 | + await Promise.allSettled(open.map(async (client) => { | ||
| 158 | + const response = await fetch(client.url); | ||
| 159 | + if (response.status === 200 && !response.redirected) { | ||
| 160 | + await seen.put(new URL(client.url).pathname, response); | ||
| 161 | + } | ||
| 162 | + })); | ||
| 163 | + await sweep(); | ||
| 164 | + })()); | ||
| 165 | +}); | ||
| 166 | + | ||
| 167 | +self.addEventListener("fetch", (event) => { | ||
| 168 | + const {request} = event; | ||
| 169 | + // Left to the browser: anything that is not a read, other origins (the | ||
| 170 | + // visit counter), and range requests - a <video> asks for its file in | ||
| 171 | + // pieces, each a 206 that the Cache API will not store. | ||
| 172 | + if (request.method !== "GET" || request.headers.has("range")) return; | ||
| 173 | + const url = new URL(request.url); | ||
| 174 | + if (url.origin !== location.origin) return; | ||
| 175 | + event.respondWith(FINGERPRINTED.test(url.pathname) ? cacheFirst(event) : networkFirst(event)); | ||
| 176 | +}); | ||
added
blog/layouts/_default/offline.html +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +{{- /* | |
| 2 | + The page sw.js answers with when there is no connection and no kept copy of | |
| 3 | + what was asked for (content/offline.md and its translations). It is served | |
| 4 | + under the address of the page it stands in for, so nothing on it may depend | |
| 5 | + on its own URL - and it is a post's header and body without the kicker: it | |
| 6 | + has no date, and nothing worth timing or quoting. | |
| 7 | +*/ -}} | |
| 8 | +{{ define "main" }} | |
| 9 | +<article class="column"> | |
| 10 | + <header class="post-header rise"> | |
| 11 | + <h1 class="post-title">{{ .Title }}</h1> | |
| 12 | + <hr class="post-rule"> | |
| 13 | + </header> | |
| 14 | + | |
| 15 | + <div class="post-body prose">{{ .Content }}</div> | |
| 16 | +</article> | |
| 17 | +{{ end }} | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +{{- /* | ||
| 2 | + The page sw.js answers with when there is no connection and no kept copy of | ||
| 3 | + what was asked for (content/offline.md and its translations). It is served | ||
| 4 | + under the address of the page it stands in for, so nothing on it may depend | ||
| 5 | + on its own URL - and it is a post's header and body without the kicker: it | ||
| 6 | + has no date, and nothing worth timing or quoting. | ||
| 7 | +*/ -}} | ||
| 8 | +{{ define "main" }} | ||
| 9 | +<article class="column"> | ||
| 10 | + <header class="post-header rise"> | ||
| 11 | + <h1 class="post-title">{{ .Title }}</h1> | ||
| 12 | + <hr class="post-rule"> | ||
| 13 | + </header> | ||
| 14 | + | ||
| 15 | + <div class="post-body prose">{{ .Content }}</div> | ||
| 16 | +</article> | ||
| 17 | +{{ end }} | ||