PWA optimization: vite-plugin-pwa, split misc.json, IndexedDB cache layer

This commit is contained in:
catarrh committed 2026-08-01 21:39:10 +03:00
1 parent c342a5b636
commit 8b27bd4767
70 files changed
+5612 -57852

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
/**
* Persistent data cache using IndexedDB.
*
* Provides cache-first fetch with TTL support for JSON and text data.
* Works alongside the service worker: SW caches static assets,
* IndexedDB caches fetched data for cross-session persistence.
*
* Usage:
* const data = await DataCache.fetch('/data/ru/lives/01.json');
* await DataCache.set('my-key', { some: 'data' });
* const cached = await DataCache.get('my-key');
* await DataCache.clear();
* const estimate = await DataCache.estimate();
*/
const DB_NAME = 'ponomar-cache';
const DB_VERSION = 1;
const STORE_NAME = 'data';
const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
interface CacheEntry {
key: string;
data: unknown;
contentType: string;
storedAt: number;
ttl: number;
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
store.createIndex('storedAt', 'storedAt', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export class DataCache {
/**
* Get a value from the cache.
* Returns null if the key doesn't exist or the TTL has expired.
*/
static async get(key: string): Promise<unknown | null> {
try {
const db = await openDB();
return new Promise<unknown | null>((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(key);
request.onsuccess = () => {
const entry = request.result as CacheEntry | undefined;
if (!entry) {
resolve(null);
return;
}
// Check TTL
if (Date.now() - entry.storedAt > entry.ttl) {
// Expired — remove and return null
db.transaction(STORE_NAME, 'readwrite')
.objectStore(STORE_NAME)
.delete(key);
resolve(null);
return;
}
resolve(entry.data);
};
request.onerror = () => resolve(null);
});
} catch {
return null;
}
}
/**
* Store a value in the cache.
*/
static async set(key: string, data: unknown, ttl: number = DEFAULT_TTL_MS): Promise<void> {
try {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const entry: CacheEntry = {
key,
data,
contentType: typeof data === 'string' ? 'text' : 'json',
storedAt: Date.now(),
ttl,
};
store.put(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// Silently fail — cache is best-effort
}
}
/**
* Remove an entry from the cache.
*/
static async remove(key: string): Promise<void> {
try {
const db = await openDB();
return new Promise((resolve) => {
db.transaction(STORE_NAME, 'readwrite')
.objectStore(STORE_NAME)
.delete(key);
resolve();
});
} catch {
// Silently fail
}
}
/**
* Clear all cached data.
*/
static async clear(): Promise<void> {
try {
const db = await openDB();
return new Promise((resolve) => {
db.transaction(STORE_NAME, 'readwrite')
.objectStore(STORE_NAME)
.clear();
resolve();
});
} catch {
// Silently fail
}
}
/**
* Get storage usage estimate.
* Returns { usage, quota } in bytes, or null if not available.
*/
static async estimate(): Promise<{ usage: number; quota: number } | null> {
try {
if (navigator.storage && navigator.storage.estimate) {
const est = await navigator.storage.estimate();
return { usage: est.usage ?? 0, quota: est.quota ?? 0 };
}
return null;
} catch {
return null;
}
}
/**
* Count entries in the cache.
*/
static async count(): Promise<number> {
try {
const db = await openDB();
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => resolve(0);
});
} catch {
return 0;
}
}
/**
* Cache-first fetch: tries IndexedDB first, falls back to network.
* If the data is fetched from network, it's cached for future use.
*/
static async fetch<T = unknown>(url: string, options?: { ttl?: number; type?: 'json' | 'text' }): Promise<T | null> {
const cacheKey = `url:${url}`;
const cached = await DataCache.get(cacheKey);
if (cached !== null) {
return cached as T;
}
try {
const resp = await fetch(url);
if (!resp.ok) {
// Don't cache 404s
return null;
}
const ct = resp.headers.get('content-type');
if (ct && ct.includes('text/html')) {
return null;
}
let data: unknown;
if (options?.type === 'text') {
data = await resp.text();
} else {
data = await resp.json();
}
// Cache the fetched data
await DataCache.set(cacheKey, data, options?.ttl);
return data as T;
} catch {
return null;
}
}
}