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
+1 -1
View File
@@ -135,7 +135,7 @@ Envs are set in Settings, under Environment (super admin only), not in `.env`. T
- **Generated assets:** `public/manifest.webmanifest`, `public/icons/*.png`, `public/offline.html` and `public/loading.html` are generated from `public/logos/logo.svg` and the theme tokens by `bun run gen:favicon` (`src/scripts/generate-brand-assets.ts` reads `src/styles/main.css`, so the manifest colors and the offline page never drift from the theme). The outputs are committed like the favicons. `src/data/pwa-data.ts` holds the shared paths, icon list, start path (`/admin/bots`) and shortcuts. The apple touch icon is opaque on paper (a transparent one renders black on iOS). - **Generated assets:** `public/manifest.webmanifest`, `public/icons/*.png`, `public/offline.html` and `public/loading.html` are generated from `public/logos/logo.svg` and the theme tokens by `bun run gen:favicon` (`src/scripts/generate-brand-assets.ts` reads `src/styles/main.css`, so the manifest colors and the offline page never drift from the theme). The outputs are committed like the favicons. `src/data/pwa-data.ts` holds the shared paths, icon list, start path (`/admin/bots`) and shortcuts. The apple touch icon is opaque on paper (a transparent one renders black on iOS).
- **Head and install:** `src/pages/__root.tsx` `Head` links the manifest, sets the apple/mobile meta tags and renders `PwaInitScript`, which captures `beforeinstallprompt` before hydration (twui's hook reads it later), points `theme-color` at `--c-paper` (following the theme without repeating the color), and rewrites bunext's fixed viewport meta to add `viewport-fit=cover` and `interactive-widget=resizes-content`. `use-install-prompt` exposes installable/standalone/iOS state; the rail user menu shows Install app while installable, hides once installed, and opens `install-app-modal` with the Safari Share steps on iOS. - **Head and install:** `src/pages/__root.tsx` `Head` links the manifest, sets the apple/mobile meta tags and renders `PwaInitScript`, which captures `beforeinstallprompt` before hydration (twui's hook reads it later), points `theme-color` at `--c-paper` (following the theme without repeating the color), and rewrites bunext's fixed viewport meta to add `viewport-fit=cover` and `interactive-widget=resizes-content`. `use-install-prompt` exposes installable/standalone/iOS state; the rail user menu shows Install app while installable, hides once installed, and opens `install-app-modal` with the Safari Share steps on iOS.
- **Service worker:** `public/sw.js`, served by `src/server.ts` through `handlePwaFile` with `no-store` and `Service-Worker-Allowed: /` (bunext's one-hour public cache would delay every deploy, including through Cloudflare). Navigations are network-only with `public/offline.html` as the fallback; authenticated HTML is never cached. `/public/`, icons and logos are stale-while-revalidate; `/api/`, `/ws`, `/media/`, `/computers/` and `/build-info.json` are never intercepted. `ServiceWorkerRegistrar` in `__root.tsx` registers it in production only (development HMR is never cached) and offers a reload when a new version waits; it never reloads on its own, so a running reply isn't cut off. - **Service worker:** `public/sw.js`, served by `src/server.ts` through `handlePwaFile` with `no-store` and `Service-Worker-Allowed: /` (bunext's one-hour public cache would delay every deploy, including through Cloudflare). Navigations are network-only with `public/offline.html` as the fallback; authenticated HTML is never cached. `/public/`, icons and logos are stale-while-revalidate; `/api/`, `/ws`, `/media/`, `/computers/` and `/build-info.json` are never intercepted. `ServiceWorkerRegistrar` in `__root.tsx` registers it in production only (development HMR is never cached) and offers a reload when a new version waits; it never reloads on its own, so a running reply isn't cut off. In development it does the opposite (`clear-development-service-worker.ts`): it unregisters every worker and deletes every cache a production deploy left on the device, then reloads once into the uncontrolled tab, because the installed worker keeps serving its build's `/.bunext/public` files cache first while every cache-busting mechanism (the precache gate, `/build-info.json`, the loading page) is production only. Development also answers `/sw.js` with a stub that unregisters itself and drops the caches, so any other registration path (Web Push, the loading page) ends the same way.
- **Precache (production):** the whole of `.bunext/public` is downloaded once per build before the app is used. On start the server reads the build id from `.bunext/build-info.json` and lists every file in `.bunext/public` (`grabPrecacheManifest`, logged, read once). `handlePrecacheGate` (in `src/server.ts`, before bunext) answers a page navigation whose `AppData.BuildCookieName` cookie isn't that build with the generated `public/loading.html` at the same address (`no-store`, `{{build}}` filled in; `needsPrecachePage`: `Sec-Fetch-Mode: navigate` or `Sec-Fetch-Dest: document`, since a navigation the worker passes on arrives as `empty`, never `/api/`, `/.bunext/`, `/media`, `/computers/`, `/ws` or a path with an extension). The page reads the build's files from `GET /build-info.json` (`handle-build-info.ts`, `no-store`) and downloads them into `cobble-assets-<build_id>` itself (`AppData.PrecacheConcurrency` at a time), so it works whichever worker version is in charge. It then checks every file is in the cache, downloading any missing ones again up to `AppData.PrecacheVerifyPasses` more times, before it writes the marker (`PwaPrecacheMarkerPath`). It deletes the old build's cache, tells a new worker waiting behind an older one to take over (`SKIP_WAITING`, after `reg.update()`, for up to `AppData.PrecacheWorkerWaitMs`), sets the cookie and reloads into the app. Without Cache Storage, when files are still missing after the last pass, or once nothing has landed for `AppData.PrecacheStallMs`, it sets the cookie anyway, so the app runs from the network. The worker only serves `/.bunext/public/*` cache-first from the one `cobble-assets-*` cache. `sw.js` never changes per build, and React knows nothing of any of this. The loading page is generated with the offline page (`bun run gen:favicon`). - **Precache (production):** the whole of `.bunext/public` is downloaded once per build before the app is used. On start the server reads the build id from `.bunext/build-info.json` and lists every file in `.bunext/public` (`grabPrecacheManifest`, logged, read once). `handlePrecacheGate` (in `src/server.ts`, before bunext) answers a page navigation whose `AppData.BuildCookieName` cookie isn't that build with the generated `public/loading.html` at the same address (`no-store`, `{{build}}` filled in; `needsPrecachePage`: `Sec-Fetch-Mode: navigate` or `Sec-Fetch-Dest: document`, since a navigation the worker passes on arrives as `empty`, never `/api/`, `/.bunext/`, `/media`, `/computers/`, `/ws` or a path with an extension). The page reads the build's files from `GET /build-info.json` (`handle-build-info.ts`, `no-store`) and downloads them into `cobble-assets-<build_id>` itself (`AppData.PrecacheConcurrency` at a time), so it works whichever worker version is in charge. It then checks every file is in the cache, downloading any missing ones again up to `AppData.PrecacheVerifyPasses` more times, before it writes the marker (`PwaPrecacheMarkerPath`). It deletes the old build's cache, tells a new worker waiting behind an older one to take over (`SKIP_WAITING`, after `reg.update()`, for up to `AppData.PrecacheWorkerWaitMs`), sets the cookie and reloads into the app. Without Cache Storage, when files are still missing after the last pass, or once nothing has landed for `AppData.PrecacheStallMs`, it sets the cookie anyway, so the app runs from the network. The worker only serves `/.bunext/public/*` cache-first from the one `cobble-assets-*` cache. `sw.js` never changes per build, and React knows nothing of any of this. The loading page is generated with the offline page (`bun run gen:favicon`).
- **Phone shell:** below `md` the rail is a 280px slide-over with a scrim, Escape to close, and a close on every route change (`RailDrawerContext`); it is a 64px icon rail at `md` and 260px at `lg`. Rail internals use the `@container` `@rail:` variant (`--container-rail` in `src/styles/main.css`), not viewport `lg:`, so the drawer shows the full rail. `RailDrawerButton` sits in both pane headers. The shell is `h-dvh` with safe-area insets, and the headers/thread/composer use mobile padding. - **Phone shell:** below `md` the rail is a 280px slide-over with a scrim, Escape to close, and a close on every route change (`RailDrawerContext`); it is a 64px icon rail at `md` and 260px at `lg`. Rail internals use the `@container` `@rail:` variant (`--container-rail` in `src/styles/main.css`), not viewport `lg:`, so the drawer shows the full rail. `RailDrawerButton` sits in both pane headers. The shell is `h-dvh` with safe-area insets, and the headers/thread/composer use mobile padding.
- **Connection recovery:** twui's `useWebSocket` stops retrying after three attempts, so `LiveSocket` is remounted on `online`, `pageshow` and a hidden tab becoming visible (`use-connection-epoch`). `useOnlineStatus` drives the offline strip and disables sending: nothing is queued offline. - **Connection recovery:** twui's `useWebSocket` stops retrying after three attempts, so `LiveSocket` is remounted on `online`, `pageshow` and a hidden tab becoming visible (`use-connection-epoch`). `useOnlineStatus` drives the offline strip and disables sending: nothing is queued offline.
@@ -7,13 +7,30 @@ import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button"; import Button from "@moduletrace/twui/layout/Button";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data"; import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
import clearDevelopmentServiceWorker from "@/src/functions/frontend/clear-development-service-worker";
const UpdateCheckMs = 60 * 60 * 1000; const UpdateCheckMs = 60 * 60 * 1000;
/**
* Development only: waits out a `clients.claim` that raced the clear, so
* the clearing reload doesn't come back controlled by the old worker.
*/
const DevelopmentClearRetryMs = 3000;
/**
* Development only: guards against reloading in a loop when the worker
* can't be cleared (a restricted profile, say).
*/
const DevelopmentClearAttemptsKey = "cobble-dev-sw-clear";
const DevelopmentClearMaxAttempts = 3;
/** /**
* Registers the app's service worker (production only, so development * Registers the app's service worker (production only, so development
* HMR is never cached) and offers a reload once a new version is * HMR is never cached) and offers a reload once a new version is
* installed. It never reloads on its own: a bot reply may be running. * installed. It never reloads on its own: a bot reply may be running.
*
* In development it does the opposite: it clears the worker and the
* caches a production deploy left on the device, so the dev server's
* code is never served from a build's cache (see
* `clear-development-service-worker.ts`).
*/ */
export default function ServiceWorkerRegistrar() { export default function ServiceWorkerRegistrar() {
const [update_ready, setUpdateReady] = useState(false); const [update_ready, setUpdateReady] = useState(false);
@@ -78,6 +95,71 @@ export default function ServiceWorkerRegistrar() {
}; };
}, []); }, []);
useEffect(() => {
if (process.env.NODE_ENV == "production") return;
if (typeof navigator == "undefined") return;
if (!("serviceWorker" in navigator)) return;
let cancelled = false;
let retry: ReturnType<typeof setTimeout> | undefined;
/**
* A worker claimed this tab while clearing or right after it:
* clear again, then reload into the uncontrolled tab. Without
* this, the reload would land back on the cached build.
*/
const onControllerChange = () => {
if (cancelled) return;
retry = setTimeout(() => {
if (cancelled) return;
clearDevelopmentServiceWorker()
.then((cleared) => {
if (!cancelled && cleared) window.location.reload();
})
.catch(() => undefined);
}, DevelopmentClearRetryMs);
};
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
clearDevelopmentServiceWorker()
.then((cleared) => {
if (cancelled) return;
if (!cleared) {
window.sessionStorage.removeItem(DevelopmentClearAttemptsKey);
return;
}
/**
* This load is still the old worker's code: one reload
* takes the dev server's, twice at most.
*/
const attempts = Number(
window.sessionStorage.getItem(DevelopmentClearAttemptsKey) || 0,
);
if (attempts >= DevelopmentClearMaxAttempts) return;
window.sessionStorage.setItem(
DevelopmentClearAttemptsKey,
String(attempts + 1),
);
window.location.reload();
})
.catch(() => undefined);
return () => {
cancelled = true;
if (retry) clearTimeout(retry);
navigator.serviceWorker.removeEventListener(
"controllerchange",
onControllerChange,
);
};
}, []);
useEffect(() => { useEffect(() => {
if (!reloading) return; if (!reloading) return;
@@ -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", [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 * 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, * 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; 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)); 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 }); return new Response("Not Found", { status: 404 });
} }
@@ -42,7 +77,7 @@ export default async function handlePwaFile({
headers.set("Service-Worker-Allowed", "/"); 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. * `Bun.file` adds a filename disposition; a manifest is inline data.