From 82a63929b4a2588d9736340219c143c6051faafc Mon Sep 17 00:00:00 2001 From: Maya Date: Sat, 4 Oct 2025 01:01:21 +0300 Subject: [PATCH] feat: caching workers --- messages/en.json | 13 +- src/lib/sections/settings/Privacy.svelte | 121 +++++++++++++- src/lib/sw/register.ts | 108 +++++++++++++ static/sw.js | 191 +++++++++++++++++++++++ 4 files changed, 430 insertions(+), 3 deletions(-) create mode 100644 src/lib/sw/register.ts create mode 100644 static/sw.js diff --git a/messages/en.json b/messages/en.json index 6d30dd2..831b4a5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -154,11 +154,20 @@ "custom_instance": "Custom" }, "privacy": { - "title": "Privacy", + "title": "Privacy & data", "plausible_title": "Plausible analytics", "plausible_description": "We use [plausible_link]Plausible[/plausible_link], a privacy-focused analytics tool, to gather completely anonymous statistics. All data is anonymized and aggregated, and no identifiable information is ever sent or stored. You can view the analytics [analytics_link]here[/analytics_link] and choose to opt out below.", "opt_in": "Opt-in", - "opt_out": "Opt-out" + "opt_out": "Opt-out", + "cache_title": "Cache management", + "cache_description": "We cache the converter files on your browser so you don't have to re-download them every time, improving performance and reducing data usage.", + "refresh_cache": "Refresh cache", + "clear_cache": "Clear cache", + "files_cached": "{size} ({count} files)", + "loading_cache": "Loading...", + "total_size": "Total Size", + "files_cached_label": "Files Cached", + "cache_cleared": "Cache cleared successfully!" }, "language": { "title": "Language", diff --git a/src/lib/sections/settings/Privacy.svelte b/src/lib/sections/settings/Privacy.svelte index 8df55dc..38cbd73 100644 --- a/src/lib/sections/settings/Privacy.svelte +++ b/src/lib/sections/settings/Privacy.svelte @@ -1,12 +1,67 @@ @@ -64,6 +119,70 @@ +
+
+

+ {m["settings.privacy.cache_title"]()} +

+

+ {m["settings.privacy.cache_description"]()} +

+
+ +
+
+
+ {m["settings.privacy.total_size"]()} +
+
+ {#if isLoadingCache} + + {m["settings.privacy.loading_cache"]()} + {:else} + {cacheInfo + ? swManager.formatSize(cacheInfo.totalSize) + : "0 B"} + {/if} +
+
+
+
+ {m["settings.privacy.files_cached_label"]()} +
+
+ {#if isLoadingCache} + + {m["settings.privacy.loading_cache"]()} + {:else} + {cacheInfo?.fileCount ?? 0} + {/if} +
+
+
+ +
+ + +
+
diff --git a/src/lib/sw/register.ts b/src/lib/sw/register.ts new file mode 100644 index 0000000..4a0ebee --- /dev/null +++ b/src/lib/sw/register.ts @@ -0,0 +1,108 @@ +import { browser } from "$app/environment"; + +export interface CacheInfo { + totalSize: number; + fileCount: number; + files: Array<{ + url: string; + size: number; + type: string; + }>; +} + +class ServiceWorkerManager { + private registration: ServiceWorkerRegistration | null = null; + private initialized = false; + + async init(): Promise { + if (!browser || !("serviceWorker" in navigator) || this.initialized) { + return; + } + + try { + this.registration = await navigator.serviceWorker.register( + "/sw.js", + { + scope: "/", + }, + ); + + this.initialized = true; + } catch (error) { + console.error( + "[SW Manager] service worker registration failed:", + error, + ); + } + } + + async getCacheInfo(): Promise { + if (!this.registration || !navigator.serviceWorker.controller) { + console.warn( + "[SW Manager] no service worker available for cache info", + ); + return { totalSize: 0, fileCount: 0, files: [] }; + } + + return new Promise((resolve, reject) => { + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + resolve(event.data); + }; + + setTimeout(() => { + reject(new Error("Timeout waiting for cache info")); + }, 5000); + + navigator.serviceWorker?.controller?.postMessage( + { type: "GET_CACHE_INFO" }, + [messageChannel.port2], + ); + }); + } + + async clearCache(): Promise { + if (!this.registration || !navigator.serviceWorker.controller) { + throw new Error("No service worker available for cache clearing"); + } + + return new Promise((resolve, reject) => { + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + if (event.data.success) { + resolve(); + } else { + reject( + new Error(event.data.error || "Failed to clear cache"), + ); + } + }; + + setTimeout(() => { + reject(new Error("Timeout waiting for cache clear")); + }, 10000); + + navigator.serviceWorker?.controller?.postMessage( + { type: "CLEAR_CACHE" }, + [messageChannel.port2], + ); + }); + } + + formatSize(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } +} + +export const swManager = new ServiceWorkerManager(); + +// Auto-initialize when imported +if (browser) { + swManager.init(); +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..f8a24e9 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,191 @@ +const CACHE_NAME = "vert-wasm-cache-v1"; + +const WASM_FILES = [ + "/pandoc.wasm", + "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.js", + "https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm/ffmpeg-core.wasm", +]; + +const WASM_URL_PATTERNS = [ + /\/src\/lib\/workers\/.*\.js$/, // dev mode worker files + /\/assets\/.*worker.*\.js$/, // prod worker files + /magick.*\.wasm$/, // magick-wasm (unneeded?) +]; + +function shouldCacheUrl(url) { + const urlObj = new URL(url); + + if (WASM_FILES.includes(urlObj.pathname) || WASM_FILES.includes(url)) { + return true; + } + + return WASM_URL_PATTERNS.some( + (pattern) => pattern.test(urlObj.pathname) || pattern.test(url), + ); +} + +self.addEventListener("install", (event) => { + console.log("[SW] installing service worker"); + + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + const staticFiles = WASM_FILES.filter((file) => + file.startsWith("/"), + ); + if (staticFiles.length > 0) { + console.log("[SW] pre-caching static files:", staticFiles); + return cache.addAll(staticFiles).catch((err) => { + console.warn("[SW] failed to pre-cache some files:", err); + }); + } + }), + ); + + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if ( + cacheName !== CACHE_NAME && + cacheName.startsWith("vert-wasm-cache") + ) { + console.log("[SW] deleting old cache:", cacheName); + return caches.delete(cacheName); + } + }), + ); + }) + .then(() => { + return self.clients.claim(); + }), + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + + if (!shouldCacheUrl(request.url)) { + return; // Let the request go through normally if not a target URL + } + + // else intercept request + event.respondWith( + caches.match(request).then((cachedResponse) => { + if (cachedResponse) { + console.log("[SW] serving from cache:", request.url); + return cachedResponse; + } + + console.log("[SW] fetching and caching:", request.url); + return fetch(request) + .then((response) => { + if (!response.ok) { + console.warn( + "[SW] not caching failed response:", + response.status, + request.url, + ); + return response; + } + + const responseToCache = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache + .put(request, responseToCache) + .then(() => { + console.log( + "[SW] cached successfully:", + request.url, + ); + }) + .catch((err) => { + console.warn( + "[SW] failed to cache:", + request.url, + err, + ); + }); + }); + + return response; + }) + .catch((err) => { + console.error("[SW] fetch failed for:", request.url, err); + throw err; + }); + }), + ); +}); + +self.addEventListener("message", (event) => { + if (!event.data) return; + const type = event.data.type; + + if (type === "GET_CACHE_INFO") { + event.waitUntil( + caches.open(CACHE_NAME).then(async (cache) => { + const keys = await cache.keys(); + let totalSize = 0; + const files = []; + + for (const request of keys) { + try { + const response = await cache.match(request); + if (response) { + const blob = await response.blob(); + const size = blob.size; + totalSize += size; + + files.push({ + url: request.url, + size: size, + type: + response.headers.get("content-type") || + "unknown", + }); + } + } catch (err) { + console.warn( + "[SW] failed to get info for cached file:", + request.url, + err, + ); + } + } + + event.ports[0].postMessage({ + totalSize, + fileCount: files.length, + files, + }); + }), + ); + } + + if (type === "CLEAR_CACHE") { + event.waitUntil( + caches + .delete(CACHE_NAME) + .then(() => { + console.log("[SW] cache cleared"); + return caches.open(CACHE_NAME); + }) + .then(() => { + event.ports[0].postMessage({ success: true }); + }) + .catch((err) => { + console.error("[SW] failed to clear cache:", err); + event.ports[0].postMessage({ + success: false, + error: err.message, + }); + }), + ); + } +});