iOS Safari evicts Blob file-backed data from IndexedDB under memory pressure, causing upload POSTs to throw 'Load failed' without ever reaching the server. Two-pronged fix: 1. Store photos as ArrayBuffer (inline bytes) instead of Blob (file reference) in IndexedDB — ArrayBuffers are not subject to eviction 2. Request navigator.storage.persist() to signal the browser not to evict our storage under pressure Also adds Storage.getBlob() helper for converting stored ArrayBuffer back to Blob at upload/display time, with backward compat for any existing Blob-stored photos. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
5.9 KiB
JavaScript
172 lines
5.9 KiB
JavaScript
// NextSnap Service Worker
|
|
// Provides offline-first caching for the app shell
|
|
|
|
const CACHE_VERSION = 'nextsnap-v15';
|
|
const APP_SHELL_CACHE = 'nextsnap-shell-v11';
|
|
const RUNTIME_CACHE = 'nextsnap-runtime-v11';
|
|
|
|
// Assets to cache on install
|
|
const APP_SHELL_ASSETS = [
|
|
'/',
|
|
'/static/css/style.css',
|
|
'/static/js/app.js',
|
|
'/static/js/auth.js',
|
|
'/static/js/camera.js',
|
|
'/static/js/storage.js',
|
|
'/static/js/sync.js',
|
|
'/static/js/filebrowser.js',
|
|
'/static/lib/dexie.min.js',
|
|
'/static/manifest.json',
|
|
'/static/icons/icon-192.png',
|
|
'/static/icons/icon-512.png'
|
|
];
|
|
|
|
// Install event - precache app shell
|
|
self.addEventListener('install', (event) => {
|
|
console.log('[SW] Installing service worker...');
|
|
event.waitUntil(
|
|
caches.open(APP_SHELL_CACHE)
|
|
.then((cache) => {
|
|
console.log('[SW] Caching app shell');
|
|
return cache.addAll(APP_SHELL_ASSETS);
|
|
})
|
|
.then(() => {
|
|
console.log('[SW] App shell cached successfully');
|
|
return self.skipWaiting(); // Activate immediately
|
|
})
|
|
.catch((error) => {
|
|
console.error('[SW] Failed to cache app shell:', error);
|
|
})
|
|
);
|
|
});
|
|
|
|
// Activate event - clean up old caches
|
|
self.addEventListener('activate', (event) => {
|
|
console.log('[SW] Activating service worker...');
|
|
event.waitUntil(
|
|
caches.keys()
|
|
.then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames.map((cacheName) => {
|
|
if (cacheName !== APP_SHELL_CACHE && cacheName !== RUNTIME_CACHE) {
|
|
console.log('[SW] Deleting old cache:', cacheName);
|
|
return caches.delete(cacheName);
|
|
}
|
|
})
|
|
);
|
|
})
|
|
.then(() => {
|
|
console.log('[SW] Service worker activated');
|
|
return self.clients.claim(); // Take control immediately
|
|
})
|
|
);
|
|
});
|
|
|
|
// Fetch event - cache-first for static assets, network-first for API
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Skip non-GET requests
|
|
if (request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// API requests - network-first with offline fallback
|
|
if (url.pathname.startsWith('/api/')) {
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then((response) => {
|
|
// Clone response for cache
|
|
const responseClone = response.clone();
|
|
|
|
// Only cache successful responses (not errors or auth failures)
|
|
if (response.status === 200) {
|
|
caches.open(RUNTIME_CACHE).then((cache) => {
|
|
// Don't cache file uploads or large responses
|
|
if (!url.pathname.includes('/upload') &&
|
|
!url.pathname.includes('/thumbnail')) {
|
|
cache.put(request, responseClone);
|
|
}
|
|
});
|
|
}
|
|
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
// Network failed - try cache, then offline response
|
|
return caches.match(request)
|
|
.then((cachedResponse) => {
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
// Return offline fallback for API
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'offline',
|
|
message: 'You are offline. This feature requires connectivity.'
|
|
}),
|
|
{
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Static assets - cache-first strategy
|
|
event.respondWith(
|
|
caches.match(request)
|
|
.then((cachedResponse) => {
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
// Not in cache - fetch from network and cache it
|
|
return fetch(request)
|
|
.then((response) => {
|
|
// Don't cache non-successful responses
|
|
if (!response || response.status !== 200 || response.type === 'error') {
|
|
return response;
|
|
}
|
|
|
|
// Clone response for cache
|
|
const responseClone = response.clone();
|
|
|
|
caches.open(RUNTIME_CACHE).then((cache) => {
|
|
cache.put(request, responseClone);
|
|
});
|
|
|
|
return response;
|
|
})
|
|
.catch(() => {
|
|
// Network failed and not in cache
|
|
// For HTML pages, could return offline page here
|
|
return new Response('Offline', { status: 503 });
|
|
});
|
|
})
|
|
);
|
|
});
|
|
|
|
// Listen for messages from clients
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
|
self.skipWaiting();
|
|
}
|
|
|
|
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
|
event.waitUntil(
|
|
caches.keys().then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames.map((cacheName) => caches.delete(cacheName))
|
|
);
|
|
})
|
|
);
|
|
}
|
|
});
|