Files
MindSift/frontend/public/sw.js
T
sandrodachandClaude Opus 5.5 141f87f3a0
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s
Merge branch 'feat/footer-share-ci' into dev
CI keeps the 'test once per code state' plan but records commit and
git tree as image labels instead of extra :sha-/:tree- tags, so the
registry only holds dev, latest, vX.Y.Z and stable; the plan test now
asserts the expected decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 21:29:56 +01:00

201 lines
6.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 = 'v3';
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80;
const SHARE_DB_NAME = 'mindsift-share-target';
const SHARE_STORE_NAME = 'shared_items';
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;
}
function openShareDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(SHARE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function savePendingShare(item) {
const db = await openShareDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
tx.objectStore(SHARE_STORE_NAME).add(item);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
tx.onabort = () => {
db.close();
reject(tx.error);
};
});
}
async function handleShareTarget(event) {
try {
const formData = await event.request.formData();
const title = formData.get('title');
const text = formData.get('text');
const url = formData.get('url');
const media = formData.get('media');
let fileName = '';
let hasMedia = false;
if (media && typeof media === 'object') {
fileName = media.name || 'image';
hasMedia = true;
}
await savePendingShare({
title: typeof title === 'string' ? title.trim() : '',
text: typeof text === 'string' ? text.trim() : '',
url: typeof url === 'string' ? url.trim() : '',
fileName,
hasMedia,
createdAt: Date.now(),
});
} catch (err) {
console.error('[SW] Share-Target Fehler:', err);
}
return Response.redirect('/?shared=pending', 303);
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method === 'POST') {
const url = new URL(request.url);
if (url.origin === self.location.origin && url.pathname === '/share-target') {
event.respondWith(handleShareTarget(event));
}
return;
}
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);
})
);
});