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:
@@ -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-<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.
|
||||
- **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 { 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<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(() => {
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user