Skip to content

Commit 1a06d3e

Browse files
authored
Improve PWA support with service worker-based offline caching (#3985)
* Implement the service worker for cached offline mode * Improve pre-loaded font menu preview visualization * Code review fixes * Simplify Response construction * Attempt to fix ERR_FAILED when reloading page on CF Pages * Reject service worker install if any precache fetch fails
1 parent 87bd3d4 commit 1a06d3e

File tree

5 files changed

+344
-4
lines changed

5 files changed

+344
-4
lines changed

frontend/src/components/floating-menus/MenuList.svelte

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
let virtualScrollingEntriesStart = 0;
4848
let keydownListenerAdded = false;
4949
let destroyed = false;
50+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- `loadedFonts` reactivity is driven by `loadedFontsGeneration`, not the Set itself
51+
let loadedFonts = new Set<string>();
52+
let loadedFontsGeneration = 0;
5053
5154
// `watchOpen` is called only when `open` is changed from outside this component
5255
$: watchOpen(open);
@@ -459,10 +462,28 @@
459462
{/if}
460463

461464
{#if entry.font}
462-
<link rel="stylesheet" href={entry.font} />
465+
<link
466+
rel="stylesheet"
467+
href={entry.font}
468+
onload={() => {
469+
document.fonts.load(`16px "${entry.value}"`).then(() => {
470+
loadedFonts.add(entry.value);
471+
loadedFontsGeneration += 1; // Modify the dirty trigger
472+
});
473+
}}
474+
/>
463475
{/if}
464476

465-
<TextLabel class="entry-label" styles={entry.font ? { "font-family": entry.value } : {}}>{entry.label}</TextLabel>
477+
<TextLabel
478+
class="entry-label"
479+
classes={{
480+
"font-preview": Boolean(entry.font),
481+
"font-loaded": loadedFontsGeneration >= 0 && loadedFonts.has(entry.value),
482+
}}
483+
styles={entry.font ? { "font-family": `"${entry.value}", "Source Sans Pro"` } : {}}
484+
>
485+
{entry.label}
486+
</TextLabel>
466487

467488
{#if entry.tooltipShortcut?.shortcut.length}
468489
<ShortcutLabel shortcut={entry.tooltipShortcut} />
@@ -548,6 +569,10 @@
548569
margin: 0 4px;
549570
}
550571
572+
.font-preview:not(.font-loaded) {
573+
opacity: 0.5;
574+
}
575+
551576
.entry-icon,
552577
.no-icon {
553578
margin: 0 4px;

frontend/src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
import { mount, unmount } from "svelte";
44
import App from "/src/App.svelte";
5+
import { registerServiceWorker } from "/src/utility-functions/service-worker";
6+
7+
// Register the service worker, except in dev mode and native (CEF) builds
8+
if (!import.meta.env.DEV && import.meta.env.MODE !== "native" && "serviceWorker" in navigator) {
9+
registerServiceWorker();
10+
}
511

612
document.body.setAttribute("data-app-container", "");
713

frontend/src/service-worker.js

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// These placeholders are replaced in `vite.config.ts` at build time
2+
const PRECACHE_MANIFEST = self.__PRECACHE_MANIFEST;
3+
const DEFERRED_CACHE_MANIFEST = self.__DEFERRED_CACHE_MANIFEST;
4+
const SERVICE_WORKER_CONTENT_HASH = self.__SERVICE_WORKER_CONTENT_HASH;
5+
6+
const STATIC_CACHE_NAME = `static-${SERVICE_WORKER_CONTENT_HASH}`;
7+
const RUNTIME_ASSETS = "runtime-assets";
8+
const RUNTIME_FONTS = "runtime-fonts";
9+
10+
const FONT_LIST_API = "https://api.graphite.art/font-list";
11+
12+
// Build a set of precache URLs for quick lookup during fetch
13+
const PRECACHE_URLS = new Set(PRECACHE_MANIFEST.map((entry) => new URL(entry.url, self.location.origin).href));
14+
15+
// Track deferred manifest URLs and revisions for cache invalidation
16+
const DEFERRED_ENTRIES = new Map(DEFERRED_CACHE_MANIFEST.map((entry) => [new URL(entry.url, self.location.origin).href, entry.revision]));
17+
18+
// ==================
19+
// Caching strategies
20+
// ==================
21+
22+
function isCacheable(response) {
23+
// Cache normal successful responses and opaque responses (cross-origin no-cors, e.g. <link> stylesheets)
24+
return response.ok || response.type === "opaque";
25+
}
26+
27+
async function cacheFirst(request, cacheName) {
28+
const cache = await caches.open(cacheName);
29+
const cached = await cache.match(request);
30+
if (cached) return cached;
31+
32+
const response = await fetch(request);
33+
if (isCacheable(response)) cache.put(request, response.clone());
34+
return response;
35+
}
36+
37+
async function networkFirst(request, cacheName) {
38+
const cache = await caches.open(cacheName);
39+
try {
40+
const response = await fetch(request);
41+
if (isCacheable(response)) cache.put(request, response.clone());
42+
return response;
43+
} catch {
44+
const cached = await cache.match(request);
45+
if (cached) return cached;
46+
throw new Error(`Network request failed and no cache available for ${request.url}`);
47+
}
48+
}
49+
50+
// ================
51+
// Lifecycle events
52+
// ================
53+
54+
self.addEventListener("install", (event) => {
55+
event.waitUntil(
56+
(async () => {
57+
// Precache app shell assets
58+
const cache = await caches.open(STATIC_CACHE_NAME);
59+
await Promise.all(
60+
PRECACHE_MANIFEST.map(async (entry) => {
61+
const response = await fetch(entry.url);
62+
if (!response.ok) throw new Error(`Precache fetch failed for ${entry.url}: ${response.status}`);
63+
// Strip the `redirected` flag which causes errors when served via respondWith
64+
const cleaned = response.redirected
65+
? new Response(response.body, {
66+
status: response.status,
67+
statusText: response.statusText,
68+
headers: response.headers,
69+
})
70+
: response;
71+
await cache.put(entry.url, cleaned);
72+
}),
73+
);
74+
75+
// Proactively cache the font catalog API
76+
try {
77+
const fontResponse = await fetch(FONT_LIST_API);
78+
if (fontResponse.ok) {
79+
const fontCache = await caches.open(RUNTIME_FONTS);
80+
await fontCache.put(FONT_LIST_API, fontResponse);
81+
}
82+
} catch {
83+
// Font catalog prefetch is best-effort, don't block installation
84+
}
85+
86+
await self.skipWaiting();
87+
})(),
88+
);
89+
});
90+
91+
self.addEventListener("activate", (event) => {
92+
event.waitUntil(
93+
(async () => {
94+
const cacheNames = await caches.keys();
95+
96+
// Delete old precache versions
97+
const deletions = cacheNames.filter((name) => name.startsWith("static-") && name !== STATIC_CACHE_NAME).map((name) => caches.delete(name));
98+
await Promise.all(deletions);
99+
100+
// Prune stale deferred (demo artwork) entries
101+
const assetsCache = await caches.open(RUNTIME_ASSETS);
102+
const assetsKeys = await assetsCache.keys();
103+
await Promise.all(assetsKeys.filter((request) => !DEFERRED_ENTRIES.has(request.url)).map((request) => assetsCache.delete(request)));
104+
105+
await self.clients.claim();
106+
})(),
107+
);
108+
});
109+
110+
// =============
111+
// Fetch routing
112+
// =============
113+
114+
self.addEventListener("fetch", (event) => {
115+
const { request } = event;
116+
const url = new URL(request.url);
117+
118+
// Pass through range requests (e.g. for large file streaming) and non-GET requests
119+
if (request.headers.has("range") || request.method !== "GET") return;
120+
121+
// Pre-cached assets (JS and Wasm bundle files, favicons, index.html)
122+
if (PRECACHE_URLS.has(url.href)) {
123+
event.respondWith(cacheFirst(request, STATIC_CACHE_NAME));
124+
return;
125+
}
126+
127+
// Deferred-cached assets (demo artwork, third-party licenses, etc.)
128+
if (DEFERRED_ENTRIES.has(url.href)) {
129+
event.respondWith(cacheFirst(request, RUNTIME_ASSETS));
130+
return;
131+
}
132+
133+
// Font catalog API: network-first to keep it fresh
134+
if (url.href.startsWith(FONT_LIST_API)) {
135+
event.respondWith(networkFirst(request, RUNTIME_FONTS));
136+
return;
137+
}
138+
139+
// Google Fonts CSS (font preview stylesheets): cache-first since responses are stable for a given query
140+
if (url.hostname === "fonts.googleapis.com") {
141+
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
142+
return;
143+
}
144+
145+
// Google Fonts static files: cache-first since they are immutable CDN URLs
146+
if (url.hostname === "fonts.gstatic.com") {
147+
event.respondWith(cacheFirst(request, RUNTIME_FONTS));
148+
return;
149+
}
150+
151+
// Navigation requests: serve cached index.html for all routes (SPA pattern)
152+
if (request.mode === "navigate") {
153+
event.respondWith(
154+
(async () => {
155+
const cache = await caches.open(STATIC_CACHE_NAME);
156+
const cached = await cache.match("/index.html");
157+
return cached || fetch(request);
158+
})(),
159+
);
160+
return;
161+
}
162+
163+
// Everything else: network-only (no respondWith, let the browser handle it)
164+
});
165+
166+
// ============================
167+
// Deferred caching via message
168+
// ============================
169+
170+
self.addEventListener("message", (event) => {
171+
if (event.data?.type !== "CACHE_DEFERRED") return;
172+
173+
event.waitUntil(
174+
(async () => {
175+
const cache = await caches.open(RUNTIME_ASSETS);
176+
const fetchPromises = DEFERRED_CACHE_MANIFEST.map(async (entry) => {
177+
const fullUrl = new URL(entry.url, self.location.origin).href;
178+
179+
// Skip if already cached with the same revision
180+
const existing = await cache.match(fullUrl);
181+
if (existing?.headers.get("x-sw-revision") === entry.revision) return;
182+
183+
try {
184+
const response = await fetch(fullUrl);
185+
if (response.ok) {
186+
// Store the service worker revision in a custom header so we can check it on future installs
187+
const headers = new Headers(response.headers);
188+
headers.set("x-sw-revision", entry.revision);
189+
const taggedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers });
190+
await cache.put(fullUrl, taggedResponse);
191+
}
192+
} catch {
193+
// Best-effort: skip files that fail to fetch
194+
}
195+
});
196+
await Promise.all(fetchPromises);
197+
})(),
198+
);
199+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export async function registerServiceWorker() {
2+
try {
3+
const registration = await navigator.serviceWorker.register("/service-worker.js", { scope: "/" });
4+
5+
// When a new service worker is found, auto-reload once it activates
6+
registration.addEventListener("updatefound", () => {
7+
const newWorker = registration.installing;
8+
newWorker?.addEventListener("statechange", () => {
9+
// Only reload if there was a previous controller, meaning this is an update, not first install
10+
if (newWorker.state === "activated" && navigator.serviceWorker.controller) window.location.reload();
11+
});
12+
});
13+
14+
const activeWorker = registration.active || registration.waiting || registration.installing;
15+
if (!activeWorker) return;
16+
17+
const scheduleDeferredCaching = () => {
18+
if (activeWorker.state !== "activated") return;
19+
const sendMessage = () => registration.active?.postMessage({ type: "CACHE_DEFERRED" });
20+
if ("requestIdleCallback" in window) window.requestIdleCallback(sendMessage);
21+
else setTimeout(sendMessage, 5000); // Fallback to a delay for Safari which doesn't support `requestIdleCallback`
22+
};
23+
24+
// Once the service worker is active, trigger deferred caching during idle time
25+
if (activeWorker.state === "activated") scheduleDeferredCaching();
26+
else activeWorker.addEventListener("statechange", scheduleDeferredCaching);
27+
} catch (err) {
28+
// eslint-disable-next-line no-console
29+
console.error("Service worker registration failed:", err);
30+
}
31+
}

frontend/vite.config.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execSync } from "child_process";
2-
import { copyFileSync, cpSync, existsSync, readFileSync, statSync } from "fs";
2+
import { createHash } from "crypto";
3+
import { copyFileSync, cpSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
34
import path from "path";
45
import { svelte } from "@sveltejs/vite-plugin-svelte";
56
import { defineConfig } from "vite";
@@ -10,7 +11,7 @@ const projectRootDir = path.resolve(__dirname);
1011
// https://vitejs.dev/config/
1112
export default defineConfig(({ mode }) => {
1213
return {
13-
plugins: [svelte(), staticAssets(), mode !== "native" && thirdPartyLicenses()],
14+
plugins: [svelte(), staticAssets(), mode !== "native" && thirdPartyLicenses(), mode !== "native" && serviceWorker()],
1415
resolve: {
1516
alias: [{ find: /\/..\/branding\/(.*\.svg)/, replacement: path.resolve(projectRootDir, "../branding", "$1?raw") }],
1617
},
@@ -96,3 +97,81 @@ function thirdPartyLicenses(): PluginOption {
9697
},
9798
};
9899
}
100+
101+
function serviceWorker(): PluginOption {
102+
// Files that should never be precached
103+
const EXCLUDED_FILES = new Set(["service-worker.js"]);
104+
const DEFERRED_PREFIXES = ["demo-artwork/", "third-party-licenses.txt"];
105+
106+
function collectFiles(directory: string, prefix: string): string[] {
107+
const results: string[] = [];
108+
if (!existsSync(directory)) return results;
109+
110+
readdirSync(directory, { withFileTypes: true }).forEach((entry) => {
111+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
112+
if (entry.isDirectory()) {
113+
results.push(...collectFiles(path.join(directory, entry.name), relativePath));
114+
} else {
115+
results.push(relativePath);
116+
}
117+
});
118+
return results;
119+
}
120+
121+
function contentHash(filePath: string): string {
122+
const contents = readFileSync(filePath);
123+
return createHash("sha256").update(contents).digest("hex").slice(0, 12);
124+
}
125+
126+
// Vite appends content hashes to filenames in its build output, like "index-BV2NauF8.js"
127+
function hasContentHash(fileName: string): boolean {
128+
return /\w+-[A-Za-z0-9_-]{6,}\.\w+$/.test(fileName);
129+
}
130+
131+
return {
132+
name: "service-worker",
133+
async writeBundle(options) {
134+
const outputDir = options.dir || "dist";
135+
const allFiles = collectFiles(outputDir, "");
136+
137+
const precacheManifest: { url: string; revision: string | undefined }[] = [];
138+
const deferredManifest: { url: string; revision: string | undefined }[] = [];
139+
140+
allFiles.forEach((relativePath) => {
141+
const fileName = path.basename(relativePath);
142+
const filePath = path.join(outputDir, relativePath);
143+
const url = `/${relativePath.replace(/\\/g, "/")}`;
144+
145+
// Skip excluded files
146+
if (EXCLUDED_FILES.has(fileName)) return;
147+
148+
// Deferred files are cached in the background after initial load
149+
if (DEFERRED_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) {
150+
deferredManifest.push({ url, revision: contentHash(filePath) });
151+
return;
152+
}
153+
154+
// Hashed filenames don't need a revision (the hash is in the URL)
155+
if (hasContentHash(fileName)) {
156+
precacheManifest.push({ url, revision: undefined });
157+
} else {
158+
precacheManifest.push({ url, revision: contentHash(filePath) });
159+
}
160+
});
161+
162+
// Compute a content hash from both manifests combined
163+
const allManifestJson = JSON.stringify({ precache: precacheManifest, deferred: deferredManifest });
164+
const serviceWorkerContentHash = createHash("sha256").update(allManifestJson).digest("hex").slice(0, 12);
165+
166+
// Read the service worker source and replace placeholder tokens with actual values
167+
const serviceWorkerSourcePath = path.resolve(projectRootDir, "src/service-worker.js");
168+
const serviceWorkerSource = readFileSync(serviceWorkerSourcePath, "utf-8");
169+
const serviceWorkerFinal = serviceWorkerSource
170+
.replace("self.__PRECACHE_MANIFEST", JSON.stringify(precacheManifest))
171+
.replace("self.__DEFERRED_CACHE_MANIFEST", JSON.stringify(deferredManifest))
172+
.replace("self.__SERVICE_WORKER_CONTENT_HASH", JSON.stringify(serviceWorkerContentHash));
173+
174+
writeFileSync(path.join(outputDir, "service-worker.js"), serviceWorkerFinal);
175+
},
176+
};
177+
}

0 commit comments

Comments
 (0)