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.
179 lines
6.9 KiB
TypeScript
179 lines
6.9 KiB
TypeScript
import { APP_VERSION } from "../version";
|
|
import { withBase } from "../basePath";
|
|
import { SW_CACHE_NAME } from "./swCache";
|
|
import { push, type PushState } from "@/jmap/push";
|
|
|
|
/**
|
|
* Reload the page when the server is serving a build this one did not come
|
|
* from.
|
|
*
|
|
* Signing out and picking up a new version are separate things, and only the
|
|
* first happens on its own. An immutable instance holds sessions in memory, so
|
|
* a deploy signs everyone out -- but the tab that was open still has the old
|
|
* bundle in it, and a 401 only swaps the view to the sign-in form. The old
|
|
* JavaScript would go on talking to the new server until someone happened to
|
|
* reload by hand.
|
|
*
|
|
* `index.html` is served `no-cache` and the assets under it are content-hashed
|
|
* and immutable, so a reload is all it takes; the only missing part was
|
|
* something to ask for one. Comparing versions rather than reloading on every
|
|
* 401 means an ordinary session expiry still lands on the sign-in form with the
|
|
* page intact -- only a build that actually moved costs the page.
|
|
*
|
|
* The reload is unconditional once the versions differ. A compose window can
|
|
* be holding text that never reached the server, and after a deploy it cannot
|
|
* be saved either, since the session went with the container -- so this will
|
|
* sometimes take an unsent draft with it. That is a deliberate trade: a tab
|
|
* running code the server no longer speaks is the worse failure, and one that
|
|
* stays behind because someone left a draft open is not automatic at all.
|
|
*/
|
|
const TRIED_KEY = "ihasmail:reloaded-for";
|
|
|
|
/** sessionStorage throws outright in some privacy modes; treat that as absent. */
|
|
function tried(): string | null {
|
|
try {
|
|
return sessionStorage.getItem(TRIED_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function remember(version: string): void {
|
|
try {
|
|
sessionStorage.setItem(TRIED_KEY, version);
|
|
} catch {
|
|
/* nothing to do: the guard below is best-effort */
|
|
}
|
|
}
|
|
|
|
function forget(): void {
|
|
try {
|
|
sessionStorage.removeItem(TRIED_KEY);
|
|
} catch {
|
|
/* as above */
|
|
}
|
|
}
|
|
|
|
let inFlight: Promise<boolean> | null = null;
|
|
|
|
/**
|
|
* True when a reload has been asked for and the caller should leave the page
|
|
* alone. False for every other outcome, including not being able to tell --
|
|
* failing to reach the server is not a reason to throw away what is on screen.
|
|
*/
|
|
export function reloadIfServerRebuilt(): Promise<boolean> {
|
|
// Several things can notice a deploy at once -- the stream dropping and the
|
|
// request that follows it -- and they should not each ask the server.
|
|
inFlight ??= check().finally(() => {
|
|
inFlight = null;
|
|
});
|
|
return inFlight;
|
|
}
|
|
|
|
async function check(): Promise<boolean> {
|
|
let serverVersion: string;
|
|
try {
|
|
const res = await fetch(withBase("/api/health"), { credentials: "same-origin", cache: "no-store" });
|
|
if (!res.ok) return false;
|
|
const body = (await res.json()) as { version?: unknown };
|
|
if (typeof body.version !== "string" || !body.version) return false;
|
|
serverVersion = body.version;
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
if (serverVersion === APP_VERSION) {
|
|
// Back in step, either because nothing changed or because an earlier
|
|
// reload worked. Clear the guard so the next deploy is not mistaken for
|
|
// one already attempted.
|
|
forget();
|
|
return false;
|
|
}
|
|
// Reloading once per version, not once per 401: if the new bundle somehow
|
|
// still reports the old version -- a stale proxy cache, a half-finished
|
|
// deploy -- this stops the two of them reloading each other in a loop.
|
|
if (tried() === serverVersion) return false;
|
|
remember(serverVersion);
|
|
await primeShell();
|
|
window.location.reload();
|
|
return true;
|
|
}
|
|
|
|
/*
|
|
* The service worker answers a navigation from its kept copy of the app page
|
|
* and refreshes that copy behind it. A reload for a new build must not get the
|
|
* old copy back, so the new page is put in place first. Best effort: if this
|
|
* fails, the loop guard above still stops a second reload.
|
|
*/
|
|
async function primeShell(): Promise<void> {
|
|
if (!("caches" in window) || !navigator.serviceWorker?.controller) return;
|
|
try {
|
|
// 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;
|
|
const html = await res.text();
|
|
if (!html.includes('id="ihasmail-assets"')) return;
|
|
const cache = await caches.open(SW_CACHE_NAME);
|
|
await cache.put(withBase("/"), new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
|
|
} catch {
|
|
/* reload anyway */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Watch for a deploy without waiting to be asked.
|
|
*
|
|
* Checking on a 401 alone was not automatic, only deferred: it needs the tab to
|
|
* make a request, so one sitting idle keeps running the old build until someone
|
|
* touches it.
|
|
*
|
|
* The obvious signal turned out to be the wrong one. A deploy kills the
|
|
* EventSource behind `/api/events`, which looks like the perfect cue -- except
|
|
* it arrives while the container is still being replaced, so the check that
|
|
* follows cannot reach the server. Waiting for the stream to come back instead
|
|
* does not work either: the session died with the old container, so the
|
|
* reconnect is answered with a 401 and never reaches "connected" at all. The
|
|
* drop is kept below because it is free and sometimes lands early enough to be
|
|
* useful, but nothing depends on it.
|
|
*
|
|
* What the guarantee rests on is a slow poll while the tab is visible, plus a
|
|
* check when it becomes visible again. Neither cares what the stream is doing
|
|
* or whether anyone is at the keyboard: a tab left open through a deploy
|
|
* notices within a minute, and a backgrounded one notices the moment it is
|
|
* looked at. `/api/health` touches nothing upstream, so the cost is one small
|
|
* request a minute per open tab.
|
|
*/
|
|
const POLL_MS = 60_000;
|
|
|
|
export function makeConnectionWatcher(): (state: PushState) => void {
|
|
let wasConnected = false;
|
|
return (state) => {
|
|
if (state === "connected") {
|
|
wasConnected = true;
|
|
return;
|
|
}
|
|
// Only a drop is news. Never having connected is not evidence of anything.
|
|
if (!wasConnected) return;
|
|
wasConnected = false;
|
|
void reloadIfServerRebuilt();
|
|
};
|
|
}
|
|
|
|
export function startBuildWatch(): void {
|
|
push.onConnection(makeConnectionWatcher());
|
|
|
|
// A page the service worker answered from its kept copy may be a build
|
|
// behind; ask now rather than a minute from now.
|
|
if (navigator.serviceWorker?.controller) void reloadIfServerRebuilt();
|
|
|
|
window.setInterval(() => {
|
|
// A hidden tab is not being read, and will be checked when it surfaces.
|
|
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
|
|
}, POLL_MS);
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "visible") void reloadIfServerRebuilt();
|
|
});
|
|
}
|