Files
boris/src/services/imageCacheService.ts
Gigi d6be6f364b refactor: migrate image cache from localStorage to Cache API
BREAKING CHANGE: Image cache now uses Cache API instead of localStorage

Benefits:
- Support for actual 210MB cache size (localStorage limited to 5-10MB)
- Store native Response objects (no base64 overhead)
- Asynchronous, non-blocking operations
- Better suited for large binary blobs like images
- Can handle hundreds of MB to several GB

Changes:
- Rewrite imageCacheService to use Cache API for image storage
- Keep metadata in localStorage for LRU tracking (small footprint)
- Update useImageCache hook to handle async Cache API
- Add blob URL cleanup to prevent memory leaks
- Update clearImageCache to async function

The cache now works as advertised and won't hit quota limits.
2025-10-09 17:48:59 +01:00

238 lines
5.9 KiB
TypeScript

/**
* Image Cache Service
*
* Caches images using the Cache API for offline access.
* Uses LRU (Least Recently Used) eviction when cache size limit is exceeded.
*/
const CACHE_NAME = 'boris-image-cache-v1'
const CACHE_METADATA_KEY = 'img_cache_metadata'
interface CacheMetadata {
[url: string]: {
size: number
lastAccessed: number
}
}
/**
* Get cache metadata from localStorage
*/
function getMetadata(): CacheMetadata {
try {
const data = localStorage.getItem(CACHE_METADATA_KEY)
return data ? JSON.parse(data) : {}
} catch {
return {}
}
}
/**
* Save cache metadata to localStorage
*/
function saveMetadata(metadata: CacheMetadata): void {
try {
localStorage.setItem(CACHE_METADATA_KEY, JSON.stringify(metadata))
} catch (err) {
console.warn('Failed to save image cache metadata:', err)
}
}
/**
* Calculate total cache size in bytes
*/
function getTotalCacheSize(): number {
const metadata = getMetadata()
return Object.values(metadata).reduce((sum, item) => sum + item.size, 0)
}
/**
* Convert bytes to MB
*/
function bytesToMB(bytes: number): number {
return bytes / (1024 * 1024)
}
/**
* Convert MB to bytes
*/
function mbToBytes(mb: number): number {
return mb * 1024 * 1024
}
/**
* Evict least recently used images until cache is under limit
*/
async function evictLRU(maxSizeBytes: number): Promise<void> {
const metadata = getMetadata()
const entries = Object.entries(metadata)
// Sort by last accessed (oldest first)
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
let currentSize = getTotalCacheSize()
const cache = await caches.open(CACHE_NAME)
for (const [url, item] of entries) {
if (currentSize <= maxSizeBytes) break
try {
await cache.delete(url)
delete metadata[url]
currentSize -= item.size
console.log(`🗑️ Evicted image from cache: ${url.substring(0, 50)}...`)
} catch (err) {
console.warn('Failed to evict image:', err)
}
}
saveMetadata(metadata)
}
/**
* Cache an image using Cache API
*/
export async function cacheImage(
url: string,
maxCacheSizeMB: number = 210
): Promise<string> {
try {
// Check if already cached
const cached = await getCachedImageUrl(url)
if (cached) {
console.log('✅ Image already cached:', url.substring(0, 50))
return cached
}
// Fetch the image
console.log('📥 Caching image:', url.substring(0, 50))
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
// Clone the response so we can read it twice (once for size, once for cache)
const responseClone = response.clone()
const blob = await response.blob()
const size = blob.size
// Check if image alone exceeds cache limit
if (bytesToMB(size) > maxCacheSizeMB) {
console.warn(`⚠️ Image too large to cache (${bytesToMB(size).toFixed(2)}MB > ${maxCacheSizeMB}MB)`)
return url // Return original URL if too large
}
const maxSizeBytes = mbToBytes(maxCacheSizeMB)
// Evict old images if necessary
const currentSize = getTotalCacheSize()
if (currentSize + size > maxSizeBytes) {
await evictLRU(maxSizeBytes - size)
}
// Store in Cache API
const cache = await caches.open(CACHE_NAME)
await cache.put(url, responseClone)
// Update metadata
const metadata = getMetadata()
metadata[url] = {
size,
lastAccessed: Date.now()
}
saveMetadata(metadata)
console.log(`💾 Cached image (${bytesToMB(size).toFixed(2)}MB). Total cache: ${bytesToMB(getTotalCacheSize()).toFixed(2)}MB`)
// Return blob URL for immediate use
return URL.createObjectURL(blob)
} catch (err) {
console.error('Failed to cache image:', err)
return url // Return original URL on error
}
}
/**
* Get cached image URL (creates blob URL from cached response)
*/
async function getCachedImageUrl(url: string): Promise<string | null> {
try {
const cache = await caches.open(CACHE_NAME)
const response = await cache.match(url)
if (!response) {
return null
}
// Update last accessed time in metadata
const metadata = getMetadata()
if (metadata[url]) {
metadata[url].lastAccessed = Date.now()
saveMetadata(metadata)
}
// Convert response to blob URL
const blob = await response.blob()
return URL.createObjectURL(blob)
} catch {
return null
}
}
/**
* Get cached image (synchronous wrapper that returns null, actual loading happens async)
* This maintains backward compatibility with the hook's synchronous check
*/
export function getCachedImage(url: string): string | null {
// Check if we have metadata for this URL
const metadata = getMetadata()
return metadata[url] ? url : null // Return URL if in metadata, let hook handle async loading
}
/**
* Clear all cached images
*/
export async function clearImageCache(): Promise<void> {
try {
// Clear from Cache API
await caches.delete(CACHE_NAME)
// Clear metadata from localStorage
localStorage.removeItem(CACHE_METADATA_KEY)
console.log('🗑️ Cleared all cached images')
} catch (err) {
console.error('Failed to clear image cache:', err)
}
}
/**
* Get cache statistics
*/
export function getImageCacheStats(): {
totalSizeMB: number
itemCount: number
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
} {
const metadata = getMetadata()
const entries = Object.entries(metadata)
return {
totalSizeMB: bytesToMB(getTotalCacheSize()),
itemCount: entries.length,
items: entries.map(([url, item]) => ({
url,
sizeMB: bytesToMB(item.size),
lastAccessed: new Date(item.lastAccessed)
}))
}
}
/**
* Load cached image asynchronously (for use in hooks/components)
*/
export async function loadCachedImage(url: string): Promise<string | null> {
return getCachedImageUrl(url)
}