Download the whole app build behind a loading page

In production, a page request without the running build's cookie gets a
small loading page instead of the app. It downloads every file in
.bunext/public (listed at /build-info.json) into that build's cache,
checks they are all there, hands over to a new service worker waiting
behind an older one, sets the cookie and reloads. The service worker
then serves the app's code from the cache, so later visits load it from
the device.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 19:33:15 +01:00
co-authored by Claude Opus 5.5
parent 9778d0b9ff
commit b3057119df
16 changed files with 991 additions and 45 deletions
+26
View File
@@ -6,6 +6,25 @@ export const AppData = {
* Shared with twui's `ColorSchemeSelector`, so it must stay `theme`.
*/
ThemeStorageKey: "theme",
/**
* How many of a build's files the loading page downloads at once.
*/
PrecacheConcurrency: 6,
/**
* How many more times the loading page downloads files it finds
* missing from the cache after a pass, before it gives up on them.
*/
PrecacheVerifyPasses: 2,
/**
* How long the loading page waits for a new service worker to take
* over before it reloads into the app anyway.
*/
PrecacheWorkerWaitMs: 10_000,
/**
* The loading page gives up and lets the app run from the network
* when no new file lands for this long.
*/
PrecacheStallMs: 30_000,
/**
* Prefix of the composer's unsent drafts in `localStorage`, one per
* bot and workspace.
@@ -38,6 +57,13 @@ export const AppData = {
TlsCaProfileFilename: `${SiteData["SiteSlug"]}-ca.mobileconfig`,
AuthKeyCookieName: `${SiteData["SiteSlug"]}_auth_key`,
AuthCSRFCookieName: `${SiteData["SiteSlug"]}_auth_csrf`,
/**
* The build this browser has been through the loading page for
* (`handlePrecacheGate`). Set by the page itself, so not `HttpOnly`;
* it holds nothing private.
*/
BuildCookieName: `${SiteData["SiteSlug"]}_build`,
BuildCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
AuthExpiryDays: 60,
APICrudBasePath: "/api/admin/crud",
ForcePrivateHeaderKey: `x-${SiteData["SiteSlug"]}-force-private`,
+15
View File
@@ -9,6 +9,21 @@ export const PwaScope = "/";
export const PwaManifestPath = "/manifest.webmanifest";
export const PwaServiceWorkerPath = "/sw.js";
export const PwaOfflinePath = "/offline.html";
/**
* The page a browser gets instead of the app while it downloads a build
* it hasn't yet (`handlePrecacheGate`), generated like the offline page.
*/
export const PwaLoadingPath = "/loading.html";
/**
* The running build's id and files (`handle-build-info.ts`). Each build's
* `.bunext/public` files are downloaded into their own cache,
* `<prefix><build_id>`, which holds the marker once every file is in.
* `public/sw.js` repeats all three.
*/
export const PwaBuildInfoPath = "/build-info.json";
export const PwaPrecacheCachePrefix = "cobble-assets-";
export const PwaPrecacheMarkerPath = "/__cobble-precached";
export type PwaIcon = {
src: string;
@@ -0,0 +1,55 @@
import { afterAll, describe, expect, test } from "bun:test";
import path from "path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import buildPrecacheManifest from "./build-precache-manifest";
const root = mkdtempSync(path.join(tmpdir(), "precache-"));
afterAll(() => rmSync(root, { recursive: true, force: true }));
function writeBuild({ dir, files }: { dir: string; files: string[] }) {
writeFileSync(
path.join(dir, "build-info.json"),
JSON.stringify({ build_id: "build-1" }),
);
for (const file of files) {
const full_path = path.join(dir, "public", file);
mkdirSync(path.dirname(full_path), { recursive: true });
writeFileSync(full_path, "x");
}
}
describe("buildPrecacheManifest", () => {
test("lists every public file under its served path", () => {
const dir = path.join(root, "full");
mkdirSync(dir);
writeBuild({
dir,
files: [
"vendor/react.js",
"pages/chunk-AEHOZS24.js",
"pages/admin/bots/[bot_id]/VXCMP4GM.js",
"pages/auth/login/X4TQCLSA.css",
],
});
expect(buildPrecacheManifest({ bunext_dir: dir })).toEqual({
build_id: "build-1",
files: [
"/.bunext/public/pages/admin/bots/[bot_id]/VXCMP4GM.js",
"/.bunext/public/pages/auth/login/X4TQCLSA.css",
"/.bunext/public/pages/chunk-AEHOZS24.js",
"/.bunext/public/vendor/react.js",
],
});
});
test("is null without a build", () => {
const dir = path.join(root, "empty");
mkdirSync(dir);
expect(buildPrecacheManifest({ bunext_dir: dir })).toBeNull();
});
});
@@ -0,0 +1,55 @@
import path from "path";
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import type { PrecacheManifest } from "@/src/types";
type Params = {
/**
* bunext's build directory (`.bunext`).
*/
bunext_dir: string;
};
/**
* Lists every file of one bunext build's `public/` directory as the path
* the browser asks for (`/.bunext/public/...`), with the build's id, so
* the service worker can download the whole build before the app runs.
* Null without a build.
*/
export default function buildPrecacheManifest({
bunext_dir,
}: Params): PrecacheManifest | null {
const public_dir = path.join(bunext_dir, "public");
const build_info_path = path.join(bunext_dir, "build-info.json");
if (!existsSync(public_dir) || !existsSync(build_info_path)) return null;
const build_id = JSON.parse(readFileSync(build_info_path, "utf-8"))
?.build_id as string | undefined;
if (!build_id) return null;
const files: string[] = [];
const walk = (dir: string) => {
for (const name of readdirSync(dir)) {
const full_path = path.join(dir, name);
if (statSync(full_path).isDirectory()) {
walk(full_path);
continue;
}
const relative = path
.relative(bunext_dir, full_path)
.split(path.sep)
.join("/");
files.push(`/.bunext/${relative}`);
}
};
walk(public_dir);
files.sort();
return { build_id, files };
}
@@ -0,0 +1,27 @@
import path from "path";
import grabDirNames from "@/src/utils/grab-dir-names";
import type { PrecacheManifest } from "@/src/types";
import buildPrecacheManifest from "./build-precache-manifest";
let manifest: PrecacheManifest | null | undefined;
/**
* The running build's precache manifest, production only (development
* rebuilds on every change and is never cached). Read once per process:
* a build never changes under a running server.
*/
export default function grabPrecacheManifest(): PrecacheManifest | null {
if (process.env.NODE_ENV != "production") return null;
if (manifest !== undefined) return manifest;
try {
manifest = buildPrecacheManifest({
bunext_dir: path.join(grabDirNames().ROOT_DIR, ".bunext"),
});
} catch (error) {
console.log("Couldn't read the build for precaching:", error);
manifest = null;
}
return manifest;
}
@@ -0,0 +1,27 @@
import { PwaBuildInfoPath } from "@/src/data/pwa-data";
import grabPrecacheManifest from "./grab-precache-manifest";
/**
* Serves the running build (`.bunext/build-info.json`'s id) with every
* file in `.bunext/public`, for the loading page to download before the
* app runs. Never cached: a deploy must show at once. Not found in
* development, which is never precached.
*/
export default function handleBuildInfo({
pathname,
}: {
pathname: string;
}): Response | null {
if (pathname != PwaBuildInfoPath) return null;
const manifest = grabPrecacheManifest();
if (!manifest) return new Response("Not Found", { status: 404 });
return Response.json(manifest, {
headers: {
"Cache-Control": "no-store, max-age=0, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
}
@@ -0,0 +1,63 @@
import path from "path";
import { AppData } from "@/src/data/app-data";
import { PwaLoadingPath } from "@/src/data/pwa-data";
import { getCookie } from "@/src/utils/cookies";
import needsPrecachePage from "@/src/utils/needs-precache-page";
import grabPrecacheManifest from "./grab-precache-manifest";
const LOADING_PAGE = path.join(
path.resolve(import.meta.dir, "../../../../public"),
PwaLoadingPath,
);
/**
* The placeholder `public/loading.html` carries for the running build.
*/
const BuildPlaceholder = "{{build}}";
/**
* Production only: a browser opening a page without the running build in
* its cookie gets the loading page in its place, at the same address. The
* page downloads the build's `.bunext/public` files into the build's
* cache, checks they are all there, sets the cookie and reloads, and the
* service worker then serves the app's code from the device. Null lets
* the request through.
*/
export default async function handlePrecacheGate({
req,
url,
}: {
req: Request;
url: URL;
}): Promise<Response | null> {
const manifest = grabPrecacheManifest();
const needs_page = needsPrecachePage({
method: req.method,
pathname: url.pathname,
fetch_mode: req.headers.get("Sec-Fetch-Mode"),
fetch_dest: req.headers.get("Sec-Fetch-Dest"),
accept: req.headers.get("Accept"),
cookie_build_id: getCookie(req, AppData["BuildCookieName"]),
build_id: manifest?.build_id,
});
if (!needs_page || !manifest) return null;
const file = Bun.file(LOADING_PAGE);
if (!(await file.exists())) return null;
const build = JSON.stringify({
build_id: manifest.build_id,
total: manifest.files.length,
}).replace(/</g, "\\u003c");
return new Response((await file.text()).replace(BuildPlaceholder, build), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, max-age=0, must-revalidate",
"X-Content-Type-Options": "nosniff",
},
});
}
+279
View File
@@ -2,8 +2,14 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
import path from "path";
import sharp from "sharp";
import { SiteData } from "../data/site-data";
import { AppData } from "../data/app-data";
import { HashedAssetPattern } from "../utils/is-hashed-asset-path";
import {
PwaBuildInfoPath,
PwaIcons,
PwaPrecacheCachePrefix,
PwaPrecacheMarkerPath,
PwaServiceWorkerPath,
PwaManifestName,
PwaManifestPath,
PwaOfflinePath,
@@ -274,6 +280,273 @@ ${logo}
`;
}
/**
* The page a browser gets instead of the app while it downloads a build
* it hasn't yet (`handlePrecacheGate`). The server fills in `{{build}}`
* (`{ build_id, total }`). The page downloads every file the running
* build lists at `/build-info.json` into that build's cache itself, so it
* works whichever service worker version is in charge, then checks every
* one is in the cache (downloading any that aren't again, up to
* `PrecacheVerifyPasses` more times) before the marker goes in. It drops
* the old build's cache and hands over to a new worker waiting behind an
* older one (nothing of the app runs here). It then sets the build cookie and
* reloads into the app. Without Cache Storage, after a failed download,
* or once nothing has landed for `PrecacheStallMs`, it sets the cookie
* anyway, so the app runs from the network instead of never opening.
*/
function buildLoadingPage({
colors,
logo,
}: {
colors: ThemeColors;
logo: string;
}) {
const name = escapeHtml(SiteData["SiteName"]);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="${colors.paper}" />
<meta name="robots" content="noindex" />
<title>${name}</title>
<link rel="manifest" href="${PwaManifestPath}" />
<style>
:root {
--paper: ${colors.paper};
--ink: ${colors.ink};
--ink-secondary: ${colors.ink_secondary};
--hairline: ${colors.hairline};
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: ${colors.dark_paper};
--ink: ${colors.dark_ink};
--ink-secondary: ${colors.dark_ink_secondary};
--hairline: ${colors.dark_hairline};
}
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: calc(24px + env(safe-area-inset-top)) 24px calc(24px + env(safe-area-inset-bottom));
background: var(--paper);
color: var(--ink);
font-family: "Lato", system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
main {
width: 100%;
max-width: 280px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
}
h1 { margin: 4px 0 0; font-size: 16px; line-height: 24px; }
p { margin: 0; font-size: 13px; line-height: 18px; color: var(--ink-secondary); }
.track { width: 100%; height: 4px; border-radius: 999px; background: var(--hairline); overflow: hidden; }
.bar { width: 0; height: 100%; border-radius: 999px; background: var(--ink); transition: width 200ms; }
.count { font-size: 12px; line-height: 16px; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) { .bar { transition: none; } }
</style>
</head>
<body>
<main role="status" aria-live="polite">
${logo}
<h1>Getting ${name} ready</h1>
<p id="note">Saving the app on this device, so it opens fast from now on.</p>
<div class="track"><div class="bar" id="bar"></div></div>
<p class="count" id="count"></p>
</main>
<script>
(function (BUILD) {
var build_id = BUILD.build_id;
var total = BUILD.total;
var saved = 0;
var last_change = Date.now();
var finished = false;
var hashed = new RegExp(${JSON.stringify(HashedAssetPattern.source)});
var bar = document.getElementById("bar");
var count = document.getElementById("count");
var note = document.getElementById("note");
var render = function () {
var done = Math.min(saved, total);
count.textContent = done + " of " + total + " files";
bar.style.width = (total ? Math.round((done / total) * 100) : 100) + "%";
};
var enter = function () {
if (finished) return;
finished = true;
clearInterval(stall_timer);
document.cookie =
"${AppData["BuildCookieName"]}=" + encodeURIComponent(build_id) +
"; Path=/; Max-Age=${AppData["BuildCookieMaxAgeSeconds"]}; SameSite=Lax" +
(location.protocol === "https:" ? "; Secure" : "");
if (document.cookie.indexOf("${AppData["BuildCookieName"]}=") < 0) {
note.textContent =
"Turn on cookies for this site to open ${name}.";
return;
}
location.reload();
};
var stall_timer = setInterval(function () {
if (Date.now() - last_change > ${AppData["PrecacheStallMs"]}) enter();
}, 1000);
render();
if (!("caches" in window) || !window.isSecureContext) return enter();
/**
* Resolves once the newest worker is the active one, telling one
* that waits behind an older version to take over.
*/
var worker = new Promise(function (resolve) {
if (!("serviceWorker" in navigator)) return resolve();
setTimeout(resolve, ${AppData["PrecacheWorkerWaitMs"]});
var skip = function (w) {
if (w && w.state === "installed") w.postMessage({ type: "SKIP_WAITING" });
};
navigator.serviceWorker
.register("${PwaServiceWorkerPath}", { scope: "/", updateViaCache: "none" })
.then(function (reg) {
return reg
.update()
.catch(function () {})
.then(function () {
return reg;
});
})
.then(function (reg) {
var check = setInterval(function () {
skip(reg.waiting);
if (reg.active && !reg.waiting && !reg.installing) {
clearInterval(check);
resolve();
}
}, 200);
})
.catch(function () {
resolve();
});
});
var download = fetch("${PwaBuildInfoPath}", { cache: "no-store" })
.then(function (response) {
if (!response.ok) throw new Error("No build info");
return response.json();
})
.then(function (build) {
build_id = build.build_id;
total = build.files.length;
render();
var cache_name = "${PwaPrecacheCachePrefix}" + build_id;
return caches
.keys()
.then(function (keys) {
return Promise.all(
keys
.filter(function (key) {
return key.indexOf("${PwaPrecacheCachePrefix}") === 0 && key !== cache_name;
})
.map(function (key) {
return caches.delete(key);
}),
);
})
.then(function () {
return caches.open(cache_name);
})
.then(function (cache) {
return cache.match("${PwaPrecacheMarkerPath}").then(function (marker) {
if (marker) return;
return save(cache, build.files, ${AppData["PrecacheVerifyPasses"]}).then(function () {
return cache.put("${PwaPrecacheMarkerPath}", new Response(build_id));
});
});
});
});
/**
* Downloads the files the cache doesn't have yet, then checks every
* one of them is in it, going again for any that aren't. Rejects when
* some are still missing after the last pass, so the marker only
* goes in for a complete build.
*/
var save = function (cache, files, passes) {
var queue = [];
return Promise.all(
files.map(function (url) {
return cache.match(url).then(function (hit) {
if (hit) return;
queue.push(url);
});
}),
)
.then(function () {
saved = files.length - queue.length;
last_change = Date.now();
render();
var work = function () {
var url = queue.shift();
if (!url) return Promise.resolve();
return fetch(url, { cache: hashed.test(url) ? "default" : "no-cache" })
.then(function (response) {
if (!response.ok) throw new Error(url + ": " + response.status);
return cache.put(url, response);
})
.then(
function () {
saved += 1;
last_change = Date.now();
render();
},
function () {},
)
.then(work);
};
var workers = [];
for (var i = 0; i < ${AppData["PrecacheConcurrency"]}; i++) workers.push(work());
return Promise.all(workers);
})
.then(function () {
note.textContent = "Checking the saved files…";
return Promise.all(
files.map(function (url) {
return cache.match(url).then(function (hit) {
return hit ? null : url;
});
}),
);
})
.then(function (results) {
var missing = results.filter(Boolean);
if (!missing.length) return;
if (passes <= 0) throw new Error(missing.length + " files missing");
note.textContent = "Saving the app on this device, so it opens fast from now on.";
return save(cache, files, passes - 1);
});
};
Promise.all([download, worker]).then(enter, enter);
})({{build}});
</script>
</body>
</html>
`;
}
/**
* The logo inline, so the offline page has no request to make while
* offline. Its own width and height give way to ours.
@@ -347,6 +620,11 @@ async function main() {
buildOfflinePage({ colors, logo: inlineLogo(await Bun.file(LOGO_SVG).text()) }),
);
writeFileSync(
path.join(PUBLIC_DIR, "loading.html"),
buildLoadingPage({ colors, logo: inlineLogo(await Bun.file(LOGO_SVG).text()) }),
);
console.log("Generated brand and PWA assets from public/logos/logo.svg:");
console.log(" public/favicon.ico (16/24/32/48 multi-size)");
console.log(" public/favicon.png (32x32)");
@@ -358,6 +636,7 @@ async function main() {
console.log(" public/icons/maskable-512.png (maskable, safe zone)");
console.log(" public/manifest.webmanifest (name, colors and icons)");
console.log(" public/offline.html (self-contained offline page)");
console.log(" public/loading.html (precache loading page)");
}
main()
+23
View File
@@ -11,6 +11,9 @@ import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import handlePwaFile from "./functions/server/pwa/handle-pwa-file";
import handleBuildInfo from "./functions/server/pwa/handle-build-info";
import handlePrecacheGate from "./functions/server/pwa/handle-precache-gate";
import grabPrecacheManifest from "./functions/server/pwa/grab-precache-manifest";
import compressResponse from "./functions/server/compress-response";
import serveLocalCa from "./functions/server/serve-local-ca";
import startHttpRedirect from "./functions/server/start-http-redirect";
@@ -30,6 +33,18 @@ const port = process.env.PORT || 7227;
await enableForeignKeys();
await bunext.bunextInit();
/**
* The running build's id and files, read once: browsers whose build
* cookie doesn't match get the loading page first (production only).
*/
const precache = grabPrecacheManifest();
if (precache) {
bunext.bunextLog.info(
`Build ${precache.build_id}: ${precache.files.length} files to precache`,
);
}
/**
* Web Push needs a key pair; the first start of the main install creates
* it (in the Environment settings) so notifications work without setup.
@@ -111,6 +126,14 @@ const server = Bun.serve({
if (pwa_file) return pwa_file;
const build_info = handleBuildInfo({ pathname: url.pathname });
if (build_info) return build_info;
const loading_page = await handlePrecacheGate({ req, url });
if (loading_page) return loading_page;
const local_ca = await serveLocalCa({ pathname: url.pathname });
if (local_ca) return local_ca;
+9
View File
@@ -697,6 +697,15 @@ export type BotTextModel = {
provider_name: string;
};
/**
* One build's `.bunext/public` files, downloaded by the service worker
* before the app runs (`grabPrecacheManifest`).
*/
export type PrecacheManifest = {
build_id: string;
files: string[];
};
export type PagePropsType = {
user?: User | null;
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
+3 -2
View File
@@ -3,9 +3,10 @@
* (`chunk-AEHOZS24.js`, `admin/bots/H6VDJMBN.js`, `X4TQCLSA.css`): one
* at such a path never changes, so it can be cached for good. The
* vendor files (`/.bunext/public/vendor/react.js`) keep fixed names and
* are not. `public/sw.js` repeats this pattern.
* are not. The loading page (`public/loading.html`) is generated with
* this pattern.
*/
const HashedAssetPattern = /^\/\.bunext\/public\/pages\/(?:.*\/)?(?:chunk-)?[A-Z0-9]{8}\.(?:js|css)$/;
export const HashedAssetPattern = /^\/\.bunext\/public\/pages\/(?:.*\/)?(?:chunk-)?[A-Z0-9]{8}\.(?:js|css)$/;
type Params = {
pathname: string;
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import needsPrecachePage from "./needs-precache-page";
const page = {
method: "GET",
pathname: "/admin/bots/1",
fetch_dest: "document",
build_id: "build-2",
};
describe("needsPrecachePage", () => {
test("stands in for a page on a build the browser hasn't downloaded", () => {
expect(needsPrecachePage(page)).toBe(true);
expect(needsPrecachePage({ ...page, cookie_build_id: "build-1" })).toBe(true);
expect(
needsPrecachePage({ ...page, fetch_dest: "empty", fetch_mode: "navigate" }),
).toBe(true);
expect(
needsPrecachePage({ ...page, fetch_dest: null, accept: "text/html,*/*" }),
).toBe(true);
});
test("lets the current build through", () => {
expect(needsPrecachePage({ ...page, cookie_build_id: "build-2" })).toBe(false);
});
test("never runs in development", () => {
expect(needsPrecachePage({ ...page, build_id: null })).toBe(false);
});
test("leaves everything that isn't a page navigation", () => {
expect(needsPrecachePage({ ...page, method: "POST" })).toBe(false);
expect(needsPrecachePage({ ...page, fetch_dest: "script" })).toBe(false);
expect(
needsPrecachePage({
...page,
fetch_dest: "empty",
fetch_mode: "cors",
accept: "text/html",
}),
).toBe(false);
expect(needsPrecachePage({ ...page, fetch_dest: null, accept: "*/*" })).toBe(false);
expect(needsPrecachePage({ ...page, pathname: "/api/admin/bots/send-message" })).toBe(false);
expect(needsPrecachePage({ ...page, pathname: "/.bunext/public/vendor/react.js" })).toBe(false);
expect(needsPrecachePage({ ...page, pathname: "/computers/2/vnc/vnc.html" })).toBe(false);
expect(needsPrecachePage({ ...page, pathname: "/media/1/private/a" })).toBe(false);
expect(needsPrecachePage({ ...page, pathname: "/build-info.json" })).toBe(false);
expect(needsPrecachePage({ ...page, pathname: "/sw.js" })).toBe(false);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Paths the loading page never stands in for: the app's code, live data,
* media, proxied computers and the files the loading page itself needs.
*/
const OpenPrefixes = ["/.bunext/", "/api/", "/ws", "/media", "/computers/"];
type Params = {
method: string;
pathname: string;
/**
* `Sec-Fetch-Mode` and `Sec-Fetch-Dest`, when the browser sends them.
* A navigation the service worker passes on keeps `navigate` but
* arrives with the destination `empty`.
*/
fetch_mode?: string | null;
fetch_dest?: string | null;
accept?: string | null;
/**
* The build in the browser's cookie, if any.
*/
cookie_build_id?: string | null;
/**
* The running build; null in development.
*/
build_id?: string | null;
};
/**
* Whether a request is a browser opening a page of the app on a build it
* hasn't downloaded yet, so it gets the loading page instead. Only page
* navigations: anything with a file extension (`/favicon.ico`,
* `/sw.js`, `/build-info.json`) goes through as usual.
*/
export default function needsPrecachePage({
method,
pathname,
fetch_mode,
fetch_dest,
accept,
cookie_build_id,
build_id,
}: Params): boolean {
if (!build_id) return false;
if (cookie_build_id == build_id) return false;
if (method != "GET") return false;
if (OpenPrefixes.some((prefix) => pathname.startsWith(prefix))) return false;
if (pathname.split("/").pop()?.includes(".")) return false;
if (fetch_mode || fetch_dest) {
return fetch_mode == "navigate" || fetch_dest == "document";
}
return Boolean(accept?.includes("text/html"));
}