Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/sw.js

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.
59const BASE = new URL('./', self.location.href);
60
61const PREFIX = 'daimond-shell-'; // one cache per build id
62const STAMP = 'build.json'; // the same staleness file js/updater.js reads
63const 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.
66const LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(self.location.hostname);
67const FORCED = new URLSearchParams(self.location.search).get('cache') === 'on';
68const SHELL = !LOOPBACK || FORCED;
69
70/// The shell: the files a cold start needs, all of them sealed and public.
71const 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.
77const 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.
82const NEVER_FILES = ['build.json', 'manifest.json', 'releases.json', 'sw.js'];
83
84let live = null; // the build id last seen at the server
85let 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.
95function 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?
103function 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.
113function key(p) { return BASE.href + (p === '' ? 'index.html' : p); }
114
115/// Drop every shell cache that is not this build's.
116async 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.
126async 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.
136async 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.
152async 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.
166async 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
194self.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
202self.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
209self.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
227self.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});