const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); const CACHE = 'stackchain-dashboard-shell-v79'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; const SHELL = [ BASE, BASE + 'manifest.webmanifest', BASE + 'static/dashboard.css', BASE + 'static/dashboard.js', BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-512.png', BASE + 'static/session.js', BASE + 'static/markdown.js', BASE + 'static/commands.js', BASE + 'static/search-preview.js', BASE + 'static/widgets.js', BASE + 'static/drafts.js', BASE + 'static/unfiled-captures.js', BASE + 'static/outbox-coordinator.js', BASE + 'static/issue-outbox.js', BASE + 'static/authored-outbox.js', BASE + 'static/offline-issue-close.js', BASE + 'static/notification-read-outbox.js', BASE + 'static/offline-work.js', BASE + 'static/offline-today.js', BASE + 'static/my-work.js', BASE + 'static/card-planning.js', BASE + 'static/today-work.js', BASE + 'static/today-completion.js', BASE + 'static/today-readiness.js', BASE + 'static/comment-next.js', BASE + 'static/plan-today.js', BASE + 'static/plan-today-preview.js', BASE + 'static/today-sync.js', BASE + 'static/update-ownership.js', BASE + 'static/later-work.js', BASE + 'static/later-sync.js', BASE + 'static/later-and-start.js', BASE + 'static/detail-defer.js', BASE + 'static/later-picker.js', BASE + 'static/pick-work.js', BASE + 'static/conversation.js', BASE + 'static/issue-sheet.js', BASE + 'static/create-issue-sheet.js', BASE + 'static/create-and-start.js', BASE + 'static/assign-and-start.js', BASE + 'static/queue-today.js', BASE + 'static/pull-sheet.js', BASE + 'static/review-sheet.js', BASE + 'static/work-route.js', BASE + 'static/task-overlay-history.js', BASE + 'static/context-poller.js', BASE + 'static/mobile-task-dock.js', BASE + 'static/mobile-work-entry.js', BASE + 'static/mobile-launch.js', BASE + 'static/install-app.js', BASE + 'static/mobile-search-viewport.js', BASE + 'static/mobile-composer-viewport.js', BASE + 'static/mention-composer.js', BASE + 'static/background-issue-sync.js', ]; async function fetchNavigation(request) { const controller = new AbortController(); let timeout; const deadline = new Promise((resolve, reject) => { timeout = setTimeout(() => { controller.abort(); reject(new Error('Dashboard navigation timed out.')); }, NAVIGATION_TIMEOUT_MS); }); try { return await Promise.race([ fetch(request, { signal: controller.signal }), deadline, ]); } finally { clearTimeout(timeout); } } async function sessionCsrf(signal) { const response = await fetch(new URL(BASE + 'api/v1/session', self.location.origin), { headers: { Accept: 'application/json' }, signal, }); if (!response.ok) return ''; const payload = await response.json().catch(() => ({})); return typeof payload.csrf_token === 'string' ? payload.csrf_token : ''; } let batchedCsrf = null; let batchedCsrfController = null; async function withSessionCsrf(work) { batchedCsrfController = new AbortController(); batchedCsrf = sessionCsrf(batchedCsrfController.signal); try { return await work(); } finally { batchedCsrfController.abort(); batchedCsrf = null; batchedCsrfController = null; } } async function purgeRevokedSessionData() { await issueSync.purge(); const keys = await caches.keys(); await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) ); const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' })); } async function notifyIdleSession() { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-idle' })); } async function storeOfflineLease(expiresAt) { if (!Number.isInteger(expiresAt) || expiresAt <= 0) return; const cache = await caches.open(CACHE); await cache.put(OFFLINE_LEASE_URL, new Response( JSON.stringify({ expires_at: expiresAt }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } }, )); } async function validOfflineLease(cache) { const response = await cache.match(OFFLINE_LEASE_URL); const payload = await response?.json?.().catch(() => ({})) || {}; return Number.isInteger(payload.expires_at) && payload.expires_at > Math.floor(Date.now() / 1000); } async function expiredOfflineResponse() { await issueSync.purge(); const keys = await caches.keys(); await Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key)) ); const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' })); return new Response( 'Your Stackchain session expired. Reconnect and sign in.', { status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } }, ); } async function cachedShellWithValidLease(cache) { if (!await validOfflineLease(cache)) return expiredOfflineResponse(); return cache.match(BASE); } async function fetchJson(url, options = {}) { const requestOptions = { ...options }; const method = String(options.method || 'GET').toUpperCase(); if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { const headers = new Headers(options.headers || {}); const cancelBatchedCsrf = () => batchedCsrfController?.abort(); if (batchedCsrf && options.signal) { if (options.signal.aborted) cancelBatchedCsrf(); else options.signal.addEventListener('abort', cancelBatchedCsrf, { once: true }); } let csrf; try { csrf = await (batchedCsrf || sessionCsrf(options.signal)); } finally { options.signal?.removeEventListener('abort', cancelBatchedCsrf); } if (options.signal?.aborted) throw new Error('Background request aborted.'); if (csrf) headers.set('X-CSRF-Token', csrf); requestOptions.headers = headers; } const response = await fetch(new URL(url, self.location.origin), requestOptions); const payload = await response.json().catch(() => ({})); if (!response.ok) { const code = payload.code || payload.detail?.code; if (response.status === 401 && code === 'session_revoked') { await purgeRevokedSessionData(); } if (response.status === 401 && code === 'session_idle') { await notifyIdleSession(); } const error = new Error(payload.error || payload.detail?.message || payload.detail || 'Background issue delivery failed.'); error.status = response.status; error.code = code; throw error; } return payload; } const issueSync = self.__issueSync || createBackgroundIssueSync({ store: createIssueSyncStore(), fetchJson, base: BASE, batch: withSessionCsrf, }); async function flushAndNotify() { const result = await issueSync.flush(); if (!result?.login || !result.receipts?.length || !await issueSync.getReceiptPreference?.(result.login)) return; for (const receipt of result.receipts) { const needsAttention = receipt.status === 'attention'; const title = needsAttention ? 'Queued work needs attention' : receipt.kind === 'issue' ? 'Queued issue created' : 'Queued message sent'; await self.registration.showNotification(title, { body: needsAttention ? 'Tap to review it in Drafts.' : 'Tap to open it in Stackchain.', tag: 'stackchain-delivery-' + receipt.id, data: { route: receipt.route }, }); } } self.addEventListener('install', event => { event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting())); }); self.addEventListener('activate', event => { event.waitUntil(caches.keys().then(keys => Promise.all( keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE) .map(key => caches.delete(key)) )).then(() => self.clients.claim())); }); self.addEventListener('sync', event => { if (event.tag === 'stackchain-issue-outbox-v1') event.waitUntil(flushAndNotify()); }); self.addEventListener('message', event => { if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => { await issueSync.resume(); await flushAndNotify(); })()); if (event.data?.type === 'stackchain-session-lease') { event.waitUntil(storeOfflineLease(event.data.expiresAt)); } if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => { try { await issueSync.purge(); event.ports?.[0]?.postMessage({ ok: true }); } catch (error) { event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') }); } })()); }); self.addEventListener('notificationclick', event => { event.notification.close(); const route = String(event.notification.data?.route || ''); if (!route.startsWith('#/my-work/')) return; const target = new URL(BASE + route, self.location.origin).href; event.waitUntil((async () => { const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); const client = windows.find(candidate => candidate.url.startsWith(self.location.origin + BASE)); if (!client) return self.clients.openWindow(target); if (client.navigate) await client.navigate(target); return client.focus(); })()); }); self.addEventListener('fetch', event => { const request = event.request; if (request.method !== 'GET' || request.url.includes('/api/')) return; const url = new URL(request.url); if (url.origin !== self.location.origin || !url.pathname.startsWith(BASE)) return; if (request.mode === 'navigate') { event.respondWith( fetchNavigation(request).then(async response => { const cache = await caches.open(CACHE); const responseUrl = new URL(response.url || request.url); const isDashboardShell = responseUrl.origin === self.location.origin && responseUrl.pathname === BASE; if (response.ok && !response.redirected && isDashboardShell) { await cache.put(BASE, response.clone()); } else if (OUTAGE_STATUSES.has(response.status)) { const cached = await cachedShellWithValidLease(cache); if (cached) return cached; } return response; }).catch(async () => { const cache = await caches.open(CACHE); const cached = await cachedShellWithValidLease(cache); return cached || new Response( 'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.', { status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }, ); }) ); return; } if (url.origin === self.location.origin && SHELL.includes(url.pathname)) { event.respondWith(caches.match(request).then(cached => cached || fetch(request))); } });