Files
OneCAD/apps/solidcraft-web/sw.js
T

228 lines
11 KiB
JavaScript

// SolidCraft's service worker: the web app keeps working offline and updates itself.
//
// `cargo xtask web-offline` (Trunk's post_build hook) writes the build into the line below: the
// version (workspace version + a hash of every built file) and the files to keep, each with its
// SHA-256, and the same as `build.json` next to it. Files the app only fetches when it needs
// them (the CJK font) aren't kept: they come from the network. Every download is checked
// against its SHA-256: a deployment that lands mid-download (another build's `index.html`) fails
// the download instead of mixing into the kept build. A new build changes this file, so the
// browser installs the new worker, which keeps the whole new build before taking over.
//
// A launch only ever starts a build whose every file is kept, so a half-downloaded update can't
// break it. It asks for `build.json` (waiting at most NAVIGATE_TIMEOUT_MS): offline, slow or
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
// started. When the site has no `build.json` (the app was removed), launches go to the network,
// the kept build answering when that fails. Each page then gets its files from the build it
// started (remembered in PAGES), never from another build with a file of the same name.
//
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
// an open page uses the build it started. Then unfinished downloads are dropped, and so are
// builds replaced by a newer worker (RETIRED). The origin is shared with other apps (github.io)
// and other copies of this one (the release zip works from any path): only caches named after
// this worker's path (CACHE_PREFIX…, PAGES) are ever touched.
//
// Unbuilt (VERSION "dev", as debug builds get it), the worker retires: it drops what a release
// build kept and unregisters, so `trunk serve` after `trunk serve --release --no-autoreload`
// shows the development build. Deploying this file unbuilt switches the worker off for everyone.
/* @build */ const VERSION = "dev", FILES = [];
const RETIRE = VERSION === "dev";
const PATH = new URL(self.registration.scope).pathname;
const CACHE_PREFIX = `solidcraft-app ${PATH} `;
const CACHE = CACHE_PREFIX + VERSION;
/** Which build each open page started: page id → cache name (or NETWORK). */
const PAGES = `solidcraft-app-pages ${PATH}`;
/** A page that came from the network gets its files from there too. */
const NETWORK = "network";
const NAVIGATE_TIMEOUT_MS = 4000;
const UPDATE_TIMEOUT_MS = 20000;
/** The entry that marks a cache as holding its whole build. */
const COMPLETE = "build-complete";
/** The entry that marks a build replaced by a newer worker. */
const RETIRED = "build-retired";
/** A page remembered this recently may still be loading (not yet listed among the clients). */
const PAGE_LOADING_MS = 30 * 1000;
const url = (file) => new URL(file, self.registration.scope).href;
const has = (name, entry) => caches.match(url(entry), { cacheName: name }).then(Boolean);
/** Run `fn` while using the cache `name`: it isn't deleted meanwhile. */
const using = (name, fn) => navigator.locks.request(name, { mode: "shared" }, fn);
/** Delete the cache `name` if nothing is using it and `unwanted()` (asked once no one can start to) agrees. */
const drop = (name, unwanted) =>
navigator.locks.request(name, { ifAvailable: true }, async (free) => free && (await unwanted()) && caches.delete(name));
const hex = (buffer) => Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
/**
* Keep every file of a build (an earlier partial download is resumed); resolves to its cache's
* name. The first file that fails cancels the others.
*/
function keep(version, files) {
const name = CACHE_PREFIX + version;
return using(name, async () => {
const cache = await caches.open(name);
await cache.delete(url(RETIRED)); // Back in use (a rollback).
if (await has(name, COMPLETE)) return name;
const cancel = new AbortController();
const results = await Promise.allSettled(
files.map(async ({ file, sha256 }) => {
try {
if (await cache.match(url(file))) return;
// `no-cache`: revalidate with the server, so a stale HTTP-cached page never mixes builds.
const response = await fetch(new Request(url(file), { cache: "no-cache" }), { signal: cancel.signal });
if (!response.ok) throw new Error(`${file}: HTTP ${response.status}`);
const body = await response.arrayBuffer();
if (hex(await crypto.subtle.digest("SHA-256", body)) !== sha256) throw new Error(`${file}: not this build's`);
await cache.put(url(file), new Response(body, { headers: response.headers }));
} catch (error) {
cancel.abort();
throw error;
}
}),
);
const failed = results.find((r) => r.status === "rejected");
if (failed) throw failed.reason;
await cache.put(url(COMPLETE), new Response(version));
return name;
});
}
const pageEntry = (id) => url(`~page/${encodeURIComponent(id)}`);
/** Remember which build a page started. */
async function started(id, build) {
if (!id) return;
const record = JSON.stringify({ build, at: Date.now() });
await (await caches.open(PAGES)).put(pageEntry(id), new Response(record));
}
/** The build a page started; a page started before it was remembered gets this worker's. */
async function startedBy(id) {
const response = id ? await caches.match(pageEntry(id), { cacheName: PAGES }) : null;
return response ? (await response.json()).build : CACHE;
}
/** The builds open pages use (pages closed since are forgotten). */
async function inUse() {
const open = new Set((await self.clients.matchAll({ includeUncontrolled: true })).map((c) => c.id));
const pages = await caches.open(PAGES);
const builds = new Set();
for (const request of await pages.keys()) {
const id = decodeURIComponent(request.url.split("~page/")[1] ?? "");
const record = await pages.match(request).then((r) => r?.json()).catch(() => null);
if (record && (open.has(id) || Date.now() - record.at < PAGE_LOADING_MS)) builds.add(record.build);
else await pages.delete(request);
}
return builds;
}
/** Drop unfinished downloads nothing is using, and replaced builds no open page uses. */
async function sweep() {
for (const name of await caches.keys()) {
if (!name.startsWith(CACHE_PREFIX) || name === CACHE) continue;
await drop(name, async () => !(await has(name, COMPLETE)) || ((await has(name, RETIRED)) && !(await inUse()).has(name)));
}
}
self.addEventListener("install", (event) => {
// Clean up first: a page open for days sees updates (and failed ones) without any launch.
const install = () => sweep().catch(() => {}).then(() => keep(VERSION, FILES));
event.waitUntil(RETIRE ? self.skipWaiting() : install().then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if (RETIRE) {
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
return self.registration.unregister();
}
for (const name of await caches.keys()) {
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
}
await sweep();
await self.clients.claim();
})(),
);
});
/** The promise's value, or null when it fails or takes longer than `ms`. */
function within(ms, promise) {
return Promise.race([promise.catch(() => null), new Promise((resolve) => setTimeout(resolve, ms, null))]);
}
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
const GONE = "gone";
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
async function deployed() {
const response = await fetch(url("build.json"), { cache: "no-cache" });
if (response.status === 404 || response.status === 410) return GONE;
const build = response.ok ? await response.json() : null;
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
return valid && build.files.every((f) => typeof f.file === "string" && typeof f.sha256 === "string") ? build : null;
}
/** Keep the worker alive for `promise` (allowed while the launch is being answered). */
function extend(event, promise) {
try {
event.waitUntil(promise.catch(() => {}));
} catch {
// The event has ended; the work goes on while the worker runs.
}
}
/** A launch: the start page of a complete build (whatever query the URL has: `?sample`), and that build. */
async function launch(event) {
const kept = await caches.match(url("index.html"), { cacheName: CACHE });
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
extend(event, sweep());
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
if (build === GONE) {
const page = await fetch(event.request).catch(() => null);
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
}
if (!build || build.version === VERSION) return [kept, CACHE];
const download = keep(build.version, build.files);
extend(event, download);
const name = await within(UPDATE_TIMEOUT_MS, download);
const page = name && (await caches.match(url("index.html"), { cacheName: name }));
return page ? [page, name] : [kept, CACHE];
}
/**
* A launch that fails unexpectedly still starts the kept build (or asks the network). A newer
* worker only takes over once the launch has ended, by when its page is remembered (in use).
*/
async function launchOrKept(event) {
let page, build;
try {
[page, build] = await launch(event);
} catch {
page = await caches.match(url("index.html"), { cacheName: CACHE }).catch(() => null);
build = page ? CACHE : NETWORK;
page = page || (await fetch(event.request));
}
await started(event.resultingClientId, build).catch(() => {});
return page;
}
/** A page's files come from the build it started, else the network. */
async function asset(event) {
const build = await startedBy(event.clientId).catch(() => CACHE);
const kept = build === NETWORK ? null : await caches.match(event.request, { cacheName: build, ignoreSearch: true });
return kept || fetch(event.request);
}
self.addEventListener("fetch", (event) => {
const request = event.request;
if (RETIRE || request.method !== "GET" || new URL(request.url).origin !== self.location.origin) return;
const path = request.url.split(/[?#]/)[0];
const start = request.mode === "navigate" && (path === url("") || path === url("index.html"));
event.respondWith(start ? launchOrKept(event) : asset(event));
});