MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s
- VAPID keys from VAPID_* or generated once (push_keys, migration 9); devices in push_subscriptions, forgotten on 404/410 from the push service - push column for every event (also due tasks) and the daily summary, bundles as one notification; /push/key, /devices, /subscribe, /unsubscribe, /test - settings: switch push on per device, test it, see the devices; the service worker shows notifications and focuses MindSift on click - tests decrypt a sent push like a browser (RFC 8291) and check VAPID Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
4.3 KiB
JavaScript
129 lines
4.3 KiB
JavaScript
// MindSift service worker: keeps the app shell so the PWA also starts without network.
|
|
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
|
|
// last loaded index.html is used.
|
|
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
|
|
// - Icons and manifest: from the cache, refreshed in the background.
|
|
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
|
|
// Raise VERSION when this file changes in a way that needs fresh caches.
|
|
const VERSION = 'v2';
|
|
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
|
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
|
const MAX_ASSETS = 80;
|
|
const SHELL_URLS = [
|
|
'/',
|
|
'/index.html',
|
|
'/manifest.json',
|
|
'/favicon.ico',
|
|
'/icon.svg',
|
|
'/icon-192x192.png',
|
|
'/icon-512x512.png',
|
|
'/apple-touch-icon.png',
|
|
];
|
|
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
caches
|
|
.open(SHELL_CACHE)
|
|
.then((cache) => Promise.all(SHELL_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}))))
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches
|
|
.keys()
|
|
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE).map((k) => caches.delete(k))))
|
|
.then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
// Old bundles pile up with every release; keep only the newest entries
|
|
async function trimAssets() {
|
|
const cache = await caches.open(ASSET_CACHE);
|
|
const keys = await cache.keys();
|
|
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX_ASSETS)).map((k) => cache.delete(k)));
|
|
}
|
|
|
|
async function networkFirstPage(request) {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
try {
|
|
const response = await fetch(request, { cache: 'no-store' });
|
|
if (response.ok && response.type === 'basic') cache.put('/index.html', response.clone());
|
|
return response;
|
|
} catch {
|
|
return (await cache.match('/index.html')) || (await cache.match('/')) || Response.error();
|
|
}
|
|
}
|
|
|
|
async function cacheFirstAsset(request) {
|
|
const cache = await caches.open(ASSET_CACHE);
|
|
const hit = await cache.match(request);
|
|
if (hit) return hit;
|
|
const response = await fetch(request);
|
|
if (response.ok && response.type === 'basic') {
|
|
await cache.put(request, response.clone());
|
|
trimAssets();
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function staleWhileRevalidate(request) {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
const hit = await cache.match(request);
|
|
const refresh = fetch(request)
|
|
.then((response) => {
|
|
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
|
|
return response;
|
|
})
|
|
.catch(() => hit || Response.error());
|
|
return hit || refresh;
|
|
}
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
if (request.method !== 'GET') return;
|
|
const url = new URL(request.url);
|
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
|
|
|
if (request.mode === 'navigate') {
|
|
event.respondWith(networkFirstPage(request));
|
|
} else if (url.pathname.startsWith('/assets/')) {
|
|
event.respondWith(cacheFirstAsset(request));
|
|
} else if (SHELL_URLS.includes(url.pathname)) {
|
|
event.respondWith(staleWhileRevalidate(request));
|
|
}
|
|
});
|
|
|
|
// Web push: show the notification; a click focuses an open MindSift window or opens a new one
|
|
self.addEventListener('push', (event) => {
|
|
let msg = {};
|
|
try {
|
|
msg = event.data ? event.data.json() : {};
|
|
} catch {
|
|
msg = { body: event.data ? event.data.text() : '' };
|
|
}
|
|
event.waitUntil(
|
|
self.registration.showNotification(msg.title || 'MindSift', {
|
|
body: msg.body || '',
|
|
icon: '/icon-192x192.png',
|
|
badge: '/icon-192x192.png',
|
|
tag: msg.tag || undefined,
|
|
data: { url: msg.url || '/' },
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin);
|
|
event.waitUntil(
|
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
|
|
for (const w of windows) {
|
|
if (new URL(w.url).origin === target.origin && 'focus' in w) return w.focus();
|
|
}
|
|
return self.clients.openWindow(target.href);
|
|
})
|
|
);
|
|
});
|