Files
ihasmail-inbuxa/web/src/lib/staleBuild.ts
T
jcoffey-dev 93d0a32af2 Serve ihasmail from a subpath
`BASE_PATH=/mail` mounts the whole app under a prefix, for a host that is not
ihasmail's alone. Unset -- every deployment that exists -- is the domain root
and is byte-for-byte what it was: the canonical form of the setting is the
empty string, and `""` concatenated onto `/api/health` is `/api/health`.

That choice of canonical form is the whole design. A trailing slash would have
been the obvious alternative, and it fails quietly in exactly one place: at the
root it makes `//api/health`, which is not a path on this host but a
protocol-relative URL to a host called `api`. One call site forgetting to
branch is a request leaving the origin. So the empty string, one leading slash,
no trailing one, worked out once in `scripts/basePath.mjs` -- plain JS, next to
`version.mjs`, because the web build and the server both have to reach the same
answer and two implementations of "what does /mail/ mean" is precisely the bug
where the server serves an app whose script tags point somewhere else.
`/mail`, `mail`, `/mail/` and `//mail//` all mean the same mount; a deployment
should not fail over a trailing slash.

Unlike everything else ihasmail is told, this one cannot wait for the process
to start. The bundle writes its own asset URLs into index.html, so `BASE_PATH`
is read at build time for Vite's `base` as well as at run time for the routes,
and the Dockerfile carries one value into both. Get them out of step and the
page comes up blank with a 404 in a console nobody has open -- so the static
handler, which is reading index.html anyway, checks what it asks for and says
so in the log once per build.

Everything moves together. The API mounts at `${base}/api`; the router is
given the base once, so every `<Route path>` and `<Link href>` stays written
root-absolute and wouter does the rest; `apiFetch` adds the prefix in one place
rather than at forty call sites; the session cookie's Path narrows to the mount
so two instances on one host cannot sign each other out.

Two things need no prefix at all, and it is worth saying why they were not
given one. A manifest's members resolve against the manifest's own address, so
relative URLs there follow the mount with nothing substituted at build time --
which is also why `public/` needed no template step. The service worker is the
same trick: it is served from the mount, so `new URL("./", self.location)`
tells it where that is, and a worker that derives the value cannot disagree
with the page that registered it.

Anything outside the mount is a 404 rather than the app shell, and
`stripBasePath` does not use `startsWith` -- under `/mail` this process shares
a hostname, and answering `/mailbox` with our index would shadow a neighbour
instead of letting it 404 honestly. For the same reason the notification-click
handler now checks the path as well as the origin: `includeUncontrolled` widens
`matchAll` to the whole origin, which off the root would have navigated a
stranger's tab to our inbox.

Inline images in a draft were the one silent trap. They are matched by their
blob URL on the way out, once unanchored and once anchored, and a bare
`/api/blob/` still appears inside `/mail/api/blob/...` -- so one pattern would
have replaced the tail and left `/mail` in front of a `cid:`, and the other
would have missed and sent the message linking to the sender's own webmail.
Both patterns are built from the base now.
2026-09-01 22:41:18 -07:00

151 lines
5.6 KiB
TypeScript

import { APP_VERSION } from "./version";
import { withBase } from "./basePath";
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);
window.location.reload();
return true;
}
/**
* 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());
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();
});
}