Keep only the app page as the app page (#396)

The service worker answers app routes from its kept page (#395), and it
kept whatever the mount's root returned at install and whatever HTML a
navigation returned. Where the root is not the app -- demo.ihasmail.com
puts its landing page there -- a returning visitor got the landing page on
every route.

The kept page is now only ever the app page, recognised by the asset list
the build writes into it: install fetches /mail instead of /, a
navigation's page is kept only if it is the app's, and a foreign page left
by the earlier worker is dropped when this one activates. Only the app's
own routes are answered from it; the root and any page in front of the app
go to the network. The reload for a new build primes the kept page from
/mail for the same reason.
This commit is contained in:
jcoffey
2026-09-16 15:07:45 -07:00
committed by GitHub
parent 82dc877fe1
commit 2740129c6a
2 changed files with 44 additions and 7 deletions
+40 -6
View File
@@ -18,16 +18,42 @@ const VERSION = "ihasmail-v2";
* eventually would. * eventually would.
*/ */
const BASE = new URL("./", self.location).pathname.replace(/\/$/, ""); const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`]; const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
/*
* Only the app page may be kept as the app page.
*
* The mount's root is not always the app: demo.ihasmail.com puts its landing
* page there, and a front door of any kind can. The worker used to cache
* whatever `/` returned at install and whatever HTML a navigation returned,
* and since app routes are answered from that copy first, a demo visitor who
* came back got the landing page on every route, for good. The app page is
* recognised by the asset list the build writes into it.
*/
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
/*
* The routes the app itself owns (App.tsx). Only these are answered from the
* kept page; anything else under the mount -- the root, a landing or farewell
* page in front of the app, a file -- goes to the network as it always did.
*/
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); event.waitUntil(
caches.open(VERSION)
.then((c) => c.addAll(SHELL))
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
.then(() => self.skipWaiting())
);
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
event.waitUntil( event.waitUntil(
caches.keys() caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))) .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k))))
.then(() => dropForeignShell())
.then(() => tidy()) .then(() => tidy())
.catch(() => {}) .catch(() => {})
.then(() => self.clients.claim()) .then(() => self.clients.claim())
@@ -91,9 +117,17 @@ async function tidy(also = "") {
} }
} }
/** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
async function dropForeignShell() {
const cache = await caches.open(VERSION);
const kept = await cache.match(SHELL_KEY);
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
}
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */ /** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
async function refreshShell(res) { async function refreshShell(res) {
const html = await res.text(); const html = await res.text();
if (!isAppPage(html)) return;
const cache = await caches.open(VERSION); const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY); const prev = await cache.match(SHELL_KEY);
const prevHtml = prev ? await prev.text() : ""; const prevHtml = prev ? await prev.text() : "";
@@ -245,9 +279,9 @@ self.addEventListener("fetch", (event) => {
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it * the version check reloads it (lib/sw/staleBuild.ts), and the assets it
* names are kept for one more build so it can run until then. * names are kept for one more build so it can run until then.
* *
* Only app routes. An address ending in a file name -- an image or the * Only the app's own routes (APP_ROUTE). The root, a page in front of the
* manifest opened in a tab of its own -- is not the app page, and goes to * app, and a file opened in a tab of its own go to the network as before. So
* the network as before. So does the first visit, which has no copy yet. * does the first visit, which has no copy yet.
*/ */
if (req.mode === "navigate") { if (req.mode === "navigate") {
const network = fetch(req).then((res) => { const network = fetch(req).then((res) => {
@@ -257,7 +291,7 @@ self.addEventListener("fetch", (event) => {
} }
return res; return res;
}); });
const appRoute = !/\.[a-z0-9]+$/i.test(url.pathname); const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
event.respondWith((async () => { event.respondWith((async () => {
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined; const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
if (kept) { if (kept) {
+4 -1
View File
@@ -108,9 +108,12 @@ async function check(): Promise<boolean> {
async function primeShell(): Promise<void> { async function primeShell(): Promise<void> {
if (!("caches" in window) || !navigator.serviceWorker?.controller) return; if (!("caches" in window) || !navigator.serviceWorker?.controller) return;
try { try {
const res = await fetch(withBase("/"), { credentials: "same-origin", cache: "no-store" }); // An app route rather than the root: the root can be a page in front of
// the app (the demo's landing page is), and only the app page may be kept.
const res = await fetch(withBase("/mail"), { credentials: "same-origin", cache: "no-store" });
if (!res.ok || !(res.headers.get("content-type") ?? "").startsWith("text/html")) return; if (!res.ok || !(res.headers.get("content-type") ?? "").startsWith("text/html")) return;
const html = await res.text(); const html = await res.text();
if (!html.includes('id="ihasmail-assets"')) return;
const cache = await caches.open(SW_CACHE_NAME); const cache = await caches.open(SW_CACHE_NAME);
await cache.put(withBase("/"), new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })); await cache.put(withBase("/"), new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
} catch { } catch {