Fetch the rest of a new build in the background (#394)

The app page names only what it loads at start. The composer, settings,
viewers and the rest were fetched when first used, and after every deploy
that first use waited on the server -- and the worker's tidy-up dropped
them again at the next deploy anyway.

The build now writes the list of all its files into the page as an inert
JSON block. The service worker keeps everything listed and, once a page
names files it does not hold, fetches them three at a time; a load cut
short is resumed at the next navigation. Language catalogs are listed
apart and left to be cached when used, and nothing is fetched ahead when
the browser is set to save data.
This commit is contained in:
jcoffey
2026-09-16 13:29:57 -07:00
committed by GitHub
parent e158ebac5a
commit 4c67460450
2 changed files with 86 additions and 6 deletions
+47 -2
View File
@@ -86,14 +86,59 @@ async function tidy() {
}
}
/** Keep the offline copy of the app page current, and tidy when it changes. */
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
async function refreshShell(res) {
const html = await res.text();
const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY);
if (prev && (await prev.text()) === html) return;
if (!prev || (await prev.text()) !== html) {
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
await tidy();
}
await precache(html);
}
/*
* Fetching the rest of the build before it is asked for.
*
* The app page lists every file of its build (see the asset-list plugin in
* vite.config.ts). Without this, the first time after a deploy that a reader
* opened the composer, settings or a viewer, it waited on the server for the
* code -- on a distant link, a visible pause. Now those files are fetched
* quietly once a page names them, a few at a time, and only those not held
* already; a load cut short is carried on at the next navigation, which calls
* this again. Language catalogs are left to be cached when used, and nothing
* is fetched ahead when the reader has asked the browser to save data.
*/
const PRECACHE_PARALLEL = 3;
function precacheList(html) {
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
if (!m) return [];
try {
const list = JSON.parse(m[1]).precache;
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
} catch {
return [];
}
}
async function precache(html) {
if (self.navigator.connection && self.navigator.connection.saveData) return;
const cache = await caches.open(VERSION);
const wanted = [];
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
const next = async () => {
for (let path = wanted.shift(); path; path = wanted.shift()) {
try {
const res = await fetch(path, { credentials: "same-origin" });
if (res.ok) await cache.put(path, res);
} catch {
/* offline, or a deploy changing over; the next navigation tries again */
}
}
};
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
}
/*
+37 -2
View File
@@ -1,4 +1,4 @@
import { defineConfig } from "vitest/config";
import { defineConfig, type Plugin } from "vitest/config";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.mjs";
@@ -22,9 +22,44 @@ const version = resolveVersion();
*/
const base = baseUrlOf(process.env.BASE_PATH);
/*
* Every file the build made, written into the app page for the service worker.
*
* The page names only what it loads at start; the composer, settings, viewers
* and the rest arrive when first used, and after each deploy that first use
* went back to the server. With the whole list in the page, the worker can
* fetch them in the background once a new version is seen, and knows to keep
* them. Language catalogs are listed apart: a reader wants one of them, which
* is cached when it is first loaded.
*
* An inert JSON block rather than prefetch links, which the browser would
* fetch on every load.
*/
function assetList(): Plugin {
return {
name: "ihasmail-asset-list",
apply: "build",
transformIndexHtml: {
order: "post",
handler(html, ctx) {
if (!ctx.bundle) return html;
const precache: string[] = [];
const onDemand: string[] = [];
for (const file of Object.values(ctx.bundle)) {
if (!file.fileName.startsWith("assets/") || file.fileName.endsWith(".map")) continue;
const catalog = file.type === "chunk" && file.moduleIds.length > 0 && file.moduleIds.every((id) => /[\\/]src[\\/]locales[\\/][^\\/]+\.ts$/.test(id));
(catalog ? onDemand : precache).push(`${base}${file.fileName}`);
}
const json = JSON.stringify({ precache: precache.sort(), onDemand: onDemand.sort() });
return html.replace("</body>", ` <script type="application/json" id="ihasmail-assets">${json}</script>\n </body>`);
},
},
};
}
export default defineConfig({
base,
plugins: [react()],
plugins: [react(), assetList()],
define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },