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
This commit is contained in:
parent
87bd3d41df
commit
1a06d3ea80
|
|
@ -47,6 +47,9 @@
|
||||||
let virtualScrollingEntriesStart = 0;
|
let virtualScrollingEntriesStart = 0;
|
||||||
let keydownListenerAdded = false;
|
let keydownListenerAdded = false;
|
||||||
let destroyed = 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<string>();
|
||||||
|
let loadedFontsGeneration = 0;
|
||||||
|
|
||||||
// `watchOpen` is called only when `open` is changed from outside this component
|
// `watchOpen` is called only when `open` is changed from outside this component
|
||||||
$: watchOpen(open);
|
$: watchOpen(open);
|
||||||
|
|
@ -459,10 +462,28 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if entry.font}
|
{#if entry.font}
|
||||||
<link rel="stylesheet" href={entry.font} />
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href={entry.font}
|
||||||
|
onload={() => {
|
||||||
|
document.fonts.load(`16px "${entry.value}"`).then(() => {
|
||||||
|
loadedFonts.add(entry.value);
|
||||||
|
loadedFontsGeneration += 1; // Modify the dirty trigger
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TextLabel class="entry-label" styles={entry.font ? { "font-family": entry.value } : {}}>{entry.label}</TextLabel>
|
<TextLabel
|
||||||
|
class="entry-label"
|
||||||
|
classes={{
|
||||||
|
"font-preview": Boolean(entry.font),
|
||||||
|
"font-loaded": loadedFontsGeneration >= 0 && loadedFonts.has(entry.value),
|
||||||
|
}}
|
||||||
|
styles={entry.font ? { "font-family": `"${entry.value}", "Source Sans Pro"` } : {}}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</TextLabel>
|
||||||
|
|
||||||
{#if entry.tooltipShortcut?.shortcut.length}
|
{#if entry.tooltipShortcut?.shortcut.length}
|
||||||
<ShortcutLabel shortcut={entry.tooltipShortcut} />
|
<ShortcutLabel shortcut={entry.tooltipShortcut} />
|
||||||
|
|
@ -548,6 +569,10 @@
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-preview:not(.font-loaded) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-icon,
|
.entry-icon,
|
||||||
.no-icon {
|
.no-icon {
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
import App from "/src/App.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", "");
|
document.body.setAttribute("data-app-container", "");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. <link> 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);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { execSync } from "child_process";
|
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 path from "path";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
@ -10,7 +11,7 @@ const projectRootDir = path.resolve(__dirname);
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
return {
|
return {
|
||||||
plugins: [svelte(), staticAssets(), mode !== "native" && thirdPartyLicenses()],
|
plugins: [svelte(), staticAssets(), mode !== "native" && thirdPartyLicenses(), mode !== "native" && serviceWorker()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [{ find: /\/..\/branding\/(.*\.svg)/, replacement: path.resolve(projectRootDir, "../branding", "$1?raw") }],
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue