10.9 KiB, 1 run
created by r2519314175:1487, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* sw.js — the shell cache, and the reason Daimond can have one at all. |
| 2 | * |
| 3 | * Daimond deliberately had NO service worker for a long time, and the reason was |
| 4 | * sound: a worker is a cache that serves code, and a cache that serves code can |
| 5 | * serve LAST WEEK'S code to somebody who cannot tell. `js/updater.js` exists to |
| 6 | * make that impossible -- it polls `build.json`, whose `build` id changes with |
| 7 | * every deploy, and reloads a tab that has fallen behind. A worker that ignored |
| 8 | * that would undo it. |
| 9 | * |
| 10 | * So this one does not have its own opinion about freshness. It has exactly the |
| 11 | * same one, from the same file: |
| 12 | * |
| 13 | * THE CACHE IS NAMED AFTER A BUILD, AND IS ONLY EVER READ WHILE THAT BUILD IS |
| 14 | * THE ONE THE SERVER IS SERVING. |
| 15 | * |
| 16 | * `build.json` is fetched `no-store` -- and is never itself intercepted, or the |
| 17 | * whole scheme would be reading its own cache -- on every page load, and again |
| 18 | * whenever a request comes in on a cold answer. The moment the id differs, every |
| 19 | * cache under the old name is deleted and the request goes to the network. There |
| 20 | * is no stale-while-revalidate here and no grace period: an old build is not |
| 21 | * served once, briefly, while a new one is fetched. It is not served. |
| 22 | * |
| 23 | * `js/updater.js` also posts each id it reads (it polls anyway, every two |
| 24 | * minutes and on every focus), so a tab that notices a deploy tells the worker in |
| 25 | * the same breath as it tells the user. One notion of "which build is live", |
| 26 | * arrived at by one file, consulted by both. |
| 27 | * |
| 28 | * WHAT IS CACHED: the shell only -- the document, the stylesheets, the scripts, |
| 29 | * the wasm, the fonts, the icons, the locale tables. Every one of those is a |
| 30 | * public, sealed artefact: `verify/manifest.json` carries a SHA-256 for each and |
| 31 | * `dev/repro-check.sh` proves the served bundle is the published source. Nothing |
| 32 | * a user has typed, nothing a model has said, nothing from `/api/`, and not |
| 33 | * `build.json`, `manifest.json` or `releases.json` -- the three files whose whole |
| 34 | * job is to say what the server is doing right now. |
| 35 | * |
| 36 | * OFFLINE: if `build.json` cannot be fetched at all, the last id that WAS seen |
| 37 | * stands and the shell is served from the cache. That is not stale code being |
| 38 | * hidden -- it is the most recent build this device ever saw the server offer, |
| 39 | * and the alternative is an app that will not open on a train. The instant the |
| 40 | * network answers again, the id is re-checked and a moved build empties the |
| 41 | * cache before anything else is served. |
| 42 | * |
| 43 | * IN DEVELOPMENT the cache is off. On a dev server the files change constantly |
| 44 | * and the build id does not move at all, so a build-keyed cache would serve an |
| 45 | * editor's last save for ever. The rule is by host, and `?cache=on` on the |
| 46 | * worker's own script URL turns it back on -- which is how `dev/verify_pwa.mjs` |
| 47 | * proves the caching rules on a loopback server, and the only thing that flag can |
| 48 | * do is switch on what production has anyway. |
| 49 | * |
| 50 | * This file is served, so it is sealed with everything else: `verify/lib.mjs` |
| 51 | * walks `www/` and excludes only the files named there, none of which is this |
| 52 | * one. A worker whose bytes were not in the transparency manifest would be the |
| 53 | * one piece of Daimond nobody could check. |
| 54 | */ |
| 55 | 'use strict'; |
| 56 | |
| 57 | /// Where the app lives, taken from where this file lives, so a deployment under |
| 58 | /// a sub-path needs no edit here. |
| 59 | const BASE = new URL('./', self.location.href); |
| 60 | |
| 61 | const PREFIX = 'daimond-shell-'; // one cache per build id |
| 62 | const STAMP = 'build.json'; // the same staleness file js/updater.js reads |
| 63 | const FRESH_MS = 30000; // how long an answer about the live build stands |
| 64 | |
| 65 | /// Cache off on a dev server, unless the script URL asks for it. See the header. |
| 66 | const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(self.location.hostname); |
| 67 | const FORCED = new URLSearchParams(self.location.search).get('cache') === 'on'; |
| 68 | const SHELL = !LOOPBACK || FORCED; |
| 69 | |
| 70 | /// The shell: the files a cold start needs, all of them sealed and public. |
| 71 | const SHELL_DIRS = ['css/', 'js/', 'i18n/', 'fonts/', 'assets/', 'pkg/']; |
| 72 | |
| 73 | /// Never, whatever else matches. |
| 74 | /// |
| 75 | /// `api/` and `webhook/` are the gateway: a user's mail, their spend, their |
| 76 | /// model traffic. `vendor/` and `console/` are not part of the sealed client. |
| 77 | const NEVER_DIRS = ['api/', 'webhook/', 'vendor/', 'console/']; |
| 78 | |
| 79 | /// Never, by name. The first three are the files that say what the server is |
| 80 | /// doing NOW, and a cached answer to that question is a wrong answer; `sw.js` is |
| 81 | /// this file, whose updates the browser handles itself. |
| 82 | const NEVER_FILES = ['build.json', 'manifest.json', 'releases.json', 'sw.js']; |
| 83 | |
| 84 | let live = null; // the build id last seen at the server |
| 85 | let seen = 0; // when it was seen, ms |
| 86 | |
| 87 | /// The path relative to the app root, or null for anything outside it. |
| 88 | /// |
| 89 | /// A query means a different resource, and none of the shell files has one -- so |
| 90 | /// `js/thing.js?v=2` goes to the network rather than matching the entry for |
| 91 | /// `js/thing.js`. A NAVIGATION is the exception, and has to be: `daimond.app/` |
| 92 | /// and `daimond.app/?anything` are the same document, the query is app state, and |
| 93 | /// judging a page load by its query would leave the document alone uncached and |
| 94 | /// unchecked -- which is precisely the request the build check matters most for. |
| 95 | function rel(url, isNav) { |
| 96 | if (url.search && !isNav) return null; |
| 97 | const here = url.origin + url.pathname; |
| 98 | if (!here.startsWith(BASE.href)) return null; |
| 99 | return here.slice(BASE.href.length); |
| 100 | } |
| 101 | |
| 102 | /// Is this one of the shell files? |
| 103 | function isShell(p) { |
| 104 | if (p === null) return false; |
| 105 | if (p === '' || p === 'index.html') return true; |
| 106 | if (NEVER_FILES.indexOf(p) >= 0) return false; |
| 107 | if (NEVER_DIRS.some(function (d) { return p.indexOf(d) === 0; })) return false; |
| 108 | return SHELL_DIRS.some(function (d) { return p.indexOf(d) === 0; }); |
| 109 | } |
| 110 | |
| 111 | /// The cache key for a shell path. The document is stored once, under the name |
| 112 | /// it has, so a visit to `/` and a visit to `/index.html` are the same entry. |
| 113 | function key(p) { return BASE.href + (p === '' ? 'index.html' : p); } |
| 114 | |
| 115 | /// Drop every shell cache that is not this build's. |
| 116 | async function sweep(keep) { |
| 117 | const names = await caches.keys(); |
| 118 | await Promise.all(names |
| 119 | .filter(function (n) { return n.indexOf(PREFIX) === 0 && n !== PREFIX + keep; }) |
| 120 | .map(function (n) { return caches.delete(n); })); |
| 121 | } |
| 122 | |
| 123 | /// Take `b` as the live build. A DIFFERENT id empties the cupboard before it is |
| 124 | /// recorded, so there is no instant at which `live` names a build whose |
| 125 | /// predecessor's files are still reachable. |
| 126 | async function adopt(b) { |
| 127 | if (b !== live) { |
| 128 | await sweep(b); |
| 129 | live = b; |
| 130 | } |
| 131 | seen = Date.now(); |
| 132 | } |
| 133 | |
| 134 | /// Ask the server which build is live. Null on any failure, and `live` is then |
| 135 | /// left exactly as it was -- see OFFLINE in the header. |
| 136 | async function stamp() { |
| 137 | try { |
| 138 | const r = await fetch(new URL(STAMP, BASE).href, { cache: 'no-store' }); |
| 139 | if (!r.ok) return null; |
| 140 | const j = await r.json(); |
| 141 | const b = (j && typeof j.build === 'string' && j.build) ? j.build : null; |
| 142 | if (b) await adopt(b); |
| 143 | return b; |
| 144 | } catch (e) { |
| 145 | return null; // offline, or no stamp deployed: say nothing |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | /// Put a fetched shell file away under the build that was live when it was |
| 150 | /// asked for -- and only if that is still the live build once it has arrived. A |
| 151 | /// deploy landing mid-load must not leave two builds' files in one cache. |
| 152 | async function store(p, res, at) { |
| 153 | if (live !== at) return; |
| 154 | if (!res || !res.ok || res.type !== 'basic') return; |
| 155 | if ((res.headers.get('cache-control') || '').indexOf('no-store') >= 0) return; |
| 156 | const c = await caches.open(PREFIX + at); |
| 157 | await c.put(key(p), res); |
| 158 | if (live !== at) await caches.delete(PREFIX + at); |
| 159 | } |
| 160 | |
| 161 | /// A shell request: the cache if the build says so, the network otherwise. |
| 162 | /// |
| 163 | /// `done` releases the hold the fetch handler took on the event's lifetime; it is |
| 164 | /// called on every path, including the failing ones, or the worker is kept alive |
| 165 | /// by a promise nothing will settle. |
| 166 | async function serve(req, p, done) { |
| 167 | try { |
| 168 | // A page load is when the question gets asked; everything else on that |
| 169 | // page rides on the answer until it goes cold. |
| 170 | if (req.mode === 'navigate' || Date.now() - seen > FRESH_MS) await stamp(); |
| 171 | |
| 172 | // Guarded on its own: a browser with no usable Cache Storage -- some |
| 173 | // private modes, a full disk -- must fall through to the network, not |
| 174 | // take the whole app down with it. The worker is an improvement to a |
| 175 | // working app and may never be the reason one fails to open. |
| 176 | if (live) { |
| 177 | try { |
| 178 | const c = await caches.open(PREFIX + live); |
| 179 | const hit = await c.match(key(p)); |
| 180 | if (hit) { done(); return hit; } |
| 181 | } catch (e) { /* no store to read from; the network stands */ } |
| 182 | } |
| 183 | |
| 184 | const at = live; |
| 185 | const res = await fetch(req); |
| 186 | if (at) store(p, res.clone(), at).then(done, done); else done(); |
| 187 | return res; |
| 188 | } catch (e) { |
| 189 | done(); |
| 190 | throw e; |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | self.addEventListener('install', function () { |
| 195 | // Straight to active. A waiting worker would mean the page and the worker |
| 196 | // disagreeing about which build is live, which is the one thing this file |
| 197 | // exists to prevent; and taking over changes nothing a page has already |
| 198 | // loaded, because the cache only ever holds the build that is live anyway. |
| 199 | self.skipWaiting(); |
| 200 | }); |
| 201 | |
| 202 | self.addEventListener('activate', function (ev) { |
| 203 | ev.waitUntil((async function () { |
| 204 | await self.clients.claim(); |
| 205 | await stamp(); // know the build before serving a byte |
| 206 | })()); |
| 207 | }); |
| 208 | |
| 209 | self.addEventListener('fetch', function (ev) { |
| 210 | if (!SHELL) return; // dev: pass everything through, untouched |
| 211 | const req = ev.request; |
| 212 | if (req.method !== 'GET') return; |
| 213 | let url; |
| 214 | try { url = new URL(req.url); } catch (e) { return; } |
| 215 | if (url.origin !== self.location.origin) return; |
| 216 | const p = rel(url, req.mode === 'navigate'); |
| 217 | if (!isShell(p)) return; |
| 218 | // The hold on the event's lifetime is taken HERE, while the event is being |
| 219 | // dispatched, because that is the only moment `waitUntil` is valid -- the put |
| 220 | // that needs it happens several awaits later, by which time the browser would |
| 221 | // refuse and the cache would quietly stay empty. |
| 222 | let done; |
| 223 | ev.waitUntil(new Promise(function (r) { done = r; })); |
| 224 | ev.respondWith(serve(req, p, done)); |
| 225 | }); |
| 226 | |
| 227 | self.addEventListener('message', function (ev) { |
| 228 | const d = ev.data; |
| 229 | if (!d || !d.type) return; |
| 230 | // js/updater.js has just read build.json, no-store, for its own purposes. |
| 231 | // Taking its answer is what makes "the build identity" one thing rather than |
| 232 | // two, and means a deploy noticed by a foreground tab empties the cache at |
| 233 | // once rather than at the next navigation. |
| 234 | if (d.type === 'daimond-build' && typeof d.build === 'string' && d.build) { |
| 235 | ev.waitUntil(adopt(d.build)); |
| 236 | return; |
| 237 | } |
| 238 | // What the worker believes, for anything that needs to ask rather than infer |
| 239 | // -- dev/verify_pwa.mjs does, and so does a bug report from a phone. |
| 240 | if (d.type === 'daimond-sw-state' && ev.ports && ev.ports[0]) { |
| 241 | const port = ev.ports[0]; |
| 242 | ev.waitUntil(caches.keys().then(function (names) { |
| 243 | port.postMessage({ |
| 244 | shell: SHELL, |
| 245 | live: live, |
| 246 | seen: seen, |
| 247 | caches: names.filter(function (n) { return n.indexOf(PREFIX) === 0; }), |
| 248 | }); |
| 249 | })); |
| 250 | } |
| 251 | }); |