From 361b8add9bd7e598a615ae40cbee90945fc318f3 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 21 Oct 2026 15:57:29 +0100 Subject: [PATCH] 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. --- AGENTS.md | 2 +- .../general/service-worker-registrar.tsx | 82 +++++++++++++++++++ .../clear-development-service-worker.ts | 54 ++++++++++++ src/functions/server/pwa/handle-pwa-file.ts | 39 ++++++++- 4 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 src/functions/frontend/clear-development-service-worker.ts diff --git a/AGENTS.md b/AGENTS.md index 0449bad..4f94032 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). - **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-` 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. - **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. diff --git a/src/components/general/service-worker-registrar.tsx b/src/components/general/service-worker-registrar.tsx index 4ef166e..9e36905 100644 --- a/src/components/general/service-worker-registrar.tsx +++ b/src/components/general/service-worker-registrar.tsx @@ -7,13 +7,30 @@ import Span from "@moduletrace/twui/layout/Span"; import Button from "@moduletrace/twui/layout/Button"; import { RefreshCw } from "lucide-react"; import { PwaServiceWorkerPath } from "@/src/data/pwa-data"; +import clearDevelopmentServiceWorker from "@/src/functions/frontend/clear-development-service-worker"; 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 * 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. + * + * 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() { 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 | 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(() => { if (!reloading) return; diff --git a/src/functions/frontend/clear-development-service-worker.ts b/src/functions/frontend/clear-development-service-worker.ts new file mode 100644 index 0000000..19d1c7b --- /dev/null +++ b/src/functions/frontend/clear-development-service-worker.ts @@ -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 { + 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; +} diff --git a/src/functions/server/pwa/handle-pwa-file.ts b/src/functions/server/pwa/handle-pwa-file.ts index 8d7256e..fb21402 100644 --- a/src/functions/server/pwa/handle-pwa-file.ts +++ b/src/functions/server/pwa/handle-pwa-file.ts @@ -8,6 +8,36 @@ const PwaFiles: Record = { [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.