From d4749d3f82bc5824dcf5d02ec5e6844753578a16 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 9 Jun 2026 12:09:44 +0800 Subject: [PATCH] feat: add PWA offline unlock support --- package.json | 4 +- webapp/index.html | 6 + webapp/public/manifest.webmanifest | 48 +++++++ webapp/src/App.tsx | 2 + webapp/src/lib/api/auth.ts | 8 ++ webapp/src/lib/app-auth.ts | 92 ++++++++++++- webapp/src/lib/offline-auth.ts | 160 ++++++++++++++++++++++ webapp/src/lib/pwa.ts | 11 ++ webapp/src/main.tsx | 2 + webapp/vite.config.ts | 204 ++++++++++++++++++++++++++++- 10 files changed, 527 insertions(+), 10 deletions(-) create mode 100644 webapp/public/manifest.webmanifest create mode 100644 webapp/src/lib/offline-auth.ts create mode 100644 webapp/src/lib/pwa.ts diff --git a/package.json b/package.json index 4da142f..b5edcff 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "main": "src/index.ts", "type": "module", "scripts": { - "dev": "npm run build && wrangler dev -c wrangler.toml", - "dev:kv": "npm run build && wrangler dev -c wrangler.kv.toml", + "dev": "wrangler dev -c wrangler.toml", + "dev:kv": "wrangler dev -c wrangler.kv.toml", "dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174", "build": "vite build --config webapp/vite.config.ts", "build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs", diff --git a/webapp/index.html b/webapp/index.html index d08ed08..e17be7b 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -18,6 +18,12 @@ + + + + + + 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(urls).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', @@ -59,7 +261,7 @@ export default defineConfig(({ mode }) => { return { root: rootDir, - plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo)], + plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)], define: { __NODEWARDEN_DEMO__: JSON.stringify(isDemo), },