From 1a06d3ea804e47d695c1d75dd8b49d468ac37328 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 2 Apr 2026 18:14:40 -0700 Subject: [PATCH] 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 --- .../components/floating-menus/MenuList.svelte | 29 ++- frontend/src/main.ts | 6 + frontend/src/service-worker.js | 199 ++++++++++++++++++ .../src/utility-functions/service-worker.ts | 31 +++ frontend/vite.config.ts | 83 +++++++- 5 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 frontend/src/service-worker.js create mode 100644 frontend/src/utility-functions/service-worker.ts diff --git a/frontend/src/components/floating-menus/MenuList.svelte b/frontend/src/components/floating-menus/MenuList.svelte index d10800d5..3e62fd0b 100644 --- a/frontend/src/components/floating-menus/MenuList.svelte +++ b/frontend/src/components/floating-menus/MenuList.svelte @@ -47,6 +47,9 @@ let virtualScrollingEntriesStart = 0; let keydownListenerAdded = false; let destroyed = false; + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- `loadedFonts` reactivity is driven by `loadedFontsGeneration`, not the Set itself + let loadedFonts = new Set(); + let loadedFontsGeneration = 0; // `watchOpen` is called only when `open` is changed from outside this component $: watchOpen(open); @@ -459,10 +462,28 @@ {/if} {#if entry.font} - + { + document.fonts.load(`16px "${entry.value}"`).then(() => { + loadedFonts.add(entry.value); + loadedFontsGeneration += 1; // Modify the dirty trigger + }); + }} + /> {/if} - + {#if entry.tooltipShortcut?.shortcut.length} @@ -548,6 +569,10 @@ margin: 0 4px; } + .font-preview:not(.font-loaded) { + opacity: 0.5; + } + .entry-icon, .no-icon { margin: 0 4px; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 2d402dfe..6eadab61 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,6 +2,12 @@ import { mount, unmount } from "svelte"; import App from "/src/App.svelte"; +import { registerServiceWorker } from "/src/utility-functions/service-worker"; + +// Register the service worker, except in dev mode and native (CEF) builds +if (!import.meta.env.DEV && import.meta.env.MODE !== "native" && "serviceWorker" in navigator) { + registerServiceWorker(); +} document.body.setAttribute("data-app-container", ""); diff --git a/frontend/src/service-worker.js b/frontend/src/service-worker.js new file mode 100644 index 00000000..9d66b464 --- /dev/null +++ b/frontend/src/service-worker.js @@ -0,0 +1,199 @@ +// These placeholders are replaced in `vite.config.ts` at build time +const PRECACHE_MANIFEST = self.__PRECACHE_MANIFEST; +const DEFERRED_CACHE_MANIFEST = self.__DEFERRED_CACHE_MANIFEST; +const SERVICE_WORKER_CONTENT_HASH = self.__SERVICE_WORKER_CONTENT_HASH; + +const STATIC_CACHE_NAME = `static-${SERVICE_WORKER_CONTENT_HASH}`; +const RUNTIME_ASSETS = "runtime-assets"; +const RUNTIME_FONTS = "runtime-fonts"; + +const FONT_LIST_API = "https://api.graphite.art/font-list"; + +// Build a set of precache URLs for quick lookup during fetch +const PRECACHE_URLS = new Set(PRECACHE_MANIFEST.map((entry) => new URL(entry.url, self.location.origin).href)); + +// Track deferred manifest URLs and revisions for cache invalidation +const DEFERRED_ENTRIES = new Map(DEFERRED_CACHE_MANIFEST.map((entry) => [new URL(entry.url, self.location.origin).href, entry.revision])); + +// ================== +// Caching strategies +// ================== + +function isCacheable(response) { + // Cache normal successful responses and opaque responses (cross-origin no-cors, e.g. stylesheets) + return response.ok || response.type === "opaque"; +} + +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request); + if (cached) return cached; + + const response = await fetch(request); + if (isCacheable(response)) cache.put(request, response.clone()); + return response; +} + +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const response = await fetch(request); + if (isCacheable(response)) cache.put(request, response.clone()); + return response; + } catch { + const cached = await cache.match(request); + if (cached) return cached; + throw new Error(`Network request failed and no cache available for ${request.url}`); + } +} + +// ================ +// Lifecycle events +// ================ + +self.addEventListener("install", (event) => { + event.waitUntil( + (async () => { + // Precache app shell assets + const cache = await caches.open(STATIC_CACHE_NAME); + await Promise.all( + PRECACHE_MANIFEST.map(async (entry) => { + const response = await fetch(entry.url); + if (!response.ok) throw new Error(`Precache fetch failed for ${entry.url}: ${response.status}`); + // Strip the `redirected` flag which causes errors when served via respondWith + const cleaned = response.redirected + ? new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + : response; + await cache.put(entry.url, cleaned); + }), + ); + + // Proactively cache the font catalog API + try { + const fontResponse = await fetch(FONT_LIST_API); + if (fontResponse.ok) { + const fontCache = await caches.open(RUNTIME_FONTS); + await fontCache.put(FONT_LIST_API, fontResponse); + } + } catch { + // Font catalog prefetch is best-effort, don't block installation + } + + await self.skipWaiting(); + })(), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const cacheNames = await caches.keys(); + + // Delete old precache versions + const deletions = cacheNames.filter((name) => name.startsWith("static-") && name !== STATIC_CACHE_NAME).map((name) => caches.delete(name)); + await Promise.all(deletions); + + // Prune stale deferred (demo artwork) entries + const assetsCache = await caches.open(RUNTIME_ASSETS); + const assetsKeys = await assetsCache.keys(); + await Promise.all(assetsKeys.filter((request) => !DEFERRED_ENTRIES.has(request.url)).map((request) => assetsCache.delete(request))); + + await self.clients.claim(); + })(), + ); +}); + +// ============= +// Fetch routing +// ============= + +self.addEventListener("fetch", (event) => { + const { request } = event; + const url = new URL(request.url); + + // Pass through range requests (e.g. for large file streaming) and non-GET requests + if (request.headers.has("range") || request.method !== "GET") return; + + // Pre-cached assets (JS and Wasm bundle files, favicons, index.html) + if (PRECACHE_URLS.has(url.href)) { + event.respondWith(cacheFirst(request, STATIC_CACHE_NAME)); + return; + } + + // Deferred-cached assets (demo artwork, third-party licenses, etc.) + if (DEFERRED_ENTRIES.has(url.href)) { + event.respondWith(cacheFirst(request, RUNTIME_ASSETS)); + return; + } + + // Font catalog API: network-first to keep it fresh + if (url.href.startsWith(FONT_LIST_API)) { + event.respondWith(networkFirst(request, RUNTIME_FONTS)); + return; + } + + // Google Fonts CSS (font preview stylesheets): cache-first since responses are stable for a given query + if (url.hostname === "fonts.googleapis.com") { + event.respondWith(cacheFirst(request, RUNTIME_FONTS)); + return; + } + + // Google Fonts static files: cache-first since they are immutable CDN URLs + if (url.hostname === "fonts.gstatic.com") { + event.respondWith(cacheFirst(request, RUNTIME_FONTS)); + return; + } + + // Navigation requests: serve cached index.html for all routes (SPA pattern) + if (request.mode === "navigate") { + event.respondWith( + (async () => { + const cache = await caches.open(STATIC_CACHE_NAME); + const cached = await cache.match("/index.html"); + return cached || fetch(request); + })(), + ); + return; + } + + // Everything else: network-only (no respondWith, let the browser handle it) +}); + +// ============================ +// Deferred caching via message +// ============================ + +self.addEventListener("message", (event) => { + if (event.data?.type !== "CACHE_DEFERRED") return; + + event.waitUntil( + (async () => { + const cache = await caches.open(RUNTIME_ASSETS); + const fetchPromises = DEFERRED_CACHE_MANIFEST.map(async (entry) => { + const fullUrl = new URL(entry.url, self.location.origin).href; + + // Skip if already cached with the same revision + const existing = await cache.match(fullUrl); + if (existing?.headers.get("x-sw-revision") === entry.revision) return; + + try { + const response = await fetch(fullUrl); + if (response.ok) { + // Store the service worker revision in a custom header so we can check it on future installs + const headers = new Headers(response.headers); + headers.set("x-sw-revision", entry.revision); + const taggedResponse = new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + await cache.put(fullUrl, taggedResponse); + } + } catch { + // Best-effort: skip files that fail to fetch + } + }); + await Promise.all(fetchPromises); + })(), + ); +}); diff --git a/frontend/src/utility-functions/service-worker.ts b/frontend/src/utility-functions/service-worker.ts new file mode 100644 index 00000000..3d3b0485 --- /dev/null +++ b/frontend/src/utility-functions/service-worker.ts @@ -0,0 +1,31 @@ +export async function registerServiceWorker() { + try { + const registration = await navigator.serviceWorker.register("/service-worker.js", { scope: "/" }); + + // When a new service worker is found, auto-reload once it activates + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing; + newWorker?.addEventListener("statechange", () => { + // Only reload if there was a previous controller, meaning this is an update, not first install + if (newWorker.state === "activated" && navigator.serviceWorker.controller) window.location.reload(); + }); + }); + + const activeWorker = registration.active || registration.waiting || registration.installing; + if (!activeWorker) return; + + const scheduleDeferredCaching = () => { + if (activeWorker.state !== "activated") return; + const sendMessage = () => registration.active?.postMessage({ type: "CACHE_DEFERRED" }); + if ("requestIdleCallback" in window) window.requestIdleCallback(sendMessage); + else setTimeout(sendMessage, 5000); // Fallback to a delay for Safari which doesn't support `requestIdleCallback` + }; + + // Once the service worker is active, trigger deferred caching during idle time + if (activeWorker.state === "activated") scheduleDeferredCaching(); + else activeWorker.addEventListener("statechange", scheduleDeferredCaching); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Service worker registration failed:", err); + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2ec20db0..c5e1c736 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,6 @@ import { execSync } from "child_process"; -import { copyFileSync, cpSync, existsSync, readFileSync, statSync } from "fs"; +import { createHash } from "crypto"; +import { copyFileSync, cpSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"; import path from "path"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import { defineConfig } from "vite"; @@ -10,7 +11,7 @@ const projectRootDir = path.resolve(__dirname); // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { return { - plugins: [svelte(), staticAssets(), mode !== "native" && thirdPartyLicenses()], + plugins: [svelte(), staticAssets(), mode !== "native" && thirdPartyLicenses(), mode !== "native" && serviceWorker()], resolve: { alias: [{ find: /\/..\/branding\/(.*\.svg)/, replacement: path.resolve(projectRootDir, "../branding", "$1?raw") }], }, @@ -96,3 +97,81 @@ function thirdPartyLicenses(): PluginOption { }, }; } + +function serviceWorker(): PluginOption { + // Files that should never be precached + const EXCLUDED_FILES = new Set(["service-worker.js"]); + const DEFERRED_PREFIXES = ["demo-artwork/", "third-party-licenses.txt"]; + + function collectFiles(directory: string, prefix: string): string[] { + const results: string[] = []; + if (!existsSync(directory)) return results; + + readdirSync(directory, { withFileTypes: true }).forEach((entry) => { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push(...collectFiles(path.join(directory, entry.name), relativePath)); + } else { + results.push(relativePath); + } + }); + return results; + } + + function contentHash(filePath: string): string { + const contents = readFileSync(filePath); + return createHash("sha256").update(contents).digest("hex").slice(0, 12); + } + + // Vite appends content hashes to filenames in its build output, like "index-BV2NauF8.js" + function hasContentHash(fileName: string): boolean { + return /\w+-[A-Za-z0-9_-]{6,}\.\w+$/.test(fileName); + } + + return { + name: "service-worker", + async writeBundle(options) { + const outputDir = options.dir || "dist"; + const allFiles = collectFiles(outputDir, ""); + + const precacheManifest: { url: string; revision: string | undefined }[] = []; + const deferredManifest: { url: string; revision: string | undefined }[] = []; + + allFiles.forEach((relativePath) => { + const fileName = path.basename(relativePath); + const filePath = path.join(outputDir, relativePath); + const url = `/${relativePath.replace(/\\/g, "/")}`; + + // Skip excluded files + if (EXCLUDED_FILES.has(fileName)) return; + + // Deferred files are cached in the background after initial load + if (DEFERRED_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { + deferredManifest.push({ url, revision: contentHash(filePath) }); + return; + } + + // Hashed filenames don't need a revision (the hash is in the URL) + if (hasContentHash(fileName)) { + precacheManifest.push({ url, revision: undefined }); + } else { + precacheManifest.push({ url, revision: contentHash(filePath) }); + } + }); + + // Compute a content hash from both manifests combined + const allManifestJson = JSON.stringify({ precache: precacheManifest, deferred: deferredManifest }); + const serviceWorkerContentHash = createHash("sha256").update(allManifestJson).digest("hex").slice(0, 12); + + // Read the service worker source and replace placeholder tokens with actual values + const serviceWorkerSourcePath = path.resolve(projectRootDir, "src/service-worker.js"); + const serviceWorkerSource = readFileSync(serviceWorkerSourcePath, "utf-8"); + const serviceWorkerFinal = serviceWorkerSource + .replace("self.__PRECACHE_MANIFEST", JSON.stringify(precacheManifest)) + .replace("self.__DEFERRED_CACHE_MANIFEST", JSON.stringify(deferredManifest)) + .replace("self.__SERVICE_WORKER_CONTENT_HASH", JSON.stringify(serviceWorkerContentHash)); + + writeFileSync(path.join(outputDir, "service-worker.js"), serviceWorkerFinal); + }, + }; +}