mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 07:09:19 +03:00
fix(webui): harden PWA service worker caching and registration
- Exclude /webui/* endpoints from the service worker cache; the /webui/bootstrap endpoint issues fresh gateway credentials on every load and must never be cached or replayed offline. - Restrict cache-first handling to hashed /assets/ files (served immutable). Un-hashed brand icons and the favicon stay network-first so future icon swaps reach installed clients. - Prune stale hashed assets whenever the app shell refreshes, so old build assets cannot accumulate even when sw.js itself is unchanged. - Serve the cached app shell for offline deep-link navigations. - Ignore service worker registration failures; add unit tests for the service worker and the main-entry registration.
This commit is contained in:
+72
-13
@@ -8,15 +8,52 @@ self.addEventListener("install", (event) => {
|
|||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Collect same-origin paths referenced by the given HTML document.
|
||||||
|
function referencedAssetPaths(html) {
|
||||||
|
const refs = new Set();
|
||||||
|
const re = /(?:src|href)="(\/[^"]*)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = re.exec(html))) {
|
||||||
|
const url = new URL(match[1], self.location.origin);
|
||||||
|
if (url.origin === self.location.origin) refs.add(url.pathname + url.search);
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop cached entries that the current index.html no longer references.
|
||||||
|
// CACHE_NAME is stable across deployments, so without this, hashed assets from
|
||||||
|
// previous builds would pile up in the same cache forever. The cached
|
||||||
|
// index.html is the latest one this client saw (navigation is network-first
|
||||||
|
// and overwrites it on every successful visit), so pruning against it keeps
|
||||||
|
// the offline shell consistent with the last loaded build.
|
||||||
|
async function pruneStaleEntries() {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedIndex = await cache.match("/");
|
||||||
|
if (!cachedIndex) return;
|
||||||
|
const refs = referencedAssetPaths(await cachedIndex.text());
|
||||||
|
const keys = await cache.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname === "/" || url.pathname === "/manifest.json") return;
|
||||||
|
if (refs.has(url.pathname + url.search)) return;
|
||||||
|
await cache.delete(request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((keys) =>
|
caches
|
||||||
Promise.all(
|
.keys()
|
||||||
keys
|
.then((keys) =>
|
||||||
.filter((k) => k !== CACHE_NAME)
|
Promise.all(
|
||||||
.map((k) => caches.delete(k))
|
keys
|
||||||
|
.filter((k) => k !== CACHE_NAME)
|
||||||
|
.map((k) => caches.delete(k))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.then(() => pruneStaleEntries())
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
@@ -24,24 +61,37 @@ self.addEventListener("activate", (event) => {
|
|||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
|
|
||||||
// Only handle same-origin GET requests
|
// Only handle same-origin GET requests. Requests are handed to fetch() as-is
|
||||||
|
// (never reconstructed), so their credentials mode is preserved and gateway
|
||||||
|
// auth cookies flow through on every path we touch. WebSocket upgrades are
|
||||||
|
// never dispatched to a service worker's fetch handler, so the WS endpoint
|
||||||
|
// cannot be cached; the /__nanobot exclusion below still protects its HTTP
|
||||||
|
// polling/socket bootstrap endpoints.
|
||||||
if (request.method !== "GET") return;
|
if (request.method !== "GET") return;
|
||||||
if (new URL(request.url).origin !== self.location.origin) return;
|
if (new URL(request.url).origin !== self.location.origin) return;
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
// Never cache API, auth, WebSocket, or HMR paths
|
// Never cache API, auth, WebSocket, HMR, or WebUI endpoint paths. In
|
||||||
|
// particular /webui/bootstrap issues fresh gateway credentials on every page
|
||||||
|
// load and must never be cached or replayed offline. The /auth prefix covers
|
||||||
|
// the default token endpoint; custom token_issue_path values should be kept
|
||||||
|
// under one of these prefixes.
|
||||||
if (
|
if (
|
||||||
path.startsWith("/api") ||
|
path.startsWith("/api") ||
|
||||||
path.startsWith("/auth") ||
|
path.startsWith("/auth") ||
|
||||||
path.startsWith("/__nanobot")
|
path.startsWith("/__nanobot") ||
|
||||||
|
path.startsWith("/webui")
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static assets: cache-first (immutable by gateway)
|
// Static assets: cache-first. Only files under /assets/ carry content hashes
|
||||||
if (/\.(js|css|png|webp|ico|svg|woff2?|ttf|eot)$/.test(path)) {
|
// (the gateway serves them immutable); brand icons, the favicon and other
|
||||||
|
// un-hashed files can change between releases and stay on the network-first
|
||||||
|
// path below so updates reach installed clients.
|
||||||
|
if (path.startsWith("/assets/")) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
caches.match(request).then((cached) => {
|
caches.match(request).then((cached) => {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -57,16 +107,25 @@ self.addEventListener("fetch", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything else: network-first (index.html, manifest, etc.)
|
// Everything else: network-first (index.html, manifest, brand assets, etc.)
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const clone = response.clone();
|
const clone = response.clone();
|
||||||
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
caches.open(CACHE_NAME).then((c) => c.put(request, clone));
|
||||||
|
// The shell just changed; prune entries the new index.html no longer
|
||||||
|
// references so hashed assets from old builds do not accumulate even
|
||||||
|
// when sw.js itself is unchanged between deployments.
|
||||||
|
if (path === "/") pruneStaleEntries();
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(() => caches.match(request))
|
.catch(() => {
|
||||||
|
// Offline: serve the app shell for navigations (deep links resolve
|
||||||
|
// client-side), the last cached copy for everything else.
|
||||||
|
if (request.mode === "navigate") return caches.match("/");
|
||||||
|
return caches.match(request);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-3
@@ -31,8 +31,13 @@ ReactDOM.createRoot(root).render(<App />);
|
|||||||
|
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
navigator.serviceWorker.register("/sw.js", {
|
navigator.serviceWorker
|
||||||
updateViaCache: "none",
|
.register("/sw.js", {
|
||||||
});
|
updateViaCache: "none",
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Service workers are progressive enhancement; registration failures
|
||||||
|
// (unsupported proxies, blocked storage) must not break the app.
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const render = vi.fn();
|
||||||
|
const createRoot = vi.fn(() => ({ render }));
|
||||||
|
|
||||||
|
vi.mock("react-dom/client", () => ({
|
||||||
|
default: { createRoot },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/App", () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("service worker registration when unsupported", () => {
|
||||||
|
const register = vi.fn(() => Promise.resolve());
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
createRoot.mockClear();
|
||||||
|
render.mockClear();
|
||||||
|
register.mockClear();
|
||||||
|
document.body.innerHTML = '<div id="root"></div>';
|
||||||
|
// happy-dom's Navigator has no serviceWorker member, so this file runs
|
||||||
|
// without defining one — the entry's `"serviceWorker" in navigator` guard
|
||||||
|
// must keep registration from being attempted.
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
delete (navigator as Navigator & { serviceWorker?: unknown }).serviceWorker;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not attempt registration when the service worker API is missing", async () => {
|
||||||
|
await import("../main");
|
||||||
|
window.dispatchEvent(new Event("load"));
|
||||||
|
|
||||||
|
expect(register).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const render = vi.fn();
|
||||||
|
const createRoot = vi.fn(() => ({ render }));
|
||||||
|
|
||||||
|
vi.mock("react-dom/client", () => ({
|
||||||
|
default: { createRoot },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/App", () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("service worker registration in main entry", () => {
|
||||||
|
const register = vi.fn(() => Promise.resolve());
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
createRoot.mockClear();
|
||||||
|
render.mockClear();
|
||||||
|
register.mockClear();
|
||||||
|
document.body.innerHTML = '<div id="root"></div>';
|
||||||
|
Object.defineProperty(navigator, "serviceWorker", {
|
||||||
|
value: { register },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
delete (navigator as Navigator & { serviceWorker?: unknown }).serviceWorker;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers /sw.js with updateViaCache none only after window load", async () => {
|
||||||
|
await import("../main");
|
||||||
|
|
||||||
|
expect(register).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event("load"));
|
||||||
|
|
||||||
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
|
expect(register).toHaveBeenCalledWith("/sw.js", { updateViaCache: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Vitest runs with the webui/ directory as the working directory.
|
||||||
|
const SW_SCRIPT = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8");
|
||||||
|
|
||||||
|
const ORIGIN = "https://nanobot.test";
|
||||||
|
const CACHE_NAME = "nanobot-static-v1";
|
||||||
|
|
||||||
|
/** Minimal Cache-compatible in-memory store with SW-style URL normalization. */
|
||||||
|
class FakeCacheStore {
|
||||||
|
entries = new Map<string, Response>();
|
||||||
|
|
||||||
|
private key(input: Request | string): string {
|
||||||
|
return new URL(typeof input === "string" ? input : input.url, ORIGIN).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addAll(urls: string[]): Promise<void> {
|
||||||
|
for (const url of urls) {
|
||||||
|
this.entries.set(this.key(url), new Response("ok"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async match(input: Request | string): Promise<Response | undefined> {
|
||||||
|
return this.entries.get(this.key(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(input: Request | string, response: Response): Promise<void> {
|
||||||
|
this.entries.set(this.key(input), response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(input: Request | string): Promise<boolean> {
|
||||||
|
return this.entries.delete(this.key(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
async keys(): Promise<Request[]> {
|
||||||
|
return [...this.entries.keys()].map((url) => new Request(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadedSw {
|
||||||
|
store: FakeCacheStore;
|
||||||
|
deletedCacheNames: string[];
|
||||||
|
fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
skipWaitingMock: ReturnType<typeof vi.fn>;
|
||||||
|
claimMock: ReturnType<typeof vi.fn>;
|
||||||
|
fire: (type: string, event?: Record<string, unknown>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSw(): LoadedSw {
|
||||||
|
const listeners = new Map<string, Array<(event: Record<string, unknown>) => void>>();
|
||||||
|
const skipWaitingMock = vi.fn();
|
||||||
|
const claimMock = vi.fn();
|
||||||
|
const self = {
|
||||||
|
location: { origin: ORIGIN },
|
||||||
|
skipWaiting: skipWaitingMock,
|
||||||
|
clients: { claim: claimMock },
|
||||||
|
addEventListener: (type: string, cb: (event: Record<string, unknown>) => void) => {
|
||||||
|
listeners.set(type, [...(listeners.get(type) ?? []), cb]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = new FakeCacheStore();
|
||||||
|
const deletedCacheNames: string[] = [];
|
||||||
|
const caches = {
|
||||||
|
open: vi.fn(async () => store),
|
||||||
|
match: vi.fn((input: Request | string) => store.match(input)),
|
||||||
|
keys: vi.fn(async () => [CACHE_NAME, "nanobot-static-v0"]),
|
||||||
|
delete: vi.fn(async (name: string) => {
|
||||||
|
deletedCacheNames.push(name);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
// Execute the plain public/sw.js script inside a controlled scope so the
|
||||||
|
// service worker globals (self/caches/fetch) are fully mocked.
|
||||||
|
new Function("self", "caches", "fetch", SW_SCRIPT)(self, caches, fetchMock);
|
||||||
|
|
||||||
|
const fire = async (type: string, event: Record<string, unknown> = {}) => {
|
||||||
|
for (const cb of listeners.get(type) ?? []) {
|
||||||
|
let waitPromise: Promise<unknown> = Promise.resolve();
|
||||||
|
const wrapped = {
|
||||||
|
...event,
|
||||||
|
waitUntil: (promise: Promise<unknown>) => {
|
||||||
|
waitPromise = promise;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await cb(wrapped);
|
||||||
|
await waitPromise;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { store, deletedCacheNames, fetchMock, skipWaitingMock, claimMock, fire };
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexHtml(assetPaths: string[]): Response {
|
||||||
|
const refs = assetPaths
|
||||||
|
.map(
|
||||||
|
(path) =>
|
||||||
|
`<script type="module" crossorigin src="${path}"></script><link rel="stylesheet" crossorigin href="${path}">`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return new Response(`<!doctype html><html><head>${refs}</head></html>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchEvent(url: string) {
|
||||||
|
const respondWith = vi.fn();
|
||||||
|
const event = {
|
||||||
|
request: new Request(url, { method: "GET" }),
|
||||||
|
respondWith,
|
||||||
|
};
|
||||||
|
return { event, respondWith };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("service worker", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("precaches the shell and manifest on install", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
|
||||||
|
await sw.fire("install");
|
||||||
|
|
||||||
|
expect(sw.skipWaitingMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes stale cache names and prunes unreferenced static entries on activate", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
await sw.fire("install");
|
||||||
|
// Simulate the latest loaded build: index.html references only v2 assets.
|
||||||
|
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js", "/assets/index-v2.css"]));
|
||||||
|
await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2"));
|
||||||
|
await sw.store.put(`${ORIGIN}/assets/index-v2.css`, new Response("v2 css"));
|
||||||
|
await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1"));
|
||||||
|
await sw.store.put(`${ORIGIN}/assets/index-v1.css`, new Response("v1 css"));
|
||||||
|
|
||||||
|
await sw.fire("activate");
|
||||||
|
|
||||||
|
expect(sw.deletedCacheNames).toEqual(["nanobot-static-v0"]);
|
||||||
|
expect(sw.claimMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/manifest.json`)).toBe(true);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(true);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.css`)).toBe(true);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.css`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept API, auth, WebSocket, or WebUI endpoint requests", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
const paths = [
|
||||||
|
"/api/v1/models",
|
||||||
|
"/auth/session",
|
||||||
|
"/__nanobot/ws",
|
||||||
|
"/__nanobot/socket.io",
|
||||||
|
"/api/chat/completions",
|
||||||
|
"/webui/bootstrap",
|
||||||
|
"/webui/session/list",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const { respondWith } = fetchEvent(`${ORIGIN}${path}`);
|
||||||
|
await sw.fire("fetch", { request: new Request(`${ORIGIN}${path}`) });
|
||||||
|
expect(respondWith, path).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
expect(sw.fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not intercept non-GET or cross-origin requests", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
|
||||||
|
const { respondWith: post } = fetchEvent(`${ORIGIN}/api/v1/models`);
|
||||||
|
await sw.fire("fetch", {
|
||||||
|
request: new Request(`${ORIGIN}/api/v1/models`, { method: "POST" }),
|
||||||
|
});
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const { respondWith: cross } = fetchEvent("https://other.test/assets/app.js");
|
||||||
|
await sw.fire("fetch", {
|
||||||
|
request: new Request("https://other.test/assets/app.js"),
|
||||||
|
});
|
||||||
|
expect(cross).not.toHaveBeenCalled();
|
||||||
|
expect(sw.fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves cached static assets without touching the network", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
const assetUrl = `${ORIGIN}/assets/index-a1b2c3.js`;
|
||||||
|
await sw.store.put(assetUrl, new Response("cached js"));
|
||||||
|
|
||||||
|
const { event, respondWith } = fetchEvent(assetUrl);
|
||||||
|
await sw.fire("fetch", event);
|
||||||
|
|
||||||
|
const response = (await respondWith.mock.calls[0][0]) as Response;
|
||||||
|
expect(response).toBeDefined();
|
||||||
|
expect(await response.text()).toBe("cached js");
|
||||||
|
expect(sw.fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches static assets on miss and caches the response, passing the original request", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
const assetUrl = `${ORIGIN}/assets/index-x9y8z7.js`;
|
||||||
|
const originalRequest = new Request(assetUrl);
|
||||||
|
sw.fetchMock.mockResolvedValue(new Response("fresh js"));
|
||||||
|
|
||||||
|
const event = { request: originalRequest, respondWith: vi.fn() };
|
||||||
|
await sw.fire("fetch", event);
|
||||||
|
|
||||||
|
const response = (await event.respondWith.mock.calls[0][0]) as Response;
|
||||||
|
expect(await response.text()).toBe("fresh js");
|
||||||
|
// The exact request object is handed to fetch() — credentials mode and
|
||||||
|
// other properties are preserved, never reconstructed.
|
||||||
|
expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest);
|
||||||
|
expect(sw.store.entries.has(assetUrl)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps un-hashed brand assets on the network-first path", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
const iconUrl = `${ORIGIN}/brand/nanobot_icon_192.png`;
|
||||||
|
const originalRequest = new Request(iconUrl);
|
||||||
|
sw.fetchMock.mockResolvedValue(new Response("png bytes"));
|
||||||
|
|
||||||
|
const event = { request: originalRequest, respondWith: vi.fn() };
|
||||||
|
await sw.fire("fetch", event);
|
||||||
|
|
||||||
|
const response = (await event.respondWith.mock.calls[0][0]) as Response;
|
||||||
|
expect(await response.text()).toBe("png bytes");
|
||||||
|
// Unlike hashed /assets/ files, brand icons are fetched every load so a
|
||||||
|
// future icon swap reaches installed clients.
|
||||||
|
expect(sw.fetchMock).toHaveBeenCalledWith(originalRequest);
|
||||||
|
expect(sw.store.entries.has(iconUrl)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves navigation network-first, prunes on shell refresh, and falls back when offline", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"]));
|
||||||
|
// Assets from an older build, still referenced by the cached v2 shell.
|
||||||
|
await sw.store.put(`${ORIGIN}/assets/index-v2.js`, new Response("v2"));
|
||||||
|
await sw.store.put(`${ORIGIN}/assets/index-v1.js`, new Response("v1"));
|
||||||
|
|
||||||
|
// Offline: network rejects, cached shell is returned.
|
||||||
|
sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
const offlineEvent = {
|
||||||
|
request: new Request(`${ORIGIN}/`),
|
||||||
|
respondWith: vi.fn(),
|
||||||
|
};
|
||||||
|
await sw.fire("fetch", offlineEvent);
|
||||||
|
const offlineResponse = (await offlineEvent.respondWith.mock.calls[0][0]) as Response;
|
||||||
|
expect(await offlineResponse.text()).toContain("assets/index-v2.js");
|
||||||
|
|
||||||
|
// Online: network response wins, refreshes the cached shell, and prunes
|
||||||
|
// entries the new index.html no longer references.
|
||||||
|
const freshShell = indexHtml(["/assets/index-v3.js"]);
|
||||||
|
sw.fetchMock.mockResolvedValue(freshShell);
|
||||||
|
const onlineEvent = {
|
||||||
|
request: new Request(`${ORIGIN}/`),
|
||||||
|
respondWith: vi.fn(),
|
||||||
|
};
|
||||||
|
await sw.fire("fetch", onlineEvent);
|
||||||
|
const onlineResponse = (await onlineEvent.respondWith.mock.calls[0][0]) as Response;
|
||||||
|
expect(await onlineResponse.text()).toContain("assets/index-v3.js");
|
||||||
|
// The prune is fire-and-forget; give its microtasks a chance to settle.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/`)).toBe(true);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v1.js`)).toBe(false);
|
||||||
|
expect(sw.store.entries.has(`${ORIGIN}/assets/index-v2.js`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the cached app shell for offline deep-link navigations", async () => {
|
||||||
|
const sw = loadSw();
|
||||||
|
await sw.store.put(`${ORIGIN}/`, indexHtml(["/assets/index-v2.js"]));
|
||||||
|
sw.fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||||
|
|
||||||
|
// happy-dom's Request ignores `mode: "navigate"` in the constructor;
|
||||||
|
// force it so the service worker treats this as a navigation.
|
||||||
|
const request = new Request(`${ORIGIN}/settings`);
|
||||||
|
Object.defineProperty(request, "mode", { value: "navigate" });
|
||||||
|
const event = { request, respondWith: vi.fn() };
|
||||||
|
await sw.fire("fetch", event);
|
||||||
|
|
||||||
|
const response = (await event.respondWith.mock.calls[0][0]) as Response;
|
||||||
|
expect(await response.text()).toContain("assets/index-v2.js");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user