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