mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add PWA offline unlock support
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
|
||||
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<title>NodeWarden</title>
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "NodeWarden",
|
||||
"short_name": "NodeWarden",
|
||||
"description": "A lightweight Bitwarden-compatible vault for Cloudflare Workers.",
|
||||
"id": "/",
|
||||
"start_url": "/vault",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||
"orientation": "any",
|
||||
"background_color": "#eef4ff",
|
||||
"theme_color": "#0f172a",
|
||||
"categories": ["security", "productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Vault",
|
||||
"short_name": "Vault",
|
||||
"url": "/vault",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
},
|
||||
{
|
||||
"name": "TOTP Codes",
|
||||
"short_name": "TOTP",
|
||||
"url": "/vault/totp",
|
||||
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { useToastManager } from '@/hooks/useToastManager';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||
import {
|
||||
@@ -746,6 +747,7 @@ export default function App() {
|
||||
setConfirm(null);
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
clearOfflineUnlockRecord();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
|
||||
@@ -95,6 +95,12 @@ export function loadSession(): SessionState | null {
|
||||
authMode: 'web-cookie',
|
||||
};
|
||||
}
|
||||
if (parsed.authMode === 'token' && parsed.email && !parsed.accessToken && !parsed.refreshToken) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
@@ -233,6 +239,7 @@ export async function loginWithPassword(
|
||||
totpCode?: string;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
@@ -262,6 +269,7 @@ export async function loginWithPassword(
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: options?.signal,
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (resp.ok) {
|
||||
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
} from '@/lib/api/auth';
|
||||
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
||||
import { t, translateServerError } from '@/lib/i18n';
|
||||
import {
|
||||
getOfflineUnlockKdfIterations,
|
||||
hasOfflineUnlockRecord,
|
||||
kdfIterationsFromLogin,
|
||||
loadOfflineProfileSnapshot,
|
||||
saveOfflineUnlockRecord,
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
@@ -93,6 +101,20 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
|
||||
};
|
||||
}
|
||||
|
||||
function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
|
||||
if (typeof AbortController === 'undefined') return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return {
|
||||
controller,
|
||||
cancel: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
function readWindowBootstrap(): WebBootstrapResponse {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
|
||||
@@ -248,6 +270,12 @@ export async function hydrateLockedSession(
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
return { session: null, profile: null };
|
||||
}
|
||||
try {
|
||||
@@ -272,7 +300,8 @@ export async function hydrateLockedSession(
|
||||
export async function completeLogin(
|
||||
token: TokenSuccess,
|
||||
email: string,
|
||||
masterKey: Uint8Array
|
||||
masterKey: Uint8Array,
|
||||
fallbackKdfIterations: number
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
@@ -291,6 +320,12 @@ export async function completeLogin(
|
||||
throw new Error('Missing profile key');
|
||||
}
|
||||
const keys = await unlockVaultKey(profile.key, masterKey);
|
||||
saveOfflineUnlockRecord({
|
||||
email: normalizedEmail,
|
||||
profile,
|
||||
profileKey: profile.key,
|
||||
kdfIterations: kdfIterationsFromLogin(token, fallbackKdfIterations),
|
||||
});
|
||||
return {
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
@@ -310,7 +345,7 @@ export async function performPasswordLogin(
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,7 +377,7 @@ export async function performTotpLogin(
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, kdfIterationsFromLogin(token, 600000));
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
@@ -361,7 +396,7 @@ export async function performRecoverTwoFactorLogin(
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||
};
|
||||
}
|
||||
@@ -397,13 +432,56 @@ export async function performUnlock(
|
||||
fallbackIterations: number
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
const offlineIterations = getOfflineUnlockKdfIterations(normalizedEmail);
|
||||
const hasOfflineUnlock = !!offlineIterations;
|
||||
const kdfIterations = offlineIterations || fallbackIterations;
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, kdfIterations);
|
||||
const unlockOffline = async (): Promise<PasswordLoginResult> => {
|
||||
try {
|
||||
const offline = await unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
return {
|
||||
kind: 'success',
|
||||
login: {
|
||||
session: offline.session,
|
||||
profile: offline.profile,
|
||||
profilePromise: Promise.resolve(offline.profile),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
signal: abortable?.controller.signal,
|
||||
});
|
||||
} catch {
|
||||
if (hasOfflineUnlock) {
|
||||
return unlockOffline();
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
} finally {
|
||||
abortable?.cancel();
|
||||
}
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { deriveLoginHashLocally, unlockVaultKey } from '@/lib/api/auth';
|
||||
import type { Profile, SessionState, TokenSuccess } from '@/lib/types';
|
||||
|
||||
const OFFLINE_UNLOCK_KEY = 'nodewarden.web.offline-unlock.v1';
|
||||
|
||||
interface OfflineUnlockRecord {
|
||||
version: 1;
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
return String(email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripOfflineProfile(profile: Profile): Profile {
|
||||
return {
|
||||
...profile,
|
||||
email: normalizeEmail(profile.email),
|
||||
key: '',
|
||||
privateKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRecord(raw: string | null): OfflineUnlockRecord | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<OfflineUnlockRecord>;
|
||||
const email = normalizeEmail(parsed.email);
|
||||
const profileKey = String(parsed.profileKey || '').trim();
|
||||
const iterations = Number(parsed.kdfIterations || 0);
|
||||
if (parsed.version !== 1 || !email || !profileKey || !Number.isFinite(iterations) || iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
const profile = parsed.profile && typeof parsed.profile === 'object'
|
||||
? stripOfflineProfile(parsed.profile as Profile)
|
||||
: {
|
||||
id: '',
|
||||
email,
|
||||
name: email,
|
||||
key: '',
|
||||
privateKey: null,
|
||||
role: 'user',
|
||||
};
|
||||
return {
|
||||
version: 1,
|
||||
email,
|
||||
profile,
|
||||
profileKey,
|
||||
kdfIterations: iterations,
|
||||
savedAt: Number(parsed.savedAt || 0) || 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(): OfflineUnlockRecord | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return parseRecord(localStorage.getItem(OFFLINE_UNLOCK_KEY));
|
||||
}
|
||||
|
||||
export function hasOfflineUnlockRecord(email?: string | null): boolean {
|
||||
const record = readRecord();
|
||||
if (!record) return false;
|
||||
const normalized = normalizeEmail(email);
|
||||
return !normalized || record.email === normalized;
|
||||
}
|
||||
|
||||
export function getOfflineUnlockKdfIterations(email?: string | null): number | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return record.kdfIterations;
|
||||
}
|
||||
|
||||
export function loadOfflineProfileSnapshot(email?: string | null): Profile | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return stripOfflineProfile(record.profile);
|
||||
}
|
||||
|
||||
export function saveOfflineUnlockRecord(args: {
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
}): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const email = normalizeEmail(args.email || args.profile.email);
|
||||
const profileKey = String(args.profileKey || '').trim();
|
||||
const kdfIterations = Number(args.kdfIterations || 0);
|
||||
if (!email || !profileKey || !Number.isFinite(kdfIterations) || kdfIterations <= 0) return;
|
||||
const record: OfflineUnlockRecord = {
|
||||
version: 1,
|
||||
email,
|
||||
profile: stripOfflineProfile({ ...args.profile, email }),
|
||||
profileKey,
|
||||
kdfIterations,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(OFFLINE_UNLOCK_KEY, JSON.stringify(record));
|
||||
}
|
||||
|
||||
export function clearOfflineUnlockRecord(): void {
|
||||
try {
|
||||
localStorage.removeItem(OFFLINE_UNLOCK_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures during logout cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlockOfflineVault(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
password: string
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const derived = await deriveLoginHashLocally(record.email, password, record.kdfIterations);
|
||||
return unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
}
|
||||
|
||||
export async function unlockOfflineVaultWithMasterKey(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
masterKey: Uint8Array
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const keys = await unlockVaultKey(record.profileKey, masterKey);
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
email: record.email,
|
||||
...keys,
|
||||
},
|
||||
profile: {
|
||||
...stripOfflineProfile(record.profile),
|
||||
key: record.profileKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function kdfIterationsFromLogin(token: TokenSuccess, fallbackIterations: number): number {
|
||||
const value = Number(token.KdfIterations || fallbackIterations || 600000);
|
||||
return Number.isFinite(value) && value > 0 ? value : 600000;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function registerNodeWardenServiceWorker(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||
// PWA support is progressive enhancement; the vault still works without it.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { render } from 'preact';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { initI18n } from './lib/i18n';
|
||||
import { registerNodeWardenServiceWorker } from './lib/pwa';
|
||||
import './tailwind.css';
|
||||
import './styles.css';
|
||||
|
||||
@@ -29,4 +30,5 @@ function renderApp(): void {
|
||||
|
||||
void initI18n().finally(() => {
|
||||
renderApp();
|
||||
registerNodeWardenServiceWorker();
|
||||
});
|
||||
|
||||
+203
-1
@@ -1,10 +1,212 @@
|
||||
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', '/vault'];
|
||||
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 = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>NodeWarden</title><style>html,body{height:100%;margin:0;background:#eef4ff;color:#0f172a;font-family:ui-sans-serif,system-ui,sans-serif}.boot-screen{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}.boot-card{width:min(420px,100%);display:grid;gap:12px;justify-items:center;padding:28px;border:1px solid rgba(148,163,184,.35);border-radius:22px;background:rgba(255,255,255,.86);box-shadow:0 20px 45px rgba(15,23,42,.1)}.boot-logo{width:74px;height:58px;object-fit:contain}.boot-title{font-weight:700}.boot-sub{color:#475569;text-align:center;font-size:14px;line-height:1.5}</style></head><body><div class="boot-screen"><div class="boot-card"><img class="boot-logo" src="/nodewarden-logo.svg" alt=""><div class="boot-title">NodeWarden</div><div class="boot-sub">Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.</div></div></div></body></html>';
|
||||
|
||||
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<string>([
|
||||
'/',
|
||||
'/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<string>(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),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user