Run development on the dev server's code, not a build's cache

A production deploy leaves its service worker on the device, and it
claims every tab. It serves /.bunext/public/* cache first without
knowing which build asked, while every cache-busting mechanism the app
has (the precache gate, /build-info.json, the loading page) is
production only. So switching to `bun run dev` kept serving the old
build, vendor files included, and wrote the dev files back into the
production build's cache.

Development now clears that worker and every cache it wrote
(`clear-development-service-worker.ts`, from ServiceWorkerRegistrar,
which did nothing in development before), reloads once into the
uncontrolled tab, and /sw.js answers with a stub that unregisters
itself so Web Push and the loading page can't register it back.
This commit is contained in:
2026-10-21 15:57:29 +01:00
parent 8bf6bedec5
commit 361b8add9b
4 changed files with 174 additions and 3 deletions
@@ -0,0 +1,54 @@
/**
* Development must run on the dev server's code, never on what an
* earlier production build left on the device. The worker registered by
* a production deploy keeps controlling this origin (`clients.claim`),
* and it serves every `/.bunext/public/*` file cache first without
* knowing which build asked, while every cache-busting mechanism the app
* has (the precache gate, `/build-info.json`, the loading page) is
* production only. So in development the worker is unregistered and
* every cache it wrote is dropped once, then the page reloads into an
* uncontrolled tab.
*
* Returns whether anything was cleared, so the caller only reloads when
* there was something to clear.
*/
export default async function clearDevelopmentServiceWorker(): Promise<boolean> {
if (typeof navigator == "undefined") return false;
if (!("serviceWorker" in navigator) || !window.isSecureContext) return false;
let cleared = false;
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(
registrations.map(async (registration) => {
cleared = true;
/**
* The worker's own caches go first, while it still owns
* them, so nothing writes a build back mid-clear.
*/
await registration.unregister().catch(() => undefined);
}),
);
} catch (error) {
return false;
}
if ("caches" in window) {
try {
const keys = await caches.keys();
if (keys.length > 0) cleared = true;
await Promise.all(
keys.map((key) => caches.delete(key).catch(() => false)),
);
} catch (error) {
// A device with caches disabled still runs the dev server.
}
}
return cleared;
}
+37 -2
View File
@@ -8,6 +8,36 @@ const PwaFiles: Record<string, string> = {
[PwaManifestPath]: "manifest.webmanifest",
};
/**
* Development serves a worker that takes itself out instead of `sw.js`.
* A production deploy leaves a worker on the device that keeps serving
* its build's code cache first, and development never precaches or
* updates anything, so a page opened from the dev server would go on
* running the old build. The page clears it too
* (`clear-development-service-worker.ts`); this catches every other
* registration path (Web Push, the loading page, an old background tab).
*/
const DevelopmentServiceWorker = `self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
await self.registration.unregister().catch(() => undefined);
if ("caches" in self) {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
}
const clients = await self.clients.matchAll({ type: "window" });
for (const client of clients) {
if ("navigate" in client) await client.navigate(client.url).catch(() => undefined);
}
})(),
);
});
`;
/**
* Serves the service worker and the manifest with headers bunext's public
* handler can't give them: neither may be cached by a browser or a CDN,
@@ -23,9 +53,14 @@ export default async function handlePwaFile({
if (!file_name) return null;
const development_stub =
file_name == "sw.js" && process.env.NODE_ENV != "production"
? DevelopmentServiceWorker
: null;
const file = Bun.file(path.join(PUBLIC_DIR, file_name));
if (!(await file.exists())) {
if (!development_stub && !(await file.exists())) {
return new Response("Not Found", { status: 404 });
}
@@ -42,7 +77,7 @@ export default async function handlePwaFile({
headers.set("Service-Worker-Allowed", "/");
}
const response = new Response(file, { headers });
const response = new Response(development_stub || file, { headers });
/**
* `Bun.file` adds a filename disposition; a manifest is inline data.