Files

650 lines
20 KiB
JavaScript
Raw Permalink Normal View History

const CACHE_NAME = 'trips-v17';
const STATIC_CACHE = 'trips-static-v17';
const DATA_CACHE = 'trips-data-v17';
const IMAGE_CACHE = 'trips-images-v17';
// Static assets to cache on install
const STATIC_ASSETS = [
'/'
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
event.waitUntil(
caches.open(STATIC_CACHE)
.then((cache) => {
console.log('[SW] Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
.then(() => self.skipWaiting())
);
});
// 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) => {
// Delete old version caches
if (cacheName.startsWith('trips-') &&
cacheName !== STATIC_CACHE &&
cacheName !== DATA_CACHE &&
cacheName !== IMAGE_CACHE) {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
// Check if response is valid (not error page, not Cloudflare error)
function isValidResponse(response) {
if (!response) return false;
if (!response.ok) return false;
if (response.redirected) return false;
if (response.status !== 200) return false;
// Check content-type to detect error pages
const contentType = response.headers.get('content-type') || '';
// If we expect HTML but got something else, it might be an error
return true;
}
// Check if response is a Cloudflare/proxy error page
async function isErrorPage(response) {
if (!response) return true;
if (response.status >= 500) return true;
if (response.status === 0) return true;
// Clone to read body without consuming
const clone = response.clone();
try {
const text = await clone.text();
// Detect common error page signatures
if (text.includes('cloudflare') && text.includes('Error')) return true;
if (text.includes('502 Bad Gateway')) return true;
if (text.includes('503 Service')) return true;
if (text.includes('504 Gateway')) return true;
} catch (e) {
// If we can't read it, assume it's fine
}
return false;
}
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Never cache auth pages - always go to network
if (url.pathname === '/login' || url.pathname === '/logout' || url.pathname.startsWith('/auth')) {
return;
}
// Handle CDN requests (Quill, etc.) - cache first
if (url.origin !== location.origin) {
if (url.hostname.includes('cdn.jsdelivr.net')) {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request);
})
);
}
return;
}
// Handle API requests
if (url.pathname.startsWith('/api/')) {
event.respondWith(handleApiRequest(event.request));
return;
}
// Handle image requests
if (url.pathname.startsWith('/images/')) {
event.respondWith(handleImageRequest(event.request));
return;
}
// Handle document requests
if (url.pathname.startsWith('/documents/')) {
event.respondWith(handleDocumentRequest(event.request));
return;
}
// Handle trip pages - CACHE FIRST for offline support
if (url.pathname.startsWith('/trip/')) {
event.respondWith(handleTripPageRequest(event.request));
return;
}
// Handle main page - cache first, then pre-cache all trips
if (url.pathname === '/') {
event.respondWith(handleMainPageRequest(event.request));
return;
}
// Default: cache first, network fallback
event.respondWith(handleStaticRequest(event.request));
});
// Handle main page - NETWORK FIRST, only cache when offline
async function handleMainPageRequest(request) {
// Try network first
try {
const response = await fetch(request);
// If redirected (to login), clear cache and return the redirect
if (response.redirected) {
console.log('[SW] Main page redirected (likely to login), clearing cache');
const cache = await caches.open(STATIC_CACHE);
cache.delete(request);
return response;
}
if (response.ok) {
// Clone to read HTML and cache main page images
const htmlText = await response.clone().text();
const cache = await caches.open(STATIC_CACHE);
cache.put(request, response.clone());
// Cache main page images in background
cacheMainPageImages(htmlText);
return response;
}
// Non-OK response, just return it
return response;
} catch (error) {
// Network failed - we're offline, try cache
console.log('[SW] Main page network failed, trying cache');
const cachedResponse = await caches.match(request);
if (cachedResponse) return cachedResponse;
return createOfflineResponse('Main page not available offline');
}
}
// Cache images from main page
async function cacheMainPageImages(html) {
const imageCache = await caches.open(IMAGE_CACHE);
const imageUrls = extractUrls(html, /\/images\/[^"'\s<>]+/g);
for (const imgUrl of imageUrls) {
try {
const existing = await caches.match(imgUrl);
if (!existing) {
const imgResponse = await fetch(imgUrl);
if (imgResponse.ok) {
await imageCache.put(imgUrl, imgResponse);
console.log('[SW] Cached main page image:', imgUrl);
}
}
} catch (e) { /* skip */ }
}
}
// Handle trip pages - NETWORK FIRST, fall back to cache only when offline
async function handleTripPageRequest(request) {
// Always try network first
try {
console.log('[SW] Fetching trip from network:', request.url);
const response = await fetch(request);
// Check if response is valid
if (response.ok && response.status === 200) {
// Check if we got redirected to login/auth page (auth failure)
const finalUrl = response.url || '';
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) {
console.log('[SW] Redirected to auth, not caching:', finalUrl);
// Clear any cached version of this page since user is logged out
const cache = await caches.open(DATA_CACHE);
cache.delete(request);
return response;
}
// Cache and return the valid response
const cache = await caches.open(DATA_CACHE);
cache.put(request, response.clone());
console.log('[SW] Cached trip page:', request.url);
return response;
}
// Non-200 response, just return it (might be login redirect)
console.log('[SW] Non-200 response:', response.status, request.url);
return response;
} catch (error) {
// Network failed - we're offline, try cache
console.log('[SW] Network failed, trying cache for trip:', request.url);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[SW] Serving trip from cache (offline):', request.url);
// Add header to indicate offline mode
const headers = new Headers(cachedResponse.headers);
headers.set('X-From-Cache', 'true');
return new Response(cachedResponse.body, {
status: cachedResponse.status,
statusText: cachedResponse.statusText,
headers: headers
});
}
return createOfflineResponse('Trip not available offline. Download this trip while online first.');
}
}
// Update trip cache in background
async function updateTripCache(request) {
try {
const response = await fetch(request);
if (response.ok && response.status === 200) {
// Skip if redirected to login/auth
const finalUrl = response.url || '';
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) return;
const cache = await caches.open(DATA_CACHE);
cache.put(request, response.clone());
console.log('[SW] Updated cache for:', request.url);
}
} catch (error) {
// Silent fail - we already served from cache
}
}
// Send progress message to all clients
async function sendProgress(message, progress, total) {
const clients = await self.clients.matchAll();
clients.forEach(client => {
client.postMessage({
type: 'CACHE_PROGRESS',
message,
progress,
total
});
});
}
// Pre-cache all trips when main page loads
let preCacheInProgress = false;
async function preCacheAllTrips() {
// Prevent duplicate runs
if (preCacheInProgress) return;
preCacheInProgress = true;
console.log('[SW] Pre-caching all trips (full offline mode)...');
// Small delay to ensure page JS has loaded
await new Promise(r => setTimeout(r, 500));
try {
const response = await fetch('/api/trips', { credentials: 'include' });
if (!response.ok) {
console.log('[SW] Failed to fetch trips list:', response.status);
await sendProgress('Offline sync failed - not logged in?', 0, 0);
preCacheInProgress = false;
return;
}
const trips = await response.json();
const dataCache = await caches.open(DATA_CACHE);
const imageCache = await caches.open(IMAGE_CACHE);
const totalTrips = trips.length;
let currentTrip = 0;
let totalAssets = 0;
let cachedAssets = 0;
await sendProgress('Starting offline sync...', 0, totalTrips);
// Cache the trips list API response
const tripsResponse = await fetch('/api/trips', { credentials: 'include' });
if (tripsResponse.ok) {
dataCache.put('/api/trips', tripsResponse);
}
// Cache each trip page and its assets
for (const trip of trips) {
currentTrip++;
const tripUrl = `/trip/${trip.id}`;
await sendProgress(`Caching: ${trip.name}`, currentTrip, totalTrips);
try {
const tripResponse = await fetch(tripUrl, { credentials: 'include' });
if (tripResponse.ok && tripResponse.status === 200) {
// Skip if redirected to login/auth
const finalUrl = tripResponse.url || '';
if (finalUrl.includes('/login') || finalUrl.includes('pocket.') || finalUrl.includes('/authorize') || finalUrl.includes('/oauth')) {
console.log('[SW] Auth required for trip:', trip.name);
continue;
}
// Clone response to read HTML and still cache it
const htmlText = await tripResponse.clone().text();
await dataCache.put(tripUrl, tripResponse);
console.log('[SW] Pre-cached trip:', trip.name);
// Extract and cache all images
const imageUrls = extractUrls(htmlText, /\/images\/[^"'\s<>]+/g);
totalAssets += imageUrls.length;
for (const imgUrl of imageUrls) {
try {
const existing = await caches.match(imgUrl);
if (!existing) {
const imgResponse = await fetch(imgUrl);
if (imgResponse.ok) {
await imageCache.put(imgUrl, imgResponse);
cachedAssets++;
console.log('[SW] Cached image:', imgUrl);
}
} else {
cachedAssets++;
}
} catch (e) { /* skip failed images */ }
}
// Extract and cache all documents
const docUrls = extractUrls(htmlText, /\/documents\/[^"'\s<>]+/g);
totalAssets += docUrls.length;
for (const docUrl of docUrls) {
try {
const existing = await caches.match(docUrl);
if (!existing) {
const docResponse = await fetch(docUrl);
if (docResponse.ok) {
await dataCache.put(docUrl, docResponse);
cachedAssets++;
console.log('[SW] Cached document:', docUrl);
}
} else {
cachedAssets++;
}
} catch (e) { /* skip failed docs */ }
}
}
} catch (e) {
console.log('[SW] Failed to pre-cache trip:', trip.name, e);
}
}
await sendProgress('complete', totalTrips, totalTrips);
console.log('[SW] Full pre-caching complete!', totalTrips, 'trips,', cachedAssets, 'files');
preCacheInProgress = false;
} catch (error) {
console.error('[SW] Pre-caching failed:', error);
await sendProgress('Offline sync failed', 0, 0);
preCacheInProgress = false;
}
}
// Helper to extract URLs from HTML using regex
function extractUrls(html, pattern) {
const matches = html.match(pattern) || [];
// Dedupe and clean
return [...new Set(matches.map(url => {
// Decode HTML entities and remove trailing quotes
return url.replace(/&amp;/g, '&').replace(/["'<>]/g, '');
}))];
}
// Handle static assets - cache first
async function handleStaticRequest(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Return cached, but also update cache in background
fetchAndCache(request, STATIC_CACHE);
return cachedResponse;
}
return fetchAndCache(request, STATIC_CACHE);
}
// Handle API requests - cache first for trip data, network for others
async function handleApiRequest(request) {
const url = new URL(request.url);
// These endpoints can be cached for offline use
const cacheableEndpoints = [
'/api/trips',
'/api/trip/'
];
const shouldCache = cacheableEndpoints.some(endpoint => url.pathname.startsWith(endpoint));
// NETWORK FIRST - always try network, only use cache when offline
try {
const response = await fetch(request);
if (response.ok && shouldCache) {
const cache = await caches.open(DATA_CACHE);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed - we're offline, try cache
console.log('[SW] API request failed (offline), trying cache:', request.url);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
const headers = new Headers(cachedResponse.headers);
headers.set('X-From-Cache', 'true');
return new Response(cachedResponse.body, {
status: cachedResponse.status,
statusText: cachedResponse.statusText,
headers: headers
});
}
return createOfflineJsonResponse({ error: 'Offline - data not cached' });
}
}
// Fetch and cache API in background
async function fetchAndCacheApi(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(DATA_CACHE);
cache.put(request, response.clone());
}
} catch (e) {
// Silent fail
}
}
// Handle image requests - cache first (images don't change)
async function handleImageRequest(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(IMAGE_CACHE);
cache.put(request, response.clone());
}
return response;
} catch (error) {
console.log('[SW] Image not available offline:', request.url);
return createPlaceholderImage();
}
}
// Handle document requests - cache first
async function handleDocumentRequest(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(DATA_CACHE);
cache.put(request, response.clone());
}
return response;
} catch (error) {
console.log('[SW] Document not available offline:', request.url);
return createOfflineResponse('Document not available offline');
}
}
// Helper: fetch and cache
async function fetchAndCache(request, cacheName) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
throw error;
}
}
// Helper: create offline HTML response
function createOfflineResponse(message) {
return new Response(
`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - Trips</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
text-align: center;
}
.offline-container {
padding: 40px;
}
.offline-icon {
font-size: 64px;
margin-bottom: 20px;
}
h1 { color: #fff; margin-bottom: 10px; }
p { color: #aaa; }
button {
background: #4a9eff;
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
margin-top: 20px;
font-size: 16px;
}
button:hover { background: #3a8eef; }
</style>
</head>
<body>
<div class="offline-container">
<div class="offline-icon">📡</div>
<h1>You're Offline</h1>
<p>${message}</p>
<button onclick="location.reload()">Try Again</button>
</div>
</body>
</html>`,
{
status: 503,
headers: { 'Content-Type': 'text/html' }
}
);
}
// Helper: create offline JSON response
function createOfflineJsonResponse(data) {
return new Response(JSON.stringify(data), {
status: 503,
headers: {
'Content-Type': 'application/json',
'X-From-Cache': 'false'
}
});
}
// Helper: create placeholder image
function createPlaceholderImage() {
// 1x1 transparent PNG
const placeholder = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const binary = atob(placeholder);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new Response(bytes, {
status: 200,
headers: { 'Content-Type': 'image/png' }
});
}
// Listen for messages from the main thread
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
// Cache specific trip data on demand
if (event.data && event.data.type === 'CACHE_TRIP') {
const tripId = event.data.tripId;
cacheTripData(tripId);
}
// Clear all caches
if (event.data && event.data.type === 'CLEAR_CACHE') {
caches.keys().then((names) => {
names.forEach((name) => {
if (name.startsWith('trips-')) {
caches.delete(name);
}
});
});
}
// Force pre-cache all trips
if (event.data && event.data.type === 'PRECACHE_ALL') {
preCacheAllTrips();
}
});
// Cache trip data for offline use
async function cacheTripData(tripId) {
const cache = await caches.open(DATA_CACHE);
try {
// Cache trip page
const tripPageUrl = `/trip/${tripId}`;
const tripPageResponse = await fetch(tripPageUrl);
if (tripPageResponse.ok) {
await cache.put(tripPageUrl, tripPageResponse);
}
console.log('[SW] Cached trip data for:', tripId);
} catch (error) {
console.error('[SW] Failed to cache trip:', error);
}
}