mirror of https://github.com/VERT-sh/VERT.git
feat: caching workers
This commit is contained in:
parent
58175462b2
commit
82a63929b4
|
@ -154,11 +154,20 @@
|
||||||
"custom_instance": "Custom"
|
"custom_instance": "Custom"
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
"title": "Privacy",
|
"title": "Privacy & data",
|
||||||
"plausible_title": "Plausible analytics",
|
"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.",
|
"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_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": {
|
"language": {
|
||||||
"title": "Language",
|
"title": "Language",
|
||||||
|
|
|
@ -1,12 +1,67 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Panel from "$lib/components/visual/Panel.svelte";
|
import Panel from "$lib/components/visual/Panel.svelte";
|
||||||
import { ChartColumnIcon, PauseIcon, PlayIcon } from "lucide-svelte";
|
import {
|
||||||
|
ChartColumnIcon,
|
||||||
|
PauseIcon,
|
||||||
|
PlayIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-svelte";
|
||||||
import type { ISettings } from "./index.svelte";
|
import type { ISettings } from "./index.svelte";
|
||||||
import { effects } from "$lib/store/index.svelte";
|
import { effects } from "$lib/store/index.svelte";
|
||||||
import { m } from "$lib/paraglide/messages";
|
import { m } from "$lib/paraglide/messages";
|
||||||
import { link } from "$lib/store/index.svelte";
|
import { link } from "$lib/store/index.svelte";
|
||||||
|
import { swManager, type CacheInfo } from "$lib/sw/register";
|
||||||
|
import { addToast } from "$lib/store/ToastProvider";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { error } from "$lib/logger";
|
||||||
|
|
||||||
const { settings = $bindable() }: { settings: ISettings } = $props();
|
const { settings = $bindable() }: { settings: ISettings } = $props();
|
||||||
|
|
||||||
|
let cacheInfo = $state<CacheInfo | null>(null);
|
||||||
|
let isLoadingCache = $state(false);
|
||||||
|
|
||||||
|
async function loadCacheInfo() {
|
||||||
|
if (isLoadingCache) return;
|
||||||
|
isLoadingCache = true;
|
||||||
|
try {
|
||||||
|
await swManager.init();
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!navigator.serviceWorker.controller) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheInfo = await swManager.getCacheInfo();
|
||||||
|
} catch (err) {
|
||||||
|
error(["privacy", "cache"], "Failed to load cache info:", err);
|
||||||
|
} finally {
|
||||||
|
isLoadingCache = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCache() {
|
||||||
|
if (isLoadingCache) return;
|
||||||
|
isLoadingCache = true;
|
||||||
|
try {
|
||||||
|
await swManager.clearCache();
|
||||||
|
cacheInfo = null;
|
||||||
|
await loadCacheInfo();
|
||||||
|
addToast("success", m["settings.privacy.cache_cleared"]());
|
||||||
|
} catch (err) {
|
||||||
|
error(["privacy", "cache"], "Failed to clear cache:", err);
|
||||||
|
addToast("error", "Failed to clear cache");
|
||||||
|
} finally {
|
||||||
|
isLoadingCache = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadCacheInfo();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Panel class="flex flex-col gap-8 p-6">
|
<Panel class="flex flex-col gap-8 p-6">
|
||||||
|
@ -64,6 +119,70 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-base font-bold">
|
||||||
|
{m["settings.privacy.cache_title"]()}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-muted font-normal">
|
||||||
|
{m["settings.privacy.cache_description"]()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="bg-button p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-muted">
|
||||||
|
{m["settings.privacy.total_size"]()}
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-bold flex items-center gap-2">
|
||||||
|
{#if isLoadingCache}
|
||||||
|
<RefreshCwIcon size="16" class="animate-spin" />
|
||||||
|
{m["settings.privacy.loading_cache"]()}
|
||||||
|
{:else}
|
||||||
|
{cacheInfo
|
||||||
|
? swManager.formatSize(cacheInfo.totalSize)
|
||||||
|
: "0 B"}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-button p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-muted">
|
||||||
|
{m["settings.privacy.files_cached_label"]()}
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-bold flex items-center gap-2">
|
||||||
|
{#if isLoadingCache}
|
||||||
|
<RefreshCwIcon size="16" class="animate-spin" />
|
||||||
|
{m["settings.privacy.loading_cache"]()}
|
||||||
|
{:else}
|
||||||
|
{cacheInfo?.fileCount ?? 0}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 w-full">
|
||||||
|
<button
|
||||||
|
onclick={loadCacheInfo}
|
||||||
|
class="btn {$effects
|
||||||
|
? ''
|
||||||
|
: '!scale-100'} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
|
||||||
|
disabled={isLoadingCache}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon size="24" class="inline-block mr-2" />
|
||||||
|
{m["settings.privacy.refresh_cache"]()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={clearCache}
|
||||||
|
class="btn {$effects
|
||||||
|
? ''
|
||||||
|
: '!scale-100'} flex-1 p-4 rounded-lg text-black dynadark:text-white flex items-center justify-center"
|
||||||
|
disabled={isLoadingCache}
|
||||||
|
>
|
||||||
|
<Trash2Icon size="24" class="inline-block mr-2" />
|
||||||
|
{m["settings.privacy.clear_cache"]()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div></Panel
|
</div></Panel
|
||||||
>
|
>
|
||||||
|
|
|
@ -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<void> {
|
||||||
|
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<CacheInfo> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
Loading…
Reference in New Issue