import { fileURLToPath } from 'node:url'; import path from 'node:path'; import { createHash } from 'node:crypto'; import preact from '@preact/preset-vite'; import { defineConfig, type Plugin } from 'vite'; const rootDir = fileURLToPath(new URL('.', import.meta.url)); function buildServiceWorkerSource(precacheUrls: string[], version: string): string { return `const CACHE_VERSION = ${JSON.stringify(`nodewarden-pwa-${version}`)}; const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`; const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1'; const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)}; const CRITICAL_SHELL_URLS = ['/', '/index.html']; const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/; const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/; const OFFLINE_FALLBACK_HTML = 'NodeWarden
NodeWarden
Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.
'; self.addEventListener('install', (event) => { event.waitUntil( caches.open(APP_SHELL_CACHE) .then(async (cache) => { await cache.addAll(CRITICAL_SHELL_URLS); const nonCriticalUrls = PRECACHE_URLS.filter((url) => !CRITICAL_SHELL_URLS.includes(url)); await Promise.allSettled(nonCriticalUrls.map((url) => cache.add(url))); }) .then(() => self.skipWaiting()) ); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((keys) => Promise.all( keys .filter((key) => key.startsWith('nodewarden-pwa-') && key.endsWith('-shell') && key !== APP_SHELL_CACHE) .map((key) => caches.delete(key)) )) .then(() => self.clients.claim()) ); }); function isSameOriginHttpGet(request) { if (request.method !== 'GET') return false; const url = new URL(request.url); return url.origin === self.location.origin; } function isCacheableResponse(response) { return response && response.ok && (response.type === 'basic' || response.type === 'default'); } async function refreshNavigationCache(request) { const cache = await caches.open(APP_SHELL_CACHE); try { const response = await fetch(request); if (isCacheableResponse(response)) { await cache.put('/', response.clone()); await cache.put('/index.html', response.clone()); await warmStaticDependencies(response.clone()); } return response; } catch { return null; } } async function warmStaticDependencies(response) { try { const html = await response.text(); const runtimeCache = await caches.open(RUNTIME_CACHE); const urls = Array.from(html.matchAll(/\\b(?:src|href)=["']([^"']+)["']/g)) .map((match) => { try { return new URL(match[1], self.location.origin); } catch { return null; } }) .filter((url) => url && url.origin === self.location.origin && STATIC_PATH_RE.test(url.pathname)) .map((url) => url.pathname + url.search); await Promise.allSettled(Array.from(new Set(urls)).map((url) => runtimeCache.add(url))); await trimRuntimeCache(runtimeCache, 120); } catch { // Dependency warming is best-effort; never slow or break navigation for it. } } async function appShellNavigation(request) { const cache = await caches.open(APP_SHELL_CACHE); const url = new URL(request.url); return ( (await cache.match(request, { ignoreSearch: true })) || (await cache.match(url.pathname, { ignoreSearch: true })) || (await cache.match('/')) || (await cache.match('/index.html')) || new Response(OFFLINE_FALLBACK_HTML, { status: 200, headers: { 'Content-Type': 'text/html; charset=UTF-8' }, }) ); } async function trimRuntimeCache(cache, maxEntries) { const keys = await cache.keys(); if (keys.length <= maxEntries) return; await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key))); } async function cacheFirst(request) { const shellCache = await caches.open(APP_SHELL_CACHE); const cachedShell = await shellCache.match(request); if (cachedShell) return cachedShell; const runtimeCache = await caches.open(RUNTIME_CACHE); const cachedRuntime = await runtimeCache.match(request); if (cachedRuntime) return cachedRuntime; const legacyRuntime = await matchLegacyRuntimeCache(request); if (legacyRuntime) return legacyRuntime; const response = await fetch(request); if (isCacheableResponse(response)) { void runtimeCache.put(request, response.clone()).then(() => trimRuntimeCache(runtimeCache, 120)); } return response; } async function matchLegacyRuntimeCache(request) { const keys = await caches.keys(); for (const key of keys) { if (key === RUNTIME_CACHE || !key.startsWith('nodewarden-pwa-') || !key.endsWith('-runtime')) continue; const cache = await caches.open(key); const cached = await cache.match(request); if (cached) return cached; } return null; } self.addEventListener('fetch', (event) => { const request = event.request; if (!isSameOriginHttpGet(request)) return; const url = new URL(request.url); if (NEVER_CACHE_PATH_RE.test(url.pathname)) return; if (request.mode === 'navigate') { event.respondWith(appShellNavigation(request)); if (navigator.onLine !== false) { event.waitUntil(refreshNavigationCache(request)); } return; } if (STATIC_PATH_RE.test(url.pathname) || request.destination === 'script' || request.destination === 'style' || request.destination === 'font' || request.destination === 'image' || request.destination === 'worker') { event.respondWith(cacheFirst(request)); } }); `; } function buildCacheVersion(isDemo: boolean, urls: string[]): string { const digest = createHash('sha256') .update(`${isDemo ? 'demo' : 'app'}\n${urls.join('\n')}`) .digest('hex') .slice(0, 16); return `${isDemo ? 'demo' : 'app'}-${digest}`; } function pwaServiceWorkerPlugin(isDemo: boolean): Plugin { return { name: 'nodewarden-pwa-service-worker', generateBundle(_, bundle) { const urls = new Set([ '/', '/index.html', '/vault', '/manifest.webmanifest', '/nodewarden-logo.svg', '/nodewarden-logo-bg.svg', '/nodewarden-wordmark.svg', '/favicon.ico', '/favicon-32.png', '/apple-touch-icon.png', '/icon-192.png', '/icon-512.png', '/logo-64.png', ]); const buildUrls = new Set(urls); for (const [fileName, output] of Object.entries(bundle)) { if (output.type !== 'chunk' && output.type !== 'asset') continue; if (fileName === 'sw.js' || fileName === 'robots.txt') continue; if (fileName.endsWith('.map')) continue; buildUrls.add(`/${fileName}`); } const sortedUrls = Array.from(buildUrls).sort(); const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort()); this.emitFile({ type: 'asset', fileName: 'sw.js', source: buildServiceWorkerSource(sortedUrls, version), }); }, }; } function searchIndexPolicyPlugin(isDemo: boolean): Plugin { return { name: 'nodewarden-search-index-policy', transformIndexHtml(html: string) { if (isDemo) return html; return html.replace( '', '\n ' ); }, generateBundle() { this.emitFile({ type: 'asset', fileName: 'robots.txt', source: isDemo ? 'User-agent: *\nAllow: /\n' : 'User-agent: *\nDisallow: /\n', }); }, }; } function resourcePriorityPlugin(isDemo: boolean): Plugin { return { name: 'nodewarden-resource-priority', enforce: 'post' as const, transformIndexHtml(html: string) { if (isDemo || !html.includes('/assets/app-suite-')) return html; const scriptMatch = html.match(/^\s*