- Rename Go module from github.com/cclohmar/ReceiptNext to NextExpense - Update all import paths across 7 Go source files - Update templates (titles, headings, branding) - Update static files (manifest.json, sw.js, CSS) - Update config (Makefile, install.sh, .env.example) - Update README with new name and URLs - Rename service file receiptnext.service -> nextexpense.service - Update install paths, service names, log paths in install.sh
265 lines
9.2 KiB
JavaScript
265 lines
9.2 KiB
JavaScript
/* ============================================================
|
|
* NextExpense — Service Worker
|
|
* Version: 2.0.0
|
|
* Cache name: nextexpense-v2
|
|
* Strategy: Cache-first for shell assets, network-only for API
|
|
* ============================================================ */
|
|
|
|
const CACHE_NAME = 'nextexpense-v1';
|
|
|
|
// Shell assets to pre-cache on install
|
|
const SHELL_ASSETS = [
|
|
'/',
|
|
'/static/css/style.css',
|
|
'/static/favicon.svg',
|
|
'https://unpkg.com/htmx.org@1.9.10'
|
|
];
|
|
|
|
// API path prefix pattern — requests matching these are never cached
|
|
const API_PATTERNS = [
|
|
'/api/',
|
|
'/auth/',
|
|
'/login',
|
|
'/logout',
|
|
'/register'
|
|
];
|
|
|
|
/* ------------------------------------------------------------
|
|
* Utility: determine whether a request targets an API endpoint
|
|
* ------------------------------------------------------------ */
|
|
function isApiRequest(url) {
|
|
return API_PATTERNS.some(pattern => url.pathname.startsWith(pattern));
|
|
}
|
|
|
|
/* ------------------------------------------------------------
|
|
* Utility: determine whether a request is a shell asset eligible
|
|
* for cache-first strategy
|
|
* ------------------------------------------------------------ */
|
|
function isShellAsset(url) {
|
|
const path = url.pathname;
|
|
const origin = url.origin;
|
|
|
|
// Same-origin shell pages
|
|
if (origin === self.location.origin && (path === '/' || path === '')) {
|
|
return true;
|
|
}
|
|
|
|
// Same-origin CSS
|
|
if (origin === self.location.origin && path.startsWith('/static/css/')) {
|
|
return true;
|
|
}
|
|
|
|
// Same-origin JS
|
|
if (origin === self.location.origin && path.startsWith('/static/js/')) {
|
|
return true;
|
|
}
|
|
|
|
// HTMX CDN (cache-first for fast loading)
|
|
if (url.href === 'https://unpkg.com/htmx.org@1.9.10') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/* ------------------------------------------------------------
|
|
* INSTALL — Pre-cache shell assets
|
|
* ------------------------------------------------------------ */
|
|
self.addEventListener('install', event => {
|
|
console.log('[SW] Install event — caching shell assets');
|
|
|
|
event.waitUntil(
|
|
caches.open(CACHE_NAME)
|
|
.then(cache => {
|
|
// Use addAll for atomic caching — if one fails, the whole
|
|
// install fails and the SW won't activate
|
|
return cache.addAll(SHELL_ASSETS).catch(err => {
|
|
console.error('[SW] Failed to cache some shell assets:', err);
|
|
// Still attempt to activate even if caching is partially
|
|
// unsuccessful — the fetch handler will fall back to network
|
|
throw err;
|
|
});
|
|
})
|
|
.then(() => {
|
|
console.log('[SW] Shell assets cached successfully');
|
|
return self.skipWaiting();
|
|
})
|
|
.catch(err => {
|
|
console.error('[SW] Install failed:', err);
|
|
// Still try to activate so the SW takes over
|
|
return self.skipWaiting();
|
|
})
|
|
);
|
|
});
|
|
|
|
/* ------------------------------------------------------------
|
|
* ACTIVATE — Clean up old caches
|
|
* ------------------------------------------------------------ */
|
|
self.addEventListener('activate', event => {
|
|
console.log('[SW] Activate event — cleaning old caches');
|
|
|
|
event.waitUntil(
|
|
caches.keys().then(cacheNames => {
|
|
return Promise.all(
|
|
cacheNames
|
|
.filter(name => name !== CACHE_NAME)
|
|
.map(name => {
|
|
console.log('[SW] Deleting old cache:', name);
|
|
return caches.delete(name);
|
|
})
|
|
);
|
|
}).then(() => {
|
|
console.log('[SW] Activated — taking control of all clients');
|
|
return self.clients.claim();
|
|
}).catch(err => {
|
|
console.error('[SW] Activation cleanup failed:', err);
|
|
// Continue even if cleanup fails
|
|
return self.clients.claim();
|
|
})
|
|
);
|
|
});
|
|
|
|
/* ------------------------------------------------------------
|
|
* FETCH — Hybrid strategy
|
|
* - Cache-first with network fallback for shell assets
|
|
* - Network-only for API / dynamic endpoints
|
|
* - Stale-while-revalidate for other same-origin assets
|
|
* ------------------------------------------------------------ */
|
|
self.addEventListener('fetch', event => {
|
|
const request = event.request;
|
|
const url = new URL(request.url);
|
|
|
|
// Ignore non-GET requests (POST, PUT, DELETE, etc.)
|
|
if (request.method !== 'GET') {
|
|
return;
|
|
}
|
|
|
|
// Ignore browser-extension and non-http(s) requests
|
|
if (!url.protocol.startsWith('http')) {
|
|
return;
|
|
}
|
|
|
|
// ── Strategy 1: Network-only for API calls ──
|
|
if (isApiRequest(url)) {
|
|
event.respondWith(
|
|
fetch(request).catch(err => {
|
|
console.warn('[SW] API fetch failed (offline?):', url.pathname, err);
|
|
// Return a lightweight JSON error so the app can handle it gracefully
|
|
return new Response(
|
|
JSON.stringify({ error: 'You are offline. Please check your connection.' }),
|
|
{
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
}
|
|
);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// ── Strategy 2: Cache-first with network fallback for shell assets ──
|
|
if (isShellAsset(url)) {
|
|
event.respondWith(
|
|
caches.match(request)
|
|
.then(cachedResponse => {
|
|
if (cachedResponse) {
|
|
// Cache hit — return immediately
|
|
return cachedResponse;
|
|
}
|
|
|
|
// Cache miss — fetch from network, then cache for future
|
|
return fetch(request)
|
|
.then(networkResponse => {
|
|
// Only cache valid responses
|
|
if (!networkResponse || networkResponse.status !== 200) {
|
|
return networkResponse;
|
|
}
|
|
|
|
// Clone the response so we can cache one and return the other
|
|
const responseToCache = networkResponse.clone();
|
|
caches.open(CACHE_NAME)
|
|
.then(cache => {
|
|
cache.put(request, responseToCache).catch(err => {
|
|
console.error('[SW] Failed to cache asset:', url.pathname, err);
|
|
});
|
|
})
|
|
.catch(err => {
|
|
console.error('[SW] Failed to open cache for storing:', err);
|
|
});
|
|
|
|
return networkResponse;
|
|
})
|
|
.catch(err => {
|
|
console.warn('[SW] Network fetch failed for shell asset:', url.pathname, err);
|
|
// Return a minimal offline fallback for navigations
|
|
if (request.mode === 'navigate') {
|
|
return new Response(
|
|
'<!DOCTYPE html><html><head><title>Offline — NextExpense</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:2rem;text-align:center;background:#0f172a;color:#f8fafc}h1{font-size:1.5rem;margin-bottom:0.5rem}p{color:#94a3b8;max-width:24rem}</style></head><body><h1>You\'re offline</h1><p>NextExpense needs an internet connection to load. Please check your connection and try again.</p></body></html>',
|
|
{
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
}
|
|
);
|
|
}
|
|
|
|
return new Response(
|
|
'Offline — resource not available',
|
|
{
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
|
|
}
|
|
);
|
|
});
|
|
})
|
|
.catch(err => {
|
|
console.error('[SW] Cache match error:', err);
|
|
// Fall through to network
|
|
return fetch(request).catch(() => {
|
|
return new Response(
|
|
'An unexpected error occurred.',
|
|
{ status: 502, headers: { 'Content-Type': 'text/plain' } }
|
|
);
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// ── Strategy 3: Network-first for all other (non-shell, non-API) assets ──
|
|
// This covers images, fonts, etc. — try network first, fall back to cache
|
|
event.respondWith(
|
|
fetch(request)
|
|
.then(networkResponse => {
|
|
// Cache successful responses for offline fallback
|
|
if (networkResponse && networkResponse.status === 200) {
|
|
const responseToCache = networkResponse.clone();
|
|
caches.open(CACHE_NAME)
|
|
.then(cache => {
|
|
cache.put(request, responseToCache).catch(err => {
|
|
console.error('[SW] Failed to cache dynamic asset:', url.pathname, err);
|
|
});
|
|
})
|
|
.catch(err => {
|
|
console.error('[SW] Failed to open cache for dynamic asset:', err);
|
|
});
|
|
}
|
|
return networkResponse;
|
|
})
|
|
.catch(err => {
|
|
console.warn('[SW] Network failed, trying cache for:', url.pathname, err);
|
|
return caches.match(request).then(cachedResponse => {
|
|
if (cachedResponse) {
|
|
return cachedResponse;
|
|
}
|
|
// Nothing in cache either — return a basic error
|
|
return new Response(
|
|
'Resource unavailable offline.',
|
|
{ status: 503, headers: { 'Content-Type': 'text/plain' } }
|
|
);
|
|
});
|
|
})
|
|
);
|
|
});
|